Repository: wuub/rxv

Issue:
HDMI inputs report False to Playback supported but they actually can over CEC
Hello,

I am using HomeAssistant which is using this library for Yamaha Media Players but I cannot use the media buttons as the library reports the HMDI input as "is_playback_supported" is returning False.

"is_playback_supported" is checking the "desc.xml" from the Receiver but none of the HDMI sources will show up in this file, even if they have a CEC enabled device connected to it.

Could this function assume HDMI devices are CEC enabled and just send the play/pause/stop/previous/next keys by default? The "desc.xml" file is static and I have not found a way to check if an HDMI source is CEC enabled or not so forcing it is the only way to make it work in Home Assistant.

Thank you.

Discussion:

Patch:
diff --git a/rxv/exceptions.py b/rxv/exceptions.py
index e91b9f5..1bb4992 100644
--- a/rxv/exceptions.py
+++ b/rxv/exceptions.py
@@ -20,6 +20,12 @@ class MenuUnavailable(RXVException):
     pass
 
 
+class MenuActionUnavailable(RXVException):
+    """Menu control action unavailable for current input"""
+    def __init__(self, input, action):
+        super().__init__(f'{input} does not support menu cursor {action}')
+
+
 class PlaybackUnavailable(RXVException):
     """Raised when playback function called on unsupported source."""
     def __init__(self, source, action):
diff --git a/rxv/rxv.py b/rxv/rxv.py
index 6ae0138..4882d7b 100644
--- a/rxv/rxv.py
+++ b/rxv/rxv.py
@@ -14,7 +14,8 @@ from math import floor
 import requests
 from defusedxml import cElementTree
 
-from .exceptions import (CommandUnavailable, MenuUnavailable, PlaybackUnavailable,
+from .exceptions import (CommandUnavailable, MenuUnavailable,
+                         MenuActionUnavailable, PlaybackUnavailable,
                          ResponseException, UnknownPort)
 
 try:
@@ -63,6 +64,8 @@ ListControlJumpLine = '<{src_name}><List_Control><Jump_Line>{lineno}</Jump_Line>
                       '</List_Control></{src_name}>'
 ListControlCursor = '<{src_name}><List_Control><Cursor>{action}</Cursor>'\
                     '</List_Control></{src_name}>'
+CursorControlCursor = '<{src_name}><Cursor_Control><Cursor>{action}</Cursor>'\
+                      '</Cursor_Control></{src_name}>'
 VolumeLevel = '<Volume><Lvl>{value}</Lvl></Volume>'
 VolumeLevelValue = '<Val>{val}</Val><Exp>{exp}</Exp><Unit>{unit}</Unit>'
 VolumeMute = '<Volume><Mute>{state}</Mute></Volume>'
@@ -84,6 +87,20 @@ ALBUM_OPTIONS = ["Album", "Radio_Text_A"]
 SONG_OPTIONS = ["Song", "Track", "Radio_Text_B"]
 STATION_OPTIONS = ["Station", "Program_Service"]
 
+# Cursor commands.
+CURSOR_DISPLAY = "Display"
+CURSOR_DOWN = "Down"
+CURSOR_LEFT = "Left"
+CURSOR_MENU = "Menu"
+CURSOR_ON_SCREEN = "On Screen"
+CURSOR_OPTION = "Option"
+CURSOR_SEL = "Sel"
+CURSOR_RETURN = "Return"
+CURSOR_RETURN_TO_HOME = "Return to Home"
+CURSOR_RIGHT = "Right"
+CURSOR_TOP_MENU = "Top Menu"
+CURSOR_UP = "Up"
+
 
 class RXV(object):
 
@@ -439,6 +456,12 @@ class RXV(object):
     def _src_name(self, cur_input):
         if cur_input not in self.inputs():
             return None
+        if cur_input.upper().startswith('HDMI'):
+            # CEC commands can be sent over the HDMI inputs to control devices
+            # connected to the receiver. These can support play methods as well
+            # as menu cursor commands. Return the zone so these features
+            # will be enabled.
+            return self.zone
         return self.inputs()[cur_input]
 
     def is_ready(self):
@@ -532,35 +555,76 @@ class RXV(object):
         )
         return self._request('PUT', request_text, zone_cmd=False)
 
+    def supported_cursor_actions(self, cur_input=None):
+        if cur_input is None:
+            cur_input = self.input
+        src_name = self._src_name(cur_input)
+        if not src_name:
+            return frozenset()
+        cursor_actions = self._desc_xml.findall(
+            f'.//*[@YNC_Tag="{src_name}"]//Menu[@Func="Cursor"]/Put_1')
+        if cursor_actions is None:
+            return frozenset()
+        return frozenset(action.text for action in cursor_actions)
+
     def _menu_cursor(self, action):
         cur_input = self.input
         src_name = self._src_name(cur_input)
         if not src_name:
             raise MenuUnavailable(cur_input)
 
-        request_text = ListControlCursor.format(
+        if self.supports_method(src_name, 'List_Control', 'Cursor'):
+            template = ListControlCursor
+        elif self.supports_method(src_name, 'Cursor_Control', 'Cursor'):
+            template = CursorControlCursor
+        else:
+            raise MenuUnavailable(cur_input)
+
+        # Check that the specific action is available for the input.
+        if action not in self.supported_cursor_actions():
+            raise MenuActionUnavailable(cur_input, action)
+
+        request_text = template.format(
             src_name=src_name,
             action=action
         )
         return self._request('PUT', request_text, zone_cmd=False)
 
     def menu_up(self):
-        return self._menu_cursor("Up")
+        return self._menu_cursor(CURSOR_UP)
 
     def menu_down(self):
-        return self._menu_cursor("Down")
+        return self._menu_cursor(CURSOR_DOWN)
 
     def menu_left(self):
-        return self._menu_cursor("Left")
+        return self._menu_cursor(CURSOR_LEFT)
 
     def menu_right(self):
-        return self._menu_cursor("Right")
+        return self._menu_cursor(CURSOR_RIGHT)
 
     def menu_sel(self):
-        return self._menu_cursor("Sel")
+        return self._menu_cursor(CURSOR_SEL)
 
     def menu_return(self):
-        return self._menu_cursor("Return")
+        return self._menu_cursor(CURSOR_RETURN)
+
+    def menu_return_to_home(self):
+        return self._menu_cursor(CURSOR_RETURN_TO_HOME)
+
+    def menu_on_screen(self):
+        return self._menu_cursor(CURSOR_ON_SCREEN)
+
+    def menu_top_menu(self):
+        return self._menu_cursor(CURSOR_TOP_MENU)
+
+    def menu_menu(self):
+        return self._menu_cursor(CURSOR_MENU)
+
+    def menu_option(self):
+        return self._menu_cursor(CURSOR_OPTION)
+
+    def menu_display(self):
+        return self._menu_cursor(CURSOR_DISPLAY)
 
     def menu_reset(self):
         while self.menu_status().layer > 1:


Test patch:
diff --git a/tests/check_integration.py b/tests/check_integration.py
index 530b468..5f6fefd 100755
--- a/tests/check_integration.py
+++ b/tests/check_integration.py
@@ -52,6 +52,12 @@ def test_menu():
     rx.menu_up()
     rx.menu_down()
 
+    rx.input = "HDMI1"
+    time.sleep(1.0)
+    rx.menu_right()
+    time.sleep(1.0)
+    rx.menu_left()
+
 
 def test_fade():
     rx.volume = -50
diff --git a/tests/test_feature_support.py b/tests/test_feature_support.py
index d1499dc..5aefe25 100644
--- a/tests/test_feature_support.py
+++ b/tests/test_feature_support.py
@@ -146,11 +146,11 @@ class TestFeaturesV675(unittest.TestCase):
         self.assertFalse(support.skip_r)
 
         support = rec.get_playback_support("HDMI1")
-        self.assertFalse(support.play)
-        self.assertFalse(support.stop)
-        self.assertFalse(support.pause)
-        self.assertFalse(support.skip_f)
-        self.assertFalse(support.skip_r)
+        self.assertTrue(support.play)
+        self.assertTrue(support.stop)
+        self.assertTrue(support.pause)
+        self.assertTrue(support.skip_f)
+        self.assertTrue(support.skip_r)
 
         support = rec.get_playback_support("SERVER")
         self.assertTrue(support.play)
@@ -158,3 +158,40 @@ class TestFeaturesV675(unittest.TestCase):
         self.assertTrue(support.pause)
         self.assertTrue(support.skip_f)
         self.assertTrue(support.skip_r)
+
+    @requests_mock.mock()
+    def test_menu_cursor_support(self, m):
+        rec = self.rec
+        # we need to mock this out so that .inputs() work
+        m.post(rec.ctrl_url, text=sample_content("rx-v675-inputs-resp.xml"))
+
+        self.assertCountEqual(
+            [
+                rxv.rxv.CURSOR_DOWN,
+                rxv.rxv.CURSOR_RETURN,
+                rxv.rxv.CURSOR_RETURN_TO_HOME,
+                rxv.rxv.CURSOR_SEL,
+                rxv.rxv.CURSOR_UP,
+            ],
+            rec.supported_cursor_actions("NET RADIO"),
+        )
+
+        self.assertCountEqual(
+            [
+                rxv.rxv.CURSOR_DISPLAY,
+                rxv.rxv.CURSOR_DOWN,
+                rxv.rxv.CURSOR_LEFT,
+                rxv.rxv.CURSOR_MENU,
+                rxv.rxv.CURSOR_ON_SCREEN,
+                rxv.rxv.CURSOR_OPTION,
+                rxv.rxv.CURSOR_SEL,
+                rxv.rxv.CURSOR_RETURN,
+                rxv.rxv.CURSOR_RETURN_TO_HOME,
+                rxv.rxv.CURSOR_RIGHT,
+                rxv.rxv.CURSOR_TOP_MENU,
+                rxv.rxv.CURSOR_UP,
+            ],
+            rec.supported_cursor_actions("HDMI1"),
+        )
+
+        self.assertCountEqual([], rec.supported_cursor_actions("TUNER"))
diff --git a/tests/test_rxv.py b/tests/test_rxv.py
index 9690369..9a6ad70 100644
--- a/tests/test_rxv.py
+++ b/tests/test_rxv.py
@@ -36,3 +36,127 @@ class TestDesc(unittest.TestCase):
         self.assertEqual(len(zones), 2, zones)
         self.assertEqual(zones[0].zone, "Main_Zone")
         self.assertEqual(zones[1].zone, "Zone_2")
+
+
+class TestMenuCursor(unittest.TestCase):
+
+    def _input_sel(self, input_sel):
+        return ''.join(
+            [
+                '<YAMAHA_AV rsp="GET" RC="0"><Main_Zone><Input><Input_Sel>',
+                input_sel,
+                '</Input_Sel></Input></Main_Zone></YAMAHA_AV>',
+            ]
+        )
+
+    @requests_mock.mock()
+    def test_net_radio(self, m):
+        m.get(DESC_XML, text=sample_content('rx-v675-desc.xml'))
+        m.post(CTRL_URL, text=sample_content('rx-v675-inputs-resp.xml'))
+        rec = rxv.RXV(CTRL_URL)
+
+        rec.input = 'NET RADIO'
+        m.post(CTRL_URL, text=self._input_sel('NET RADIO'))
+
+        rec.menu_up()
+        self.assertIn(f'>{rxv.rxv.CURSOR_UP}<', m.last_request.text)
+        self.assertIn('<List_Control>', m.last_request.text)
+
+        rec.menu_down()
+        self.assertIn(f'>{rxv.rxv.CURSOR_DOWN}<', m.last_request.text)
+        self.assertIn('<List_Control>', m.last_request.text)
+
+        rec.menu_sel()
+        self.assertIn(f'>{rxv.rxv.CURSOR_SEL}<', m.last_request.text)
+        self.assertIn('<List_Control>', m.last_request.text)
+
+        rec.menu_return()
+        self.assertIn(f'>{rxv.rxv.CURSOR_RETURN}<', m.last_request.text)
+        self.assertIn('<List_Control>', m.last_request.text)
+
+        rec.menu_return_to_home()
+        self.assertIn(f'>{rxv.rxv.CURSOR_RETURN_TO_HOME}<', m.last_request.text)
+        self.assertIn('<List_Control>', m.last_request.text)
+
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_left()
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_right()
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_on_screen()
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_top_menu()
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_menu()
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_option()
+        with self.assertRaises(rxv.exceptions.MenuActionUnavailable):
+            rec.menu_display()
+
+    @requests_mock.mock()
+    def test_tuner(self, m):
+        m.get(DESC_XML, text=sample_content('rx-v675-desc.xml'))
+        m.post(CTRL_URL, text=sample_content('rx-v675-inputs-resp.xml'))
+        rec = rxv.RXV(CTRL_URL)
+
+        rec.input = 'TUNER'
+        m.post(CTRL_URL, text=self._input_sel('TUNER'))
+        with self.assertRaises(rxv.exceptions.MenuUnavailable):
+            rec.menu_up()
+
+    @requests_mock.mock()
+    def test_hdmi(self, m):
+        m.get(DESC_XML, text=sample_content('rx-v675-desc.xml'))
+        m.post(CTRL_URL, text=sample_content('rx-v675-inputs-resp.xml'))
+        rec = rxv.RXV(CTRL_URL)
+
+        rec.input = 'HDMI1'
+        m.post(CTRL_URL, text=self._input_sel('HDMI1'))
+
+        rec.menu_up()
+        self.assertIn(f'>{rxv.rxv.CURSOR_UP}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_down()
+        self.assertIn(f'>{rxv.rxv.CURSOR_DOWN}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_left()
+        self.assertIn(f'>{rxv.rxv.CURSOR_LEFT}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_right()
+        self.assertIn(f'>{rxv.rxv.CURSOR_RIGHT}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_sel()
+        self.assertIn(f'>{rxv.rxv.CURSOR_SEL}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_return()
+        self.assertIn(f'>{rxv.rxv.CURSOR_RETURN}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_return_to_home()
+        self.assertIn(f'>{rxv.rxv.CURSOR_RETURN_TO_HOME}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_on_screen()
+        self.assertIn(f'>{rxv.rxv.CURSOR_ON_SCREEN}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_top_menu()
+        self.assertIn(f'>{rxv.rxv.CURSOR_TOP_MENU}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_menu()
+        self.assertIn(f'>{rxv.rxv.CURSOR_MENU}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_option()
+        self.assertIn(f'>{rxv.rxv.CURSOR_OPTION}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)
+
+        rec.menu_display()
+        self.assertIn(f'>{rxv.rxv.CURSOR_DISPLAY}<', m.last_request.text)
+        self.assertIn('<Cursor_Control>', m.last_request.text)