Old tech meets modern software.
Playstick turns an ASUS VivoStick TS10 into a compact, dedicated AirPlay mirroring receiver that plugs directly into a projector's HDMI port.
It provisions and measures a minimal Ubuntu Server installation running UxPlay,
with video rendered directly through the Intel integrated graphics stack without an X server or desktop environment.
- The device is managed entirely over SSH from a separate control machine.
- Plays movies from a NAS (simple enough for a child to use).
- Web based movie library.
I discovered UxPlay while looking for a way to mirror an Apple device to a Linux machine. Not long afterwards, I found an ASUS VivoStick TS10 listed on Finn and offered 400 NOK for it.
A few days later, it arrived. I installed Ubuntu Server (deliberately avoiding a desktop distribution or other graphics-heavy environment) with the goal of turning this tiny, ageing PC into a single-purpose AirPlay receiver.
Playstick documents and automates that process: provisioning the device, configuring direct graphics output, and measuring how well the hardware performs in its new role.
lspci reports the GPU as Atom/Celeron/Pentium x5-E8000/J3xxx/N3xxx Integrated Graphics, which
is ambiguous — PCI ID 8086:22b0 is shared between Braswell and Cherry Trail. The TS10 is a
VivoStick, so this is Cherry Trail:
| SoC | Intel Atom x5-Z8350, 4 × 1.44 GHz Airmont |
| GPU | Gen8 LP, 12 EU |
| RAM | 2 GB LPDDR3 |
| Storage | 32 GB eMMC |
| Ports | 1 × USB 3.0, 1 × USB 2.0, micro-USB power, 3.5 mm combo |
| Network | SDIO Wi-Fi on board — but this unit runs on a USB Ethernet adapter |
Three consequences shape everything here:
- Memory bandwidth is the real constraint, not CPU. This is the single most important
thing measured on this box, and it was not the assumption the project started with — see
Results. The display engine, CPU and GPU all share one modest LPDDR3
subsystem, and saturating it produces
CPU pipe C FIFO underrunin dmesg and visible corruption.drm_force_modetherefore pins the output; it is1280x720@60on this projector. - Cherry Trail is i965-only.
intel-media-driver(iHD) does not cover Gen8 LP, soLIBVA_DRIVER_NAME=i965is pinned everywhere to stop libva autodetection wandering off. - 2 GB RAM / 32 GB eMMC. No desktop, no build toolchain — hence the archive UxPlay package rather than a source build, and zram rather than a swapfile.
A founding assumption that turned out to be wrong. This project began from "Airmont at 1.44 GHz cannot sustain 1080p30 H.264 in software, so VA-API is a hard requirement." The decode-only measurements support it. The end-to-end ones do not: once frames have to reach a display, software decode outperformed both hardware decoders, because the VA path pays an uncached readback that software decode never performs. The
graphicsrole still asserts that hardware VLD exists — it is a useful signal that the driver stack is healthy — but it is no longer true that the design depends on using it.
Verified present in Ubuntu 26.04 "Resolute": uxplay 1.73.2-1, gstreamer1.0-vaapi 1.26.8-2, and
the i965 driver in two mutually exclusive builds — i965-va-driver 2.4.1+dfsg1-2build1 (universe,
DFSG-free) and i965-va-driver-shaders 2.4.1-2build1 (multiverse, with the non-free shader
binaries). They Conflict; exactly one may be installed. i965_nonfree_shaders picks, and
defaults to the full shader set because this SoC has no performance to spare.
Audio is deliberately out of scope. UxPlay runs with -a. The Cherry Trail LPE/SST audio
stack is the least reliable part of this platform, and skipping it removes the largest single
source of failure. If audio is wanted later, a USB audio adapter sidesteps the SoC path entirely.
ansible.cfg inventory path, pipelining, longer timeouts
inventory.yml single host: vivostick, and nothing site-specific
host_vars/vivostick/ local.yml <- YOUR address, login, NAS. gitignored
vault.yml <- secrets. gitignored AND encrypted
group_vars/all.yml every tunable lives here
site.yml base -> trim -> graphics -> uxplay -> idle -> nas -> player -> probe
fetch-results.yml run the probes, pull output into results/
Dockerfile the Ansible control node
Dockerfile.gui the web UI on a dev machine, no hardware needed
scripts/playstick-prep.py prepare the movie library on the DEV machine
scripts/projector-probe.py talk to the projector by hand, ON THE DEVICE, before
trusting the daemon to do it
scripts/make-testclip.sh build H.264 test clips on the CONTROL machine
scripts/gui-* entrypoint and AirPlay stub for Dockerfile.gui
roles/
base/ apt components, uxplay service account, zram
trim/ strip services/packages the kiosk does not need
graphics/ i965 VA-API stack, modetest, the hardware-decode assert gate
uxplay/ uxplay + avahi + cage/seatd, both units, one of them started, tty1
idle/ the framebuffer clock shown when nothing is mirroring
nas/ read-only CIFS automount for the movie library
player/ mpv + the web UI a child drives it from, the display arbiter,
and the RS-232 control that switches the projector on and off
probe/ measurement scripts and test clips
docs/ install.md — every command, in order, including the vault
matrix-narrative.md — how this was measured, and what fooled me
results/ probe output fetched back (gitignored)
Only ansible.builtin modules are used — a plain ansible-core install is enough, no collections.
Debian 12 ships ansible-core 2.14; the image pins something current and keeps the control
machine clean. It carries the whole control-side toolchain — ansible, ansible-lint, ffmpeg for the
test clips, iperf3 for the throughput probe — so Docker is the only host requirement.
./actl # build on first run, then syntax-check
./actl 'ansible -m ping vivostick'
./actl 'ansible-playbook site.yml --check --diff'
./actl ./scripts/make-testclip.sh
./actl bash # poke around
docker compose up iperf # iperf3 -s on host networking
docker compose up gui # the web UI at :8080, no deviceThat last one is a different image with a different job — Dockerfile.gui runs
what Ansible would have installed rather than installing it. See
The UI, on a development machine.
actl wraps docker compose run, exporting HOST_UID/HOST_GID so anything written into the
mounted repo — results/, generated clips — comes back owned by you rather than by root. (Not
UID: bash makes that readonly, so exporting it aborts the wrapper.)
SSH. ~/.ssh is mounted read-only so the container can never rewrite your keys or
known_hosts, and $SSH_AUTH_SOCK is forwarded so a passphrase never enters the container. The
tradeoff is that an unknown host key cannot be recorded from inside — SSH to the device once from
the host first. ansible.cfg keeps host_key_checking = True; that is the point.
If key auth to the device is not set up yet, ansible fails with
Permission denied (publickey,password). Fix it on the host, once:
ssh-copy-id <user>@<host> # the ansible_user and ansible_host from local.ymlOr, if you would rather stay on passwords, sshpass is in the image and
./actl 'ansible-playbook site.yml --ask-pass --ask-become-pass' works.
sudo-rs. Ubuntu 26.04 makes sudo-rs the default sudo through
update-alternatives. It does not honour the custom prompt ansible passes with -p, so ansible
never matches the prompt and become dies after 60 s with:
Timeout (62s) waiting for privilege escalation prompt
Classic sudo is still installed at /usr/bin/sudo.ws, so group_vars/all.yml sets
ansible_become_exe: /usr/bin/sudo.ws. Nothing on the device changes — the alternatives link and
sudo-rs itself are left alone. Drop that variable if you ever repoint the alternative, or if
sudo-rs gains prompt support.
The short version is below. Every command in order, including the vault, is in docs/install.md.
# 0. control machine: a test clip (skip if using ./actl for everything)
./scripts/make-testclip.sh # needs ffmpeg; writes to roles/probe/files/
# 1. point the inventory at the stick. local.yml is gitignored: your address,
# login and NAS live there, not in the committed files.
cp host_vars/vivostick/local.yml.example host_vars/vivostick/local.yml
$EDITOR host_vars/vivostick/local.yml # ansible_host, ansible_user, nas_*
ansible -m ping vivostick
# 2. provision. -K because sudo on the device wants a password.
ansible-playbook site.yml --check --diff -K
ansible-playbook site.yml -K
# 3. drm_force_mode needs a reboot before it takes effect
ssh <user>@<host> sudo reboot
# 4. find out what this box can actually do
iperf3 -s & # on the control machine
ansible-playbook fetch-results.yml -K -e probe_iperf_server=<x1-ip>
ansible-playbook fetch-results.yml -K -e run_matrix=true # ~20 min, owns the displaysite.yml enables and starts one UxPlay unit — uxplay_output_path, which defaults to kms.
It used to enable neither, on the grounds that which one wins is a probe result rather than a
guess; the sweep has since been run, and a device that comes up as an AirPlay receiver by itself
is worth more than that reservation. Set uxplay_output_path: none to get the old behaviour
back.
Step 3 is not optional on first provision: drm_force_mode writes the kernel cmdline, and until
the box reboots you are still measuring the old display mode.
The clips total ~114 MB and are copied to the device by the probe role — a few seconds over the
USB Ethernet link. Later runs reuse whatever is already in /usr/local/share/uxplay-probe.
Shorten them with DURATION=30 ./scripts/make-testclip.sh if you want faster sweeps.
Two units are installed, Conflicts= each other, and only one should ever be enabled.
uxplay_output_path picks which — kms, cage, or none for neither — and the uxplay role
enables and starts that one while stopping and disabling the other. Disabling matters: an
enabled loser comes back at the next boot and races the winner for the card.
uxplay-kms.service — pure DRM/KMS, no compositor at all. The default.
UxPlay's GStreamer pipeline ends in kmssink, which writes to a DRM plane on the i915 node. It
becomes DRM master simply by being the first process to open the card: i915's fbdev console
emulation lives in the kernel and holds no master, so the only real contender is a getty painting
tty1 — which is why the unit conflicts with getty@tty1 and the base role masks it. tty2 stays
as a rescue console.
The DRM node is not card0. On this box simpledrm claims card0 as the EFI framebuffer at
boot, i915 then binds and lands on card1, and a stale by-path symlink to the dead card0 is
left behind. The numbering is a boot-order artifact and is not stable, so the graphics role
discovers the node — preferring /dev/dri/by-path/pci-0000:00:02.0-card and falling back to
whichever card reports i915 as its driver — and feeds it to both units. Override with
drm_card_path if needed.
Do not set driver-name=i915 on kmssink. It looks like the obvious way to pin the device
and it is actively harmful: unprivileged it gives Could not open DRM module i915, and as root it
opens something that is not KMS-capable, so kmssink fails at start() with driver does not provide mode settings configuration. Bare kmssink auto-detects correctly. force-modesetting=true
also fails on this driver — measured separately, all 6 attempts. Both are omitted from the unit.
Connectors present: card1-HDMI-A-1 (the projector) and card1-DP-1.
uxplay-cage.service — UxPlay under cage, a single-window wlroots kiosk compositor. Still
no X. Costs ~30 MB and one more moving part, and buys seat/DRM-master handling plus EDID re-read
on projector hotplug.
Switch paths:
./actl 'ansible-playbook site.yml -K -e uxplay_output_path=cage' # try it
$EDITOR group_vars/all.yml # keep itExpect the hotplug behaviour to be the deciding factor rather than raw throughput: a projector
that gets unplugged between sessions is the normal case, and wlroots handles that re-read where
bare kmssink may not.
Two things follow the choice automatically, and both would be silent bugs if they did not.
player_airplay_unit is derived from it, so the film player stops and restarts whichever
receiver is actually running rather than a hardcoded one. And the idle role refuses to run at
all on the cage path — see Limitations below.
Between sessions the projector would otherwise show the tail of the boot log and a blinking
cursor — claim_tty1 masks getty@tty1, so once multi-user.target is reached nothing paints
tty1 at all. uxplay-idle.service puts a large clock and the advertised AirPlay name there
instead, from boot onwards.
The first implementation printed block digits to /dev/tty1. The reasoning was that the
framebuffer console holds no DRM master, so the kernel would suspend fbcon while kmssink owned
the card and restore it afterwards — no arbitration code needed. Supporting it was uxplay -h:
-nc Do NOT Close video window when client stops mirroring, implying the default releases the
display between sessions.
It froze on screen at 18:04. Two things were wrong:
- UxPlay opens the DRM device inside
gst_kms_sink_start, during service startup, before any client connects.fuserconfirms it:/dev/dri/card1: uxplaywith nothing mirroring.-ncgoverns an X/Wayland window; withkmssinkthere is no window. - Whoever opens the device first becomes DRM master, and the fbdev helper calls
drm_master_internal_acquire()before pushing console updates to the screen. That fails while another master exists. So the clock kept writing to tty1, the console text buffer kept updating, and none of it ever reached the framebuffer.
The fix came from what the failure didn't break: the stale image survived a whole mirroring
session and reappeared afterwards, so the framebuffer's contents were still live — only the
console-to-framebuffer path was blocked. i915's fbdev emulation maps the real scanout buffer with
no shadow, so writing to /dev/fb0 skips that path entirely. Confirmed before rewriting anything:
# with uxplay-kms running, nothing mirroring
head -c 200000 /dev/zero | tr '\0' '\377' > /dev/fb0 # white bar appearsuxplay-idle-clock.py therefore composes a full frame and writes it to /dev/fb0. While a client
mirrors, the CRTC scans out UxPlay's buffer and those writes are invisible; when the session ends
the CRTC returns to the fbdev buffer and the clock is there, current. Nothing is coordinated
between the two services, and they deliberately do not Conflict.
Glyphs are parsed from a PSF console font by the clock itself, which means fbcon's font
validation no longer applies — the 32x16 face this kernel refuses to load with setfont
(Unable to load such font with such kernel version, i.e. KDFONTOP returning EINVAL on a
font that is not 8 px wide) renders perfectly here. Everything is greyscale, so the framebuffer's
channel order never has to be worked out; geometry comes from
/sys/class/graphics/fb0/{virtual_size,bits_per_pixel,stride}.
The clock is scaled to fit — 90% of width or 55% of height, whichever binds first. On 1280×720
with the 16×32 face that is scale 12, giving 192×384 px digits. The subtitle steps down until it
fits, so a long hostname shrinks rather than running off the edge. Composing a frame costs a few
milliseconds and happens once a minute, when HH:MM changes.
Blanking after idle_blank_minutes (30) needs to know when a session ends. It asks ss for an
established connection to UxPlay's port — deliberately not "is the DRM node open", because
uxplay-kms holds that from startup to shutdown and so can never distinguish idle from
mirroring. The kernel's own 10-minute console blanker is switched off (setterm --blank 0) so it
cannot black the framebuffer out from under the clock; --powersave off keeps the HDMI signal
alive while black, since DPMS-off makes a projector report "no signal" and re-sync on wake.
Both concern whether UxPlay hands the screen back, and both are enforced:
uxplay_nofreeze: true → -nofreeze |
Without it UxPlay "leaves a frozen screen in place after reset", so the CRTC keeps scanning out its buffer after the -reset timeout and the clock never returns. |
-nc rejected in uxplay_extra_args |
Same hazard on the clean-stop path. The idle role asserts it is absent. -nc no is fine. |
The cage path cannot have this. uxplay-cage.service Conflicts=uxplay-idle.service. A
compositor owns the CRTC for its whole lifetime and never returns it to the fbdev buffer, so the
clock would be permanently invisible. An idle screen for that path would have to be a Wayland
client.
Now that the receiver is started rather than left for an operator, the idle role asserts the
combination away: uxplay_output_path: cage with idle_enabled: true fails the play and names
the line to change. The dangerous half is not the invisible clock — Conflicts= runs both ways,
so starting the clock afterwards would stop the receiver, trading a working projector for a
screen nobody can see.
Stop it during probe sweeps. uxplay-probe-matrix.sh runs pipelines on VT1 via openvt;
systemctl stop uxplay-idle first removes the variable.
systemctl disable --now uxplay-idle.service # rollbackThe second thing this box does: play files off a read-only CIFS share directly, with no compositor and no phone in the loop, driven from a web page anyone on the LAN can open. The page is deliberately small enough for a child — a grid of posters, tap one, then a large play/pause and a large stop. There is no seek bar; a control that can lose your place is a control that produces tears.
# host_vars/vivostick/local.yml -- gitignored, so your LAN stays yours
nas_server: "10.0.1.5"
nas_share: "movies" # the exported share NAME. A Synology's /volume1/movies
# is exported as //nas/movies -- the last component alone.
# host_vars/vivostick/vault.yml -- encrypted, and that path is load-bearing
nas_username: "kyle"
nas_password: "…"Gitignored is not encrypted, so the password is in the vault rather than beside the
address. Both are host_vars, and that path is the load-bearing part: vivostick is a
host, so group_vars/vivostick/ is silently never read, and group_vars/all/ silently
shadows group_vars/all.yml. vault.yml also overrides local.yml where both define
the same variable — lexical load order — so keep each variable in one file. The full
command sequence is in docs/install.md.
scripts/playstick-prep.py runs on the developer machine and does everything the
stick would otherwise have to do badly, or could not do at all:
./scripts/playstick-prep.py --library /mnt/nas/videoIt walks the share, probes every file, and writes playstick-library.json next to the
films. The daemon reads that instead of walking, and everything in it was decided on a
machine with cores to spare:
| it does | because on the stick |
|---|---|
| verifies each file decodes, and that the ending is actually there | a partial download plays fine for 40 minutes and then stops, in the dark, at bedtime |
| de-duplicates — same film, best copy wins | the grid otherwise shows Ponyo three times and a child picks the 480p one |
| transcodes anything that is not already 8-bit H.264 at ≤720p | HEVC, 10-bit and 4K do not play at all; software H.264 at 720p is measured at 29 fps with nothing to spare |
| extracts posters — sidecar, embedded cover, or a frame | the daemon does this with mpv, one at a time, only while nothing is playing, over CIFS. A hundred films is an afternoon |
| extracts subtitles to UTF-8 SRT | the share is mounted read-only, so nothing can be written beside the film |
collects rating and genre from .nfo files and container tags |
there is no metadata database on a 2 GB appliance |
| matches against TMDb on title, year and runtime, and refuses rather than guesses | the shelf is what a child picks from, and a wrong film on it is worse than a plain one |
Artifacts go in <library>/.playstick/, which the daemon's fallback walk skips — so a
half-prepared library shows the originals rather than each film twice. Nothing under the
library is ever modified or deleted; losing duplicates are reported, and only moved if
you pass --duplicates-dir.
The index is a preference, not a requirement. Missing, unparseable or written by a newer version, it logs one line and walks the share as before.
--verify full # decode every frame, not just the first and last seconds
--transcode never # index and collect metadata, encode nothing
--dry-run # say what would happen
--tmdb-key <key> # ratings, genres and posters from themoviedb.org.
# OFF by default: it sends your film titles to a third party
--refresh-posters # re-download every TMDb poster instead of keeping what
# is on disk. Run once after upgrading — see belowThe first run of this against a real library published Red Hook Summer — its title,
its poster, its plot and its rating — for Hook (1991)/Hook.1991.720p.BRrip.x264.YIFY.mp4.
Thirty-three of a hundred and seventeen entries were somebody else's film. Nothing about
it looked like a failure: every step succeeded and returned a plausible answer.
The cause was a chain, and the first link is worth knowing about if you have YIFY rips:
that file's only date is creation_time: 2012-10-08, the moment it was muxed. It was
read as a release year, so TMDb was asked for a 1991 film released in 2012, and the top
hit for query=Hook&year=2012 is Red Hook Summer. 69 of 547 films were dated by their
muxer this way. creation_time is no longer read, a container tag can no longer overwrite
a year the filename gave, and the year is taken from the folder when the file has none
(Die.Hard.1988…/die_hard.mkv had no year at all).
The second link is that the search result was simply believed. It now has to survive three independent checks — the title, compared after normalising accents, punctuation and articles; the year; and the runtime, which is the one signal that owes nothing to anybody's spelling and would have caught this on its own: 121 minutes of Red Hook Summer against 142 minutes of Hook. Popularity breaks ties and nothing else, since sorting by popularity is what caused this. Below the bar, the run says so and moves on:
TMDb: 112 matched, 5 not confident enough to use
no match: Contact (1997)/Contac.1997.720p.x264.YIFY.mkv -- best was …
An unmatched film keeps its filename title and year and gets a frame for a poster, which
is what an unmatched film has always looked like. That is deliberate: a wrong film on a
shelf a child picks from is worse than a plain one. The usual cause is a typo in the
filename — Contac above — and renaming the file fixes it.
Two notes for a library prepared by an earlier version. The state cache stores the derived
title and year, so entries written under the old rules are re-probed automatically; it
costs one ffprobe per film and no re-encoding. Posters are not so lucky — a JPEG
downloaded for the wrong film is indistinguishable from a right one here — so pass
--refresh-posters once.
Then http://vivostick.local/ from any phone on the LAN — the player role
advertises the UI over the avahi daemon that is already running for AirPlay, so nobody
has to know the stick's IP address, and player_port: 80 means there is nothing to
remember after the hostname either. The daemon runs as root for reasons that predate the
port (mpv needs DRM master), so binding below 1024 costs nothing here.
Prep collects a year, a score and a genre list for every film, and until now the page threw all three away — the grid was every film the share held, in one order, and at a couple of hundred posters that is a wall to scroll past rather than a shelf to pick from. A funnel button next to the sound icon opens a second sheet:
| Order | A to Z, newest first, oldest first |
| Kind | one row per genre the library actually has, each carrying the count that choosing it would leave |
| Score | any, 6+, 7+, 8+ — the 0–10 number from the .nfo, the container tag or TMDb |
| Headphones | only the films a phone can hear |
A to Z is the order the server already sends, and the page does not re-sort it. Prep
files by a normalised title so The Fifth Element sits under F the way it would on a shelf,
and the daemon keeps that order verbatim; a page that sorted on title instead would file
every The together and quietly disagree with its own default. sort_title is on the wire
for exactly this, and falls back to the title for a library nobody has prepped.
A film with no year is last in both year orders, not oldest. It is unknown, and burying it under "oldest first" is a lie the grid would tell silently. Same reasoning for a missing score: it drops out only once a threshold is actually asked for.
"Ready for headphones" is keyed on extracted audio tracks, not the index's prepared
flag. That flag is set only where prep had to transcode, so a film it judged already
stick-friendly reads false while playing perfectly on every phone in the room — the property
somebody in the room can notice is whether there is a soundtrack to send them, and that is
audio_langs.
The filtering is in the page, not in the daemon. /api/library is one small payload the page
already holds in full, so a filter is an array operation over it; the alternative — query
parameters — would have this process parsing attacker-shaped strings, when its whole design
is that clients send opaque ids and small integers.
Two consequences of a child being the user. A choice is remembered across reloads
(ps.lib.* in localStorage), so the grid they left is the grid they come back to — which
means a filter that hides everything must never be a dead end: the active filters show as a
chip above the grid that clears on tap, an empty grid says "Nothing matches what you
picked" with a full-width way out, and a genre that has left the library since it was
chosen un-picks itself on the next scan. And a library nobody has prepped has no genres, no
years and no scores, so those sections are taken away rather than rendered empty, and the
sheet says why.
One latent bug fell out of this. refreshThumbs() used to swap posters by matching the
grid's <img> list against the server's item list by position, which held only while the
grid was the whole library in the order it arrived. Tiles are now looked up by id.
This is a property of the hardware, not a shortcut, and it is the same fact that forced
the idle clock to draw pixels: there is one CRTC and one DRM master, and uxplay-kms
takes the card when the service starts, not when a client connects. mpv therefore cannot
share it. The receiver has to be stopped for the length of a film, and while it is stopped
the stick does not appear in the iOS AirPlay list at all, because UxPlay is what publishes
the mDNS record. It comes back a few seconds after the film ends.
The interlock in the other direction is a check rather than a race: playstick-web refuses
to start a film while an established TCP connection to UxPlay's port says somebody is
mirroring. Mirroring wins, because somebody is standing there holding a phone. The check is
debounced over two samples a second apart — iOS opens brief connections to :7000 merely
from having the AirPlay picker on screen, and a single sample would refuse to play a film
because someone across the room glanced at a menu.
Conflicts=uxplay-kms.service on an mpv unit was the first design. systemd will happily
stop the receiver for the conflict and then has no reason to ever start it again — a film
that ends at 21:30 leaves the projector with no AirPlay until somebody notices. Restoring
it is a decision, so it lives in the daemon, which records what it took the display from
before it stops anything. If the daemon is killed mid-film, Restart=always brings it back
and it reads that record on startup; KillMode=control-group means mpv died with it, and
ExecStopPost= covers a clean stop. /run is tmpfs, so a reboot clears the lot, which is
correct — a boot starts the receiver itself.
The idle clock needs no arbitration, for the reason it never did: it holds no DRM master,
its writes land in the fbdev buffer, and they are invisible while anything else scans out.
It gains exactly one thing, IDLE_BUSY_FILE, so the blank countdown does not run out
partway through a film and leave the clock blanked when it comes back.
player_vo and player_hwdec ship at drm / no — software decode into a dumb
buffer, the closest structural analogue of the pipeline that measured 29.19 fps / 0.00%
drop for UxPlay. That looks like it contradicts the VA-API result above and it does not:
that result was a GStreamer readback stall, and mpv's --vo=gpu --gpu-context=drm --hwdec=vaapi path exports the VA surface as a dmabuf and samples it on the GPU, so the
stall is structurally absent. Hardware decode very likely wins here. It is an expectation,
not a measurement, and nothing on this box has ever run GL — so the default is the
configuration with the fewest unproven parts until the sweep says otherwise:
./actl 'ansible-playbook fetch-results.yml -e run_player_probe=true'Watch the hwdec_used column: a vaapi run that reports vaapi-copy fell back to the
readback path, and its numbers are not measuring the hypothesis.
--drm-mode is pinned to drm_force_mode and that is load-bearing. kmssink never
modesets, so UxPlay inherits whatever the kernel cmdline set; mpv picks its own mode,
and left at preferred it would choose this projector's EDID-preferred 1920x1080i@60 —
silently undoing drm_force_mode and putting a deinterlacer back in the path.
Audio is off and films play silently. player_audio: false, mpv runs --ao=null. On
Cherry Trail, HDMI audio does not come out of an HDA codec at hw:0,3 the way it does on
desktop Intel — it goes through the i915-created hdmi-lpe-audio device, i.e. the same LPE
block uxplay_suppress_audio exists to avoid. Nothing on this box has ever produced a
sample. The facts probe now reports the ALSA cards, their ELD state and whether the LPE
module is bound; read that, then set player_audio: true and player_audio_device. If the
platform is as unreliable as this README warns elsewhere, a USB audio adapter sidesteps the
SoC path entirely and player_audio_device is the only line that changes.
H.265 is the real content risk, not 1080p. If vainfo shows no VAProfileHEVCMain : VAEntrypointVLD, software HEVC on 4×1.44 GHz Airmont will not save it. The facts probe
prints the decodable profiles.
Until this existed, a child who picked a poster got mpv drawing to a plane nobody could
see: the film ran, correctly, on a screen that was switched off, until an adult found the
remote. playstick-web now talks to the projector over RS-232C, so picking a poster is the
whole of what it takes to watch a film — and, more importantly, so the lamp goes out again
when the room empties.
It is off by default and that is not timidity. With player_projector_model unset the
daemon builds a NullProjector: every step below is a no-op, the film starts exactly as it
did before, and an appliance with no serial cable — or the development GUI, which never has
one — behaves identically. Setting a model is the only thing that turns any of it on.
QPW -> 000, standby
Waking the projector up… PON
Waiting for the lamp… ....... 10 s of documented deafness .......
QPW -> 000 ... 000 ... 001, lit
Pointing it at the movie… IIS:HD3 then QIN to check it landed
Making room on the screen… systemctl stop uxplay-kms.service
Starting the movie… mpv
POST /api/play now returns as soon as the film is accepted rather than when mpv is
running, because a cold PT-AE4000 takes the better part of a minute to answer QPW with
001 and no browser holds a request open that long. The work happens on a thread and the
page watches /api/status, which is the mechanism it already used for everything else.
That is also what makes the wait bearable. A child who taps a poster and sees nothing for forty seconds cannot tell a warming lamp from a broken appliance, and the second guess is the one they act on — they press things, or they fetch somebody, or they give up. So each step names itself, the preparing view shows the poster of the film they picked so they can see the right one is coming, the bar is deliberately indeterminate (there is no honest percentage; a bar that crept to nine tenths and stopped would be a lie they can see through), and there is always a Never mind button.
A projector that cannot be reached never stops a film. Every serial fault in the
sequence is logged, reported to the page in one sentence, and stepped over. This is the
same judgement library.py makes about a corrupt index, and it matters more here: if the
lamp will not strike, the likely explanations are that somebody already switched the
projector on by hand or that a cable is loose, and in the first case the film is exactly
what was wanted while in the second an adult standing in the room can fix in two seconds
something this daemon cannot fix at all. Refusing to play would help nobody. Unplug the
adapter mid-provision and the only difference is a banner.
A keeper thread ticks every fifteen seconds. The clock is reset by a film playing or being prepared, or a confirmed AirPlay session — and deliberately not by a phone with the page open. The page polls every three seconds, so counting that would mean the projector stays lit until every browser tab in the house is closed, and one phone left in a pocket would keep a lamp burning all night.
The same tick can switch the projector on for a mirroring session, and the two AirPlay questions it asks are deliberately different:
| direction | check | why |
|---|---|---|
| keep the lamp lit | airplay_active(), one ss sample |
a false positive only postpones a power-off, which costs nothing |
| strike a cold lamp | airplay_confirmed(), sustained across player_projector_airplay_wake_ticks |
iOS opens brief connections to :7000 merely from having the AirPlay picker on screen |
Without that asymmetry, somebody glancing at an AirPlay menu across the room would light a
lamp in an empty one. Two ticks is about thirty seconds of sustained connection: a glance
does not survive it, a session does. player_projector_airplay_wake_ticks is the number to
raise if the projector ever switches itself on unbidden, and
player_projector_wake_on_airplay: false turns the direction off entirely.
The input cannot be selected at wake time — the projector is deaf for ten seconds after
PON and refuses IIS until the lamp is up — so a later tick does it once QPW says the
lamp is lit.
Run scripts/projector-probe.py on the device first. It is not a formality; it is the
step that decides whether this feature can work at all.
$ sudo ./projector-probe.py status # the one that proves the cable
$ sudo ./projector-probe.py --verbose status
$ sudo ./projector-probe.py on # times the warm-up
$ sudo ./projector-probe.py cycle # times the cool-down tooIt shares no code with the daemon on purpose. The driver is written to degrade quietly, and quiet degradation is precisely the wrong behaviour for the question being asked, which is did any bytes come back at all. Here every frame is printed in both directions and silence is a headline.
Two things it settles, neither of which software can:
- Whether the adapter is the right kind. The one in use reports USB ID
0403:6015— the FTDI FT230X/FT231X, sold both as a real RS-232 cable with a MAX3232 on board and as a bare 3.3 V TTL breakout. The projector wants ±12 V and cannot hear the second kind. A moulded D-sub 9 on the projector end is the good sign; a bare header is the bad one. - Whether the protocol is right. The command strings come from the PT-AE4000 manual
(TQBJ0313, pp. 42–44) by way of a Rust implementation in a sibling repository that had
only ever been tested against mocks.
tests/test_projector_protocol.pyports that crate's vectors byte for byte — two implementations written from the same manual agreeing on the same bytes is worth more than either agreeing with itself — but agreeing with the manual is not the same as agreeing with the projector.
The manual is also ambiguous between models: the PT-AE3000U has two component inputs
(CP1/CP2) where the PT-AE4000 has a computer input (RG1). Both code sets are offered
and the projector answers ER401 for whichever it lacks, which the sequence steps over.
Also worth knowing: the port is straight-through to a PC (pin 2 TXD, 3 RXD, 5 GND), so a
null-modem cable — identical from the outside — will not work; in standby the projector
accepts nothing but PON; and no DeviceAllow= is needed in the unit, because it already
runs as root and ProtectSystem=full does not touch /dev.
docker compose up gui runs a fake projector made of arithmetic, with a three-second
warm-up and a two-minute idle timeout, so the preparing view can be looked at and its
wording argued about on a laptop. It honours the two rules the sequence is built around —
standby accepts nothing but PON, and the lamp is not lit the instant PON returns — and
ignores everything else, because the rest would only be scenery. Set
PLAYSTICK_GUI_PROJECTOR= (empty) to get the NullProjector instead, which is the path
that must never stop a film playing.
A file in src/server/playstick/projector/ and a line in its MODELS. Nothing
else in the daemon names a model, an input code or a baud rate. base.py is the whole
interface — power_state, power_on, power_off, set_input, current_input — and
serial_io.py is reusable by anything that frames commands between two bytes.
The projector is silent, so everybody who wants to hear the film listens on their own phone. Keeping that audio pinned to the picture is the hardest thing this appliance does. Nobody hears room speakers, but everybody is looking at the screen, and the eye starts noticing when sound leads the picture by about 45 ms or lags it by about 125 ms — over a Wi-Fi link whose round trip is a good fraction of that on its own.
The daemon publishes the film's clock as a timecode, on /api/status:
"timecode": {"tc": 1421.834, "at": 918273.4551, "rate": 1.0, "epoch": 7}Where the film was, the instant on this machine's clock when it was there, whether it is moving, and which timeline that belongs to. A phone that knows how its own clock compares to the daemon's can evaluate that line continuously, without asking anybody:
film_now = tc + rate * (server_now() - at)
Every audio decision in the page — start, place, seek, nudge, park, switch track, call a stall — reads that one function. Nothing reads a poll.
The clock offset is measured on its own route, and that is the point. What time is it there and where is the film are unrelated facts. One question that answers both — which is what subtracting a polled position from a local clock does — makes a slow poll indistinguishable from a film that moved, and that conflation was the cause of everything below it. So there is a second route that answers nothing else:
$ curl -s http://vivostick/api/time
{"now": 918273.4551, "session": "9f3c1a2b"}It takes no lock, reads no disk and asks mpv nothing, so the round trip it measures is the network and the kernel rather than this daemon's own queueing. A phone times the exchange, takes the midpoint as the estimate and half the round trip as the error bar on it, and keeps the quickest recent exchange — delay is one-sided, so the fastest round trip is the one with the least room to be wrong and averaging it against slower ones can only move it away from the truth. NTP's rule, for NTP's reason. Eight requests fired back to back on arrival lock the offset in under a second; before this the same thing took eight status polls, and a film opened with two seconds of deliberate silence rather than risk placing the audio off a thin estimate.
session is eight hex characters generated once per run. time.monotonic() counts from an
arbitrary origin and a restart picks a new one, so without it a phone would keep applying an
offset wrong by an unbounded amount, with no reading it could take that would say so.
The epoch is what a discontinuity costs. While it holds, the line a phone is
extrapolating is still the right line and a fresh anchor only sharpens it. When it changes,
whatever that phone had is wrong. It moves for a film starting or ending, a pause in either
direction, the demuxer stalling on the NAS, and — the one there is no other way to see — a
time-pos that is not where the last timecode said it would be, which covers whatever mpv
does that this process did not ask it to.
That one number is most of what the rewrite bought. A pause, a resume, a buffering blink and a film change used to empty an eight-sample offset window that took eight polls to refill, and the audio was not allowed to make a sound for any of them. Each of them now costs one seek, because the clock offset and the crystal ratio have nothing to do with which film is playing or where in it we are, and are not touched.
Two things the daemon does so that six phones do not each do them worse. The reading is
stamped around the one IPC call that takes it rather than after the four that follow —
which is what this used to do, dating every sample by however long mpv took to answer them.
And the published anchor is the least-late reading in a four-second window, not the
newest: mpv reports time-pos on frame boundaries, so a reading is up to 42 ms behind the
truth at 24 fps and never ahead of it, and a one-sided error is the kind a maximum removes.
The crystal ratio is measured, not searched for. The offset samples have a slope, and
that slope is this phone's clock against the daemon's — up to ~100 ppm between two consumer
parts, which is 0.7 s over a feature film. Fitting it over three minutes of samples resolves
it to under 10 ppm, and it is fed forward to the element's playbackRate directly.
It is not the whole answer and the code says so. The slope compares the clock that timed the
round trips — performance.now() — against the daemon's; the element is clocked by the audio
hardware, which on iOS is not that oscillator. So the integrator stays, with a far smaller
job: whatever is left is the phone's own CPU against its own DAC. The telemetry reports the
two separately as ratio and drift for exactly this reason — a drift that grows to
look like the old whole-model number is a measurement that is not reaching the element.
This is also what makes a pocketed phone work, and it is the part that is easy to leave out.
iOS keeps a playing <audio> element going when the screen locks but suspends the page's
timers, so for the twenty minutes somebody has their phone in a pocket there is nobody home
to correct anything. A playbackRate baked into the element before the screen locked keeps
working after it locks; an offset correction would not. Coming back out, nothing is torn
down: the winning offset sample has aged out by itself, while the samples that aged out are
still in the window holding up a slope that is exactly as true as when it was measured.
Headphone audio that breaks up for a few milliseconds every second or two only
reproduces on a real phone over real Wi-Fi — where there is no console to read
and nothing to attach a profiler to. So the phone measures and the stick keeps
the record: open the page with ?debug, and every status poll carries the
listener's own numbers in an X-Playstick-Sync request header, which the daemon
writes to the journal next to what mpv believed at the same instant.
ssh vivostick 'journalctl -u playstick-web -f' | grep sync # watch live
ssh vivostick 'journalctl -u playstick-web --since "1 hour ago" -o short-iso' \
> sync.log # keep a filmOne line per phone per second, and one field per thing that could be wrong:
sync 192.168.1.42 playing pos=1421.83 buf=0 v=2;id=8f2c;t=612.4;st=play;hid=0;
ct=1421.79;rs=4;nb=1;ahead=48.2;amin=47.9;err=-38;errp=-41;rate=-712;ratio=-640;
drift=-72;off=-1204.8;ort=11;ns=34;ep=7;tcage=430;rtt=24;trim=0;w=1;dw=140;sk=0;
wt=0;bf=0;lag=22;ls=0
Everything before v=2 is the daemon's own view. After it: id distinguishes
phones (and reloads), t is seconds since that page loaded, ct the element's
currentTime, ahead/amin seconds of buffer now and at its low-water mark,
err/errp sound-minus-picture in ms and its signed peak, rate the
correction command in ppm, ratio/drift the crystal difference measured off
/api/time and the residual the integrator still had to find, off/ort the
clock offset and the round trip it came out of, ep/tcage the timeline being
followed and how old its anchor is, w/dw writes to playbackRate and the
largest of them, wt waiting/stalled events, bf polls where mpv reported
paused-for-cache, and lag/ls the worst shortfall in the element's own clock
and how many exceeded 30 ms. The full legend is in the docstring of
Handler._log_sync.
v=1 captures are still readable. The 2026-08-02 capture below is where
half the controller's constants came from, and sync-log-to-csv.py keeps its
columns — including step and ns, which described machinery the page no
longer has. Its drift is not the same quantity as v=2's: it was the whole
clock-ratio estimate, where the split above now puts most of that in ratio.
Counts and peaks describe the interval since the previous line, not the film. The correction loop runs at 250 ms and the poll at 1 s, so a line that sampled rather than accumulated would miss three quarters of what happened — which is most of the point, since the fault is shorter than either.
Ten minutes of one phone is six hundred lines, which is past the point of
reading them. scripts/sync-log-to-csv.py flattens the journal into a table,
one column per field, and prints a digest first so a wasted capture is obvious
before anything gets plotted:
./scripts/sync-log-to-csv.py --summary sync.log > sync.csv3 telemetry lines from 2 phone(s)
phone lines span play stalls worst lag min buf rate writes gaps
10.0.1.237/d32b8e 612 611s 598 31 310 ms 0.4s 842 4
10.0.1.99/aa11cc 8 8s 0 0 - - 0 0
It reads whichever journalctl format it is handed (-o short-iso, -o json,
-o cat, or the default) and ignores everything that is not telemetry, so a
whole unfiltered journal can be piped in. --id narrows to one page load and
--playing drops the lines from a phone that was not listening.
Three columns are added to the ones the phone sends:
| column | |
|---|---|
dt |
seconds since that phone's previous line — divide the counters by it. The poll backs off to 5 s behind a locked screen, so a pocketed phone otherwise looks calm when it is not |
gap |
1 when a poll was skipped before this line: do not read a trend across it |
ctpos |
ct - pos in ms, the element's clock against mpv's. A coarse cross-check on err, biased by the track-offset and trim corrections err includes and by pos being the daemon's own extrapolation rather than the timecode — good for catching an err that looks healthy because the page's own clock model has drifted |
?debug also puts a Playback parameters section in the sound sheet, one
row per constant the sync loop runs on — seek threshold, proportional and
integral gain, write deadband, rate clamp, error smoothing, clock sample
interval, offset shelf life, ratio fit window, correction interval, stall
threshold. Minus and plus, applied immediately, on the phone that is hearing
the problem.
This exists because the loop between "change a number" and "hear whether it
helped" was an Ansible run, a service restart and a reload — and two of these
constants have already been set wrong from the armchair. SEEK_LIMIT below the
cost of the seek it triggers, RATE_EPS below the noise it was meant to
reject: neither is findable without a real device making a real sound.
Values are shown in the same units the telemetry uses — ppm and milliseconds —
so a number read off a capture can be typed straight back in. Each row names
the constant and its shipped value, so git grep still finds the thing you
just changed.
Three rules, all deliberate:
?debugonly. A value tuned during one film would otherwise sit in that phone'slocalStoragefor good, invisibly, and the next listener would be debugging a build that exists nowhere.- This phone only. Nothing is sent to the daemon and no other listener is affected. Six people can hold six different tunings at once, which is the cheapest A/B this system will ever offer.
- Every telemetry line records them, in a
tunfield listing whatever is not the shipped value (tun=sl:350,re:300). Empty on a stock build. Without it a capture taken mid-experiment is a capture of an unknown build.
Reset to shipped puts everything back. Nothing survives a reload without
?debug, so the way out of a tuning that made things worse is to drop the
query string.
./scripts/sync-log-plot.py sync.csv -o sync.html # or pipe the journal straight inA standalone HTML file — inline SVG, no libraries, no network — with every
metric stacked against one time axis. Hovering anywhere reports every field at
that instant, which is what an SVG <title> is for and why there is no
JavaScript in it.
One axis, because none of these faults is visible in a single series. A
dropout is a coincidence: the element lost time and the rate was written, or
it lost time and the buffer collapsed, or it lost time and neither. Two
separate plots make that a guess. The panels are, in order: sync error with the
±45/−125 ms perception band drawn in, element clock loss against the 30 ms
stall threshold, per-interval counts (stalls, rate writes, seeks,
waiting/stalled, mpv buffering), playbackRate against its clamp, buffer
headroom, and rtt/ns/dt. A state strip along the top says whether the phone
was even playing, and stretches where a poll was skipped are shaded — a spike
in a count there may only mean the interval was five seconds instead of one.
--id picks a page load when several are in the capture (one page is one
phone; two of them share no clock), and --start/--end clip to a window in
seconds. Y ranges are set by Tukey fences, so a 13-second startup error does
not flatten the next four minutes; anything outside is drawn on the panel edge
and the panel says so.
Three readings settle which of the two candidate causes it is:
| what the log shows | what it means |
|---|---|
ahead/amin collapsing toward zero |
the daemon's pacing or the radio is starving the element |
w/dw moving with the dropouts |
this page is re-arming the render pipeline; on iOS playbackRate lands on AVPlayer.rate |
lag/ls at zero through an audible break |
neither — the clock never stopped, and the interruption is below anything the page can observe |
lag sits around 16–21 ms even when nothing is wrong: iOS reports currentTime
on decoded-frame boundaries, 21.3 ms for AAC-LC, so a reader sampling at 4 Hz
sees a staircase. That is why ls only counts shortfalls past 30 ms.
Nothing is logged unless a client sends the header, and only the page with
?debug does — there is no server-side switch, because the alternative to
logging this is not logging less, it is having no way to see what a phone in
another room was doing. The value is a header from an unauthenticated LAN
client, so it is filtered to [A-Za-z0-9=;:.,+_-] (newline and % are not in
that set), truncated to 400 characters, passed to the log call as an argument
rather than interpolated, and capped at 20 lines a second across all clients —
triple the design load of six phones, and a bound on what a client stuck in a
retry loop can do to a journal sharing 32 GB of eMMC with everything else.
68 seconds of an iPhone over Wi-Fi, 2026-08-02, and it settled the question in
the table above on the second row. Buffer headroom ran 80–296 s and never
dipped; mpv never reported paused-for-cache; the element never fired
waiting/stalled on a line where it lost time. Not starvation, and not the
daemon's pacing. What it was:
| stalled | clean | |
|---|---|---|
wrote playbackRate in that second |
7 | 1 |
| didn't | 1 (the startup seek) | 58 |
Each write cost about 43 ms — two AAC-LC frames, which is what a re-armed
AVPlayer discards — and up to three ticks in one second, so 110 ms of audio
gone. ctpos, which compares mpv's clock to the element's without involving
the page's own model at all, confirmed the loss on every one.
The writes were not the disease. rate sat at the +20000 ppm clamp on 64 of
67 playing lines, because the element had been placed 1.01 s behind the
film at the top of the playback and RATE_LIMIT can only walk that out at 2%.
The command leaves the clamp when |err| < RATE_LIMIT/KP = 133 ms; the |err|
on all seven writing lines was 97–132 ms. So: error shrinks to ~100 ms, command
comes off the clamp, write, 40–110 ms lost, error back to ~200 ms, re-saturate.
A limit cycle, and the audio stayed 100–250 ms behind the picture — past the
125 ms where a listener sees it — for the whole capture without converging.
The second was inherited at t=24.1, from ns=1: the element was placed off
a single offset sample. The max filter in sndSample() is what rejects a
sample that arrived late, and with one sample there is nothing to reject, so
the daemon's cached position — stale, because mpv had only just started — went
straight into the target. Two changes, both in playstick-ui.html:
SEEK_SAMPLES = 3. Nothing is placed until the offset window has a population. The element is not started before then either, so a film opens with up to two seconds of silence rather than with a second of error that takes a minute to come out. A phone coming out of a pocket is the one case that keeps playing through a thin window —visibilitychangeempties it on purpose while keeping the clock ratio, and silencing that phone every time somebody glanced at the time would be its own bug.SEEK_LIMIT1.0 → 0.25 s. An error takeserr/RATE_LIMITto nudge out, so the old limit meant fifty seconds pinned at the clamp with the integrator frozen. 0.25 caps that at twelve, and stays above the worst standing error ever measured (216 ms) so that a loop which somehow re-saturates degrades into nudging rather than into a cut every few seconds.
Replaying the capture's opening against the page's real clock model: the old constants plant the element 1180 ms late, the new ones land it within 10 ms.
Both of those fixes were mitigations for a payload that could not say when.
The daemon reported a position with no instant attached, so a phone had to
guess how stale it was — and the whole apparatus above, the eight-sample
window, the max filter, SEEK_SAMPLES, the two seconds of opening silence,
existed to make that guess survivable. A timecode carries the instant, so there
is nothing left to guess and nothing left to average.
What survives the rewrite, deliberately: SEEK_LIMIT, RATE_EPS, ERR_LP,
RATE_LIMIT and STALL keep their values and their reasons. They describe
an iPhone's audio pipeline rather than the architecture that was replaced, and
they were not derivable from the armchair — two of them had already been set
wrong from it. SEEK_SAMPLES and the offset slew are gone with the machinery
they served. The capture's opening is still replayed in tests/js/clock.js,
now as the acceptance test for the new model: the same reading, taken 1.19 s
before it was sent, must land the element within 10 ms.
One thing the rewrite did not settle, and the next capture should. At a full
21.3 ms currentTime quantum the loop writes playbackRate on about half its
ticks, because ERR_LP leaves the command moving by ~90 ppm a tick against a
RATE_EPS of 100. The 2026-08-02 capture shows a real iPhone writing once or
twice a second, so the device's own currentTime is smoother than a
full-amplitude staircase — but by how much is not something a harness can know,
and it is not a number to set from the armchair either. tests/js/clock.js
bounds the failure that would matter (writing on every tick, at 43 ms of audio
each) and leaves the rest to a measurement.
Deploying used to change nothing on any phone in the house. / is served
no-store, so a browser that asks for the page always gets the current one —
but after the first visit none of them ask again. The page polls /api/status
forever and never navigates, so a tab opened last month keeps running last
month's JavaScript until somebody thinks to pull down and refresh. Children do
not refresh, and the adults do not think to.
So the daemon stamps each copy on the way out and reports the same value on every poll:
$ curl -s stick.local/ | grep 'var BUILD'
var BUILD = "c20e48476c19";
$ curl -s stick.local/api/status | python3 -m json.tool | grep build
"build": "c20e48476c19",A page whose own stamp no longer matches reloads itself, normally within three seconds of the deploy finishing.
The stamp is a hash of ui.html, and every alternative was worse. A version
number is something somebody has to remember to raise. The daemon's start time
would order every phone in the house to reload after a power cut, for a page
that had not changed by a byte — and copy gives the file a new mtime on every
playbook run, most of which have nothing to do with the page, so a timestamp is
the same mistake with extra steps. Hashing the daemon as well would be wrong in
the other direction: this payload is additive by house rule, an older page
against a newer daemon is a supported combination, and reloading for it would
be interrupting people to deliver nothing.
It waits for a moment when there is nothing to lose. A reload is a page that forgets which film it is following, throws away the clock offset the headphone sync spent a minute measuring, and drops the audio element out of somebody's ears. So a mismatch found while a film is playing, paused, or a lamp is warming is held until the state goes idle. In practice the wait never happens — the deploy restarts the daemon, which stops the film — but a deploy run while somebody was watching should not be the thing that ends it.
The hash is computed from the file on disk and cached against its mtime and
size, which costs one stat per poll and gets the dev container the same
behaviour for free: rebuild the page (cd src/player && ./dx npm run build),
and every browser pointed at docker compose up gui reloads itself. That is the
shipped mechanism, exercised every time anybody touches the page.
The same stamp busts the posters and the soundtracks. There is no third
asset to worry about — no CDN, no web fonts, no external images, one file — but
reloading the page does not empty a browser's image or media cache, and those
two are the only things this page fetches that a browser is allowed to keep. A
prepared poster is held for a day, an extracted frame for a year under
immutable, and a soundtrack for an hour. So every URL for one carries the
build:
/api/thumb/0123456789abcdef?v=c20e48476c19
/api/audio/0123456789abcdef/0?v=c20e48476c19
It goes in the query, and that is the whole reason it is safe. Every route matches against the parsed path, so nothing on the daemon reads this — no new value crosses the boundary, and those routes still accept exactly what they accepted before: an opaque sixteen-hex id and a small integer. The no-authentication argument is untouched.
The cost is one deploy's worth of re-downloading: after a playbook run every
phone pulls the posters in its grid again, and a listener who reconnects pulls
their soundtrack again rather than resuming from the hour-long cache entry.
That is the trade being made deliberately — a poster or a track cached for a
year that the current release no longer produces is a fault nobody can see and
nobody can clear, and Ctrl-Shift-R is not a thing you can ask a child for.
ufw is purged by explicit decision, so port 80 is open to the LAN and anyone on it can
start a film — consistent with UxPlay next door accepting unauthenticated mirroring.
player_allow_networks rejects clients outside RFC1918 at the handler, which keeps a
misconfigured router from publishing the UI to the internet and is not a defence against
anybody already on your Wi-Fi. The control that does matter is that no filesystem path ever
crosses the HTTP boundary: the page addresses films by an opaque id that indexes a table
the daemon built from the index or by walking the share, every resolved path is re-checked
for containment before it reaches mpv, and no endpoint takes a path.
Exactly one endpoint streams file bytes, and this sentence used to say that none did.
/api/audio is what lets several people watch one silent projector and each hear their own
language in their own headphones. It is narrowed to the point of being dull: the route is a
regex matched against the whole path, the id must be exactly the sixteen lowercase hex
characters the library table is keyed by, and the track is a small integer indexing a list
whose paths were already proved to sit under the library root when the index was read. The
only files it can name are ones playstick-prep.py wrote.
/api/time is the newest route and the smallest. It answers {"now": <float>, "session": <8 hex>} and nothing else: a counter with an arbitrary origin, and an opaque name for one
run of one process. It reveals no path, no film, no address and no uptime that /healthz
did not already imply, it takes no argument — a query string is ignored, like everywhere
else here — and it is the one route that touches neither the library nor mpv, which is why
it can be answered before either of them. It is still behind
player_allow_networks.
systemctl disable --now playstick-web.service # rollback
systemctl disable --now srv-movies.automount # and the shareDockerfile.gui runs the player without a VivoStick, a projector or a NAS in
the loop, so the page can be iterated on at a desk — and looked at on a phone,
which is what it is for.
docker compose up gui # http://localhost:8080/ -> :80 inside
PLAYSTICK_GUI_LIBRARY=~/Videos docker compose up gui # your own films
docker compose down -v # reset the library and postersThe container serves on port 80 exactly as the device does; only the published port is
8080, because a host port below 1024 is refused under rootless Docker. Override with
PLAYSTICK_GUI_PORT.
The daemon and the page are bind-mounted — the daemon from src/server/ and the
built page from src/player/dist/ — and the daemon re-reads ui.html on every
request: rebuild the page (cd src/player && ./dx npm run build) and reload the
browser. Python changes need docker compose restart gui.
The daemon itself is the playstick/ package next to playstick-web.py under
src/server/, which is only an entry point — it prefers a playstick/ sitting
beside it, so a clone runs without being installed:
| module | what is in it |
|---|---|
__init__.py |
why the daemon arbitrates the display itself, and what never crosses the HTTP boundary |
config.py |
every environment variable, read once |
airplay.py |
the UxPlay interlock: one sample, and the debounced version |
library.py |
Library, plus the title cleaning and sidecar search |
thumbs.py |
Thumbs, and the placeholder for films without a poster |
player.py |
Player and Busy — mpv, and the timecode every phone follows |
http.py |
Handler — every route |
main.py |
build the workers, hand them to the handler, serve |
With no library mounted the
entrypoint generates one out of lavfi test patterns, named the way a real
collection is — most of what the library code does is undo those names, and a
test library of movie1.mkv would never exercise it. Two of the seven files
are filtered on purpose, one by the skip regex and one by player_scan_depth.
What it tests faithfully. mpv is real, so the library scan, clean_title,
the poster pipeline, the play/pause/stop state machine, the progress bar and
the AirPlay interlock behave exactly as they do on the device. The interlock is
the interesting one, and it needs no iPhone — the daemon only ever asks ss
whether something holds an established connection to UxPlay's port:
docker compose exec gui fake-airplay # ^C to releaseThe grid greys out, and a play request is refused with 409. Expect the refusal
to take two seconds: airplay_confirmed() samples twice, a second apart.
What it cannot tell you: anything about the display, which is most of what
makes this project hard. A container has no DRM node, no tty1, no VA-API and no
systemd, so --vo=drm, --drm-mode, the DRM-master arbitration against
uxplay-kms.service and every number under Results stay
device-only questions. mpv runs --vo=null here: the film is decoded and paced
in real time and nothing is drawn. Two deviations from the unit are deliberate
and worth knowing before trusting what you see — PLAYSTICK_MIN_SIZE_MB=0,
because the generated clips are a few MB against the device's 100 MB floor, and
PLAYSTICK_AUDIO=1, because the page hides its volume controls otherwise and
they would never be looked at. Set it to 0 to see the layout as it ships.
python3 -m unittest discover -s tests # ~355 tests, about 12 seconds
./actl 'python3 -m unittest discover -s tests' # the same, in the control nodeNo dependencies and no device: unittest from the standard library, a real
ThreadingHTTPServer on a loopback port the kernel picks, and fakes in place of
the three workers behind the handler. What is exercised is the wire — status
lines, headers, Range arithmetic, and when each piece of a paced body arrives.
Two things to know before adding a test, both in tests/support.py:
configuration is read from the environment once at import of
playstick.config, so tests reach the package through the names that module
re-exports rather than importing it themselves; and because http.py binds its
constants with from .config import ..., overriding one for a single test means
patching it in the handler's namespace, which is what patched() does.
The audio route has its own file. Most of what is in it is there because the
client is iOS Safari: Range: bytes=0-1 answered with a 200 rather than a 206
makes it refuse the resource outright, and it will pull a progressive file as
fast as the socket allows across the one radio that is also reading the film off
the NAS — hence the pacing, and hence a test that asserts the delivery
granularity AUDIO_CHUNK implies rather than only its value.
tests/test_sync_csv.py covers the telemetry-to-CSV script above, and needs
none of that harness. A field quietly landing in the wrong column there would
not look like a failure — it would look like an answer.
tests/test_prep_metadata.py is the same argument about the library index, and
it exists because that failure really happened: every case in it is a real file
from a real shelf, under its own name, with the runtime ffprobe measured and
the result set TMDb actually returned. It holds the mux date out of the year,
the release string out of the title, and — the one that matters — asserts that
being asked the wrong question is survivable: fed the recorded year=2012
response for Hook, the lookup returns nothing rather than Red Hook Summer. It
also diffs clean_title() against the daemon's copy, because the comment saying
those two are kept byte-for-byte in step was, until now, only a comment.
tests/test_prep_media.py is the same argument again, one level down: what prep
names the encode it made. That one also really happened —
033fa22cc64e9f97-f1-the-movie.mp4 and 033fa22cc64e9f97-f1.mp4 side by side,
the same film twice, because the name used to carry a slug of the title and the
title is re-derived on every run. It is the quietest class of bug this tool has.
Nothing returns an error, nothing looks wrong on the projector, and the only
symptom is a share filling up at twice the rate it should. So the file holds the
naming contract from both ends: one encode per film, named for the id alone, and
everything else that shares that id — another film's encode, a poster, a
subtitle, a half-written .part, a --force run that failed — left exactly
where it was.
The page is TypeScript under src/player/, built into one self-contained
dist/playstick-ui.html, and has its own test and benchmark suite that needs
node:
cd src/player
./dx npm test # vitest: every module's unit tests
./dx npm run bench # vitest bench: the mobile hot-path budgets
./dx npm run build # bundle -> dist/playstick-ui.html (committed)./dx runs the toolchain in node:22 when there is no local node, the same
fallback as the Python control node. Each module carries both a *.test.ts
and a *.bench.ts, and the controllers take their clock, network, storage and
audio element as injected seams — so time is a variable the test holds,
which is the only way a controller is testable at all, and a route can be given
a round trip and a one-sided delay so an estimate built out of timing is tested
against a truth the test knows exactly.
clock.ts is the offset and the crystal ratio — the quickest exchange wins and
a congested one cannot walk the estimate off the truth, a drifting offset is
fitted to a slope rather than chased, and a daemon that restarted resets rather
than being absorbed. timecode.ts and sync.ts are the audio: where the film
is on this phone's clock, a stopped timeline parking the element, a
discontinuity costing one seek and not a clock model, the two write gates that
keep the loop from feeding on its own 43 ms writes, and a phone whose DAC is off
by tens of ppm being closed without a seek. library.ts is the grid's filters
— A to Z byte-identical to the order the server sent, a film with no year last
in both year orders, no combination leaving the grid empty without a way back
— and dom.ts is the keyed reconciler, which moves nothing on an unchanged poll
and lands a poster arriving mid-scan on its own tile. The views, the presenter,
the two sheets, the poll loop and the debug telemetry each have their own file;
the benchmarks assert the per-tick and per-poll paths stay allocation-free.
Four sweeps. The first three were taken on a 2560×1440 monitor at its EDID-preferred mode — not on
the projector this device is for — so every row carried a 1080p→1440p rescale. matrix-20260731-153753
is the one that counts: 78 runs at the projector's native 1280x720@60, where the 720p rows are
pixel-exact with no scaling anywhere in the chain.
Zero FIFO underrun messages across the whole sweep. At 74250 kHz / ~297 MB/s the bandwidth
problem that started this investigation is closed.
Frame metrics reproduce across independent sweeps to within about 1%. cpu_pct does not —
identical config (1080p vaapih264dec → fakesink) measured 18.0% in one sweep and 3.5% in
the next. It is sampled system-wide from /proc/stat over the run window, so 100% means all four
cores, and it is meaningless on short or failed rows. Conclusions rest on fps and drop rate.
Only three to-screen rows in 78 reached ok, and all three are avdec_h264:
| clip | pipeline | fps | drop | CPU |
|---|---|---|---|---|
| 720p | avdec_h264 → videoconvert → kmssink |
29.19 | 0.00% | 40.1 |
| 720p | avdec_h264 → videoconvert → kmssink skip-vsync |
29.21 | 0.00% | 41.2 |
| 1080p | avdec_h264 → videoconvert → kmssink skip-vsync |
28.55 | 0.51% | 49.8 |
Best hardware-decode rows, same glue and sink:
| 720p | vah264dec |
19.76 | 0.33% | 17.5 |
|---|---|---|---|---|
| 720p | vaapih264dec |
19.58 | 0.00% | 17.5 |
| 1080p | vaapih264dec |
13.39 | 16.58% | 18.3 |
This retires the assumption the project was founded on. It is not a rescaling artefact: the 720p rows have no scale in them at all.
Why hardware decode loses. vaapih264dec into fakesink is 29.59 fps at 11.4% CPU — the
cheapest number in the matrix. Decoding is not the problem; delivery is. Convert the to-screen
rows to milliseconds per frame and fit against pixel count:
vaapih264dec 720p 51.1 ms 1080p 74.7 ms -> 20.5 ms/Mpx + 32.2 ms fixed
vah264dec 720p 50.6 ms 1080p 87.9 ms -> 32.4 ms/Mpx + 20.7 ms fixed
A per-frame cost of 20–32 ms that does not scale with resolution, against a 33.3 ms budget,
while CPU sits at 17.5% of four cores. Idle and failing is the signature of a stall, not of
compute. No memory:VAMemory or memory:DMABuf feature appears in any negotiated caps, so
zero-copy never happens and every frame is read back out of GPU memory over an uncached mapping.
avdec_h264 decodes into cacheable system memory, never reads back, costs 2.5× the CPU and wins.
The 19.58 fps / 0.00% drop row is worth keeping as a teaching case: fpsdisplaysink counted
1197 frames and dropped none, because 603 never reached it. drop_pct alone is blind to that,
which is why the status rule also requires fps ≥ 0.95 × nominal.
| sink | result |
|---|---|
kms-default / kms-novsync |
the only working paths, and only with videoconvert |
kms-modeset (force-modesetting=true) |
0 of 24. Broken on this driver |
waylandsink |
0 of 18 — a harness artefact, see below |
glue none |
0 of 24, not-negotiated. kmssink always needs a converter |
kms-modeset is not academic: UxPlay's -fs sets exactly that property, and it is why
uxplay-kms.service shipped a command line that could not start. The sweep found the bug before
the service ever ran.
vapostproc as glue is a trap — 21 of its 24 rows land between 45% and 52% drop, including with
avdec_h264, which produces no VA surfaces at all. It uploads system memory to a VA surface and
pulls it straight back, manufacturing the very stall described above.
| config | default | skip-vsync |
|
|---|---|---|---|
vah264dec 720p |
6.76 | 19.76 | 2.9× |
vaapih264dec 720p |
7.26 | 19.58 | 2.7× |
avdec_h264 1080p |
27.35 | 28.55 | +4% |
avdec_h264 720p |
29.19 | 29.21 | — |
Large for hardware decode, nil for the configuration that ships. Earlier sweeps recorded this as "a large win"; at a native 60 Hz mode that reading does not survive, and tearing is not worth 4% on a path we no longer take. Deliberately not applied to the unit.
waylandsinkcannot be judged from this data. The probe runs pipelines bare underopenvtwith no compositor for it to connect to, so 0 of 18 says nothing about the cage path. The column should either wrap incageor be dropped — as it stands it reads like evidence and is not.- The 720p/1080p CPU inversion, third reproduction.
avdec_h264→fakesinkcosts 65.6% at 720p against 46.6% at 1080p, both at full rate. Not thermal (core_throttle_count = 0), and not the display mode — it has now appeared at 1440p, 1080p30 and 720p60. The 1080p clip carries more bits per frame and more pixels, so the obvious explanations point the wrong way. The one left standing:make-testclip.shdoes not produce a pixel-proportional pair (6 Mbps at 720p is 0.217 bits/px against 10 Mbps at 1080p's 0.161), and IDCT/deblocking scale with non-zero coefficients rather than pixel count. Cheap to test — re-encode 720p at 4.4 Mbps and re-run the twofakesinkrows.
A first-person write-up of how this was measured — including several ways the harness fooled me before it produced anything trustworthy — is in docs/matrix-narrative.md.
uxplay-probe-matrix.sh sweeps decoder × sink × resolution and writes a CSV:
| dimension | values |
|---|---|
| decoder | avdec_h264 (software baseline), vah264dec, vaapih264dec |
| sink | fakesink (ceiling), kms-default, kms-modeset, kms-novsync, waylandsink (under cage) |
| clip | 1280×720@30, 1920×1080@30 |
Narrow a sweep with -e probe_decoders=… and -e probe_sinks=… (comma-separated sink labels).
The kms-* variants isolate one property each, so a failure points at a specific cause rather
than at a bundle — that is how driver-name and force-modesetting were separated.
Everything runs with sync=true against a clip at native rate, so a combination that cannot keep
up shows as dropped frames — the same failure mode AirPlay mirroring would have — rather than
as a slow batch job. Pipelines run on a real VT via openvt, because over SSH there is no console
at all (fgconsole says so) and neither DRM master nor a seat can be acquired without one.
The glue column records what GStreamer needed between decoder and sink. Empty is the good
answer: DMABuf straight into kmssink with no copy. A vapostproc or videoconvert there means
negotiation refused the zero-copy path and you are paying for a full-frame download.
How status is decided. A row is ok only if drop rate < 5% and sustained fps ≥ 95% of
nominal. Both conditions are needed, and getting this wrong three times is most of the story of
this repo:
- exit code —
openvtdoes not propagate the child's, so every row read as an error; - drop rate alone — a row at 5 fps discarding half its frames was reported
ok, which also stopped the glue loop before it reached the variant that worked; - drop rate alone again —
fpsdisplaysinkcounts only frames it received and threw away, so a run can reach EOS reportingdropped: 0having shown 1427 of 1800 frames.
rc is still recorded, but nothing is judged by it.
With 1537 MB usable RAM and four 1.44 GHz cores, anything running in the background is competing
with the decoder. The trim role strips the server install back toward what a kiosk needs. Every
entry was chosen from what is actually installed and running on this device, not from a generic
debloat list.
Purged (109 packages including dependencies): apport, bolt, fwupd, kdump-tools,
landscape-common, modemmanager, multipath-tools, networkd-dispatcher, open-iscsi,
packagekit, plymouth, rsyslog, snapd, software-properties-common, udisks2, ufw,
upower.
Modules blacklisted: spi_intel_platform, spi_intel. These fail to bind on this firmware
and log two errors onto a console that happens to be the projector:
intel-spi intel-spi: unsupported C0DEN: 0xc
intel-spi intel-spi: probe with driver intel-spi failed with error -22
spi-intel drives the BIOS SPI flash. Nothing here needs it, it cannot work on this hardware, and
a driver capable of writing firmware is not one to leave loaded on a kiosk. Both blacklist and
install … /bin/true are written, because blacklist alone does not stop a modalias-triggered load.
kdump-tools deserves its own note. It brings /etc/default/grub.d/kdump-tools.cfg, which is
where crashkernel=2G-4G:320M,… on the kernel cmdline comes from. On this box that reserves
nothing — /proc/iomem shows 00000000-00000000 : Crash kernel, because total RAM falls below
the 2 G floor of its own range list — yet the service runs anyway, servicing a crash kernel that
cannot exist. Purging the package is what removes the cmdline; editing /etc/default/grub would
not, since the snippet re-adds it. Takes crash, kexec-tools and makedumpfile with it.
Boot console noise is deliberately left alone (trim_quiet_boot: false). Tempting on a device
whose display is the output, but the message that cracked the performance investigation open was
i915 … *ERROR* CPU pipe C FIFO underrun, read straight off tty1. Hiding that class of message
costs more than a tidy boot screen is worth while anything is still being measured.
Disabled but kept: periodic timers (apt-daily, motd-news, man-db, dpkg-db-backup,
e2scrub_all) and unattended-upgrades. fstrim.timer is deliberately left alone — TRIM is worth
having on eMMC.
Kept on purpose: thermald (a passively cooled stick doing sustained video decode wants
thermal management), chrony (pairing and TLS care about time), lvm2, dbus, avahi-daemon.
Three safety properties, because this role removes things from a device reachable only over SSH:
- A
trim_protected_packageslist is asserted against the purge list before anything runs.lvm2is on it — root is/dev/mapper/ubuntu--vg-ubuntu--lv, so removing it would leave an unbootable box. - cloud-init is disabled, never purged. Purging it can take
/etc/netplan/50-cloud-init.yamlwith it. That file is how this headless device gets on the network. - The purge was verified with
apt-get --simulate ... --autoremoveagainst the real machine before being enabled; the audit confirmed no critical package is dragged out by autoremove.
rsyslog going away means journald is the only log sink, so it gets capped at
trim_journal_max_use (64 M) rather than being left to grow into the eMMC.
Set trim_enabled: false to skip the whole role.
No firewall. ufw is purged by explicit decision. UxPlay listens on 7000–7002 (TCP and UDP)
plus 5353/udp, and nothing filters them — the device is only as protected as the LAN it sits on.
Patching is manual. unattended-upgrades is disabled because an unattended apt run on this CPU
would visibly disrupt a live mirroring session. That trade buys smooth playback and costs automatic
security updates; run apt update && apt full-upgrade yourself periodically.
From uxplay-probe-facts.sh, 2026-07-31:
| VA-API driver | Intel i965 driver for Intel(R) CherryView - 2.4.1, VA-API 1.23 |
| H.264 decode | VAProfileH264High : VAEntrypointVLD — hardware decode confirmed |
| Elements | vah264dec, vaapih264dec, vapostproc, kmssink, waylandsink all present |
| Display | HDMI-A-1 connected, connector id 120, EDID good — see On drm_force_mode |
| Network | enx6c6e070fd5e1 — USB Ethernet, 107 Mbps sustained to the control machine |
| Display server | no Xorg, no compositor |
Two things this settled:
The network risk is closed. The stick is on a USB Ethernet adapter, not its SDIO Wi-Fi, and sustains 107 Mbps — four to ten times what 1080p mirroring needs.
VA plugins need render access to register. gst-inspect-1.0 vah264dec and vainfo report
nothing at all when run by a user outside the render group, which is indistinguishable from a
failed install. base_login_gpu_access puts the login user in video/render to close that trap.
Run the probes as root regardless.
AirPlay discovery is mDNS, so the iOS device must be on the same L2 segment.
systemctl disable --now uxplay-kms.service uxplay-cage.service
systemctl disable --now uxplay-idle.service # stop the console clock
systemctl unmask --now getty@tty1.service # restore the local consoleThe first line is undone by the next site.yml run, which is the point of uxplay_output_path:
set it to none if the receiver should stay off across provisions.
uxplay-idle.service sets TTYReset=yes, so stopping it hands tty1 back in a usable state. The
console font and blanking timeout it changed are not restored — setfont with no argument and
setterm --blank 10 do that, or just reboot.
All in group_vars/all.yml.
| variable | default | what it does |
|---|---|---|
drm_force_mode |
1280x720@60 |
Forces the HDMI mode via kernel cmdline. Reboot required. |
drm_force_connector |
HDMI-A-1 |
Which connector the above applies to |
drm_card_path |
(auto) | i915 DRM node; discovered, not assumed to be card0 |
uxplay_output_path |
kms |
Which receiver is enabled and started: kms, cage or none |
uxplay_decoder |
avdec_h264 |
Software. See Results — measured, not a compromise |
uxplay_request_size |
(derived) | -s, the resolution asked of the client. Follows drm_force_mode |
uxplay_request_fps |
30 |
Frame rate asked of the client; not the scanout rate |
uxplay_extra_args |
"" |
Appended verbatim to both units. -nc is rejected — see Idle screen |
uxplay_nofreeze |
true |
-nofreeze, so the display is released when a session resets |
idle_enabled |
true |
The framebuffer clock shown when nothing is mirroring |
idle_blank_minutes |
30 |
Black the screen after this long idle; 0 never blanks |
idle_fonts |
(list) | PSF files the clock renders glyphs from; first readable wins |
idle_time_format |
%H:%M |
strftime format for the clock |
idle_clock_scale |
0 |
0 fits the clock to the framebuffer; set a number to pin it |
idle_subtitle |
"" |
Second line; empty derives name@hostname from the UxPlay config |
uxplay_advert_name / uxplay_port |
Projector / 7000 |
mDNS name, and ports n..n+2 |
i965_nonfree_shaders |
true |
Picks the multiverse driver build; the two Conflict |
libva_driver |
i965 |
Pinned; iHD does not cover Gen8 LP |
base_login_gpu_access |
true |
Login user into video/render so vainfo works unsudoed |
enable_zram / claim_tty1 |
true / true |
zram swap; mask getty@tty1 for kmssink |
trim_enabled |
true |
The whole trim role |
probe_iperf_server |
"" |
Control-machine address for the throughput test |
nas_server / nas_share |
"" / "" |
The CIFS movie library. Set these in host_vars/vivostick/local.yml, not here. Both empty skips the role entirely. nas_share is the share name, not the server-side path |
nas_mount_point |
/srv/movies |
Where the share appears; the unit names are derived from it |
nas_username / nas_password |
"" |
Vault these. Empty username mounts as guest |
player_vo / player_hwdec |
drm / no |
Unproven pair — see Movies from the NAS |
player_audio |
false |
--ao=null. Films play silently until HDMI audio is probed |
player_enabled / player_port |
true / 80 |
The web UI. Below 1024 needs player_user: root, which is the default |
player_index_file |
<library>/playstick-library.json |
The index playstick-prep.py writes. Present means the daemon reads it instead of walking the share; "" ignores one that is there |
player_subtitles |
true |
Hand mpv the subtitles the prep tool extracted. They are passed as --sub-file because the share is read-only and --sub-auto cannot reach them |
player_airplay_unit |
(derived) | The unit the player stops to take DRM master. Follows uxplay_output_path |
player_projector_model |
"" |
pt-ae4000, pt-ae3000u, or empty for no projector. Run scripts/projector-probe.py before setting it — see Turning the projector on |
player_projector_device |
(auto) | Serial port; discovered under /dev/serial/by-id, not assumed to be /dev/ttyUSB0 |
player_projector_input |
HD3 |
The IIS: parameter. Empty leaves the input alone |
player_projector_idle_minutes |
30 |
Lamp off after this long with no film and no mirroring; 0 never |
player_projector_wake_on_airplay |
true |
Whether mirroring may strike the lamp as well as a film |
player_projector_airplay_wake_ticks |
2 |
Consecutive confirmed ticks first. Raise this if the projector ever switches itself on unbidden |
The rest live in roles/nas/defaults/main.yml and roles/player/defaults/main.yml, the way
idle_* and trim_* already do.
This is the one tunable set from a measured failure rather than a preference. On the 1440p monitor
used for development the kernel logs i915 0000:00:02.0: [drm] *ERROR* CPU pipe C FIFO underrun at
the preferred 2560×1440 — the display engine's FIFO running dry mid-scanline, which corrupts the
picture regardless of what fps the pipeline reports. Scanout bandwidth tracks the pixel clock.
The real target is now attached: a Panasonic PT-AE4000 projector (EDID monitor name AE-4000,
2009, 1080p LCD). Its 17 modes, abridged, with scanout cost at 4 B/px:
| mode | pixel clock | ~scanout @ 4 B/px | |
|---|---|---|---|
| 1920×1080i @ 60.00 | 74250 kHz | ~297 MB/s | EDID preferred |
| 1920×1080 @ 60.00 | 148500 kHz | ~594 MB/s | |
| 1920×1080 @ 50.00 | 148500 kHz | ~594 MB/s | |
| 1920×1080 @ 24.00 | 74250 kHz | ~297 MB/s | default |
| 1920×1080 @ 23.98 | 74176 kHz | ~297 MB/s | |
| 1280×720 @ 60.00 | 74250 kHz | ~297 MB/s |
Three things this changed:
The preferred mode is interlaced. Both detailed timings in the base EDID block are 1080i.
Leaving drm_force_mode empty does not merely cost bandwidth here — it puts a deinterlacer in the
path. That alone justifies forcing a mode on this display.
The projector advertises no 1080p30. CEA VIC 34 is absent from its video data block. The
earlier 1920x1080@30 default was therefore a kernel-synthesized CVT mode — modetest reported
type: userdef — at 80192 kHz. It was legal for this panel (range descriptor: 24–61 Hz, 28–68 kHz,
150 MHz max pixel clock; HDMI VSDB: 190 MHz max TMDS) and it did lock, but it was not a mode the
display declares.
@23.98 is not expressible on the kernel cmdline. drm_mode_parse_cmdline_refresh() reads the
refresh with simple_strtol, so the . ends the parse, the whole video= argument is discarded,
and the connector falls back to the preferred mode — 1080i60 — with no warning. A fractional parser
would not help either: drm_mode_vrefresh() rounds with DIV_ROUND_CLOSEST, so 23.976 and 24.000
both report 24 and the first match in the mode list wins. The two differ by 74 kHz, about 0.1% of
scanout. Hence @24.
The tradeoff being accepted: AirPlay mirroring is a 30 fps source and 24 Hz cannot show it evenly — one frame in five is dropped or held, which is visible on motion and on cursor movement. That buys 321 → 297 MB/s, roughly 7.5%. Two alternatives at the same or better smoothness:
1280x720@60— same 297 MB/s, smooth, trades resolution instead.1920x1080@60— smooth at full resolution, but 594 MB/s, which is the territory the FIFO underruns came from.
Set drm_force_mode: "" to use whatever the EDID prefers (here: 1080i60).
Note that the projector offers nothing above 1080p, so the 1440p rescale that inflated every development sweep is gone on the real target.
i915.enable_fbc=0 remains a deliberate non-default: apply only if tearing or flicker shows up.