← All projects

Office Window

Deployed

What it is

The TV in the basement shows an outdoor camera full-screen, like a window. A clock sits in the top-right corner. The lower-left corner shows a circular radar about 50 miles across, with home marked by a red dot. The lower right shows the weather. The jukebox sits at the bottom center: a new track shows its artwork and details for a moment, then shrinks to a single line until it ends. When nothing is playing, the jukebox shows nothing. Weather alerts slide down from the top center, and one shortcut button changes the camera view to a three-hour radar loop.

Built with:

The jukebox card over the camera picture: album art, the track title Space Lion (4 Hero remix), the artist in katakana and the album, a progress bar at 5:19 of 6:19, PAUSED, shuffle off, repeat off, 14 of 15, and a row of key hints
A track starts. The card holds for 5 seconds: art, title, artist and album, the bar, the state column, the key hints.
The bare line over grass: the track title, the artist and album in a smaller line, and a progress bar with 5:19 on the left and 6:19 on the right
Five seconds later, the bare line for the rest of the track. The katakana is Noto Sans CJK doing its job.

A Roku remote controls the TV. Home opens the library, OK plays, and the arrows change the volume or move through the lists; a Search row at the top of the library opens an on-screen keyboard. Since 2026-10-06, the box behind the TV has been a Raspberry Pi 4, which runs the display and controls the camera and jukebox through the bridge. Before that, the Roku handled the display, the camera, and the jukebox. The old path ran Roku remote to Roku channel to bridge. The new path runs Roku remote to Pi to bridge to camera and jukebox.

The library panel over the camera picture: a Library heading, the rows Search (focused), Albums, Artists, Shuffle everything and Tracks, a now-playing strip with pause bars, and key hints
The library root. Search comes first; the strip at the bottom shows what is playing, including pause bars.

The Roku line

We started with a Roku channel. MediaMTX on the bridge host takes the camera feed and serves it to the Roku. The bridge also creates the radar image. The first camera was a eufy indoor cam aimed outdoors; its stream dropped every few minutes, and there was no local way to restart it. We replaced it with an Amcrest PoE camera.

The jukebox came next, and it hit the Roku's hard limit: a channel can have only one media player, so we had to put the music into the camera stream. A service on the bridge fetches each track from Jellyfin, decodes it with ffmpeg, and mixes it into the camera stream before publishing a second MediaMTX path. When no music is playing, it writes silence so the stream doesn't stall. Each key press lagged three to four seconds because the Roku buffers HLS; we had got that down from about 5. MediaMTX's low-latency HLS was not an option: the Roku Express cannot play it, and the channel reconnected every 5 seconds until we reversed the change. The service delivers audio from the most recent segment within one second of a command; the rest of the delay is the Roku's own buffering.

The worst part was the progress bar; it started moving as soon as you pressed Play, about seven seconds before the music started. We used playback status from five seconds earlier and adjusted the elapsed time, which brought the bar closer to what we were hearing. For Pause, we muted the player instead of waiting for the stream to catch up. The delay stayed, but at least Pause stopped the sound.

By that evening, we had given up on the Roku. We left the channel in its own repository and connected the Pi to the TV.

The Pi set-top

We're using a Raspberry Pi 4 Model B with 8 GB in an Argon ONE V2 case, running Raspberry Pi OS Lite (64-bit), booting from a 128 GB M.2 SATA SSD with no SD card in the slot. We put it on the IoT Wi-Fi, and the picture stuttered. On 2.4 GHz, it got about 0.8 Mbit/s from the bridge, compared with the camera's 4.5, and no buffer fixes that. It has been Ethernet since, with the radio off.

Office Window architecture Data flow of the Office Window, left to right. The Amcrest camera streams RTSP to MediaMTX on a bridge host, which re-serves it over RTSP to the video mpv player on a Raspberry Pi 4; that player decodes H.264 in hardware and drives the TV over HDMI. Jellyfin's music library is read by a jukebox service on the bridge, which holds the Jellyfin login, serves each track over /stream to the Pi's audio mpv player, and serves /browse and /art to window.py. A radar compositor on the bridge builds a radar circle every 2 minutes from NOAA's radar and a three-hour loop every 5 minutes from Iowa State's NEXRAD mosaic, both over a USGS topo base, and window.py fetches both. window.py also polls Open-Meteo for conditions every 15 minutes and the National Weather Service for alerts every 2 minutes, reads the Roku infrared remote through the case's receiver, draws every overlay on the video player, keeps the music queue, drives the two mpv players over their JSON IPC sockets, and spawns a third, short-lived mpv for each alert tone. Raspberry Pi 4: window.py the mpv players are its children Bridge host NOAA + Iowa State radar on a USGS topo base Amcrest camera 1080p H.264 over PoE Jellyfin the music library, FLAC Open-Meteo current conditions NWS alert feed alerts at the home point Roku IR remote the old Roku's remote Radar compositor Python, Pillow, ffmpeg MediaMTX pulls the camera once Jukebox service holds the Jellyfin login mpv: video H.264 in hardware mpv: audio one track at a time mpv: tone one per alert tone draws the clock, weather, radar, panel and alerts as overlays on the picture keeps the music queue, library, search reads the remote from /dev/input polls conditions and the alert feed retries the bridge until it answers drives both players over JSON IPC TV HDMI HTTPS RTSP FLAC radar circle every 2 min, 3 h loop every 5 min RTSP over TCP /stream /browse, /art conditions every 15 min alerts in effect, every 2 min infrared, decoded by the case's receiver
What runs where.

The bridge host is the same unit that fed the Roku. It runs the camera and jukebox services and holds every credential; the Pi has none, reaches Jellyfin only through the jukebox service, and does the display.

One Python program, window.py, does all of it. It starts two mpv players as child processes and controls them over JSON IPC sockets. The video player takes the camera feed from MediaMTX over RTSP and draws it through DRM/KMS with no desktop under it. The audio player plays one song at a time from the bridge's jukebox service; pause, seek, and skip are local commands, so each key press takes effect at once. The card follows the player's position and pause state, so it matches what you hear instead of estimating from five seconds ago.

The video player draws everything on top of the picture. Text goes in as ASS subtitle events on a design grid scaled to the TV's 1920x1080 mode. The radar circle and the album art go in as raw image files through mpv's overlay command. The panel copies the Roku channel's coordinates, fonts, and colors, so the two lines look the same on the TV.

The Albums list: breadcrumb Library, Albums; the count 186 albums, 136 more; six rows of album and artist names with the first row focused; the now-playing strip and key hints
Albums, six rows at a time, drawn as subtitle events. The count on the right is the catalog's answer and how much of it is still to load.

MediaMTX draws one stream from the camera and gives the Pi read-only access to it. The jukebox service handles the Jellyfin login, the catalog, and the album art; it also proxies the tracks and forwards Range requests so mpv can seek.

The radar compositor also runs on the bridge. It rebuilds the circle every two minutes and the loop every five minutes. Both versions use the same bridge, so switching to the Pi didn't move any of that work.

The radar loop

We took the idea from The Weather Channel's 'Local on the 8s'. The loop is 37 frames five minutes apart, three hours of radar on a darkened topographic map centered on home, with each frame's time and a countdown from T-2:55 to NOW along the bottom. The compositor pulls frames from a time-enabled NEXRAD mosaic in Web Mercator format so the map isn't stretched. It keeps each frame on disk so a refresh is quick, then encodes the sequence with ffmpeg and serves it with an ETag. We first tried NOAA's time-enabled mosaic, but its window runs anywhere from two to four hours, so it can't promise a three-hour loop, and we dropped it.

The Pi keeps its own copy of the file, so the view opens at once, and you can fast-forward and rewind through the frames locally. We play the loop with software decoding, because stepping back a frame is unreliable with the hardware decoder. When you leave the view, the camera returns to hardware decoding. The music keeps playing. Only the clock and any weather alert stay on screen, over the map.

Weather alerts and the sound problem

Window.py checks every two minutes for active alerts from the National Weather Service at the home location; the service is free, needs no API key, and takes the coordinates as the query.

Each alert appears in a toast that slides down from the top of the screen in 0.3 seconds, and no timer dismisses it. The toast remains until you press OK or the alert itself ends, and no other key acknowledges it. The stripe color follows the severity: red for Extreme, orange for Severe, yellow for Moderate, and blue for Minor.

When there's more than one alert, the most severe comes first. Pressing OK acknowledges it and brings up the next one. Alerts you've acknowledged stay visible as badges until they end; those you haven't acknowledged sound again every five minutes.

The toast is brief because the details are in the radar view, so there is no separate alerts button. Press OK there, and a panel opens on the right, listing every active alert, most severe first, with its times, areas, instructions, and event name; Up and Down scroll it, and OK or Back closes it. Entering the radar view while a toast is up opens the panel. The map shows each alert's area too: polygon warnings by their geometry, county and zone alerts by zone boundaries fetched once from the NWS and kept, filled with the severity color at 25 percent and the event name at the centroid. The bridge sends the map's bounding box with the loop, so the Pi projects the outlines onto the same map the loop was drawn on and the two stay aligned.

We thought more about the identity than the toast. Whenever the NWS issues an update, it sends a new message with a new ID, and the previous one vanishes from the feed, so keying on the ID would sound the alarm at every update. Instead, window.py identifies each alert by the VTEC string, the NWS's identifier for an event across all updates and cancellations; if that fails, it uses the reference chain, then the ID. An update that raises severity or urgency counts as an escalation and sounds again; any other update is a revision and only refreshes the text.

A thread closes when its end time passes, when a cancellation refers to it, or when it is missing from two polls in a row. We keep the record for six more hours so a late update doesn't sound the same alarm again.

Window.py saves the threads and their acknowledgments to disk, so a restart doesn't replay alerts you've acknowledged.

Each severity has its own sound. We picked four tones from E2S's library, from low to high: a chime, an intermittent 745 Hz sound, the 420 Hz Australian alert, and the 1 kHz general alarm for Extreme. We don't reproduce the EAS attention signal. The Pi's HDMI audio device accepts only the IEC958 subframe format, which ALSA's dmix cannot mix, so two players can never use the device at the same time. So a tone stops the music, notes where it left off, and plays through a temporary third mpv with 500 ms of silence in front so the TV's HDMI lock-in doesn't distort its start; then it reloads the track at the same position, paused, so the room stays quiet until someone presses Play. Press Play while the tone is sounding and the music resumes instead. Tones play one at a time, 20 seconds apart, or 3 seconds when a more severe one is waiting.

It's a convenience for the house, not an emergency broadcast channel. The Pi can be off, and the feed can lag.

The remote

The Roku Express's infrared remote works with the receiver built into the Argon case. window.py reads the Linux input devices itself, with no library, and rescans every 30 seconds so you can use a USB keyboard for testing and a virtual remote on /dev/uinput for automated checks. The Roku remote uses NEC extended, and a kernel keymap maps its buttons to normal keys. This took one hour on 2026-10-07 for two reasons: the keymap tool is a separate package on Debian Trixie, and the receiver only enables RC-6 decoding by default, so the NEC frames arrived undecoded until the keymap loaded a protocol named exactly nec. Name it necx and the tool looks for a plugin and switches NEC off.

We meant to send volume to the TV over HDMI-CEC, but while the TV answers CEC commands, it ignores volume and mute from the active source. So the arrow buttons adjust the music player's software volume in steps of 3, show a "Volume N" bar at the top for 1.5 seconds, and keep the setting across a restart. The tones have their own volume. CEC can also carry the TV's own remote, but that TV has no remote, so we haven't tested it.

A small dark bar over treetops reading Volume 32 with a short progress line under it
Up or Down with nothing open: the player's own volume, in steps of 3, shown for 1.5 seconds.

Coming back without hands

A random power failure in the house tested this for us: the Pi, the camera, and the network restarted. The bridge host did not and stayed dark. Dave pressed the power button; the BIOS now powers the machine on after an AC power loss. The Pi's case has a jumper set to always-on, so it boots as soon as power returns.

The TV showed a console login prompt because the video player, with nothing to play, had given up the screen. Window.py now keeps the player window open with nothing loaded, so during a bridge outage the screen shows black with the clock and the message 'camera unavailable / waiting for the bridge'; everything else stays on screen, and the camera retries every five seconds. A player that exits restarts within two seconds; if window.py exits, systemd restarts it after five seconds.

The music catalog catches up within a minute of the bridge. While the jukebox doesn't respond, the Pi works from its saved copy and asks again every minute, then every hour once the bridge is back. It used to wait 20 minutes between attempts, which left search and shuffle on the saved list for that long after the bridge returned. The library pages ask the bridge directly, so they come back the moment it does.

Small things that took a day

The music dimmed in and out, and nothing in the chain alters the level, so we didn't know why. We put an EBU R128 loudness meter in the audio player's filter chain to log short-term, momentary, and integrated loudness every 30 seconds alongside the volume setting and the track.

The Pi's output stayed steady through a drop Dave heard, so the cause was further down the chain: the TV had DTS TruVolume on, a slow automatic volume control that turns the level down after loud sections. We switched it off, and the quiet intros are the music itself: a trance intro at -31 LUFS stays where it is, even after a Cher track at -8.

A Japanese artist name drew as a row of empty boxes. The Pi had only DejaVu, which has no CJK glyphs. Noto Sans CJK fixed the glyphs and changed the widths because libass scales a fallback font so its line height matches the requested size, making a kana or ideograph about 0.69 of the font size wide. window.py measures East Asian wide characters that way now, so a cut line still ends in an ellipsis where it should.

Library search came next, a Netflix-style on-screen keyboard with results drawn from the catalog in memory, every track with its album and artist, so they change with every key, and no request leaves the Pi. Albums whose name matches, then artists, then tracks, at most 60 rows with the full count on the right.

The search panel: the query a with 2702 matches on the right, a four-row on-screen keyboard with a focused, and five result rows of albums including one with a Japanese artist name, over the now-playing strip
One key in: 2,702 matches, albums and artists first, then tracks, all from the catalog in memory.

Where it stands

It has run on the basement office TV since the night it replaced the Roku. Dave uses it daily because the basement office has no window, and he feels disconnected from the rest of the world without it.