# HomeLab Guides: all posts > Project guides and notes from a self-hosted home. The index of these posts is https://blog.jacq.cc/llms.txt. --- # Reach Home Assistant, Vaultwarden and Gitea from anywhere with your own client certificates > One port forward to Nginx Proxy Manager, a small certificate authority of my own and one nginx rule. From outside, only my devices get an answer; at home, nothing changes. No VPN, no tunnel, no third party in the middle. Guide · Published 2026-10-16 · Updated 2026-10-16 Web page: https://blog.jacq.cc/guides/client-certificates-home-assistant-vaultwarden-gitea/ I wanted Home Assistant on my phone away from home, in the car too, without switching on a VPN. The usual answers didn't fit. A Cloudflare tunnel decrypts every request, so Cloudflare would see every login and my phone's location. An always-on WireGuard VPN drains the battery, and my family wouldn't use it. The HA Android app can't add a secret header for the proxy to check ([a long-standing request](https://github.com/orgs/home-assistant/discussions/4185)), and HA has no built-in passkeys. What the app does support is a client certificate: mutual TLS (mTLS), where the phone proves who it is during the TLS handshake, before a single HTTP byte reaches Home Assistant. So I forwarded one port to the Nginx Proxy Manager (NPM) that already ran on my Pi, made a small certificate authority (CA) of my own, and added one nginx rule. A day later Vaultwarden and Gitea went behind the same rule. I couldn't find all three behind one proxy written up anywhere, and Vaultwarden with client certificates barely at all. [Image: A drawing: a phone shows a certificate with a gold seal and gets a green tick through a shield with a padlock in front of a house, while a browser window without a certificate is stopped by a red no-entry sign] :::note[Status, October 2026] Working: the HA app on my phone over mobile data since 7 October, and git over HTTPS from outside since the same day. All three names tested from outside with `curl`, and one certificate revoked for real. Not tried yet: a desktop browser, and the Bitwarden apps and extension, with a certificate. Those steps below come from the browsers' and Bitwarden's docs. ::: [Image: Diagram of who gets what: a phone on mobile data with a certificate from my CA gets 200 from Home Assistant, Vaultwarden and Gitea; any other browser without a certificate gets no response; a scanner using the bare IP or an unknown name gets its TLS handshake refused; a laptop or phone at home on the LAN, IoT network or WireGuard needs no certificate and reaches all three apps and the LAN-only names. The router forwards TCP 443 only; Nginx Proxy Manager checks the name, the certificate and one global rule] *Who gets what. The router forwards one port, and Nginx Proxy Manager decides everything else.* ## What you need | Part | Mine | | --- | --- | | A router that forwards one port | EdgeRouter X SFP: TCP 443 to the Pi, nothing else | | Nginx Proxy Manager | The HA app, on a Raspberry Pi 5 running Home Assistant OS | | A certificate for your public names | Let's Encrypt wildcard `*.example.com`, ECDSA, by DNS challenge, renewed by NPM | | DNS that doesn't proxy | Cloudflare, DNS-only CNAMEs to a dynamic DNS name | | OpenSSL 3 on a laptop | For the CA. Its key never leaves the laptop | | A shell on the Pi | The Advanced SSH & Web Terminal app, to place files and reload nginx | [Image: A black EdgeRouter X SFP from above, five Ethernet cables plugged into its ports and the green link lights on] *My router. It forwards TCP 443 to the Pi; the only other open port is my WireGuard VPN.* [Image: A Raspberry Pi 5 board seen from above] *The Pi 5 runs Home Assistant OS, and NPM runs on it as an app. Photo: SparkFun Electronics, [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/), via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:23551-Raspberry-Pi-5-8G.jpg).* ## Step 1: Forward one port, and keep DNS out of the way - **Router:** forward WAN TCP 443 to the Pi's port 443. No port 80: the server certificate comes by DNS challenge. - **DNS:** a CNAME for each public name (`hass`, `vault`, `git`) to your dynamic DNS name, with the proxy **off** ("DNS only" in Cloudflare). A proxy ends TLS at its own servers, so the client certificate never reaches NPM. - **NPM:** a proxy host for each name, with Force SSL, HTTP/2 and websockets on, and HSTS on with subdomains off. I leave Block Common Exploits off: it's a list of URL patterns that protects nothing here, once the rule below decides who gets in. My NPM's default server refuses the TLS handshake (`ssl_reject_handshake on`) for any name it doesn't host, so a scan of the IP address shows nothing. ## Step 2: Make your own certificate authority [Image: Diagram: on my laptop, the encrypted CA key signs the CA certificate and one .p12 file per device; the proxy gets copies of the CA certificate (whom to trust) and the revocation list (whom not to trust any more), never the CA key; a revoked phone's certificate gets 400 everywhere] *What lives where. A stolen Pi can't make new certificates, because the CA key isn't on it.* All of this runs on the laptop, in a folder only you can read (`mkdir -m 700 ~/certs && cd ~/certs`). First the CA: an ECDSA P-256 key, encrypted with 600,000 password rounds, and a certificate valid for 10 years that may only sign client certificates: ```bash openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \ | openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256 -iter 600000 -out ca.key openssl req -new -x509 -key ca.key -sha256 -days 3650 \ -subj "/O=My Home/CN=My Home client CA" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.crt ``` Then one certificate per device, good for TLS client logins only, valid for 3 years: ```bash cat > client.ext <<'EOF' basicConstraints = critical, CA:FALSE keyUsage = critical, digitalSignature extendedKeyUsage = clientAuth EOF n=phone openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out $n.key openssl req -new -key $n.key -subj "/O=My Home/CN=$n" -out $n.csr openssl x509 -req -in $n.csr -CA ca.crt -CAkey ca.key -days 1095 -sha256 \ -extfile client.ext -out $n.crt openssl pkcs12 -export -iter 600000 -inkey $n.key -in $n.crt -certfile ca.crt \ -name "My Home $n" -out $n.p12 ``` - **Why `-iter 600000`:** OpenSSL's default for a `.p12` is 2,048 password rounds, which makes guessing the password of a stolen file cheap. 600,000 makes each guess about 300 times slower. git reads my 600,000-round file fine; my phone got its file before I made the change. - **Keep every `.crt`.** Revoking a certificate later needs its file (step 6). - **The `.p12` holds the device's private key.** Once a device has it, delete the loose `.key` and `.p12`, or keep them only in a password manager. A copy of the CA key lives there too. To give an older `.p12` the stronger setting, export it again. OpenSSL 3.6 needs the certificate and the key in two separate pipes; with one, it fails with "Could not find certificates": ```bash openssl pkcs12 -export -iter 600000 -name "My Home laptop" -out new.p12 \ -in <(openssl pkcs12 -in old.p12 -nokeys) \ -inkey <(openssl pkcs12 -in old.p12 -nocerts -nodes) ``` ## Step 3: Give NPM the CA certificate and a revocation list nginx checks revocations against a certificate revocation list (CRL) signed by the CA. Make an empty one now, so the hosts can load it from the start. Its expiry matters: **when a CRL expires, nginx rejects every certificate**. So make it valid until just before the CA itself expires: ```bash cat > ca-crl.cnf <<'EOF' [ ca ] default_ca = home_ca [ home_ca ] database = index.txt crlnumber = crlnumber certificate = ca.crt private_key = ca.key default_md = sha256 EOF touch index.txt; echo 01 > crlnumber ca_end=$(date -ud "$(openssl x509 -in ca.crt -noout -enddate | cut -d= -f2)" +%s) days=$(( (ca_end - $(date -u +%s)) / 86400 - 3 )) openssl ca -config ca-crl.cnf -gencrl -crldays $days -out ca.crl ``` Copy `ca.crt` and `ca.crl` to the Pi, into `/addon_configs/a0d7b954_nginxproxymanager/mtls/`, which NPM sees as `/config/mtls/`. Then paste this into the **Advanced** tab of each of the three proxy hosts: ```nginx ssl_client_certificate /config/mtls/ca.crt; ssl_verify_client optional; ssl_crl /config/mtls/ca.crl; ``` `optional` means nginx asks for a certificate and checks any it gets, but lets a request without one through to the next step. The rule in step 4 then decides who needs one. ## Step 4: One rule for every host NPM loads two custom files if they exist: `http_top.conf` once, at the top of nginx's `http` block, and `server_proxy.conf` in every proxy host, new ones too. That makes them the right place for one rule that covers everything, instead of an Access List on each host that's easy to forget on the next one. On the Pi they live in `/addon_configs/a0d7b954_nginxproxymanager/nginx/custom/`. First `http_top.conf`: ```nginx # Which networks count as home. Use your own subnets. geo $outside { default 1; 127.0.0.0/8 0; 192.168.50.0/24 0; # LAN (hairpin NAT visitors show up as the router) 192.168.60.0/24 0; # IoT 10.20.30.0/24 0; # WireGuard } # Key: outside:name:certificate result (NONE, SUCCESS or FAILED:...) map "$outside:$host:$ssl_client_verify" $block { default 0; "1:hass.example.com:SUCCESS" 0; "1:vault.example.com:SUCCESS" 0; "1:git.example.com:SUCCESS" 0; "~^1:" 1; } ``` Then `server_proxy.conf`: ```nginx if ($block) { return 444; } ``` Reload NPM from the SSH app with `sudo docker exec app_a0d7b954_nginxproxymanager sh -c 'nginx -t && nginx -s reload'`. From home, `$outside` is 0, so the map never blocks: no certificate is needed, and LAN-only names work as before. From outside, a request gets through only on one of the three public names, and only with a certificate nginx verified. Everything else gets 444, nginx's code for "close the connection without an answer". A browser shows "didn't send any data", and a scanner learns nothing. Three details. NPM sees each visitor's real address, since nothing proxies in front of it, so the rule can trust it. Hairpin NAT visitors (a phone at home using the public name) reach NPM from the router's LAN address, so they count as home. And I left HAOS's internal Docker network, `172.30.32.0/23`, out of the home list on purpose: outside IPv6 connections can arrive from its gateway through Docker's userland proxy. The side effect is that apps on that network get 444 too; Home Assistant and the SSH app use the host network, so they're fine. [Image: Two browser windows: nginx's white page "400 Bad Request, No required SSL certificate was sent", and below it a dark browser error page "This page isn't working, vault.example.com didn't send any data, ERR_EMPTY_RESPONSE"] *Top: `ssl_verify_client on`. Bottom: `optional` plus the rule. Both from a local test with example names.* **Why `optional` and not `on`:** my Home Assistant host started with `on`. nginx then answers a missing certificate with its own 400 page, before any rule runs. That page tells a scanner there's a server behind the name, and at home it wanted a certificate too. Since 14 October all three hosts use `optional`, and outsiders without a certificate get no answer at all. A bad certificate (revoked, or from another CA) still gets 400, even at home: `optional` only lets through requests with *no* certificate. What each case gets, tested from a real outside network (my laptop at work, whose split-tunnel WireGuard sends the public IP over the internet) and again in a local `nginx:alpine` container with throwaway certificates: | Request | Answer | | --- | --- | | From outside, no certificate | Nothing (444) | | From outside, my certificate | 200, and websockets work (101) | | From outside, revoked or another CA's certificate | 400 | | At home (LAN, IoT, WireGuard, hairpin NAT), no certificate | 200 | | At home, revoked certificate | 400 | | From outside, a LAN-only name, even with my certificate | Nothing (444) | | From outside, an unknown name or the bare IP | TLS handshake refused | | One name in SNI, another in the `Host` header | 421, or nothing | An outside port scan found every TCP port filtered except 443, and nmap graded the TLS setup A: TLS 1.3, plus TLS 1.2 with ECDHE and AES-GCM or ChaCha20 only. ## Step 5: Put a certificate on each device **The Home Assistant Android app.** Copy the phone's `.p12` to it over USB, then in Android's Settings search for **Install a certificate** and pick **VPN & app user certificate**, and type the file's password. In the app, set the External URL to `https://hass.example.com`, and the Internal URL to your home name for your home Wi-Fi. The first time it connects, Android asks which certificate to use ([the app's docs](https://companion.home-assistant.io/docs/getting_started/#tls-client-authentication)). Mine has worked on mobile data since 7 October, with Wi-Fi off. Delete the copy in the phone's Download folder afterwards. **Browsers on Linux.** Chrome and Brave use the NSS store in your home folder. `pk12util` comes with the `libnss3-tools` package: ```bash pk12util -d sql:$HOME/.pki/nssdb -i laptop.p12 ``` Restart the browser. When a site asks for a certificate, Chrome and Brave let you pick one from that store. **Bitwarden, for Vaultwarden.** The iOS and Android apps can now upload a client certificate for a self-hosted server ([release notes](https://bitwarden.com/help/releasenotes/)): log out, choose Self-hosted, enter `https://vault.example.com` and import the `.p12` there. The desktop app has no such option, so use the browser extension. If the web vault opens but the extension can't connect, the extension can't show the certificate prompt. Then the browser policy [`AutoSelectCertificateForUrls`](https://chromeenterprise.google/policies/#AutoSelectCertificateForUrls) picks the certificate by its issuer: a JSON file in `/etc/brave/policies/managed/` (Chrome: `/etc/opt/chrome/policies/managed/`): ```json { "AutoSelectCertificateForUrls": [ "{\"pattern\":\"https://vault.example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"My Home client CA\"}}}" ] } ``` Also set Vaultwarden's `DOMAIN` to the public URL: it needs it for passkey two-factor logins, attachment links and Sends. And turn signups off. **git, for Gitea.** git can send a password-protected `.p12`, and only to the one URL that needs it: ```ini [http "https://git.example.com/"] sslCert = ~/certs/laptop.p12 sslCertType = P12 sslCertPasswordProtected = true [credential] helper = p12 ``` git then asks its credential helpers for the `.p12` password, with `protocol=cert` and `path=` the file. A URL-scoped `[credential "cert://…"]` section never matches that request, because it has no host, so it takes a small helper. Mine reads the password from KDE Wallet; save it as `git-credential-p12` on your `PATH`: ```bash #!/bin/bash # Answers git's request for the .p12 password from KDE Wallet; silent for everything else. P12="$HOME/certs/laptop.p12" protocol='' path='' while IFS='=' read -r key value; do [ -z "$key" ] && break case "$key" in protocol) protocol=$value ;; path) path=$value ;; esac done [ "$1" = get ] && [ "$protocol" = cert ] && [ "$path" = "$P12" ] || exit 0 pass=$(kwallet-query -r p12-laptop -f home kdewallet 2>/dev/null) && [ -n "$pass" ] \ && printf 'password=%s\n' "$pass" exit 0 ``` The Gitea login itself is an access token, not the password, since the account has two-factor login on. My git uses libcurl with GnuTLS, which reads `.p12` files. Git over SSH stays at home and on WireGuard: I didn't open an SSH port. And Gitea passkeys only work on the one name in Gitea's `DOMAIN` setting (WebAuthn ties them to it), so make the public name Gitea's main name. ## Step 6: Revoke a lost phone On the laptop: revoke the certificate, make a new CRL with the same long validity, copy it to the Pi and reload nginx. nginx reads the CRL only when it starts or reloads. ```bash cd ~/certs openssl ca -config ca-crl.cnf -revoke phone.crt -crl_reason keyCompromise ca_end=$(date -ud "$(openssl x509 -in ca.crt -noout -enddate | cut -d= -f2)" +%s) days=$(( (ca_end - $(date -u +%s)) / 86400 - 3 )) openssl ca -config ca-crl.cnf -gencrl -crldays $days -out ca.crl ``` Then on the Pi, put the new `ca.crl` in place of the old one and run the same reload as in step 4. The revoked phone now gets 400 on all three names, at home too. Check that a good certificate still gets 200, because a broken or expired CRL shows up as 400 for everyone: ```bash curl --cert-type P12 --cert laptop.p12: -o /dev/null -w '%{http_code}\n' \ https://hass.example.com/manifest.json ``` The certificates were signed with `openssl x509 -req`, not `openssl ca`, so `index.txt` lists only the revoked ones. That's why `-revoke` needs the certificate's `.crt` file. Give the phone a new certificate with a new name, like `phone-2`. I revoked one unused certificate this way on 11 October, and checked that all three names refused it. ## Around it - **Home Assistant login bans:** `login_attempts_threshold` defaults to `-1`, which never bans. I set 5. Visitors through hairpin NAT all share the router's address, though, so one ban locks out the whole house until you delete the entry in `ip_bans.yaml` and restart HA. - **HA 2026.9 ignores `http:` in YAML.** HTTP settings now live in `.storage/http`. Change them over the websocket API: `http/config/configure` with the full config, then `http/config/promote` within 5 minutes, or HA restarts again on the old settings. - **Trusted proxy:** HA needs `use_x_forwarded_for` with NPM's container network (`172.30.33.0/24` on HAOS) as a trusted proxy, or it sees every visitor as NPM. - **Two-factor login** on every HA account that can log in from outside. A certificate proves the device, not the person holding it. ## Sources - nginx: [ssl module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html) (`ssl_verify_client`, `ssl_client_certificate`, `ssl_crl`, `ssl_reject_handshake`), [geo](https://nginx.org/en/docs/http/ngx_http_geo_module.html) and [map](https://nginx.org/en/docs/http/ngx_http_map_module.html) - Home Assistant companion app: [TLS client authentication](https://companion.home-assistant.io/docs/getting_started/#tls-client-authentication); HA [HTTP integration](https://www.home-assistant.io/integrations/http/) (trusted proxies, login bans); the request for [custom headers in the app](https://github.com/orgs/home-assistant/discussions/4185) - Bitwarden [release notes](https://bitwarden.com/help/releasenotes/) (client certificates in the iOS and Android apps) - Chrome Enterprise policy [`AutoSelectCertificateForUrls`](https://chromeenterprise.google/policies/#AutoSelectCertificateForUrls) - git: [`http.sslCert`, `http.sslCertType`, `http.sslCertPasswordProtected`](https://git-scm.com/docs/git-config) ## Change history - 2026-10-16: First published. Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Credit "HomeLab Guides (blog.jacq.cc)" with a link to https://blog.jacq.cc/guides/client-certificates-home-assistant-vaultwarden-gitea/. Code downloads: MIT. Details: https://blog.jacq.cc/licence/ --- # Add a USB SSD to a Raspberry Pi 5 running Home Assistant OS > A cheap USB-SATA adapter hung in UAS mode and took the disk offline at 3 am. With UAS off, it hid the drive's write cache, so Linux stopped sending flushes. Both fixes done the Home Assistant OS way, then the SSD as Home Assistant's data disk. Guide · Published 2026-10-16 · Updated 2026-10-16 Web page: https://blog.jacq.cc/guides/usb-ssd-raspberry-pi-5-home-assistant-os/ I was planning to run a lot of services on my Home Assistant Pi. I'd meant to give them a machine of their own, but the Pi 5 handles most of it well. The one part I expected to fail was the SD card, and I didn't want all those services wearing it out. I also wanted room for media, so I added a 120 GB SATA SSD in a cheap USB adapter. On Raspberry Pi OS that's a ten-minute job. On Home Assistant OS (HAOS) it isn't: there's no shell on the host, the SSH app can't see USB disks, and everything that matters (the kernel command line, udev rules, mount points) lives on the host. Then the adapter caused two problems. Three days in, it hung at 03:15 and the kernel took the disk offline. With UAS turned off, it hid the drive's write cache, so Linux stopped sending cache flushes. The UAS fix is well known on Raspberry Pi OS. I couldn't find the write-cache side or the HAOS side written up anywhere, so here's all of it, and how the SSD ended up as Home Assistant's own data disk. [Image: A drawing: a green Raspberry Pi board plugged by a blue USB cable into a SATA SSD with a green tick, under the moon and an alarm clock showing 03:15] :::note[Status, October 2026] Working: UAS off and flushes back on since 13 October, through a reboot. The SSD has been Home Assistant's data disk since 15 October, and the media moved to a 2 TB USB disk next to it. Still to check: whether the next HAOS update keeps the kernel parameter. ::: [Image: A Raspberry Pi 5 board seen from above: two blue USB 3 ports and two black USB 2 ports on the right, the USB-C power socket at the bottom left] *The Pi 5. The blue ports are USB 3; power comes in on the USB-C socket at the bottom left. Photo: SparkFun Electronics, [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/), via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:23551-Raspberry-Pi-5-8G.jpg).* ## What you need | Part | Mine | | --- | --- | | Raspberry Pi 5 running HAOS | 8 GB, HAOS 18.3 (kernel 6.18), booting from a 64 GB SD card | | A 5 V 5 A power supply | The official Raspberry Pi 27 W USB-C supply | | A 2.5-inch SATA SSD | WD Green 120 GB | | A USB 3 to SATA adapter | UGREEN, about AU$14. Inside: a Realtek RTL9201, USB ID `0bda:9201` | | A shell on the host | The community **Advanced SSH & Web Terminal** app, protection mode off | My SSD is an old one I took out of a laptop, and it's turned out really useful here. I bought the rest on AliExpress, the UGREEN adapter included. [Image: A WD Green 120 GB SATA SSD, black with a green and white label, sitting in a metal drive bracket] *A WD Green 120 GB like mine (this one is in a desktop drive bracket, which you don't need). Photo: Monoklon, [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Western_Digital_Green_120_Gb.jpg).* ## Step 1: Reach the host from the SSH app [Image: Diagram: the SSH app starts a privileged helper container through the Docker API; the helper edits cmdline.txt, the udev rules and the media mount point on the host, and mounts the USB disk there; apps that map media see that host mount; apps with localdisks mount the USB disk themselves] *Who sees what. Blue: the helper container reaches the host's files. Green: where the disk gets mounted.* HAOS runs everything in containers. Even with protection mode off, the Advanced SSH app has its own view of the filesystem: no `/dev/sd*`, no boot partition, no `/proc/device-tree`. What protection mode off does give it is the Docker API. So it can start a short-lived privileged container that joins the host's mount namespace, and whatever runs there sees the host's files. Run this as root in the SSH app (`sudo -i`): ```bash IMG=$(docker inspect app_a0d7b954_ssh --format '{{.Config.Image}}') host() { docker run --rm --privileged --pid=host --network none \ --entrypoint nsenter "$IMG" -t 1 -m -- "$@" } host ls /mnt/boot ``` - It reuses the SSH app's own image, so nothing is downloaded. On older Supervisor versions the container is called `addon_a0d7b954_ssh`; `docker ps` shows the name. - `--pid=host` makes PID 1 the host's init, and `nsenter -t 1 -m` joins its mount namespace, so the command runs with the host's own `mount`, `blkid`, `chattr`, `udevadm` and `vcgencmd`. The rest of this guide uses `host` and `IMG`. :::caution This is root on the host, with nothing in the way. Anyone who gets a shell in the SSH app owns the whole Pi, so treat that login like a root password. ::: ## Step 2: Check the power The Pi 5 asks its supply over USB Power Delivery how much current it can give. With a 5 A supply it lets the USB ports draw 1.6 A in total; with anything else, 600 mA, and it won't boot from USB. A 2.5-inch SSD and its adapter take about 0.3–0.5 A, and a 2.5-inch hard drive about 0.5 A, or 1 A while it spins up (specs, not measured). The firmware records what it negotiated in `/proc/device-tree/chosen/power/`, which only the host can see: ```bash for f in max_current usb_max_current_enable usb_over_current_detected; do echo "$f: $(host hexdump -v -e '1/1 "%02x"' /proc/device-tree/chosen/power/$f)" done host vcgencmd get_throttled host vcgencmd pmic_read_adc EXT5V_V ``` Mine read `max_current` `0x1388` (5000 mA: the 5 A profile), `usb_max_current_enable` 1 (the ports get 1.6 A), no USB over-current, `get_throttled` `0x0` (no undervoltage or throttling since boot) and 5.12 V on the input, with the SSD busy. Home Assistant's own power check only reports undervoltage. If you have a Zigbee dongle, keep it on a short USB extension cable away from the SSD: USB 3 gives off 2.4 GHz noise. ## Step 3: Find out which chip your adapter has [Image: A small green circuit board: a square chip on the left, a crystal, a mini-USB socket and a black SATA connector along the front] *Inside a USB-SATA adapter (an old USB 2 one, not mine). The square chip on the left is the USB-to-SATA bridge, here an Initio INIC-1618L. That chip decides how the adapter behaves, and listings rarely name it. Photo: Raimond Spekking, [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:SATA_2_USB_adapter_25U2V06-4501.jpg).* My adapter's listing didn't name its chip. I'd assumed an ASMedia (ASM1153E or ASM235CM, often recommended for the Pi). Both of my UGREEN adapters turned out to be Realtek RTL9201. It wasn't the chip I expected, and it caused all the trouble below. In the SSH app: ```bash for d in /sys/bus/usb/devices/*; do [ -f "$d/idVendor" ] && echo "${d##*/} $(cat "$d/idVendor"):$(cat "$d/idProduct") $(cat "$d/speed") Mb/s $(cat "$d/product" 2>/dev/null)" done ls /sys/bus/usb/drivers/uas/ /sys/bus/usb/drivers/usb-storage/ ``` Mine showed `0bda:9201`, 5000 Mb/s, "UGREEN Storage Device", with its interface under `uas`. The first list gives the vendor and product ID you need below; the second shows which driver has the disk. UAS (USB Attached SCSI) queues many commands at once; the older usb-storage driver handles one at a time. Linux uses UAS whenever an adapter offers it, unless the kernel has a quirk entry for the chip, and it has none for the RTL9201. For three days that was fine. ## The night the disk went offline [Image: Timeline: 03:15:49 reads stop answering, two USB resets, 03:15:59 device offlined, ext4 read-only, I/O errors until 10:30, then about 15 minutes of recovery] *The incident, from the kernel log.* At 03:15:49 the adapter stopped answering a batch of reads while my media server was reading. The kernel reset the USB device twice (`uas_eh_device_reset_handler`), then gave up: `Device offlined - not ready after error recovery`. ext4 aborted its journal and went read-only, and every read failed until I noticed at 10:30. It wasn't power: `get_throttled` was still `0x0` (it keeps the undervoltage flags since boot) and the input read 5.12 V. It wasn't the drive: SMART passed afterwards, with no errors logged, no CRC errors and no reallocated sectors. A power dip shows up as a USB disconnect. This was commands timing out on a link that stayed up. An offlined disk never comes back by itself. Recovery took about 15 minutes: 1. Stop every app that uses the disk. 2. Drop the dead host mount: `host umount -l /mnt/data/supervisor/media/usb`. The lazy unmount also clears it from every app that maps `media`. 3. Unplug the adapter, wait 10 seconds, plug it back into the same port. Mine came back as `/dev/sda` instead of `/dev/sdc`, which is why everything here mounts by label. 4. Check the filesystem from a privileged container: `docker run --rm --privileged --entrypoint sh "$IMG" -c 'apk add -q e2fsprogs && e2fsck -fp /dev/sda1'`. It replayed the journal and fixed the free-block count (exit code 1 means "errors fixed"). Nothing went to `lost+found`. 5. Mount it again ([step 6](#step-6-mount-it-where-apps-can-see-it)) and start the apps. ## Step 4: Turn UAS off for the adapter Turning UAS off is a trade-off, but one I had to make: usb-storage can't queue commands, so some tests get slower ([measured below](#speed-before-and-after)). The fix is a usb-storage quirk on the kernel command line: `usb-storage.quirks=VID:PID:u`, where `u` means "don't bind to the uas driver". On HAOS the command line is `cmdline.txt` on the boot partition, which the host mounts at `/mnt/boot`. The firmware reads it because `config.txt` has `cmdline=/cmdline.txt`. It's one line, and the quirk goes at the start: ```bash host cat /mnt/boot/cmdline.txt host cp /mnt/boot/cmdline.txt /mnt/boot/cmdline.txt.bak host sed -i '1s/^/usb-storage.quirks=0bda:9201:u /' /mnt/boot/cmdline.txt host cat /mnt/boot/cmdline.txt ha host reboot ``` Use your adapter's ID from step 3. Several adapters share one parameter, comma-separated: `usb-storage.quirks=aaaa:bbbb:u,cccc:dddd:u`. If you'd rather not edit it live, it's on the small FAT partition of the SD card, which any PC can open. After the reboot: ```bash grep -o 'usb-storage.quirks=\S*' /proc/cmdline host dmesg | grep -i uas ls /sys/bus/usb/drivers/usb-storage/ ``` The kernel log should say `UAS is ignored for this device, using usb-storage instead`, and the adapter's interface (`2-1:1.0` on mine) is now under `usb-storage`. :::caution[Check it after every HAOS update] HAOS's updater rewrites parts of this file (`root=` and `rauc.slot=`) when it switches boot slots. A reboot keeps the quirk, but I haven't been through an HAOS update since adding it, so I don't know yet whether an update does. The `grep` above tells you in a second. ::: ## Step 5: Turn the cache flushes back on [Image: Diagram of three fsync paths: with UAS the kernel sends a flush and the drive writes its cache to flash, 120 per second; with UAS off the kernel thinks there is no cache and sends no flush, 1,284 to 1,499 per second, unsafe; with the udev rule the flush is back, 160 per second] *What an fsync did in each setup, with the measured rates.* With UAS off, the disk was stable, but there's a second, quieter problem. SATA drives have a write cache: they say "done" once the data is in their RAM, and write it to flash a moment later. When a program calls `fsync`, or ext4 commits its journal, Linux sends the drive a flush (SYNCHRONIZE CACHE), so the data is really on flash before `fsync` returns. Linux only sends flushes to disks it believes have a write cache. In usb-storage mode the kernel doesn't ask the bridge for the drive's caching page directly: the driver skips that request, because many USB bridges choke on it. It asks for all pages at once and takes the bridge's answer. The RTL9201 answers that the write cache is off, while the drive keeps it on. So Linux never flushes, an `fsync` can return while the data still sits in the drive's cache, and a power cut loses it. That matters most for a database. Check both sides: ```bash docker run --rm --privileged --entrypoint sh "$IMG" -c \ 'apk add -q smartmontools && smartctl -g wcache -d sat /dev/sda' host sh -c 'cat /sys/block/sda/device/scsi_disk/*/cache_type' ``` The drive said its write cache was enabled; the kernel said `write through`. The benchmark gives it away too: 4K writes with an `fsync` after each ran at 120 a second with UAS, and at up to 1,499 a second without. Nothing gets ten times faster at that by changing USB protocol; the flushes had stopped. The fix is a udev rule that tells the kernel the cache is on: ```text ACTION=="add|change", SUBSYSTEM=="scsi_disk", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="9201", ATTR{cache_type}="temporary write back" ``` `temporary` changes only the kernel's view and sends nothing to the drive ([kernel docs](https://docs.kernel.org/scsi/sd-parameters.html)). The kernel just starts flushing again. On HAOS, `/etc/udev/rules.d/` is on the overlay partition and survives updates. The SSH app's `/media` is the host's `/mnt/data/supervisor/media`, so the file can travel that way: ```bash curl -fsS -o /media/99-usb-sata-write-cache.rules \ https://blog.jacq.cc/usb-ssd-pi5/99-usb-sata-write-cache.rules host cp /mnt/data/supervisor/media/99-usb-sata-write-cache.rules /etc/udev/rules.d/ rm /media/99-usb-sata-write-cache.rules host udevadm control --reload host udevadm trigger --subsystem-match=scsi_disk host sh -c 'cat /sys/block/sda/device/scsi_disk/*/cache_type' ``` The last line should now say `write back`. Download [the rule file](https://blog.jacq.cc/usb-ssd-pi5/99-usb-sata-write-cache.rules) ([MIT licensed](https://blog.jacq.cc/usb-ssd-pi5/LICENSE)) to read it first. It has a line for my adapter and one for the 2 TB disk [below](#move-the-media-to-a-second-disk); change the IDs to yours, one line per drive. With flushes back, the `fsync` test ran at 160 a second, a little faster than with UAS. The kernel also has a quirk flag meant for this, `y` (as in `usb-storage.quirks=0bda:9201:uy`), but I haven't tried it. ## Speed before and after [Image: Bar charts of six tests in three setups. Large file read: 338, 333 MB/s. Large file write: 92, 73 MB/s. 4K random read one at a time: 4,434, 4,517 per second. 4K random read 32 at a time: 16,895, 4,788 per second. 4K random write 32 at a time: 5,571, 3,224 per second. 4K write plus fsync: 120, 1,284 to 1,499 (unsafe), 160 per second] *The WD Green on the Pi, each test on its own scale. Reads didn't change once flushes came back.* Measured with `dd` and `fio`: reads on the raw disk, writes to a 2 GB test file on the ext4. Large files and one-at-a-time access don't change, and the Pi's 1 Gb/s network (about 110 MB/s) is slower than any of these anyway. Only many requests at once lose, about 3.5× on random reads, because usb-storage can't queue. Database-style writes are as fast as with UAS once the flushes are back. No loss that matters. ## Step 6: Mount it where apps can see it I erased the disk (`blkdiscard`), made one GPT partition and formatted it with a label and no reserved blocks (`mkfs.ext4 -L media -m 0`), from a privileged container like the `e2fsck` one above. Always mount by label, because the `sdX` letters move. And name the label after the job, not the disk: when my media later moved to a bigger disk, that disk took over the label and no app needed changing. There are two ways for apps to reach the disk, and I use both. **Apps that mount it themselves.** Some apps, such as the alexbelgium ones, have a `localdisks` option. Set it to the label (`localdisks: media`) and the app mounts the disk at `/mnt/media` in its own container. [My media server](https://blog.jacq.cc/guides/jellyfin-home-assistant-os-old-tvs/) works this way. Two catches. If the disk is missing when the app starts, the app writes into its own container instead, so check its log after any USB change. And the lookup matches any label that merely contains the name, so never give another disk a label like `media-old`. **A host mount for everything else.** File Explorer and Home Assistant's media browser only see HA's shared folders. A disk mounted on the host at `/mnt/data/supervisor/media/usb` appears in every app that maps `media`, as `/media/usb`. The core of my mount script, with `IMG` and `host()` from step 1 above it: ```bash LABEL=media HOST_DIR=/mnt/data/supervisor/media/usb # the mount point, on the data disk DIR=/media/usb # the same folder, seen from the apps mountpoint -q "$DIR" && exit 0 host blkid -L "$LABEL" >/dev/null || { echo "disk $LABEL missing" >&2; exit 1; } host sh -c "mkdir -p '$HOST_DIR' && { mountpoint -q '$HOST_DIR' || chattr +i '$HOST_DIR'; } && { mountpoint -q '$HOST_DIR' || mount -o noatime LABEL='$LABEL' '$HOST_DIR'; }" ``` Save it as `/homeassistant/media-mount.sh` and run it every time the SSH app starts, from the app's `init_commands` option: ```yaml init_commands: - bash /homeassistant/media-mount.sh || true ``` The `|| true` matters: the app runs each init command with `eval`, and a failing one stops the SSH app. The `chattr +i` is the guard. It makes the empty mount point immutable before anything is mounted on it. If the disk is missing, `/media/usb` is an empty folder nothing can write to (`Operation not permitted`), so a download or upload can't quietly fill Home Assistant's own disk. Once the disk is mounted, you see the disk's root instead and the flag doesn't get in the way. Set it only while unmounted, or `chattr` hits the disk's root. Mounting one ext4 twice on the same kernel, once by an app and once on the host, is safe: both mounts share one superblock, like a bind mount. :::danger[Never tick Media in a backup] With the disk under `/media`, a backup that includes the Media folder takes the whole disk, to every backup location, cloud ones too. My automatic backups leave Media out. ::: ## Step 7: Make the SSD Home Assistant's data disk After the night it went offline, I wanted Home Assistant itself on the SSD: it's the healthy drive, it takes the constant small writes off the SD card, and HA can't carry on without its disk. HAOS has this built in, **Settings → System → Storage → ⋮ → Move data disk**, but it moves everything (config, database, apps, backups, `/share`, `/media`) and overwrites the target. So the media had to move off the SSD first. ### Move the media to a second disk It went to a WD Elements Portable 2 TB USB hard drive (`1058:1048`, WD's own USB chip, plain usb-storage). - **Power.** I had a USB hub with its own charger ready, but tried the drive straight on the Pi's other blue port first. Its spin-up next to the running SSD was clean, and a minute into a full-disk write the Pi still read `0x0` at 5.08 V, so it stayed there. If the Pi ever cuts USB power, both drives drop, so Home Assistant pushes an alert on undervoltage or when the kernel's `over_current_count` for any USB port goes up. So far the monitoring shows the power is stable. Still, two drives sharing 1.6 A doesn't leave much headroom, so I plan to move them to a powered hub. - **Cache.** Its chip hides the drive's write cache too (`Assuming drive cache: write through`), so it got its own line in the udev rule before it held anything. - **Health.** SMART showed one pending sector, so I started a full zero-fill: writing the sector makes the drive fix or replace it. By 300 GB it was fixed, but the drive had reached 58–59 °C in its plastic case (WD's limit is 60 °C), so I stopped there. - **Copy.** `mkfs.ext4 -m 0 -T largefile` (one inode per MiB, which saves about 7 GB of inode tables), then `rsync -aHAX` with the apps stopped. To read the SSD in a container while the host still had it mounted read-write, mount it and then `mount -o remount,bind,ro`: a plain `-o ro` mount fails with "Resource busy". - **Swap the labels** with `e2label`, refresh them with `udevadm trigger --action=change --subsystem-match=block` and run the mount script again. The apps found the 2 TB with no change, and a reboot brought everything back by label. ### Move the data disk Make a full backup, then **Move data disk** and pick the SSD. Supervisor partitions it and reboots. At boot, HAOS copies the data partition with `e2image -ra -p` (only the used blocks: about 6 minutes for my 16.6 GB) and renames the old one on the SD card `hassos-data-old`. Home Assistant was down for about 8 minutes, and came back with every app as it was. Nothing from this guide needed redoing. `cmdline.txt` and the udev rule live on the SD card's boot and overlay partitions, not on the data partition. The `chattr +i` mount point came along with the copy, and the 2 TB mounted inside it didn't: `e2image` copies the filesystem, not what's mounted on it. If the SSD dies, Home Assistant won't start. Until I wipe the SD card's old partition, relabelling it `hassos-data` (`e2label`, on a PC) and restoring the newest backup gets it back. After that, it's a fresh install and a restore from the off-site backup. ## How it turned out I'm happy with it. The 2 TB disk holds the things that aren't critical, like movies and photos. The SSD holds what matters: Home Assistant's data and its backups. That leaves the SD card with just the system, so it gets far fewer writes. Both drives have SMART monitoring, so I should see a failure coming. Their wear levels are fine for now, and I'm keeping a close eye on them. ## Sources - Raspberry Pi documentation: [Power supply](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#power-supply) and USB (1.6 A vs 600 mA, USB boot on a 3 A supply, `/proc/device-tree/chosen/power`, `pmic_read_adc`) - Raspberry Pi forums: [USB 3 SSDs and the UAS quirk](https://forums.raspberrypi.com/viewtopic.php?t=245931) - Linux kernel: [`usb-storage.quirks`](https://docs.kernel.org/admin-guide/kernel-parameters.html), [`cache_type`](https://docs.kernel.org/scsi/sd-parameters.html), and [scsiglue.c](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/usb/storage/scsiglue.c), where usb-storage skips the caching mode page - Home Assistant OS: [configuration](https://developers.home-assistant.io/docs/operating-system/configuration) (`cmdline.txt`, persistent udev rules) and [using an external data disk](https://www.home-assistant.io/common-tasks/os/#using-external-data-disk) - [Advanced SSH & Web Terminal](https://github.com/hassio-addons/app-ssh) (Docker API, `init_commands`) - alexbelgium apps: [mounting local drives](https://github.com/alexbelgium/hassio-addons/wiki/Mounting-Local-Drives-in-Addons) (`localdisks`) ## Change history - 2026-10-16: First published. Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Credit "HomeLab Guides (blog.jacq.cc)" with a link to https://blog.jacq.cc/guides/usb-ssd-raspberry-pi-5-home-assistant-os/. Code downloads: MIT. Details: https://blog.jacq.cc/licence/ --- # Dictation on Linux with Handy, and tidy text with OpenCode Go > Handy and NVIDIA's Parakeet turn speech into text on the laptop's GPU, and ydotool types it into Wayland apps. A small local proxy lets Handy's clean-up step use an OpenCode Go subscription, which refuses Handy's requests on its own. Only text leaves the laptop, never audio. Guide · Published 2026-10-15 · Updated 2026-10-15 Web page: https://blog.jacq.cc/guides/local-voice-dictation-kde-wayland/ I wanted the feature that phones have had for years: hit a key, speak, and the words show up in the focused window. The laptop's speech recognition is handled by the open-source [Handy](https://github.com/cjpais/Handy). The sophisticated premium app, Wispr Flow, transmits all recordings to its servers and lacks a Linux build. [Image: A drawing: a clip-on mic sends sound waves to a laptop, where the words appear as tidy lines of text] Two parts weren't covered anywhere in one place. Handy requires a helper with access to `/dev/uinput` since one program cannot type into another on KDE Plasma under Wayland. Additionally, OpenCode Go, the $10-per-month subscription I already had, is incompatible with Handy's optional clean-up, which uses a language model to eliminate "ums" and correct punctuation. Go rejects all of Handy's requests. The majority of this article focuses on the second section, including the little local proxy that resolves it, how it uses opencode's own sign-in, and how to maintain that sign-in. :::note[Status, October 2026] Working on Kubuntu 26.04, Plasma 6.6 on Wayland, with Handy 0.9.8 and opencode 2.0.26: plain dictation on one key, dictation plus clean-up on another. Clean-up through OpenCode Go since 14 October 2026. ::: [Image: Diagram of the path from voice to text: clip-on mic, USB-C receiver, Handy, Parakeet on the GPU, an optional clean-up through a local proxy and an OpenCode Go model, clipboard, ydotool, /dev/uinput, then KWin and the focused app] *The whole path, top to bottom. Only the dashed step is optional, and it's the only one that sends anything out: text, never audio.* ## What you need | Part | Notes | | --- | --- | | A Linux PC with KDE Plasma on Wayland | Mine: a 2026 laptop with an Intel Core Ultra X7 358H, its built-in Arc B390 GPU and 32 GB of RAM | | [Handy](https://github.com/cjpais/Handy) 0.9.8 | MIT licence. A `.deb` from GitHub; not in apt | | `ydotool` | In Ubuntu's apt (1.0.4 on 26.04) | | A microphone | The built-in one works. A clip-on works better ([below](#the-microphone)) | | Optional: [OpenCode Go](https://opencode.ai/docs/go/) | For the clean-up: $10 a month, plus [opencode](https://opencode.ai/) 2.x for the sign-in, and Python 3 for the proxy | ## Step 1: Install Handy and ydotool ```bash v=0.9.8 curl -LO https://github.com/cjpais/Handy/releases/download/v$v/Handy_${v}_amd64.deb sudo apt install ./Handy_${v}_amd64.deb ydotool ``` Use `apt`, not `dpkg -i`, so the dependencies come along. Handy notices new releases later but doesn't install them: download the next `.deb` the same way. ## Step 2: Let ydotool create a virtual keyboard Wayland stops one app from typing into another, so Handy hands the text to a helper. On KDE, `kwtype` isn't packaged for Ubuntu, `wtype` needs a protocol KWin doesn't offer, and `dotool` isn't in apt. **`ydotool` works**: it makes a virtual keyboard through the kernel's `/dev/uinput`. [Image: Diagram: Handy can't send keys straight to another Wayland app; instead it asks ydotoold, which writes to /dev/uinput, and KWin sees a new keyboard and passes the key press to the focused app] *Why Handy needs a helper, and where the udev rule fits in.* Its daemon, `ydotoold`, runs as your user, so your user needs write access to `/dev/uinput`. I already had a udev rule for that from my [GameSir controller bridge](https://blog.jacq.cc/guides/gamesir-t1d-linux-gamepad/). It's one line: ```text KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess" ``` `uaccess` gives whoever is logged in at the desktop an ACL on `/dev/uinput`: no sudo, no group change. That's narrower than joining the `input` group, which would let any program you run read every keyboard. ```bash curl -o 60-uinput-uaccess.rules https://blog.jacq.cc/gamesir-t1d/60-t1d-bridge-uinput.rules sudo install -m 644 60-uinput-uaccess.rules /etc/udev/rules.d/ sudo udevadm control --reload && sudo udevadm trigger --name-match=uinput getfacl /dev/uinput # look for a user::rw- line systemctl --user enable --now ydotool ``` Keep the file's number below 73: systemd applies `uaccess` in `73-seat-late.rules`. If the rule ever goes, dictation stops typing with no error you'd notice, so check `getfacl /dev/uinput` first when text doesn't appear. ## Step 3: Pick the model and the shortcut 1. Open Handy, allow microphone access, and on the Models page pick **Parakeet Unified EN 0.6B**: NVIDIA's English-only model, which shows the text live while you talk. 2. Advanced: turn on **Launch on Startup** and **Start Hidden**. Without them, the shortcut does nothing after a reboot until you open Handy by hand. 3. General → **Transcribe Shortcut** → `Meta+Space` (Handy calls it Super), which is free in KDE. The default, `Ctrl+Space`, clashes with autocomplete in VS Code. [Image: Handy's General page: Transcribe Shortcut set to Super + Space, Shortcut Behavior set to Auto] *Handy's General page.* Handy's README says that on Wayland the shortcut has to come from the desktop. Handy's own worked for me anyway. If it stops firing in some app, change Handy's shortcut to something else, then add a KDE one: System Settings → Keyboard → Shortcuts → **Add New** → **Command or Script**, command `handy --toggle-transcription`. ## Step 4: Pick the paste method Handy can type the text key by key (**Direct**), or put it on the clipboard and send a paste key. Direct was slow here: `ydotool` types one key at a time. [Image: Bar chart: Direct typing takes 4.7 seconds, a clipboard paste with Shift+Insert 0.19 seconds] *From Handy's log.* [Image: Handy's Advanced page, Output section: Paste Method set to Clipboard (Shift+Insert), Clipboard Handling set to Copy to Clipboard, Auto Submit off] *Advanced → Output.* I use **Clipboard (Shift+Insert)**, the paste key I've used since DOS. It works in editors, browsers, KDE apps and LibreOffice. The catch: in a terminal, Shift+Insert pastes the text you last highlighted, not the clipboard. For terminal-heavy work, pick **Clipboard (Ctrl+Shift+V)**. ## Step 5 (optional): Tidy the text with OpenCode Go Handy's post-processing sends the finished transcript to a language model, with a prompt, and pastes the reply instead of the raw text. It has its own hotkey, so I keep both: `Meta+Space` for plain dictation, `Ctrl+Shift+Space` for dictation plus clean-up, about 2 seconds slower. Parakeet writes what you said: ```text wrap um so can you uh book the the plumber for thursday at like three thirty pm and tell him the hot water system is leaking near the uh tempering valve ``` The clean-up pastes this instead: ```text wrap Can you book the plumber for Thursday at 3:30 pm and tell him the hot water system is leaking near the tempering valve? ``` I point it at [OpenCode Go](https://opencode.ai/docs/go/), the $10-a-month subscription for opencode's coding models. Its docs say most of its models aren't trained on your prompts and keep nothing. Getting Handy to use it took two problems, a proxy and a timer. ### What Handy sends I read Handy 0.9.8's source ([`llm_client.rs`](https://github.com/cjpais/Handy/blob/v0.9.8/src-tauri/src/llm_client.rs)) to see exactly what its **Custom** provider sends: - `POST /chat/completions`, OpenAI style: `{model, messages, stream: false, reasoning_effort: "none"}`, with one user message: your prompt, with `${output}` replaced by the transcript. - If the server answers 400 or 422, Handy tries once more without `reasoning_effort`, and remembers that until it quits. - Headers: `Authorization: Bearer `, its own `User-Agent`, `Referer` and `X-Title`. **There's no setting for other headers.** - No `max_tokens`, no temperature, no timeout. Any `` block is cut from the reply. Go serves each model on one of three endpoints: `/chat/completions`, `/responses` or `/messages`. Handy calls only the first, so only some Go models work with it. Go's docs list `qwen3.8-flash` under `/messages`, but it answers on `/chat/completions` too. ### Problem 1: Go needs a header Handy can't send You can see this one without an account: ```bash curl -s https://opencode.ai/inference/go/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model": "qwen3.8-flash", "messages": [{"role": "user", "content": "hi"}]}' ``` ```json wrap {"type":"error","error":{"type":"MissingSessionID","message":"Request is missing x-opencode-session and cannot be routed efficiently. Please see https://opencode.ai/docs/go/#where-can-i-use-it"}} ``` That's HTTP 400. Go's docs say other clients *should* send a session id and their own `User-Agent`; the API requires the session id. Add `-H 'x-opencode-session: test-1'` and the answer becomes a 401, "Go requires a credential": the session check comes before the key check. Handy can't add the header, so something between Handy and Go has to. ### Problem 2: No API key to copy Go's docs say to copy your API key from the Console. In October 2026 the Console's Overview and Go pages had no such button. Keys come from Settings → Service accounts, which I haven't tried. What I already had was opencode's own sign-in: `opencode auth login opencode`, pick **OpenCode Console account**, and approve the code in the browser. Its token works against Go. Finding that token took a while. opencode 2.x doesn't keep sign-ins in `~/.local/share/opencode/auth.json`, the file older docs point to: that was version 1. Version 2 keeps them in a SQLite database, `~/.local/share/opencode/opencode.db`, table `credential`. The supported way out is `opencode auth export`, which prints every sign-in **with its secrets** as JSON. To see the shape without printing any secret: ```bash opencode auth export opencode | jq 'walk(if type == "string" and length > 12 then "<\(length) chars>" else . end)' ``` ```text [ { "id": "<31 chars>", "integrationID": "opencode", "label": "Default", "active": true, "value": { "type": "oauth", "methodID": "device", "refresh": "<39 chars>", "access": "<39 chars>", "expires": , "metadata": { … } } } ] ``` `access` is what Go takes as the bearer token, and `expires` is 30 days after you sign in. opencode 2.x is one Bun binary with its JavaScript inside as plain text, so `grep -a` on the binary is how I found the rest: where it renews, and what it sends. ### The proxy [Image: Diagram: Handy sends the text to handy-go-proxy on 127.0.0.1 port 8082, with a local secret as its API key. The proxy checks the secret, adds opencode's sign-in token, a session id and its own User-Agent, and forwards the request to OpenCode Go, where qwen3.8-flash tidies the text. Handy never sees the real token.] *The proxy borrows opencode's own sign-in, so there's no API key to copy around.* [handy-go-proxy.py](https://blog.jacq.cc/handy-go-proxy/handy-go-proxy.py) is about 160 lines of Python, standard library only ([MIT](https://blog.jacq.cc/handy-go-proxy/LICENSE)). It: - listens on `127.0.0.1:8082` only, and refuses requests without the local secret or with another `Host`, so a web page in your browser can't use it; - reads the token with `opencode auth export opencode` at the first request, and again when the token has expired or Go answers 401; - adds `x-opencode-session` (one id per day) and a `User-Agent` that says what it is; - passes on only `POST /v1/chat/completions` and `GET /v1/models`, the second so that Handy's model list fills in; - logs one line per request (model, status, time), never the text or the token. ### Set it up ```bash opencode auth login opencode # pick "OpenCode Console account" mkdir -p ~/.local/bin ~/.config/handy-go-proxy ~/.config/systemd/user curl -o ~/.local/bin/handy-go-proxy.py https://blog.jacq.cc/handy-go-proxy/handy-go-proxy.py curl -o ~/.config/systemd/user/handy-go-proxy.service https://blog.jacq.cc/handy-go-proxy/handy-go-proxy.service (umask 077; head -c 24 /dev/urandom | base64 | tr -d '/+=' > ~/.config/handy-go-proxy/secret) ``` Check that the unit's `PATH` line has the folder opencode is in (`dirname "$(command -v opencode)"`), then start it: ```bash systemctl --user daemon-reload systemctl --user enable --now handy-go-proxy curl -s http://127.0.0.1:8082/health # ok ``` ### Hook it up to Handy On the **Post Process** page: [Image: Handy's Post Process page: Post-Processing Hotkey Shift + Ctrl + Space; Provider Custom; Base URL http://127.0.0.1:8082/v1; API Key hidden as dots; Model qwen3.8-flash] *Handy's Post Process page, set up for the proxy.* | Setting | Value | | --- | --- | | Provider | Custom | | Base URL | `http://127.0.0.1:8082/v1` | | API Key | The contents of `~/.config/handy-go-proxy/secret`, not a Go key | | Model | `qwen3.8-flash` ([why](#step-6-pick-the-model)) | The refresh button next to Model lists every Go model, including the ones Handy can't use. Below it is the prompt. Handy's built-in one, **Improve Transcriptions**, works well: it asks the model to fix spelling and punctuation, turn number words into digits and spoken punctuation into symbols, drop filler words, and clean up a question instead of answering it. Mine is a shorter version that also asks for Australian spelling. If you ever script these settings: Handy writes its settings file (`~/.local/share/com.pais.handy/settings_store.json`) back when it quits, so quit Handy before editing the file. ### Check it works One request through the proxy, the way Handy sends it: ```bash curl -s http://127.0.0.1:8082/v1/chat/completions \ -H "Authorization: Bearer $(cat ~/.config/handy-go-proxy/secret)" \ -H 'Content-Type: application/json' \ -d '{"model": "qwen3.8-flash", "reasoning_effort": "none", "messages": [{"role": "user", "content": "Remove filler words and turn spoken punctuation into symbols. Return only the text: so um the the meeting is at uh half past two on tuesday period"}]}' \ | jq -r '.choices[0].message.content' ``` ```text The meeting is at 2:30 on Tuesday. ``` The proxy's log has one line for it: ```bash journalctl _SYSTEMD_USER_UNIT=handy-go-proxy.service -o cat | tail -1 ``` ```text POST /v1/chat/completions model=qwen3.8-flash -> 200 in 1613 ms ``` (`journalctl --user -u handy-go-proxy` shows nothing on my laptop; the form above works.) The OpenCode Console's **Logs** tab shows the same request with its session id and the proxy's `User-Agent`, but not the text. Then press `Ctrl+Shift+Space` in any app and talk. ### Keep the sign-in alive The Console sign-in lasts 30 days. opencode renews it only after a request fails with 401, and the proxy reads the token without going through opencode's renewal. So on day 30 the clean-up stops, and Handy pastes the raw text instead. Three ways to avoid that: 1. **Sign in again before it runs out:** `opencode auth login opencode`. The proxy picks up the new sign-in by itself. Each login adds a sign-in and keeps the old one, inactive: `opencode auth list` shows them, `opencode auth logout opencode ` removes one. 2. **Use a service-account API key** (Console → Settings → Service accounts, then opencode's **API key (service account)** login). It doesn't expire. I haven't tried it. 3. **Renew it early, on a timer.** That's what I do: [Image: Diagram of the renewal in four steps: a daily timer checks the expiry; the OpenCode Console hands out a new token pair for the refresh token; the new pair goes back into opencode with auth import under a new id and auth switch; the old id is logged out and the proxy restarted. Skipping the store step signs opencode out, because the old refresh token stops working.] *The part opencode doesn't do for you.* Renewing is one request, the same one opencode makes: ```text wrap POST https://opencode.ai/console/auth/device/token {"grant_type": "refresh_token", "refresh_token": "", "client_id": "opencode-cli"} ``` It returns a new `access_token`, a new `refresh_token` and `expires_in` in seconds. The catch: **the refresh token changes every time**, so the new pair has to go back into opencode, or opencode is signed out. And `opencode auth import` skips any sign-in id it already has, so it can't update one in place. What works: import the renewed sign-in under a new id, `opencode auth switch opencode `, then `opencode auth logout opencode `. My script tries that round once with the unchanged sign-in before it renews, because a renewal can't be undone. A daily systemd timer runs it. It renews once fewer than 7 days are left, then restarts the proxy so it reads the new token. ### Go settings worth checking All in the OpenCode Console: - **Privacy → Regions.** Europe and the United States are always on. **Global** adds models hosted elsewhere, and DeepSeek's Go models refuse to run without it: "This Go model requires Global regions". - **Privacy → the two "endpoints that train on request data" switches**, one for paid and one for free models. I keep both off. With the free one off, the free preview models stop working. - **The Models tab doesn't control Go.** It's for the pay-as-you-go models: Go models worked while they were switched off there. - **Limits.** Each Go model has a monthly dollar limit; any 5 hours can use 20 % of it and a week 50 %. A dictation is a couple of hundred tokens. With **Extra usage** off, reaching a limit stops requests instead of billing you. Go is made for coding agents, and its docs say traffic is watched for abuse. Dictations are small requests, so this is light use, but it isn't what Go is sold for. ## Step 6: Pick the model Go has about 15 models on `/chat/completions`. I benchmarked the fast ones, leaving out the free previews and a model whose maker trains on your prompts. Each got Handy's exact request and my prompt, 4 times, taking turns, with two made-up dictations as Parakeet writes them: the plumber one above, and this one: ```text wrap so the solar made twenty two kilowatt hours today period the battery was at ninety five percent by noon comma which is pretty good period we should organize a check up of the inverter next week ``` The script checked each reply for what it must have (`3:30`, `Thursday`, `22`, `95%`, the Australian "organise") and mustn't (`um`, `uh`, `the the`, a spoken "period" or "comma", a `` block, "Sure, here's…"). [Image: Bar chart of the median time to tidy a short dictation: qwen3.8-flash 1.9 seconds, slowest 2.5, my pick. kimi-k2.6 2.2, slowest 2.9. glm-5.3-flash 6.7, slowest 24. mimo-v2.6-flash 17, slowest 36. In a second run with Global regions on: deepseek-v4.1-flash and deepseek-v4-flash 1.8 seconds each.] *The bars stop at 4 seconds; glm and mimo go well past it.* - **qwen3.8-flash** was the fastest in the first run and passed all 30 checks. It's my pick. - **kimi-k2.6** was close behind, and also passed everything. - **glm-5.3-flash** and **mimo-v2.6-flash** wrote good text too, but took up to 24 and 36 seconds. - **Both DeepSeek models** refused every request until I switched the workspace to Global regions. Then they were 0.2 seconds faster than qwen3.8-flash, about the difference between two runs of the same model. I stayed with qwen: its text was as clean (one DeepSeek kept the opening "So"), and it doesn't need models hosted outside the US and Europe. Every model that answered got the meaning right: "The solar made 22 kWh today. The battery was at 95% by noon, which is pretty good. We should organise a check-up of the inverter next week." (kimi and glm wrote "22 kilowatt hours".) So the clean-up adds about 2 seconds to a dictation. To run it offline instead, any local OpenAI-style server works with the same Handy settings and no proxy: before Go, I ran Qwen3.5-4B in llama.cpp on the laptop's GPU, at 3 to 4 seconds a sentence. ## Where the speech model runs **On the GPU.** Handy runs Parakeet through its own `transcribe.cpp`, built on ggml, the engine under llama.cpp, with a Vulkan engine for the GPU. To check yours: ```bash grep -E "bound backend|real-time" ~/.local/share/com.pais.handy/logs/handy.log | tail -3 ``` [Image: Bar chart: 8.8 seconds of speech against 0.92 seconds of compute on the GPU] *One dictation, from Handy's log.* That's about 10 times faster than real time, and most of it runs while you're still talking. The laptop's Intel NPU sits idle: Handy has no engine for it, and it would save little, since dictation uses the GPU for about a second at a time. ## The microphone For the first week I used a USB conference puck. Its levels were fine, but it hears the whole room, and the desk carries every key tap into it. Since 9 October 2026 I use a **Hollyland Lark M2**, the USB-C version: two wireless clip-on mics in a charging case, and a receiver that plugs into the laptop. [Image: Diagram: two mics send over 2.4 GHz to one receiver, which mixes them; the laptop sees a 2-channel, 48 kHz, 24-bit USB sound card, and Handy averages the channels to one] *What the laptop actually receives from the Lark M2.* - **Nothing to install:** the laptop sees an ordinary USB sound card. Pick it by name in Handy (General → Microphone → `Wireless microphone`), so calls keep using the built-in mic. - **The case is the on/off switch.** A mic in the case, or out of range, sends silence while the receiver stays listed. If Handy records nothing, look there first. - **Use a data port.** In a USB hub's power-only USB-C input, the receiver lights up but the laptop doesn't see it. - **Clip it about a hand-width below your chin**, clear of fabric. It hears all around, so nearby voices can end up in a dictation. - **Keep the spare mic in the case**, or the receiver mixes its keyboard noise into every dictation. ## Sources - Handy: [GitHub](https://github.com/cjpais/Handy) (README: Linux and Wayland notes), [releases](https://github.com/cjpais/Handy/releases), [`llm_client.rs`](https://github.com/cjpais/Handy/blob/v0.9.8/src-tauri/src/llm_client.rs) (what post-processing sends) - Parakeet Unified EN 0.6B: [NVIDIA model card](https://huggingface.co/nvidia/parakeet-unified-en-0.6b) - [ydotool](https://github.com/ReimuNotMoe/ydotool) - OpenCode Go: [docs](https://opencode.ai/docs/go/) (endpoints, models, limits, privacy, client headers) - Hollyland Lark M2 controls: [UW iSchool guide](https://www.kb.ischool.uw.edu/2025/01/09/larkm2) ## Change history - 2026-10-15: First published. Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Credit "HomeLab Guides (blog.jacq.cc)" with a link to https://blog.jacq.cc/guides/local-voice-dictation-kde-wayland/. Code downloads: MIT. Details: https://blog.jacq.cc/licence/ --- # Run Jellyfin on Home Assistant OS for old TVs and Fire TV sticks > Jellyfin as a Home Assistant OS app on a Raspberry Pi 5, serving older Samsung TVs over DLNA and Fire TV sticks through the Jellyfin app. Includes the bind-address fix that makes DLNA visible. Guide · Published 2026-10-14 · Updated 2026-10-14 Web page: https://blog.jacq.cc/guides/jellyfin-home-assistant-os-old-tvs/ I wanted our films and shows on the TVs. The Raspberry Pi 5 that runs Home Assistant OS is on all day anyway, so Jellyfin went on it as an app. The Fire TV stick gets the Jellyfin app. The Samsung TVs use DLNA, the old UPnP media-server standard that nearly every smart TV still speaks. The 2016 one is too old for the Jellyfin app, which Samsung only offers on 2021 and newer models. [Image: A drawing: a TV plays a big purple play button, with a streaming stick in its side, a remote, a bucket of popcorn and a small Raspberry Pi sending it the film] Three things took the time, and I couldn't find them written down together: - The DLNA plugin announced the server on 127.0.0.1 only, so no TV could see it. - The Samsungs hid one of my libraries. - The Pi 5 has no hardware video encoder, so video conversion has to stay off. :::note[Status, October 2026] Running since 10 October 2026, on Jellyfin 12.2.0 in the "Jellyfin NAS" app. Works: a 2016 and a 2021 Samsung over DLNA, and the Jellyfin app on a Fire TV Stick 4K Max. ::: [Image: Diagram: the Samsung TVs find Jellyfin with SSDP multicast, then browse and play over HTTP. Fire TV sticks and phones find it on UDP 7359 and use the same HTTP port. A browser reaches it through Home Assistant's ingress and the app's own nginx, which talks to Jellyfin on 127.0.0.1. Jellyfin reads the media from a USB drive.] *Who talks to whom. Dashed lines find the server, solid lines browse and stream.* ## What you need - A Raspberry Pi 5 with Home Assistant OS (HAOS). Jellyfin idles at about 300 MB of RAM. - A USB drive for the media, formatted ext4 with a label. Below, the label is `media`. - TVs on the same network and subnet as the Pi. DLNA discovery is multicast, and it doesn't cross subnets or VLANs. - A fixed address for the Pi (a DHCP reservation is fine). About an hour, plus copying the films. [Image: A Raspberry Pi 5 board from above, with its USB, Ethernet and HDMI ports] *A Raspberry Pi 5 like mine. Photo: SparkFun Electronics, [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/), via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:23551-Raspberry-Pi-5-8G.jpg), cropped.* ## Step 1: Install the Jellyfin NAS app In Home Assistant's app store, open the ⋮ menu → **Repositories** and add alexbelgium's repository: ```text https://github.com/alexbelgium/hassio-addons ``` Then install **Jellyfin NAS**. Pick this one because it runs on the host network. DLNA discovery is multicast on UDP 1900, and from the Docker bridge network the Community Apps Jellyfin uses, that multicast doesn't reach the TVs. On the Configuration tab, point it at the drive: ```yaml localdisks: media data_location: /mnt/media/jellyfin ``` `localdisks` takes the drive's label, and the app mounts it at `/mnt/