Add driver for the Bender ATV RCU reference design Test board Change-Id: I391ea8e388c1ed056457a8a7e67f41080ada03c0
diff --git a/test/benderDriver.py b/test/benderDriver.py new file mode 100644 index 0000000..2697c3d --- /dev/null +++ b/test/benderDriver.py
@@ -0,0 +1,631 @@ +#!/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) \ No newline at end of file