Merge "soft version 2.34" into main
diff --git a/test/benderDriver.py b/test/benderDriver.py
new file mode 100644
index 0000000..9713849
--- /dev/null
+++ b/test/benderDriver.py
@@ -0,0 +1,630 @@
+#!/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    | APP03   | 8        | BLUE   |       |
+# | Row5 | UP        | VOL_UP   | APP04   | 7        | YELLOW |       |
+# | Row6 | LEFT      | CH_UP    | FREETV  |          |        |       |
+# | 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),
+    "APP03": (4, 2),
+    "APP04": (5, 2),
+    "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
+    "DOWN": (1, 1),
+    "FREETV": (6, 2),
+    "GREEN": (0, 5),
+    "GUIDE": (4, 1),
+    "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.
+  """
+  release_time = time.monotonic() + down_time
+  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)
+
+  if not wait_for_led(timeout=down_time):
+    pin_set("RED_LED_OUT", 1)
+    release_time += 0.1
+    print("Warning: RF button press failed. Holding longer for IR")
+  else:
+    pin_set("GREEN_LED_OUT", 1)
+  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")
+        return False
+    # 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=timeout_time - now)
+        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)
+
+  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"), 1.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)