How I Over-Engineered a Roku Sleep Timer | by Nick Schenone | Nov, 2022

My thought process for making made certain design decisions while creating a self-hosted microservice API using Docker, Python, and Flask

Roku Sleep Timer in Action — Image by Author

While the problem itself is fairly trivial, the real story here is my journey to creating a reusable template for self-hosting custom services and securely exposing them as an API endpoint. This blog is not a tutorial on the underlying technologies like Flask and Docker, but rather an overview of how I built the application and my thought process for making certain design decisions.

First-world problems and unnecessary solutions — name a better combo. While unessential, these problems can lead to interesting solutions that address more than the originally intended scope. One such example is where a few missing episodes on Netflix led to a number of custom microservice APIs running on my network controlling various devices and services.

In 2019 I bought my first inexpensive Insignia 32″ TV and a Roku streaming stick. It was by no means a home theatre, but it certainly got the job done. As a result, I got into the habit of falling asleep to shows using the TV’s built-in sleep timer. Everything seemed great…until I realized I was much farther into my show than I originally realized. After much investigation, it turns out that while the TV’s sleep timer would turn off the TV itself, Netflix was still playing in the background overnight.

Using my Python and Docker experience, I decided to take matters into my own hands. The final result can be found in this GitHub repo and looks like the following:

Roku Sleep Timer Architecture — Image by Author

My requirements included:

  • Ability to “sleep” the Roku and TV (e.g. close the currently running app and turn the TV off) after a specified period of time
  • Ability to cancel a currently running sleep timer
  • Easily schedule/cancel sleep timer from my phone
  • Expose control of sleep timer to application agnostic API
  • Expose API outside my home network in a secure way

I came up with a solution that resulted in a few main parts:

  • Application Logic (Roku Control, Job Scheduling, Flask)
  • Packaging (Docker, Docker Compose)
  • Deployment (Self Hosting, Cloudflare Proxy, NGINX Proxy Manager)
  • Invocation (Apple Shortcuts)
Photo by Glenn Carstens-Peters on Unsplash

As a first step, I found the excellent library python-roku which allows you to control your Roku via Python SDK. There is a convenient feature to automatically discover Roku’s on your local network and simulate a remote control:

from roku import Roku

timeout = 10 # 10 second timeout
my_roku = Roku.discover(timeout)[0] # returns a list of Roku devices

my_roku.home()
my_roku.right()
my_roku.select()

It is incredibly satisfying (yet inefficient) to control your TV with your laptop in this way. Additionally, if your TV supports HDMI-CEC, you can also use your Roku remote to control volume and power. This also works with the Python SDK:

my_roku.poweron()
my_roku.poweroff()

After some experimentation, I found that the best combination of commands was to close the currently running app, wait 2 seconds for the animation, and then power off the TV:

from time import sleep

# Prevents Roku from waking up TV if timer is triggered while TV is off
if my_roku.active_app.id != "None":
my_roku.home()

# Gives enough time to close open apps before turning off
sleep(2)

# Power off TV once app is closed
my_roku.poweroff()

I also added a check to make sure an app is currently open on the Roku before going home. This prevented the TV from being turned on if the sleep timer is started on accident.

While these commands were exactly what I was looking for, I did not want to run them instantly. Instead, I wanted to run them after a specified period of time.

Photo by insung yoon on Unsplash

Next, I found the APScheduler or Advanced Python Scheduler library. This robust library allows for scheduling one-off or repeated commands using a variety of schedulers, job stores, executors, and triggers.

For my simple application, all I needed was to queue a job to run a given Python function after X minutes as well as clear the current job queue. This can be easily done using the in-memory BackgroundScheduler. For a more serious application, there are more fault-tolerant ways available to store and execute job queues.

The following example runs the trigger_sleep Python function after 5 minutes:

from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler

def trigger_sleep():
print("ZzZzZz")

scheduler = BackgroundScheduler()
scheduler.start()

scheduler.add_job(
trigger_sleep,
run_date=(timedelta(minutes=5) + datetime.now())
)

Additionally, you can clear the current job queue like so:

for job in scheduler.get_jobs():
job.remove()

This was everything I needed to queue commands from python-roku, but only execute them when they are scheduled to do so.

RokuSleepTimer Python script in context — Image by Author

Finally, I brought the Roku control and job scheduling together into a dedicated class to more easily control from the Flask app. The final RokuSleepTimer class handles the sleep and scheduling logic:

from datetime import datetime, timedelta
from time import sleep

from apscheduler.schedulers.background import BackgroundScheduler
from roku import Roku

class RokuSleepTimer:
"""
Sleep timer for Roku. Will interact with Roku via Python SDK
and schedule sleep job using APScheduler. Automatically connects
to the first Roku it finds on the network.
"""

def __init__(self):
# Start background scheduler for sleep jobs
self.scheduler = BackgroundScheduler()
self.scheduler.start()

# Find first roku on network
self.discover()
print(f"Using roku at IP: self.host")

def on(self) -> str:
"""
Turn TV on via Roku.

:return: On message
"""
self.roku.poweron()
return "on"

def off(self) -> str:
"""
Turn TV off via Roku.

:return: Off message
"""
self.roku.poweroff()
return "off"

def discover(self, timeout: int = 10) -> None:
"""
Find the first Roku on the network.

:param timeout: Number of seconds before search times out.
"""
self.roku = Roku.discover(timeout=timeout)[0]

def trigger_sleep(self) -> None:
"""
Sleep mechanism. If an app is open, go to the
home screen, wait, and power off.
"""
# Prevents Roku from waking up TV if timer is triggered while TV is off
if self.roku.active_app.id != "None":
self.roku.home()

# Gives enough time to close open apps before turning off
sleep(2)
self.roku.poweroff()

def stop_sleep(self) -> str:
"""
Stop sleep timer by removing all jobs in scheduler.

:return: Stop message
"""
for job in self.scheduler.get_jobs():
job.remove()
return "Stopped sleep timer"

def schedule_sleep(self, minutes: int) -> str:
"""
Schedule sleep job after the specified number of minutes.

:param minutes: Number of minutes to wait before sleeping

:return: Job schedule success message
"""
self.scheduler.add_job(
self.trigger_sleep, run_date=(timedelta(minutes=minutes) + datetime.now())
)
return (
f"Started sleep timer for minutes minutes. Enjoy the show, sleep tight!"
)

@property
def host(self) -> str:
"""
Helper to get IP address of Roku device.

:return: IP address of Roku
"""
return self.roku.host

Now that the sleep/schedule logic is complete, I needed a way to invoke theRokuSleepTimer as an API.

Flask Python script in context — Image by Author

Since this application is relatively simple, I decided to use Flask — a classic lightweight web framework. It’s easy, not opinionated, and dead simple to use.

In terms of designing the API, the sleep timer only requires two routes:

  • GET — /start/: Start sleep timer for X minutes
  • GET — /stop: Cancel any running sleep timers

I also added a few quality-of-life routes to make debugging easier:

  • GET — /: Sanity test to ensure service is running
  • GET — /on: Manually turn Roku + TV on
  • GET — /off: Manually turn Roku + TV off
  • GET — /discover: Re-connect Roku by searching network
  • GET — /host: Get IP address of Roku device

The final Flask application looks like the following:

import os

from flask import Flask

from roku_sleep_timer import RokuSleepTimer

app = Flask(__name__)
sleep_timer = RokuSleepTimer()

@app.route("/")
def home() -> str:
"""
Test connection to app.

:return: Successful connection message
"""
return "Connected"

@app.route("/host")
def host() -> str:
"""
Get IP address of Roku.

:return: IP address of Roku
"""
return sleep_timer.host

@app.route("/on")
def on() -> str:
"""
Turn TV on via Roku.

:return: On message
"""
return sleep_timer.on()

@app.route("/off")
def off() -> str:
"""
Turn TV off via Roku.

:return: Off message
"""
return sleep_timer.off()

@app.route("/discover")
def discover() -> str:
"""
Reset Roku connection to re-find first
Roku on the network.

:return: IP address of Roku
"""
sleep_timer.discover()
return sleep_timer.host

@app.route("/start/")
def schedule_sleep(minutes: int):
"""
Schedule sleep job after the specified number of minutes.

:param minutes: Number of minutes to wait before sleeping

:return: Job schedule success message
"""
return sleep_timer.schedule_sleep(minutes=minutes)

@app.route("/stop")
def stop_sleep() -> str:
"""
Stop sleep timer by removing all jobs in scheduler.

:return: Stop message
"""
return sleep_timer.stop_sleep()

if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.getenv("FLASK_PORT")))

Now that I was able to invoke the RokuSleepTimer through the Flask API, I was satisfied with the application logic. The next task was to package everything up using Docker and Docker Compose.

Photo by Dominik Lückmann on Unsplash

First off, why did I use Docker to package the application?

While I could simply run the Flask app as is, Docker allows for portability/consistency between environments, isolation from the host device, and granular control over packages and hardware resources.

It also makes it very simple to create more services in the future using the same technique — spoiler alert — I created my own re-usable microservice template for this very purpose.

To package the application, first I organized the directory structure like so:

├── Dockerfile           # Recipe for building Docker image
├── docker-compose.yml # Builds image and deploys Docker container
├── requirements.txt # Python dependencies
├── .env # Environment file for secret management
├── Makefile # Helper to start, stop, and restart app
└── src # Source code
├── app.py # Flask application
└── roku_sleep_timer.py # Roku control and job scheduling

The Dockerfile does most of the work — it is responsible for copying the source code, installing Python dependencies, and starting the Flask server:

#Download Python from DockerHub and use it
FROM python:3.11

#Set the working directory in the Docker container
WORKDIR /code

#Copy the dependencies file to the working directory
COPY requirements.txt .

#Install the dependencies
RUN pip install -r requirements.txt

#Copy the Flask app code to the working directory
COPY src/ .

#Run the container
CMD [ "python", "./app.py" ]

The Python dependencies are specified in the requirements.txt file — best practice dictates pinning specific versions of libraries to prevent unintended behavior due to updates:

Flask==2.0.1
roku==4.1.0
APScheduler==3.7.0

Finally, the app.py and roku_sleep_timer.py files in the src directory were populated using the code from the previous sections

Docker Container in context — Image by Author

So what are the docker-compose.yml, .env , and Makefile files for? They are to actually use the Dockerfile to start up the application container.

Docker Compose is one step above Docker — it allows for creating multi-container applications defined in a YAML file (docker-compose.yml):

version: "3.3"

services:
flask:
restart: unless-stopped
build: .
image: roku_sleep
container_name: roku_sleep
network_mode: "host" # This was necessary to find Roku on network
environment:
- FLASK_PORT=$FLASK_PORT

Although this application only has one container, there are also other benefits. Docker Compose allows for easily building Docker images and injecting environment variables from a .env file. This is useful for adding any API keys or other secrets into the application without hard coding any values. In this case, the Flask port is added via the .env file.

Perhaps most importantly, it means that there are no longer any unwieldy docker run commands with many, many parameters. To build the Docker image and run the application, all that is needed is to run docker-compose up -d --build. This will build the image with your updated source code and spin the container up as a background process. Similarly, you can stop the container with docker-compose down.

Finally, I added a helper Makefile for quality-of-life:

.PHONY: help

help:
@echo "Usage:"
@echo " up: Start app"
@echo " down: Stop app"
@echo " restart: Restart app"

up:
docker-compose up -d --build

down:
docker-compose down

restart: down up

All of this together means that I could quickly experiment and iterate while developing — editing the source code and running make up to redeploy the application. This whole directory and file structure was built into my own re-usable microservice template for creating future services.

Now that the application is packed up with Docker and Docker Compose, I needed a place for it to live.

Photo by İsmail Enes Ayhan on Unsplash

Because the RokuSleepTimer requires direct connectivity to the Roku, the application needs to be hosted on my home network. I chose to host it on my personal server — a computer tower under my desk running Ubuntu that is responsible for my media library, data science workbench, custom microservice APIs, and other miscellaneous Docker services.

That being said, a Raspberry Pi, desktop computer, or laptop would all work perfectly fine — the only requirement is that the Docker container is running. The parameter restart: unless-stopped in the docker-compose.yml file will ensure that the container is restarted if the underlying host is restarted or turned off.

While the API was now available within my home network over localhost, one of my original requirements was the ability to access the API outside the network in a secure way.

Cloudflare Connecting Client and Home Network — Image by Author

To connect to my API over the public internet, I needed to expose it on a custom domain. You can get a free domain from Freenom, however, I ran into a few issues while using it. If you do, be sure to stay on top of renewing your domain — mine expired and I was not able to retrieve it. I chose to purchase my domain from Namecheap which is relatively inexpensive and has worked flawlessly so far.

From there, Cloudflare is responsible for managing DNS and acting as a proxy to hide my home public IP address:

Cloudflare DNS Entry — Image by Author

The proxy functionality cannot be understated — exposing your public IP address online is a great way to invite unwanted guests onto your network. Using Cloudflare DNS and Proxy, I can access my API at sleeptimer.mydomain.com while only exposing a Cloudflare IP address.

Cloudflare also provides a few other security options like the ability to block incoming requests by geography using their WAF (Web Application Firewall). I added a rule to block any incoming requests outside of the United States:

Cloudflare WAF Firewall Rule — Image by Author

I found that this was the most helpful setting for preventing unwanted requests from even reaching my network. Cloudflare even has some excellent analytics dashboards for showing how many threats were blocked per country:

Threats Blocked by Country — Image by Author

This approach worked great for routing incoming requests to my network — however, the DNS record only points to my public IP. There is no guidance on what to do once a request gets into the network.

NGINX Proxy Manager in Architecture — Image by Author

Think of NGINX Proxy Manager (NPM) as the usher at a theatre — it directs incoming requests to the desired service based on the domain and subdomain. This is the mechanism to make sure that requests coming into sleeptimer.mydomain.com are routed to a specific IP address and port on the network. I mainly followed this excellent video to setup NPM via Docker and connect to Cloudflare:

Super Simple Cloudflare and Nginx Proxy Manager Setup Using YOUR Domain — Geeked

The following NPM configuration connects my domain to the service running on my on-premise server with IP address 192.168.1.168 on port 5001:

NGINX Proxy Manager Setup — Image by Author

This works because the ports for HTTP (80) and HTTPS (443) are being forwarded on the router to the machine running NPM — in this case, the same server at 192.168.1.168:

Router Port Forwarding Settings — Image by Author

There are also additional security settings like the ability to automatically add SSL certificates and require user/password authentication when making a request. These settings are more fully explained in the linked video above.

SSL Certificate Setup — Image by Author

All of this together means that my application is finally ready to be invoked by some end-user client.

HTTP(S) Client in Architecture — Image by Author

The beauty of exposing the API in this way is that it is client agnostic — any method of making an HTTP(S) request will work.

The following is a simple example using the Python requests library where the authorization information username:password is provided in the header in a base64 encoded format:

import requests
requests.get(
url="https://sleeptimer.mydomain.com",
headers="Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ"
)

# returns "Connected"

This general pattern can be replicated using any method. Because I have an iPhone, I chose to use the native Apple Shortcuts app that comes with all newer Apple products. It is a simple GUI-based tool for general-purpose automation in the Apple ecosystem. Another great option for non-Apple users is IFTTT.

The following shortcut prompts the user for a number of minutes and starts a sleep timer using the provided number and authorization information. It is available as a template here and can be modified to fit your own needs:

Apple Shortcut for Sleep Timer — Image by Author

From there, the sleep timer can be easily invoked from a widget on the home screen or with voice control via Siri:

Roku Sleep Timer in Action — Image by Author

Overall, this was a lot of engineering effort to solve a simple, unimportant problem. However, the real story here is the overall methodology and technologies used to self-host services at home and securely expose them as a public-facing API.

This project was the gateway for creating additional services on my network. I used what I learned from the Roku project to create my own re-usable microservice template for things like hosting websites, sending notifications to my personal Slack workspace, automating spending reports via YNAB, and retrieving tasks from Notion databases.

Adding a new service to the system is as simple as:

  • Use the template to deploy new Dockerized application to a given port on a server
  • Add DNS route in Cloudflare for a new subdomain (e.g. service.mydomain.com)
  • Add route in NGINX Proxy Manager to route incoming requests to the new service

I hope this guide was helpful and inspires you to create your own self-hosted services for whatever is useful to you!

Leave a Reply