Supporting a Digital Immigrant


I remember being called a “digital native” in school; someone who grew up with computers, the internet, and digital technology as a natural part of their environment. For us, the workflows and design languages that underpin digital technology are second nature. We instinctively know to press and hold the power button to turn on a phone, to press the volume and power button to take a screenshot, to pinch or swipe at photos in our cameral roll, to check the top-right corner of a website for account options, or to head straight to the app store when we need to install a new tool or service. We can all fluently speak the language of digital technology.
Continuing with my teacher’s metaphor, people who came to digital technology later in life could be called digital immigrants. Unlike digital natives, they didn’t grow up with the design patterns and workflows that underpin modern devices and interfaces. They might not be fluent in our digital language of menus, windows, gestures and forms. It makes sense that tasks like navigating a smart TV menu, installing an app, or signing into a streaming service could feel foreign.
My Digital Immigrant
There is one digital immigrant I spend time with often. Someone from the land of rotary phones, tube radios, and cutting-edge 1940s technology like black-and-white television. Unfortunately they have reached the “disheartened” or “uninterested” phase of digital engagement. To them, modern technology feels confusing and needlessly complex. Concepts that seem second nature to me, like the distinction between the TV remote and the set-top box (Sky television) remote, remain hard to internalise, even after repeated explanations. Opening an app on the television feels, to them, like trying to operate a super computer.
This individual has a favourite YouTube channel they enjoy watching, but they can only access it when someone is around to help. They need assistance to open the app, update it if necessary, search for the channel, open it, and choose a video they haven’t already seen. Just as I wouldn’t be able to follow instructions in Italian, they struggle with the unfamiliar language of digital technology. As a result, they often settle for whatever comes on when they turn on the TV - or nothing at all, especially if the set-top box doesn’t behave exactly as they expect.
With the television being this persons main source of entertainment, I wanted to try and think of a way to make their favourite YouTube channel more accessible to them.
The Plan
Find a way to, with the press of a single button:
Turn on the TV
Open YouTube
Automatically Choose the “watch as guest” option - they don’t have or want an account.
Play a video from their favourite youtube channel that they haven’t seen before.
Controlling the TV Programatically
The Research
When I first started thinking about how to control the TV, I explored a few initial ideas, the first involving a microcontroller and IR blaster. The concept was simple: learn the remote codes and transmit a sequence of IR signals to turn the TV on, open YouTube, and perform other basic functions. But the more I considered it, the more fragile the solution seemed. For instance, if the TV was already on, sending an "on" command would actually turn it off. And if someone changed the order of apps on the home screen, the carefully timed sequence of button presses to launch YouTube would no longer work. The whole setup would rely on a brittle choreography of commands that could easily go out of sync. It would have been error prone and frustrating for the user.
Then I turned to the TV’s manual and was excited to find documentation about external control via a serial interface. This approach promised much more reliable functionality. You could send a definitive "power on" command, directly switch to a specific input source by name, or set an exact volume level; eliminating the guesswork and inconsistency of IR-based control. It was exactly the level of precision I was hoping for. Unfortunately, when I inspected the back of the TV, I realised there was no serial port available. I suppose this feature is only present on commercial models, and the manual was simply covering all possible variants. It was a bit of a letdown.
After more investigating I came across a Git Hub repository that contained a Python library that would let me connect to the TV and do all sorts of things. Crucially it would let me:
Connect to the television from a Python program (that will run on a Raspberry Pi)
Open YouTube by name, and even a specific YouTube video!
Programatically navigate and select the “watch as guest” option with the libraries facility to emulate basic up, down and ok button presses.
In addition it would let me:
Set the volume to a specific number.
Send notifications to the TV which I could use to tell the user what’s going on.
The only thing this library couldn’t do was turn the TV on - a very important step. However, I realised (and the documentation for this library points out) that you can use Wake on LAN and send a ‘magic’ packet to the television to wake it - you just need to enable this feature on the TV.
With this library I am now able to accomplish steps 1 to 3 of ‘the plan’.
The Implementation
Environment Variables
There are three runtime parameters that are needed in this program.
TV_ADDR="XXX.XXX.XXX.XXX" # IP address of the television (or hostname if you're fancy)
TV_MAC="FF:FF:FF:FF:FF:FF" # Hardware address of the network interface on the TV
WEBOS_KEY="11c4bc438d420b33cc2334323d77dddf" # Access key returned by registering with TV
I have chosen to store them in a .env file and use the load_dotenv()
function to allow the values of the environment variables to be used by the program. This has to be done before any function that uses these parameters is used.
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
Turning on the TV
The first component I needed was a function to turn the TV on using Wake on LAN. A quick Google revealed the wakeonlan Python package, which was easily installed with pip. All I have to do is send the magic packet to the TV’s hardware address to get it to turn on. In testing I found that sending it twice was always reliable.
from wakeonlan import send_magic_packet
def turn_on_tv():
tv_mac = os.getenv('TV_MAC')
# Turn on the TV through wake on LAN
send_magic_packet(tv_mac)
send_magic_packet(tv_mac)
Determining if the TV is on the Network
I then wanted a component to indicate if the TV was online. If its already on, I don’t have to turn it on. I also don’t want to attempt to connect to the TV before the it is on the network. This function simply pings the television, and if it responds within a second we can be pretty confident its online. If not, then it probably isn’t.
import os
def tv_online():
tv_addr = os.getenv('TV_ADDR')
# Check if the TV is online by pinging it
# -c 1 (send one packet)
# -t 1 (wait at most one second for response)
response = os.system(f"ping -c 1 -t 1 {tv_addr}")
if response == 0:
print(f"{tv_addr} is online")
return True
else:
print(f"{tv_addr} is offline")
return False
Connecting to the Television
The next piece of functionality was establishing a connection to the television. For this, I used code largely borrowed from the Python library I mentioned earlier. The first time you attempt to connect, the tv_key
should be an empty dictionary. Without a client key, the TV will display a pop-up asking you to confirm the connection. Once you approve it on-screen, the registration completes and the program will print out a unique client key.
This key can then be added to a .env
file and the tv_key
line in the script can be uncommented. From that point forward, the program can connect automatically without requiring manual confirmation each time.
In my implementation, I added a loop that retries the connection every five seconds, up to a maximum number of attempts. I noticed that even when the TV was online and responding to pings, the WebOS service sometimes wasn’t ready to accept connections immediately. This retry loop gives the TV time to fully boot its network services. If it still can’t connect after several tries, the script gives up; useful in cases where something is genuinely wrong.
Ideally, I’d like to improve this in the future by making the registration process fully automatic, including storing the client key persistently. But for now, this solution works well for my needs.
def connect_to_tv(max_tries=10):
tv_key = {}
#tv_key = {'client_key': os.getenv('WEBOS_KEY')}
tv_addr = os.getenv('TV_ADDR')
for i in range(max_tries):
try:
# Establish Connection With LG WebOS TV
client = WebOSClient(tv_addr)
client.connect()
for status in client.register(tv_key, timeout=10):
if status == WebOSClient.PROMPTED:
print("Please accept the connect on the TV!")
elif status == WebOSClient.REGISTERED:
print("Registration successful!")
print(f"Store this as the environment variable WEBOS_KEY: {tv_key}")
return client
except Exception as e:
print(f"Connection attempt {i+1} failed: {e}")
time.sleep(5)
Pulling it all Together
With all the key components in place, I built a main function to bring everything together and automate the experience from start to finish. Here's what it does:
If the TV isn't already online, it powers it on and waits for it to become available on the network.
It establishes a connection using a previously registered key.
It sets the volume to a comfortable level (low enough to avoid surprises).
It notifies the user that everything is working and YouTube will launch shortly.
It fetches a new video from the user’s favourite channel (implementation covered in the next section).
It launches YouTube with the selected video.
It waits a bit to ensure the YouTube app has loaded - this part is admittedly guesswork, as there's no API feedback about app state.
Finally, it navigates the YouTube guest mode menu and starts playback.
Here’s the function that brings it all together:
def configure_tv_play_video():
if not tv_online():
turn_on_tv()
# Wait for the TV to appear on the network, try up to 10 times.
for _ in range(10):
if tv_online():
break
else:
time.sleep(3)
# Establish Connection With LG WebOS TV
client = connect_to_tv()
# Set volume to something low
media = MediaControl(client)
media.set_volume(8)
# System control allows notifications to be sent to TV
system = SystemControl(client)
system.notify("Connected to the TV. Youtube will launch shortly.")
# Get a youtube video to watch
video = get_youtube_video()
# Launch YouTube on the TV with the selected video.
app = ApplicationControl(client)
apps = app.list_apps()
yt = [x for x in apps if "youtube" in x["title"].lower()][0]
app.launch(yt, content_id=video)
# Wait for the app to launch
system.notify("Waiting 15 seconds for Youtube to launch...")
time.sleep(15)
# Select YouTube guest mode
inp = InputControl(client)
inp.connect_input()
inp.down()
time.sleep(1)
inp.ok()
inp.disconnect_input()
system.notify("Video should now be playing. Enjoy!")
Getting an Unwatched YouTube Video
Generic YouTube Channel Class
To retrieve a new video from the user’s favourite YouTube channel, I implemented a class that uses the yt_dlp
library. This lets me scrape the necessary video metadata directly from a given YouTube channel.
The class, YoutubeChannel
, is initialised with the name of the channel. When the scrape_video_urls
method is called, it fetches all video entries from the channel's /videos
page. For each entry, I check whether the video meets a defined acceptance criteria using _accept_video
. By default the function always returns True
, but I expect to overwrite this later when implementing a subclass for a specific channel.
Each video ID is added to a dictionary where the key is the ID and the value is a boolean indicating whether the video has been "watched" or not (we start by assuming all videos are unwatched). When a video is selected via get_unwatched_video_url
, it randomly picks an unwatched one from the list, marks it as watched, and returns the ID so it can be launched in YouTube.
Here’s the class implementation:
import yt_dlp
import random
from urllib.parse import urlparse, parse_qs
class YoutubeChannel:
def __init__(self, channel_name):
self.channel_name = channel_name
self.channel_url = f'https://www.youtube.com/@{channel_name}/videos'
self.video_urls = dict()
def scrape_video_urls(self):
ydl_opts = {
'quiet': True,
'extract_flat': True, # Do not download, just get metadata
'skip_download': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(self.channel_url, download=False)
entries = info.get('entries', [])
for entry in entries:
if self._accept_video(entry) and entry['url'] not in self.video_urls:
parsed_url = urlparse(entry['url'])
query_params = parse_qs(parsed_url.query)
video_id = query_params.get('v', [None])[0]
self.video_urls[video_id] = False # False means not watched yet
def get_video_urls(self):
return list(self.video_urls.keys())
def get_unwatched_video_urls(self):
unwatched = [url for url, watched in self.video_urls.items() if not watched]
# If there are no unwatched videos, return all video URLs
if len(unwatched) == 0:
return self.get_video_urls()
return unwatched
def get_unwatched_video_url(self):
unwatched = self.get_unwatched_video_urls()
chosen_video = random.choice(unwatched)
self.video_urls[chosen_video] = True
return chosen_video
def _accept_video(self, entry):
return True
Dedicated YouTube Channel Subclass
My family members favourite channel is 48 Hours, so a dedicated subclass is implemented. I overwrite the _accept_video
function so that only full episodes and no parts are loaded. As the videos are chosen at random, landing on Part 1 of a multi-part story, without Part 2 automatically following, and no easy way to find it manually, would be maddening. So, I’ve removed that possibility with this filter.
class YoutubeChannel48Hours(YoutubeChannel):
def __init__(self):
super().__init__('48hours')
def _accept_video(self, entry):
return "Full Episode" in entry['title'] and "Part" not in entry['title']
Persistent Storage and Getting Videos
Keen readers may have noticed that, up to this point, there hasn’t been any persistent storage of the YouTube videos we've scraped or whether they’ve been watched. I handle this in the get_youtube_video
function using Python’s built-in pickle
library. This allows the entire object, complete with its internal state and watched/unwatched video list, to be serialised to disk and reloaded on future runs.
When the function is called, it first checks for the presence of a previously saved file. If the file exists, it loads the YoutubeChannel48Hours
instance from disk, giving us instant access to the existing video list and watched statuses. If not, it creates a fresh instance of the class and populates it by scraping the channel again.
In either case, it then selects a random unwatched video. After that, the updated object, now with one more video marked as watched, is serialised and saved for next time. I thought this was a neat solution: no need to manually parse or manage separate data files, and it all just works with a couple of lines of code.
import pickle
def get_youtube_video():
# Get a youtube video to watch
if os.path.exists('ytc_48_hours.pickle'):
with open('ytc_48_hours.pickle', 'rb') as f:
ytc_48_hours = pickle.load(f)
else:
ytc_48_hours = YoutubeChannel48Hours()
ytc_48_hours.scrape_video_urls()
video = ytc_48_hours.get_unwatched_video_url()
# Pickle youtube channel object(s) to file
with open('ytc_48_hours.pickle', 'wb') as f:
pickle.dump(ytc_48_hours, f)
return video
Controlling from a Button
The final step was making everything work with a single button press. I wanted this to be as simple as possible for my family member—no apps, no remotes, just a physical button.
From previous experiments, I had a 433 MHz wireless button and a compatible receiver lying around. Since everything else was already running on a Raspberry Pi, it made perfect sense to hook the receiver up to it and listen for the specific signal sent by this button. The Raspberry Pi has General-Purpose Input/Output (GPIO) pins that make this possible.
I wrote a small script using the rpi-rf
library to monitor incoming RF codes. When it detects the code sent by this particular button, it calls the configure_tv_play_video()
function and kicks off the whole process.
import time
from rpi_rf import RFDevice
from webos import configure_tv_play_video
RX_PIN = 27
rfdevice = RFDevice(RX_PIN)
rfdevice.enable_rx()
timestamp = None
print("Listening for RF signals...")
try:
while True:
if rfdevice.rx_code_timestamp != timestamp:
timestamp = rfdevice.rx_code_timestamp
received_code = rfdevice.rx_code
print(f"Received code: {received_code}")
if received_code == 13739617: # the code sent from my RF button.
try:
configure_tv_play_video()
except Exception as e:
print(f"Couldn't configure tv and play video: {e}")
time.sleep(0.01)
except KeyboardInterrupt:
rfdevice.cleanup()
Complete Code
The complete code for my project can be found in this GitHub repository:
Leave to Remain
With this setup, my elderly family member, my digital immigrant, has finally crossed into the land of modern technology. With a single button press, the TV powers on (if it isn’t already), launches YouTube, and starts playing a new video from their favourite channel. If the TV is already on, it simply loads another video.
The one-button interface transforms what was once an intimidating, multi-step process into something effortless and accessible. It’s been amazing to see them confidently engage with a platform that used to feel completely out of reach. In a small but meaningful way, they now feel more connected to the digital world—and that, to me, makes the whole project worth it.
Subscribe to my newsletter
Read articles from Gregor Soutar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Gregor Soutar
Gregor Soutar
Software engineer at the UK Astronomy Technology Centre, currently developing instrument control software for MOONS, a next-generation spectrograph for the Very Large Telescope (VLT).