blob: 2697c3d3820ed9d268b8552f20f0cd8f37dc518a [file] [log] [blame]
#!/usr/bin/python3
"""BenderDriver is a tool for controlling a Bender RCU Raspberry Pi Hat.
It should be run on a Raspberry Pi with a Bender board attached, and can be
adapted to test the integration of the remote control firmware & hardware,
the Bluetooth stack of a TV device, and the TV OS & apps. It provides an
end-to-end test of the remote control that is identical to what the end user
experiences.
Copyright(c) 2024 Google, LLC.
"""
import subprocess
import time
import RPi.GPIO as GPIO
# The reference RCU implements a subset of these buttons. Each pressed
# button connects a column pin to a row pin, and the MCU detects this
# and implements the button press as IR or Bluetooth signaling.
#
# +------+-----------+----------+---------+----------+--------+-------+
# | | Col0 | Col1 | Col2 | Col3 | Col4 | Col5 |
# +======+===========+==========+=========+==========+========+=======+
# | Row0 | POWER | RIGHT | VOL_DN | INFO | 4 | GREEN |
# | Row1 | INPUT | DOWN | CH_DN | 0 | 3 | RED |
# | Row2 | BOOKMARK | BACK | YOUTUBE | SUBTITLE | 2 | 6 |
# | Row3 | ASSIST | HOME | NETFLIX | 9 | 1 | 5 |
# | Row4 | DASHBOARD | GUIDE | DISNEY | 8 | BLUE | |
# | Row5 | UP | VOL_UP | HBOMAX | 7 | YELLOW | |
# | Row6 | LEFT | CH_UP | | | | |
# | Row7 | CENTER | VOL_MUTE | | | | |
# +------+-----------+----------+---------+----------+--------+-------+
# Map from a button name to the MCU row and column
# Which buttons work depends on the RCU firmware
buttons = {
"0": (1, 3),
"1": (3, 4),
"2": (2, 4),
"3": (1, 4),
"4": (0, 4),
"5": (3, 5),
"6": (2, 5),
"7": (5, 3),
"8": (4, 3),
"9": (3, 3),
"ASSIST": (3, 0),
"BACK": (2, 1),
"BLUE": (4, 4),
"BOOKMARK": (2, 0),
"CENTER": (7, 0),
"CH_DN": (1, 2),
"CH_UP": (6, 1),
"DASHBOARD": (4, 0), # The settings gear button
"DISNEY": (4, 2),
"DOWN": (1, 1),
"GREEN": (0, 5),
"GUIDE": (4, 1),
"HBOMAX": (5, 2),
"HOME": (3, 1),
"INFO": (0, 3),
"INPUT": (1, 0),
"LEFT": (6, 0),
"NETFLIX": (3, 2),
"POWER": (0, 0),
"RED": (1, 5),
"RIGHT": (0, 1),
"SUBTITLE": (2, 3),
"UP": (5, 0),
"VOL_DN": (0, 2),
"VOL_MUTE": (7, 1),
"VOL_UP": (5, 1),
"YELLOW": (5, 4),
"YOUTUBE": (2, 2),
}
# The Bender HW allows us to connect the rows and columns of the keyboard
# matrix by configuring various ICs though Raspberry Pi GPIOs:
#
# +--------+
# | U104 |
# | |
# Col0|S1-\ |
# Col2|S2--+>D1+-----------------------+
# Col3|S3--/ | |
# |S4-/ | |
# +--------+ |
# |
# +--------+ |
# | U105 | |
# | | |
# Col1|S1-\ | |
# Col4|S2--+>D1+-----+ +---------+ |
# Col5|S3--/ | | | U106 | |
# |S4-/ | | | | |
# +--------+ +----+COM1--NO1+--+
# | | | |
# +--------+ | +---------+ |
# | U102 | | |
# | | | |
# Row0|S1-\ | | |
# Row2|S2--+>D1+-----+ |
# Row4|S3--/ | |
# Row5|S4-/ | |
# +--------+ |
# |
# +--------+ |
# | U103 | |
# | | |
# Row1|S1-\ | |
# Row3|S2--+>D1+-----------------------+
# Row6|S3--/ |
# Row7|S4-/ |
# +--------+
# rows[r] gives the IC and switch port for a particular MCU row
rows = [
("U102", 1), # Row0
("U103", 1), # Row1
("U102", 2), # Row2
("U103", 2), # Row3
("U102", 3), # Row4
("U102", 4), # Row5
("U103", 3), # Row6
("U103", 4), # Row7
]
# cols[c] gives the IC and switch port for a particular MCU col
cols = [
("U104", 1), # Col0
("U105", 1), # Col1
("U104", 2), # Col2
("U104", 3), # Col3
("U105", 2), # Col4
("U105", 3), # Col5
("U105", 4), # Col6 (actually not connected)
("U105", 4), # Col7 (actually not connected)
]
# These ICs have a separate enable pin mapped to a GPIO. Other ICs are always
# enabled.
icsWithEnable = ["U102", "U103"]
# U106 has ports COM1 and NO1. If our button press combines ICs across these
# ports, we need to enable U106
icsOnCOM1 = ["U102", "U105"]
icsOnNO1 = ["U103", "U104"]
# The GPIO pinout of the Bender HW
pins = {
# The operator/test status output RGB LED, an output
"RED_LED_OUT": 21,
"GREEN_LED_OUT": 19,
"BLUE_LED_OUT": 23,
# The red/green status LED set by the remote control firmware (input)
# Used to confirm keypresses were sent, pairing mode entered, etc.
"RED_LED": 24,
"GREEN_LED": 26,
# Backlight LED and FindMyRemote input/output
# Used to test the FindMyRemote & backlight features, or can be used as an
# output to the operator.
"BACKLIGHT": 22,
"BUZZER": 37,
# Detects the audio jack insertion (input)
# Audio jack is used to send audio from RaspberryPi to the Assistant
"JACK_DETECT": 18,
# CPU reset for the Remote control MCU. Low = reset, high = operating
"RESET": 11,
# Switch ICs to bridge the remote control keyboard matrix.
# Used to press buttons.
"U102A0": 31,
"U102A1": 32,
"U102EN": 29,
"U103A0": 36,
"U103A1": 12,
"U103EN": 33,
"U104A0": 35,
"U104A1": 38,
"U105A0": 15,
"U105A1": 16,
"U106IN2": 7,
}
def pin_set(p, v):
"""Sets a named GPIO pin.
Args:
p: The name of the pin
v: Boolean value True/high or False/low
"""
if v:
# print(" Setting " + p + " HIGH")
GPIO.output(pins[p], GPIO.HIGH)
else:
# print(" Setting " + p + " LOW")
GPIO.output(pins[p], GPIO.LOW)
def switch_set(ic, s):
s = s - 1
pin_set(ic + "A0", s & 1)
pin_set(ic + "A1", s & 2)
def release_switches():
"""Releases all held switches."""
print("\tReleasing switches")
pin_set("U106IN2", 0)
pin_set("U102EN", 0)
pin_set("U103EN", 0)
switch_set("U105", 4)
switch_set("U104", 4)
# We expect that the green status LED turns off after we release the buttons
wait_for_led(green_seq=[0])
pin_set("BLUE_LED_OUT", 0)
pin_set("GREEN_LED_OUT", 0)
pin_set("RED_LED_OUT", 0)
def press_buttons(btns: list[str], down_time: float = 0.1, release=True):
"""Presses one or more named buttons on the remote control.
Args:
btns: List of button name strings.
down_time: How long to press the buttons.
release: If True, releases the buttons at the end; otherwise, leaves them
held.
"""
touched_ics = set()
# Setup the switch inputs for the one or more buttons
for b in btns:
r, c = buttons[b]
ric, rsw = rows[r]
cic, csw = cols[c]
touched_ics.add(ric)
touched_ics.add(cic)
switch_set(ric, rsw)
switch_set(cic, csw)
# Determine if U106 needs to be activated to bridge com1/no1
if (ric in icsOnCOM1 and cic in icsOnNO1) or (
cic in icsOnCOM1 and ric in icsOnNO1
):
pin_set("U106IN2", 1)
# Enable the ICs that were touched and have an enable
for eic in icsWithEnable:
if eic in touched_ics:
pin_set(eic + "EN", 1)
release_time = time.monotonic() + down_time
pin_set("BLUE_LED_OUT", 1)
if not wait_for_led():
pin_set("BLUE_LED_OUT", 0)
pin_set("GREEN_LED_OUT", 1)
release_time += 0.2
print("Warning: RF button press failed. Holding longer for IR")
while time.monotonic() < release_time:
time.sleep(0.01)
if release:
release_switches()
def led_flash(color=2, times=1):
"""Flash the operator status RGB LED.
Args:
color: 0 -> 4 color sequence; otherwise, interpreted as r/g/b for bits 0/1/2
times: How many times to flash
"""
delay = 0.1
while times > 0:
times -= 1
if color % 8 == 0:
# Flash the Google colors
for r, g, b in [(0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 0)]:
pin_set("BLUE_LED_OUT", b)
pin_set("GREEN_LED_OUT", g)
pin_set("RED_LED_OUT", r)
time.sleep(2 * delay)
else:
pin_set("RED_LED_OUT", color & 1)
pin_set("GREEN_LED_OUT", color & 2)
pin_set("BLUE_LED_OUT", color & 4)
time.sleep(delay)
pin_set("BLUE_LED_OUT", 0)
pin_set("GREEN_LED_OUT", 0)
pin_set("RED_LED_OUT", 0)
time.sleep(delay)
time.sleep(0.5)
def read_leds():
return (
1 if GPIO.input(pins["GREEN_LED"]) == GPIO.HIGH else 0,
1 if GPIO.input(pins["RED_LED"]) == GPIO.HIGH else 0,
)
def monitor_led(timeout=1.0):
"""Polls for changes in the red/green status LEDs.
Args:
timeout: maximum time to wait for a change.
"""
now = time.monotonic()
start = now
timeout_time = now + timeout
polling_interval = 0.001
last_leds = None
last_leds_time = now
while now < timeout_time:
time.sleep(polling_interval)
now = time.monotonic()
cur_leds = read_leds()
if cur_leds != last_leds:
last_leds = cur_leds
print("\t\t%f (%f): %s" % (now - start, now - last_leds_time, cur_leds))
last_leds_time = now
def wait_for_led(
green_seq: list[int] = None,
red_seq: list[int] = None,
timeout=5,
min_duration=0.001,
):
"""Tests a sequence of red/green LEDs output by the remote control firmware.
This allows detecting error states of the firmware, completion of commands
like button presses, etc.
Args:
green_seq: The sequence of green LEDs to be observed
red_seq: The sequence of red LEDs to be observed
timeout: Maximum time to wait for the LED sequence
min_duration: minimum time for each stage of the LED sequence
Returns:
True if passed, false otherwise.
"""
if green_seq is None:
green_seq = [1]
if red_seq is None:
red_seq = [0] * len(green_seq)
expectations = tuple(zip(green_seq, red_seq))
now = time.monotonic()
start = now
timeout_time = now + timeout
polling_interval = 0.001
wait_print = True
first_exp = expectations[0]
while now < timeout_time:
time.sleep(polling_interval)
now = time.monotonic()
cur_leds = read_leds()
if cur_leds == first_exp:
print("\tFirst LEDs expectation achieved")
break
# We want an LED to be on, so wait while all LEDs are off
elif first_exp[0] or first_exp[1]:
if cur_leds == (0, 0):
if wait_print:
print("\tWaiting for any LED to turn on...")
wait_print = False
continue
else: # Wrong LED(s) are on
print("\tWrong LEDs on")
break
# We want all LEDs off, so wait while any LED is on
else:
if wait_print:
print("\tWaiting for all LEDs to turn off...")
wait_print = False
continue
step = 0
num_steps = len(expectations)
step_end_time = now + min_duration
print(
"\tStarting step %d of LED sequence: %s, %fs remaining total time"
% (step, expectations[step], timeout_time - now)
)
while now < timeout_time:
time.sleep(polling_interval)
now = time.monotonic()
cur_leds = read_leds()
if cur_leds == expectations[step]:
# Need to monitor that the LEDs meet expectations for the min_duration
if now < step_end_time:
# print("... " + str(step_end_time - now) + " " + str(cur_leds))
continue
elif step == num_steps - 1:
print("waitForLED: Success")
return True
else:
# If the LEDs don't match, but we have already matched for min_duration
# try the next step in the sequence
print(
"\t\tStep %d Current LEDs:%s, Expected LEDs:%s, Time elapsed:%fs,"
" Step time remaining:%fs"
% (
step,
cur_leds,
expectations[step],
now - start,
step_end_time - now,
)
)
if now >= step_end_time and step < num_steps - 1:
step += 1
step_end_time = now + min_duration
print(
"\tStarting step %d of LED sequence: %s, %fs remaining total time"
% (step, expectations[step], timeout_time - now)
)
continue
else:
print(
"\tAt step %d of LED sequence: wanted %s, got %s"
" with %fs remaining step time"
% (step, expectations[step], cur_leds, step_end_time - now)
)
monitor_led(timeout=1.0)
return False
# print("---")
print("\tAt step %d of LED sequence: timed out" % step)
return False
def assistant(text):
espeak_cmd = 'espeak -a 200 "%s"' % text
press_buttons(["ASSIST"], release=False)
time.sleep(10)
print(espeak_cmd)
completed_proc = subprocess.run(
espeak_cmd, shell=True, check=True, timeout=30
)
print(completed_proc.stdout if completed_proc.stdout else "")
print(completed_proc.stderr if completed_proc.stderr else "")
release_switches()
def test_backlight():
"""Tests the backlight functionality, both input and output."""
GPIO.setup(pins["BACKLIGHT"], GPIO.OUT)
print(
"Flashing backlight. If you can see this flashing, this test won't work"
)
for _ in range(10):
time.sleep(0.1)
GPIO.output(pins["BACKLIGHT"], GPIO.HIGH)
time.sleep(0.1)
GPIO.output(pins["BACKLIGHT"], GPIO.LOW)
GPIO.setup(pins["BACKLIGHT"], GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
now = time.monotonic()
timeout_time = now + 10
passed = False
led_flash(3)
print("Checking that backlight is OFF...")
if (
GPIO.input(pins["BACKLIGHT"]) == GPIO.HIGH
or GPIO.wait_for_edge(pins["BACKLIGHT"], GPIO.BOTH, timeout=2000)
is not None
):
print("Backlight is ON")
led_flash(1, 3)
return
# Press a button and see if the backlight responds
press_buttons(["DOWN"])
while time.monotonic() < timeout_time:
print("Waiting for backlight...")
channel = GPIO.wait_for_edge(pins["BACKLIGHT"], GPIO.BOTH, timeout=2000)
if channel is not None:
print("Detected backlight.")
led_flash(2, 3)
passed = True
break
if not passed:
print("Failed to detect backlight.")
print("Detection only works with the BL_on switch in the OFF position")
led_flash(1, 3)
def test_find_my_remote():
"""Tests the buzzer functionality, input and output.
User needs to activate "find my remote" from the TV before the timeout.
"""
GPIO.setup(pins["BUZZER"], GPIO.OUT)
print("Sounding buzzer. If you can hear this sound, this test won't work")
for _ in range(200):
time.sleep(0.001)
GPIO.output(pins["BUZZER"], GPIO.HIGH)
time.sleep(0.001)
GPIO.output(pins["BUZZER"], GPIO.LOW)
GPIO.setup(pins["BUZZER"], GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
now = time.monotonic()
timeout_time = now + 10
passed = False
led_flash(3)
while time.monotonic() < timeout_time:
print("Waiting for buzzer...")
channel = GPIO.wait_for_edge(pins["BUZZER"], GPIO.BOTH, timeout=2000)
if channel is not None:
print("Detected sound on buzzer. Checking LED")
# Expect a slow blinking RED LED
if wait_for_led(
green_seq=[0] * 5,
red_seq=[1, 0, 1, 0, 1],
min_duration=0.4,
timeout=10,
):
passed = True
led_flash(2, 3)
break
if not passed:
print("Failed to detect sound on buzzer or LEDs.")
print("Detection only works with the FMR_on switch in the OFF position")
led_flash(1, 3)
reset() # Silence the buzzer
def setup():
"""Sets up the Raspberry Pi GPIOs to run the Bender board."""
print("Setup...")
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BOARD)
# Set all pins to OUTPUT and LOW
for p in range(41):
try:
GPIO.setup(p, GPIO.OUT)
except:
pass
try:
pin_set(p, 0)
except:
pass
GPIO.setup(pins["JACK_DETECT"], GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(pins["GREEN_LED"], GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.setup(pins["RED_LED"], GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.setup(pins["BUZZER"], GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.setup(pins["BACKLIGHT"], GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
pin_set("RESET", 0)
led_flash(0)
print("BenderDriver for v0.3 hardware")
pin_set("RESET", 1)
if GPIO.input(pins["JACK_DETECT"]) == GPIO.LOW:
print("Warning: Audio jack out; Assistant can't receive audio")
else:
print("Audio jack detected")
def reset():
print("Resetting controller...")
pin_set("RESET", 0)
led_flash(0, 1)
pin_set("RESET", 1)
# Sometimes the red LED briefly lights
GPIO.wait_for_edge(pins["RED_LED"], GPIO.FALLING, timeout=500)
wait_for_led(green_seq=[1, 0, 1, 0, 1])
# ------------------------------------------------------------------------------
setup()
cmd, lastcmd, l = "", "", 0
while True:
cmd = input("> ")
if not cmd:
cmd = lastcmd
print(cmd)
if not cmd:
continue
cmd = cmd.upper()
lastcmd = cmd
if cmd == "PAIR":
press_buttons(("BACK", "HOME"), 5.0)
elif cmd == "CLEAR": # Clear bond cache
press_buttons(("CENTER", "VOL_MUTE"), 5.0)
elif cmd == "BUG": # Bugreport
press_buttons(("CENTER", "BACK"), 5)
elif cmd == "TALKBACK": # Accessibility shortcut
press_buttons(("BACK", "DOWN"), 1.5)
elif cmd == "EXIT":
break
elif cmd == "LED":
led_flash(l, max(1, l / 8))
l += 1
elif cmd == "RESET":
reset()
elif cmd == "FINDMY":
test_find_my_remote()
elif cmd == "BACKLIGHT":
test_backlight()
else:
multipleButtons = cmd.split(" ")
if multipleButtons[0] == "ASSIST" and len(multipleButtons) > 1:
assistant(" ".join(multipleButtons[1:]))
else:
press_buttons(multipleButtons)