- HTML 40%
- Shell 36.1%
- Python 23.9%
| config | ||
| web | ||
| .gitignore | ||
| app.py | ||
| install.sh | ||
| README.md | ||
| requirements.txt | ||
catheterization-cam-ap
A USB camera viewer for single-board computers. The board runs as a permanent, open WiFi access point; you join it from a phone or tablet, pick one of the connected USB cameras, and watch it fullscreen. No internet, no router, no password.
It also works over Ethernet with the AP disabled, which turns any USB camera into a plain network camera.
Everything comes up on boot as systemd services.
Hardware
Any ARM SBC running Debian or Ubuntu with a WiFi radio capable of AP mode. Two are tested — see Board support below.
| Camera | Any UVC device — strongly prefer one that outputs MJPG natively |
| Storage | A known-good SD card. Cheap or counterfeit cards corrupt /var/lib/dpkg |
| Power | A dedicated 5V/2A+ supply, not a laptop port, hub, or UART adapter |
| Console | Orange Pi Zero LTS: 3-pin UART header, 115200 8N1. Raspberry Pi: SSH or HDMI |
Brownouts and failing flash both present as Input/output error on random
binaries, or as apt failures no amount of repair fixes. If that happens,
change the power supply and reflash to a different card rather than debugging
the software.
The MJPG requirement
These boards have no usable hardware JPEG encoder. If the camera emits MJPG, those frames are passed to the browser untouched and CPU use stays near idle — measured at roughly 10% of one core and 70 MB RAM on an H2+. If the camera only emits YUYV, every frame must be JPEG-encoded in software across all cores, and throughput collapses.
Check before you commit to a camera:
v4l2-ctl -d /dev/video0 --list-formats-ext
Look for [0]: 'MJPG'. The web UI flags cameras without it.
Board support
Tested on the Orange Pi Zero LTS and the Raspberry Pi 3B. install.sh detects
the radio and adjusts hostapd accordingly — no manual edits needed:
| Board | Radio | Driver | Verdict |
|---|---|---|---|
| Raspberry Pi 3B / 3B+ / 4 | Broadcom BCM434xx | brcmfmac, in-tree |
Recommended |
| Orange Pi Zero LTS | Allwinner XR819 | xradio, out-of-tree |
Ethernet only |
The Orange Pi Zero LTS has 512 MB RAM and an Allwinner H2+ (quad Cortex-A7 @ 1.2 GHz) — ample for this workload. Its limitation is the radio, not the SoC.
The app, the services and the installer are identical on both. Only
ieee80211n differs, and that is set automatically.
Using it over Ethernet instead
The access point is optional. With the board on your LAN the same app is
reachable at http://<board-ip>, with none of the wireless instability — this
turns any USB camera into a network camera. On an Orange Pi Zero LTS this is
the reliable way to run it: roughly 10% CPU and 70 MB RAM while streaming, at
a steady 300 KB/s for 320x240.
For Ethernet-only use, skip the AP services entirely:
sudo systemctl disable --now hostapd dnsmasq
camweb alone is enough.
The XR819 caveat
The xradio driver is an out-of-tree, reverse-engineered port of the mainline
cw1200 driver, effectively unmaintained since 2017. In AP mode it wedges its
own TX buffers under sustained load — the stream runs cleanly, then freezes for
seconds, then recovers.
The giveaway is in dmesg:
sudo dmesg | grep -iE "WSM_ERR|AP_WRN|hw_bufs"
wsm_flush_tx: No pending, but hw_bufs_used=1 or Multicast delivery timeout
means the firmware has stalled. No configuration fixes this.
What measurably helps, in order of effect:
ieee80211n=0— the largest single improvement. Applied automatically.dtim_period=1andiw dev wlan0 set power_save off. Both applied automatically.- A less congested channel — 1, 6, or 11. Note the XR819 cannot scan while in AP mode, so this is trial and error.
- Lower framerate. 10 fps is meaningfully more stable than 15.
Even with all of it, expect intermittent stalls above roughly 320x240. The
capture path is not the limit — curl http://127.0.0.1:8080/state will show
a full 15 fps captured and queued while the radio is stalling. For reliable
wireless use, prefer a Raspberry Pi or add a USB dongle (RTL8188EUS,
MT7601U). For reliable use on this board, use Ethernet.
Install
Flash a Debian-based image and create the admin user during setup.
- Raspberry Pi — Raspberry Pi OS Lite (64-bit). In Raspberry Pi Imager's
advanced settings, pre-create the user
adminand enable SSH; that avoids the serial console entirely. - Orange Pi Zero LTS — Armbian, either the Debian (Bookworm) or Ubuntu
(Noble) build. Create
adminin the first-boot wizard over UART.
Use a known-good SD card. Corruption in /var/lib/dpkg will surface as
apt errors that no amount of repair fixes; if apt fails on a fresh image
with statoverride or file-list syntax errors, reflash to a different card
rather than trying to patch the database.
Prerequisites
Connect the board to your router by Ethernet. install.sh masks
wpa_supplicant and NetworkManager partway through, so WiFi cannot be your
only link to the board. Keep UART or Ethernet available throughout.
Confirm the board is online, then install the tools needed before cloning:
ip -4 addr show # your Ethernet interface may be end0, not eth0
ping -c3 github.com
sudo apt update
sudo apt full-upgrade -y
sudo apt install -y git xterm
Run the upgrade here, before install.sh. The installer deliberately does
not run it: a mirror problem or a half-finished upgrade partway through
provisioning is much harder to recover from than one you hit on its own. Pass
RUN_APT_UPGRADE=1 sudo -E ./install.sh if you want it done inside the script
anyway.
Only these two are needed up front — git to clone the repo, and xterm for
resize (see the serial console fix below). install.sh installs both again
along with tmux, tree and htop.
Verify the board is healthy first
Undervoltage silently corrupts SD card writes. The damage appears later as
Illegal instruction, bad marshal data, not an ELF file, or apt errors
that no amount of repair fixes. Confirm the base is sound before installing:
vcgencmd get_throttled # want throttled=0x0
sudo apt update && sudo apt full-upgrade -y
sudo dpkg --audit # want empty output
vcgencmd get_throttled # want 0x0 again, after load
The second get_throttled matters more than the first: the flags are sticky
since boot, so checking after a large upgrade is what proves the supply
holds under load.
Use a dedicated supply — 5V/2.5A for a Pi 3, 5V/3A for a Pi 4 or later —
not a laptop port, a hub, or a thin cable. install.sh refuses to run if
throttling is reported or dpkg is in a broken state, because anything built
on a corrupt filesystem will fail later and more confusingly.
Note that get_throttled reports only what has happened since the current
boot. A clean reading does not undo damage written earlier: if corruption has
already occurred, fix the power and reflash.
Locale warnings over SSH (macOS clients)
macOS terminals forward LC_CTYPE="UTF-8" — a bare encoding, not a valid
locale name — and glibc warns on every shell and perl invocation:
-bash: warning: setlocale: LC_CTYPE: cannot change locale (UTF-8)
Nothing on the board is misconfigured; the client is sending a broken value
and sshd accepts it via AcceptEnv LC_*.
install.sh handles the board side by setting LC_ALL in /etc/environment,
which overrides anything a client sends. Override the locale with
SYSTEM_LOCALE=es_MX.UTF-8 sudo -E ./install.sh if you want a different one.
To also fix it at the source, on the Mac:
echo 'export LC_CTYPE=C.UTF-8' >> ~/.zshrc
Or stop forwarding it entirely, in ~/.ssh/config:
Host *
SendEnv -LC_*
Open a new terminal and check echo "[$LC_CTYPE]" prints [C.UTF-8], not
[UTF-8].
Fix the serial console
A serial getty sets TERM=linux and never reports the window size. Two
consequences: colors are disabled, and full-screen tools like htop draw at
80x24 and smear. Both are fixed the same way.
install.sh does this for you — it is listed here for reference, and for
setting up a user other than admin.
TERM must be exported before the case "$TERM" in xterm*|rxvt*) block
in .bashrc runs, so it is prepended rather than appended:
sed -i '1i export TERM=xterm-256color' ~/.bashrc
sed -i 's/^#force_color_prompt=yes/force_color_prompt=yes/' ~/.bashrc
echo 'eval "$(resize)"' >> ~/.bashrc
source ~/.bashrc
Verify:
echo $TERM # xterm-256color
tput colors # 256
resize comes from the xterm package and queries the terminal for its real
dimensions. Re-run it after resizing the window — serial cannot detect that
on its own.
If the terminal shows escape-sequence garbage instead of color, the client
does not support 256 colors; fall back to plain xterm:
sed -i '1s/.*/export TERM=xterm/' ~/.bashrc
Verify the hardware
ls /sys/class/net/ # must list wlan0
id admin # must be in the video and sudo groups
lsusb # camera should appear
ls /dev/video*
A UVC camera exposes two nodes — video0 is the capture device, video1 is
metadata. Only the first is used.
Run it
cd /home/admin
git clone <your-repo-url> catheterization-cam-ap
cd catheterization-cam-ap
chmod +x install.sh
sudo ./install.sh
The directory must be exactly /home/admin/catheterization-cam-ap — the systemd unit
hardcodes it. To use another path, edit APP_DIR at the top of install.sh.
The apt full-upgrade inside the script takes 20–40 minutes on a slower board
such as the H2+, and is mostly silent. The script is idempotent, so if it is
interrupted you can simply run it again.
If your serial adapter tends to disconnect — for example when the host laptop
sleeps — run it under tmux instead, so the install survives losing the
console:
tmux new -s install # then: sudo ./install.sh
# reattach after a drop with: tmux attach -t install
When it finishes:
sudo reboot
After reboot
- Join the open network
CATH-CAM - Open
http://192.168.4.1— or literally any hostname, DNS is wildcarded - Tap a camera, tap Start stream, tap Fullscreen
How it works
phone ──── WiFi (open, 192.168.4.0/24) ──── the board
│
hostapd ── the radio │
dnsmasq ── DHCP + wildcard DNS │
│
:80 Flask (app.py) ─── UI, camera list, start/stop
│ spawns
:8080 ustreamer ─── MJPEG frames ──► <img> in the browser
Video bytes never pass through Python. The browser fetches the stream straight from ustreamer on port 8080; Flask only enumerates devices and supervises the ustreamer child process. Proxying MJPEG through WSGI would burn CPU and RAM for nothing.
dhcp-option=3 in the dnsmasq config sends no default gateway. This is
deliberate: the phone keeps routing internet traffic over mobile data and
won't auto-drop a network it thinks is broken.
Layout
app.py Flask app + ustreamer supervisor
web/index.html the entire frontend, one file
install.sh one-time provisioning
config/hostapd.conf access point
config/dnsmasq-ap.conf DHCP + DNS
config/10-wlan0-ap.network static IP on wlan0
config/camweb.service systemd unit
API
| Method | Path | Notes |
|---|---|---|
GET |
/api/cameras |
capture nodes with formats and resolutions |
GET |
/api/status |
what's streaming right now |
POST |
/api/start |
{"device":"/dev/video0","resolution":"640x480"} |
POST |
/api/stop |
kills the streamer |
Only one camera streams at a time. That's a USB 2.0 bandwidth limit as much as a design choice.
Operating
systemctl status camweb hostapd dnsmasq
journalctl -u camweb -f
journalctl -u hostapd -f
iw dev wlan0 info # is the radio in AP mode?
iw dev wlan0 station dump # who is connected?
ip addr show wlan0 # should be 192.168.4.1/24
After editing app.py or web/index.html:
sudo systemctl restart camweb
Troubleshooting
The SSID never appears, intermittently across reboots.
brcmfmac registers wlan0 asynchronously. With no Ethernet to slow the boot
down, hostapd can start first, and Debian's unit gives up permanently after
five rapid failures. install.sh adds drop-ins that wait for the interface,
retry forever, and re-run rfkill unblock wifi on every start. Verify both
are present:
ls /etc/systemd/system/hostapd.service.d/
# rfkill.conf deps.conf powersave.conf
sudo journalctl -u hostapd -b -1 --no-pager | tail -40
The SSID never appears.
journalctl -u hostapd -n 50. Usually wlan0 is still held by
NetworkManager or wpa_supplicant — confirm both are masked. Also check
rfkill list for a soft block.
Connects, but the page won't load.
ip addr show wlan0. If there's no address, systemd-networkd didn't apply
the config; check networkctl status wlan0. If dnsmasq is dead, it probably
lost :53 to systemd-resolved's stub listener — the installer disables it,
verify with ss -lnup | grep :53.
Camera list is empty.
lsusb and ls /dev/video*. The USB-A port on this board is current-limited;
a camera that browns out won't enumerate. Try a powered hub.
"ustreamer exited" when starting.
The resolution isn't supported for that pixel format, or something else has
the device open. Confirm with --list-formats-ext, then
sudo fuser /dev/video0.
Stream starts, then freezes after a few seconds.
Classic XR819 saturation. Drop to a lower resolution, or lower
DEFAULT_FPS in the service environment.
Tuning
Override defaults in the unit without editing app.py:
sudo systemctl edit camweb
[Service]
Environment=DEFAULT_WIDTH=320
Environment=DEFAULT_HEIGHT=240
Environment=DEFAULT_FPS=10
Reverting
The installer masks your normal WiFi stack. To get an ordinary client back:
sudo systemctl disable --now hostapd dnsmasq camweb
sudo systemctl unmask NetworkManager wpa_supplicant
sudo systemctl enable --now NetworkManager
sudo rm /etc/systemd/network/10-wlan0-ap.network
sudo reboot