Batteries? Where we're going, we don't need batteries.
My Roku remote died Friday, and I didn't have any batteries. So—armed with a low-spec 2014 laptop running Linux Mint, a Broadlink RM4 Mini IR Universal Remote, and Google—I set out to "replace" the batteries.
This is what I came up with by Saturday afternoon. All in all, it was a fun weekend project.
Luckily, I've used my RM4 Mini for this before. But I've never interacted with it outside the official Broadlink app.
A quick Google search produced python-broadlink. Its last published version was two years ago and is available on PyPI; good enough for me.
Skimming the README, the method I'm mostly interested in is device.send_data(packet). I'm not interested in re-teaching my device commands, though. One, because that's just annoying and time-consuming. And two—more importantly—because I haven't used the TV remote ever since teaching the Roku remote the power and volume commands, so those batteries have long since been repurposed (and are absent from said remote).
With that potential endeavor-ending roadblock in mind—because weekends are mostly for being lazy and this is supposed to be "for fun":
- I re-downloaded the Broadlink Android app.
- Logged into my Broadlink account, which I created years ago when I first got the device.
- And, luckily, it had all my previously taught commands for devices saved (all hail the mighty cloud!).
- Now, how to use this to my advantage? 🤔
More Googling led me to numerous Home Assistant threads about how to set up the Broadlink device integration on the platform. On the integration page, there was something that gave me the idea to poke around my Android device's data: Home Assistant: Broadlink Integration - Using e-control remotes.
So, after a few minutes of browsing through my device, I found a cache folder with a JSON file containing my device's commands.
Bingpot!
(Shoutout to my fellow Brooklyn Nine-Nine fans)
Looks like I might be back in business.
The last small hurdle here was figuring out how to use the codes with python-broadlink. What threw me off was the fact that the codes didn't initially appear to be conventional hex values. Yet again, Google helped with that.
Short answer: They're still valid hex values, so just wrap the codes in bytes.fromhex(...) before passing them to device.send_data(code).
All that's left for this step is actually coding the logic.
The first class—main.TVRemote—is born.
The first test run of the newly minted class blew up almost immediately. After the initial connection was established, any commands attempted would raise an exception. Luckily, the Device class had an is_locked flag, which told me what the issue was. And the same Home Assistant link had a subheading (Home Assistant: Broadlink Integration - Device is locked) for this very situation.
With that taken care of and the class properly handling this for future cases—onward!
Luckily, I know Roku has well-established SDKs for interacting with its devices, so there's bound to be a few pre-existing packages utilizing them.
Another quick Google search produced python-roku. As expected.
Checking PyPI, though, it looks like it hasn't had any official releases for quite a few years. However, the commit history shows the author actively maintaining it. That's somewhat more important than the Broadlink piece because the majority of the commands are focused around the Roku Stick.
So, I uv added the Git repo and pinned it to the latest commit as of writing this to preserve the package's current state for this project.
All that's left for this step is actually coding the logic.
main.RokuStick becomes the second class created.
This was only a mild inconvenience, but the cause was pretty annoying, as it had me feeling crazier than normal.
Turns out, while you are able to control Roku devices using the official app, the setting to allow other means of control is disabled by default. Google told me where in the settings I needed to go to enable third-party control.
The setting is located at:
Settings >
System >
Advanced system settings >
Control by mobile apps >
Limited (default) -> Enabled
Only problem is, when I navigated there, I didn't see the setting! Albeit, it was in the early AM at this point, so I figured I'd missed something.
Nope! Turns out there's a known "quirk" with newer Roku devices' firmware/software versions that sometimes causes the setting to be hidden from some users. The solution to get it to show up was an odd one, which I initially doubted.
- Open the Roku mobile app on the phone.
- Connect to the device.
- Immediately navigate to the above settings area.
And, unbelievably, it worked! Apparently, connecting from the official app briefly "fixes" the quirk and allows you to see the setting. It may or may not remain visible later once the device power cycles, but that's not a concern, so long as I'm able to update the setting now.
With that taken care of, now we can move on. Next!
Came across pynput—I love me some cross-platform compatibility.
Since I want to block all keyboard input outside the application while it's running, keyboard.Listener(..., suppress=True) is perfect.
Tacked on key_map properties and dispatch_key methods to both classes, so I can easily match and dispatch keyboard events to the appropriate device and device event.
This is a personal project, meant to run on a specific laptop with a specific keyboard layout.
Key mappings optimized for the ASUS X551MA laptop keyboard:
Took me a few minutes to figure out how to convert the literal character produced in my main.print_event_keys helper function to actual keyboard.Key instances when they didn't already exist.
After looking through the documentation and code, the answer was to use a keyboard.KeyCode in those instances, creating it with keyboard.KeyCode.from_char("-").
With that, the events produced by the listener will match the mappings and be properly dispatched.
Tackling that next!
main.TVRemotemain.RokuStickkeyboard.Listener(..., suppress=True)
This is where main.MediaMonitor materialized.
What it does:
- Holds a reference to each of the above.
- Upon a single
start()call, it:- Creates an instance for each of the above.
- Calls
.connect()for both devices. - Starts the listener's thread, providing the callback that provides continued access to the devices within the thread.
- Calls
join()on the listener's thread, where it waits until the callback returnsFalse, indicating that it's time to terminate the program.
- The
on_presscallback determines if a keypress is meaningful and, if so, dispatches to the appropriate device class instance thanks to thekey_mapanddispatch_keyitems created in the previous step.
From here, I have a functional replacement for my dead-battery situation. But there's one final feature I'd like to include.
Since keyboard.Listener(..., suppress=True) blocks all keypresses that are not handled in the on_press callback, this required a bit of thinking.
Time to update main.MediaMonitor.
- Added a flag to indicate when typing is enabled (set by specific keys).
- Added an input buffer that gets appended to on each keypress of an allowed character.
- Accounted for realistic typing, allowing for backspacing, which
.pop()s the last character from the buffer list. - Upon hitting
Enterwhile typing is enabled, join the entire buffer of characters into a string and send it to themain.RokuStick.enter_text(...)method. - This had me learning about how to deal with
sys.stdout.write(...)andsys.stdout.flush(). Not very fond of it. Already flushed it from my memory (:awkward_husky_face_meme:).
Created a simple shell script tv-remote.sh that:
cds to the project directory- Activates the virtual environment
- Runs
main.pyas a module:python -m main
Once I marked it as executable (chmod +x ./tv-remote.sh) it worked like a charm. I didn't feel like selecting "Run in terminal" every time I clicked the script on my desktop to launch it, though. So, I decided to make a .desktop shortcut for the script. After another quick Google search and the help of desktop-file-validate, I had my one-click script. I even tracked down a suitable icon to make it stand out.
Fin.
Now it's time to resume my normal viewing habits.
Okay, it feels like it took me longer to create this write-up than it did to create the actual project. Is that a right-brain, left-brain thing? IDK. All I know is that you reached the end, so you deserve a cookie: 🍪.
- Convert the project layout to a proper package structure. I didn’t realize I was using
uvversion 0.11.8 when I started, where the default structure withuv initis a flat layout. That has since changed to a src layout starting in version 0.12.0 😑. - Use proper logging 😛
- Make terminal output more appealing—and maybe semi-animated—using
rich. - Add a non-interactive UI component that shows a Roku remote and the side buttons, along with the keys that correspond to the buttons. Maybe indicate keypresses on the remote as well.
- TUI using
textual - GUI using
kivy
- TUI using
- Dead batteries
- Old Galaxy S7 Edge (running Android 8.0—Oreo)
- Broadlink RM4 Mini IR Universal Remote (the "bean")
- Roku 4K Streaming Stick
- Low-spec 2014 ASUS laptop (stats cherry-picked from
inxi -Fxzandsudo lshw -short):- Intel(R) Celeron(R) CPU N2830 @ 2.16G
- 4GiB System Memory DDR3 @ 1333 MHz
- i915 Gen-7 Intel Atom Processor Z36xxx/Z37xxx Series Graphics & Display
- Distro: Linux Mint 22.2 Zara
- Kernel: 7.0.0-28-generic arch: x86_64 bits: 64 compiler: gcc v: 13.3.0
- Desktop: Cinnamon v: 6.4.14
