feature-image

Nearly every day I turn on MBC news for my wife on our LG WebOS TV by hand. This task is challenging due to a variety of reasons. We pay for an OnDemandKorea subscription and for some reason the ad-free version of this application overheats my Roku streaming stick. I moved back to using a Roku 2 that my dog ate the buttons off the remote when she was a puppy. The steps to change to this are change the TV input, navigate to the ODK button click, wait, navigate to categories, news, then find MBC and play. Doing this with multiple remotes every day is a bit of a pain. This past weekend I automated this whole process.

This library PyWebOSTV was the most complete I found. I also wanted to learn golang and tried to find as a complete library but failed to do so. I also used this Roku library.

Using the example below the steps the calling API call

  • client = connect.connectTv()
  • connect.selectRoku(client)
  • executor.submit(connect.OnDemandKorea) // Using a thread pool executor here because this is a long blocking call

The Automation

from pywebostv.discovery import *
from pywebostv.connection import *
from pywebostv.controls import *
from roku import Roku
import os
import logging

import time

def connectTv():
    client_key = os.environ.get("CLIENT_KEY")
    tvip = os.environ.get("TVIP")
    store = {'client_key': client_key}
    client = WebOSClient(tvip)
    client.connect()
    register = client.register(store)
    for status in register:
        logging.debug(status)
    return client

def selectRoku(client):
    source_control = SourceControl(client)
    for source in source_control.list_sources():
        if source.label == "Set-Top Box":
            source_control.set_source(source)

def selectSwitch(client):
    source_control = SourceControl(client)
    for source in source_control.list_sources():
        if source.label == "Game Console":
            source_control.set_source(source)

def OnDemandKorea():
    roku = Roku(LOCAL_IP_ADDRESS,timeout=10)
    odk = roku['OnDemandKorea Plus']
    odk.launch()
    time.sleep(40)
    roku.up()
    time.sleep(1)
    roku.right()
    time.sleep(1)
    roku.right()
    time.sleep(1)
    roku.select()
    time.sleep(1)
    roku.down()
    roku.down()
    roku.down()
    roku.select()
    time.sleep(7)
    roku.up()
    roku.up()
    time.sleep(5)
    roku.right()
    roku.select()
    time.sleep(10)
    roku.right()
    roku.select()
    roku.right()
    roku.right()
    roku.right()
    roku.right()
    roku.select()
    time.sleep(7)
    roku.select()
    time.sleep(7)
    roku.select()

def selectYouTube(client):
    app = ApplicationControl(client)
    apps = app.list_apps()
    for thisapp in apps:
        if thisapp.data.get('title') == "YouTube":
            app.launch(thisapp)

Triggering the Automation

OK well that is all well and good but how is this going to be used now that it is automated. I tried a couple different ways and one of these might be a better fit for you. I bought a raspberry pi 2 a few years ago and haven’t used it for much other than weekend tinkering every once in a while. There are two routes you can go.

  • Use Node-Red, this is easiest
  • Use Nginx and a python API framework like Flask, this requires more code and familiarity with nginx, apis, coding

Node-Red

Install node-red if not using a raspberry pi go here for more options. I happen to like using pm2 to manage the uptime and restart of my processes and it probably uses systemd units under the hood. The steps in the getting started are more than fine but since I used pm2.

#assuming you are using ubuntu otherwise substitute yum or dnf or use the official [docs](https://nodejs.org/en/download/package-manager/)
sudo apt install nodejs npm -y
npm install pm2@latest -g
pm2 start node-red-pi --node-args="--max-old-space-size=256"

Once installed you should be able to go to your IP:1880 and start making a flow. The flow could look like this. Node-Red HTTP Flow I have pasted the same flow file below if you want to start with this. The benefit of this flow is you can now call the endpoint with other apps, a browser, iPad Shortcuts, Tasker to trigger calling your python script instead of setting a webserver and full code library stack.

[
    {
        "id": "c66b6839.ff1998",
        "type": "tab",
        "label": "Flow 2",
        "disabled": false,
        "info": ""
    },
    {
        "id": "66106011.922c6",
        "type": "http in",
        "z": "c66b6839.ff1998",
        "name": "",
        "url": "/watchmbc",
        "method": "get",
        "upload": false,
        "swaggerDoc": "",
        "x": 170,
        "y": 140,
        "wires": [
            [
                "49c288f1.8cf8e8"
            ]
        ]
    },
    {
        "id": "49c288f1.8cf8e8",
        "type": "exec",
        "z": "c66b6839.ff1998",
        "command": "python3",
        "addpay": false,
        "append": "fun.py",
        "useSpawn": "false",
        "timer": "",
        "oldrc": false,
        "name": "",
        "x": 410,
        "y": 160,
        "wires": [
            [
                "65862216.c2c48c"
            ],
            [],
            []
        ]
    },
    {
        "id": "65862216.c2c48c",
        "type": "http response",
        "z": "c66b6839.ff1998",
        "name": "",
        "statusCode": "200",
        "headers": {},
        "x": 690,
        "y": 180,
        "wires": []
    }
]

Flask API and Nginx

Let’s say that you did not want to go the low code route or you already make enough APIs that another one running somewhere is not a big deal. I started with this route first and it tripped me up quite a bit because I am rusty with python APIs and I have not used nginx very much. Luckily there is a lot of great resources such as this Digital Ocean article on serving flask applications with gunicorn and nginx.

Where my project differed is that I chose to design the API endpoints first via swaggerhub and download a ready made code repository. The benefit of SwaggerHub API designer is the focus on the API contract and the ability to download the ready made repository and get started. I supplied the below yaml and generated my server stub.

swagger: '2.0'
info:
  description: Remote Control TV API
  version: 1.0.0
  title: Remote Control API
  # put the contact info for your development or API team
  contact:
    email: alan@alanmbarr.com

  license:
    name: Apache 2.0
    url: http://www.apache.org/licenses/LICENSE-2.0.html

paths:
  /watch:
    get:
      summary: list of watchable resources
      operationId: watchList
      description: |
        You can get a list of content to watch
      produces:
      - application/json
      responses:
        200:
          description: watchable items
        400:
          description: bad input parameter
  /watch/{content}:
    post:
      summary: choose what to watch
      operationId: watchTrigger
      description: |
        Post the ID of the content you want to watch
      parameters:
        - name: content
          in: path
          required: true
          description: The id of the content to watch
          type: string
      produces:
      - application/json
      responses:
        200:
          description: Action started
        400:
          description: bad input parameter

basePath: /WatchTV/1.0.0
schemes:
 - http

The first gotcha I ran into was the generated server stub main.py did not play nicely with gunicorn. In the two examples below you can see there is a wrapping main function this prevents gunicorn accessing Flask’s app.app.

def main():
    app = connexion.App(__name__, specification_dir='./swagger/')
    application = app.app
    app.app.json_encoder = encoder.JSONEncoder
    app.add_api('swagger.yaml', arguments={'title': 'Remote Control API'})
    app.run(port=8080)

if __name__ == '__main__':
    main()
app = connexion.App(__name__, specification_dir='./swagger/')
application = app.app
app.app.json_encoder = encoder.JSONEncoder
app.add_api('swagger.yaml', arguments={'title': 'Remote Control API'})

My systemd unit file ended up looking like this

[Unit]
Description=Gunicorn instance to serve watchtv
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/WatchTV
Environment="PATH=/home/ubuntu/WatchTV/tvprojectenv/bin"
Environment="CLIENT_KEY=SECRET_KEY_IN_TEXT_HERE"
Environment="TVIP=MY_TELEVISION_IP"
ExecStart=/home/ubuntu/WatchTV/tvproject/bin/gunicorn --workers 4 --bind unix:myproject.sock -m 007 swagger_server.__main__:app

[Install]
WantedBy=multi-user.target

The exec start command creates a unix socket for nginx to connect to. The benefit is that it avoids the overhead of communicating over http. Now create your site configuration file for nginx. I tend to use vim for editing files but nano is easier to understand if not exposed to vim.

sudo touch /etc/nginx/sites-available/watchtvproject
sudo nano /etc/nginx/sites-available/watchtvproject
server {
    listen 80;
    location /WatchTV {
        include proxy_params;
        proxy_pass http://unix:/home/ubuntu/WatchTV/myproject.sock:/WatchTV;
    }

Then create a symlink to sites-enabled. Also remove the default website so it doesn’t conflict.

sudo ln -s /etc/nginx/sites-available/watchtvproject /etc/nginx/sites-enabled
sudo unlink /etc/nginx/sites-enabled/default
# Check the nginx conf file is good
sudo nginx -t
sudo systemctl reload nginx

At this point ip/WatchTV/1.0.0/watch should respond if it doesn’t check

sudo journalctl -u watchtvproject # to see the status of gunicorn or the nginx error logs
sudo tail -f /var/log/nginx/error.log

What would you prefer?

Personally node-red was much easier to setup and see results directly. Coding is a fun activity but for wiring simple things together I do not want to think a lot about maintaining and moving libraries and methods around.

Contact me

Let’s Start a Project

Sitemap