Repository navigation
Conversation
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
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Put a PyDevices app on a Roku TV and play it with the TV's remote.
RokuDisplayis a framebuffer display whose frames show full-screen on the TV, andget_events()turns the remote's buttons into ordinary key events, soApp(host_read=display.get_events)drives an app from the couch. Run any example throughlib/examples/roku/board_config.pyand it gets both; testris plays that way.The TV side is a small sideloaded channel, the PyDevices Companion, in
tools/roku_companion_app(withsideload.shand 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:
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.