Skip to content

roku_companion: a Roku TV as a PyDevices display, with its remote as input - #158

Merged
bdbarnett merged 8 commits into
mainfrom
cast/roku
Oct 6, 2026
Merged

bdbarnett merged 8 commits into
mainfrom
cast/roku

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Put a PyDevices app on a Roku TV and play it with the TV's remote. RokuDisplay is a framebuffer display whose frames show full-screen on the TV, and get_events() turns the remote's buttons into ordinary key events, so App(host_read=display.get_events) drives an app from the couch. Run any example through lib/examples/roku/board_config.py and it gets both; testris plays that way.

The TV side is a small sideloaded channel, the PyDevices Companion, in tools/roku_companion_app (with sideload.sh and a README). The library also does text-to-speech, a text dashboard and audio through the TV. Frames are PNGs from pygraphics' encode_png, which no pygraphics release has yet (PyDevices/pygraphics#36 is merged, unreleased).

Tested on a 65" TCL Roku:

  • All four examples work: TTS, the dashboard, camera frames, and the TV as a display at 480×270.
  • Frames: over 60 s at 10 updates/s, the TV gets each frame once, with a median of 2 ms from show() to the wire. The longest gap is 0.44 s, where a fresh connection per frame used to stall for up to 3.3 s.
  • Remote: all eleven buttons a channel can see arrive as press and release, and held buttons repeat like a keyboard's.
  • Exit: when the program ends, the channel closes and the TV goes home (0.4 s). If the program is killed, the channel notices and closes within about 5 s. If someone switched to another channel meanwhile, it's left alone.

testris also gets a fix that isn't Roku-specific: its key waits spun inside one App tick, so pausing froze the game on any host. Its pause, rotation and resume now work from a remote too.

Adds roku_companion.py library and example apps for casting
PyDevices display output and audio to Roku TVs over the local
network using the External Control Protocol (ECP).

Library (lib/utils/roku_companion.py):
- RokuCompanion: ECP client for app launch, keypress, camera mode
- _FrameServer: HTTP server for PNG frame streaming (threaded or polled)
- RokuDisplay/RokuDisplayWrapper: display drivers that stream to TV
- RokuAudioSink: PCM audio sink that streams WAV to TV
- Cooperative poll() fallback for single-threaded runtimes

Examples (lib/examples/roku/):
- roku_companion_camera.py: stream live camera frames to TV
- roku_companion_dashboard.py: render a status dashboard on TV
- roku_companion_displaydev.py: use TV as a display device
- roku_companion_tts.py: text-to-speech output through TV
- board_config.py: example board config with Roku monkeypatch

Work in progress: audio sink threading fallback for micropython.exe
(no _thread module) and Windows Firewall connectivity are unresolved.
The TV fetched frames unevenly: one fresh TCP connection per frame over
the TV's Wi-Fi, so a lost SYN cost 1-3 s, and the channel waited for the
next 100 ms timer tick after each load. The frame server now keeps
HTTP/1.1 connections alive (one thread per connection) and the channel
requests the next frame as soon as one is ready. A 60 s trace at 10
updates/s went from 5.9 to 11.9 fetches/s, worst gap 3.3 s to 0.44 s,
p99 update-to-TV 2.7 s to 0.15 s, all on one connection.

Also: each camera session's URL carries a token so the TV cannot replay
frames it cached from an earlier session; timing uses a monotonic clock;
the server records an optional per-request trace; mode switches end the
camera fetch chain. Examples find the TV with roku_host() instead of a
hardcoded address, the display example runs at 480x270 with its
animation scaled to the width, and the camera example draws its frames
with pygraphics encode_png. The channel source and sideload script are
in tools/roku_companion_app.
With keep-alive the TV re-asked the moment a frame landed, so a third of
its fetches resent a frame it already had. The frame server now holds a
request on a kept-alive connection until show() draws a frame that
connection has not had, up to one second (a still screen is resent so
the connection stays alive).

60 s at 10 updates/s on the 65" Roku: repeated frames 227 of 724 -> 2
of 545, updates never shown 17% -> 10%, median update-to-TV 54 ms ->
2 ms; p99 336 -> 399 ms, within what one Wi-Fi run varies by.

Correction to the previous commit: its "p99 update-to-TV 0.15 s" came
from a report that measured against connection accept times. Measured
per update against the first response carrying it, that run's p99 was
0.34 s.
While frames stream, the channel sends every remote button it can see to
the frame server (a Task node, one reused connection), and
RokuDisplay.get_events() returns them as events.Key records, so
App(host_read=display.get_events) or appdev's HostEvents drive an app
from the remote. Arrows, OK, Back, *, play/pause, rewind, fast-forward
and instant replay map to keys.K_* codes; a name not in the map still
arrives with key code 0. Back no longer leaves the channel while an app
is streaming; Home always does.

roku_companion_remote.py is the test app: a tile grid the arrows and OK
drive, and a checklist that ticks off each button as it arrives. On the
65" Roku all eleven buttons arrived as press and release; holding a
button sends one press and one release, no repeats.
The Roku sends one press and one release however long a button is held.
RokuDisplay.get_events() now repeats a held button's KEYDOWN after 0.4 s,
then every 0.1 s (key_repeat=, None turns it off). If the channel goes
away mid-hold (Home), its release never comes, so repeats also stop with
a KEYUP once the TV has not asked for a frame in 3 s.

On the 65" Roku a held arrow steps the remote test's cursor across the
grid and stops on release.
testris's three key waits (start, pause, game over) dropped the "still
waiting" False instead of yielding it, so a wait spun inside one App tick
and no key could ever arrive: pausing froze the game for good, and the
start wait only passed if START was still held from the splash. Each wait
now yields while it waits.

A TV remote has no D, F or Escape, so rewind and fast-forward rotate and
Back pauses; the pause key also resumes once it has been released.

The roku folder's board_config now uses RokuDisplay and exposes its
get_events, so any example run through it takes the TV remote as its
keyboard. Played on the 65" Roku: moves, rotation, held keys, pause and
resume.
When a program using RokuDisplay or RokuDisplayWrapper ends (appdev's
quit, close(), or at exit), it sends the channel a new "quit" command
and the TV returns to its home screen, instead of freezing on the last
frame. It sends nothing when another channel is on screen, so whoever
switched to something else keeps watching it.

A program that dies without cleaning up (killed, crashed, the computer
asleep) cannot say so; the channel closes itself once no frame has
arrived for 5 s. That is timed rather than counted because a dead
server's fetches took ~10 s each just to fail over this network.

The channel's exit goes through an exitChannel field Main observes; the
observer only fires when registered after screen.show().

On the 65" Roku: clean exit home 0.4 s after the program ended; kill -9
home 5.6 s after the kill; with YouTube on screen, the program ending
left YouTube up.
@bdbarnett bdbarnett changed the title roku_companion: display and audio to a Roku TV over ECP roku_companion: a Roku TV as a PyDevices display, with its remote as input Oct 6, 2026
@bdbarnett
bdbarnett marked this pull request as ready for review October 6, 2026 08:38
The local hook skips this file when the generator is not checked out;
regenerated with the publishing-v6 generator CI pins.
@bdbarnett
bdbarnett merged commit 8aadc72 into main Oct 6, 2026
4 checks passed
@bdbarnett
bdbarnett deleted the cast/roku branch October 6, 2026 08:41
bdbarnett added a commit that referenced this pull request Oct 9, 2026
On a board the runner ended a timed run by calling machine.soft_reset() from
inside the display's show(). show() runs from a callback there (the app's
refresh timer, or an LVGL flush), and anything raised in a callback is printed
and dropped. Now that display_driver's flush always ends (#158), the
SystemExit no longer freezes the board but no longer ends the run either: the
example kept running after its CAST line.

The runner now holds the main thread once the example's script body returns,
reports when SECONDS are up, and soft-resets from its own top level. An
example that loops in its own body is unwound by an exception raised from
show() back to the runner, which then soft-resets the same way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant