diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb4e993..b56a912 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.6, 3.8, 3.9] + python-version: ["3.9", "3.10"] steps: - uses: actions/checkout@v2 diff --git a/anthemav/connection.py b/anthemav/connection.py index e0b2b35..3b6aab6 100644 --- a/anthemav/connection.py +++ b/anthemav/connection.py @@ -1,14 +1,10 @@ """Module containing the connection wrapper for the AVR interface.""" import asyncio import logging +from typing import Callable from .protocol import AVR -__all__ = "Connection" - -try: - ensure_future = asyncio.ensure_future -except Exception: - ensure_future = getattr(asyncio, "async") +__all__ = ["Connection"] class Connection: @@ -17,16 +13,25 @@ class Connection: def __init__(self): """Instantiate the Connection object.""" self.log = logging.getLogger(__name__) + self.host = "" + self.port = 0 + self._loop: asyncio.AbstractEventLoop = None + self._retry_interval = 1 + self._closed = False + self._closing = False + self._halted = False + self._auto_reconnect = False + self.protocol: asyncio.Protocol = None @classmethod async def create( cls, - host="localhost", - port=14999, - auto_reconnect=True, - loop=None, - protocol_class=AVR, - update_callback=None, + host: str = "localhost", + port: int = 14999, + auto_reconnect: bool = True, + loop: asyncio.AbstractEventLoop = None, + protocol_class: asyncio.Protocol = AVR, + update_callback: Callable[[str], None] = None, ): """Initiate a connection to a specific device. @@ -55,7 +60,7 @@ async def create( :type update_callback: callable """ - assert port >= 0, "Invalid port value: %r" % (port) + assert port >= 0, f"Invalid port value: {port}" conn = cls() conn.host = host @@ -67,10 +72,10 @@ async def create( conn._halted = False conn._auto_reconnect = auto_reconnect - def connection_lost(): + async def connection_lost(): """Function callback for Protocoal class when connection is lost.""" if conn._auto_reconnect and not conn._closing: - ensure_future(conn.reconnect(), loop=conn._loop) + await conn.reconnect() conn.protocol = protocol_class( connection_lost_callback=connection_lost, @@ -102,10 +107,11 @@ def _increase_retry_interval(self): self._retry_interval = min(300, 1.5 * self._retry_interval) async def reconnect(self): + """Connect to the host and keep the connection open""" while True: try: if self._halted: - await asyncio.sleep(2, loop=self._loop) + await asyncio.sleep(2) else: self.log.debug( "Connecting to Anthem AVR at %s:%d", self.host, self.port @@ -122,7 +128,7 @@ async def reconnect(self): self.log.warning("Connecting failed, retrying in %i seconds", interval) if not self._auto_reconnect or self._closing: raise - await asyncio.sleep(interval, loop=self._loop) + await asyncio.sleep(interval) if not self._auto_reconnect or self._closing: break diff --git a/anthemav/protocol.py b/anthemav/protocol.py index c8d657c..a88fbc2 100755 --- a/anthemav/protocol.py +++ b/anthemav/protocol.py @@ -1,18 +1,12 @@ """Module to maintain AVR state information and network interface.""" import asyncio import logging -import time +from typing import Awaitable, Callable -__all__ = "AVR" - -# In Python 3.4.4, `async` was renamed to `ensure_future`. -try: - ensure_future = asyncio.ensure_future -except AttributeError: - ensure_future = getattr(asyncio, "async") +__all__ = ["AVR"] # These properties apply even when the AVR is powered off -ATTR_CORE = {"Z1POW", "IDM", "IDN"} +ATTR_CORE = {"Z1POW", "IDM"} # Audio Listening mode ALM_NUMBER = { @@ -41,26 +35,15 @@ LOOKUP = {} LOOKUP["Z1POW"] = {"description": "Zone 1 Power", "0": "Off", "1": "On"} -LOOKUP["FPB"] = { - "description": "Front Panel Brightness", - "0": "Off", - "1": "Low", - "2": "Medium", - "3": "High", -} LOOKUP["Z1VOL"] = {"description": "Zone 1 Volume"} LOOKUP["IDR"] = {"description": "Region"} LOOKUP["IDM"] = {"description": "Model"} LOOKUP["IDS"] = {"description": "Software version"} LOOKUP["IDB"] = {"description": "Software build date"} LOOKUP["IDH"] = {"description": "Hardware version"} -LOOKUP["IDN"] = {"description": "MAC address"} -LOOKUP["ECH"] = {"description": "Tx status", "0": "Off", "1": "On"} -LOOKUP["SIP"] = {"description": "Standby IP control", "0": "Off", "1": "On"} LOOKUP["ICN"] = {"description": "Active input count"} LOOKUP["Z1INP"] = {"description": "Zone 1 current input"} LOOKUP["Z1MUT"] = {"description": "Zone 1 mute", "0": "Unmuted", "1": "Muted"} -LOOKUP["Z1ARC"] = {"description": "Zone 1 ARC", "0": "Off", "1": "On"} LOOKUP["Z1VIR"] = { "description": "Video input resolution", "0": "No video", @@ -134,12 +117,48 @@ } LOOKUP["Z1DIA"] = {"description": "Dolby digital dialog normalization (dB)"} +# Model specific lookupp +# MRX 520, 720, 1120 +LOOKUP["IDN"] = {"description": "MAC address"} +LOOKUP["ECH"] = {"description": "Tx status", "0": "Off", "1": "On"} +LOOKUP["SIP"] = {"description": "Standby IP control", "0": "Off", "1": "On"} +LOOKUP["Z1ARC"] = {"description": "Zone 1 ARC", "0": "Off", "1": "On"} +LOOKUP["FPB"] = { + "description": "Front Panel Brightness", + "0": "Off", + "1": "Low", + "2": "Medium", + "3": "High", +} + +# MRX 540, 740, 1140 +LOOKUP["WMAC"] = {"description": "Wi-Fi MAC address"} +LOOKUP["EMAC"] = {"description": "Ethernet MAC address"} +LOOKUP["IS1ARC"] = {"description": "Zone 1 ARC", "0": "Off", "1": "On"} +LOOKUP["GCFPB"] = {"description": "Front Panel Brightness"} +LOOKUP["GCTXS"] = { + "description": "Tx status", + "0": "Off", + "1": "IP On", + "2": "IP and RS232 on", +} + +COMMANDS_X20 = ["IDN", "ECH", "SIP", "Z1ARC", "FPB"] +COMMANDS_X40 = ["WMAC", "EMAC", "IS1ARC", "GCFPB", "GCTXS"] + +EMPTY_MAC = "00:00:00:00:00:00" + # pylint: disable=too-many-instance-attributes, too-many-public-methods class AVR(asyncio.Protocol): """The Anthem AVR IP control protocol handler.""" - def __init__(self, update_callback=None, loop=None, connection_lost_callback=None): + def __init__( + self, + update_callback: Callable[[str], None] = None, + loop: asyncio.AbstractEventLoop = None, + connection_lost_callback: Callable[[], Awaitable[None]] = None, + ): """Protocol handler that handles all status and changes on AVR. This class is expected to be wrapped inside a Connection class object @@ -167,14 +186,28 @@ def __init__(self, update_callback=None, loop=None, connection_lost_callback=Non self._input_names = {} self._input_numbers = {} self._poweron_refresh_successful = False - self.transport = None + self.transport: asyncio.Transport = None + self._ignored_commands = [] + self._force_refresh = False + self._model_series = "" + self._deviceinfo_received = asyncio.Event() for key in LOOKUP: setattr(self, f"_{key}", "") self._Z1POW = "0" - def refresh_core(self): + async def wait_for_device_initialised(self, timeout: float): + """Wait to receive the model and mac address for the device""" + await asyncio.wait_for(self._deviceinfo_received.wait(), timeout) + self.log.debug("device is initialised") + + def _set_device_initialised(self): + """Indicate if the model and mac address have been received""" + if self._model_series and self.macaddress != EMPTY_MAC: + self._deviceinfo_received.set() + + async def refresh_core(self): """Query device for all attributes that exist regardless of power state. This will force a refresh for all device queries that are valid to @@ -189,8 +222,10 @@ def refresh_core(self): self.log.warning("Lost connection to receiver while refreshing device") break self.query(key) + # small delay between command + await asyncio.sleep(0.01) - def poweron_refresh(self): + async def poweron_refresh(self): """Keep requesting all attributes until it works. Immediately after a power on event (POW1) the AVR is inconsistent with @@ -203,10 +238,11 @@ def poweron_refresh(self): if self._poweron_refresh_successful or self.transport is None: return else: - self.refresh_all() - self._loop.call_later(2, self.poweron_refresh) + await self.refresh_all() + await asyncio.sleep(2) + await self.poweron_refresh() - def refresh_all(self): + async def refresh_all(self): """Query device for all attributes that are known. This will force a refresh for all device queries that the module is @@ -216,17 +252,19 @@ def refresh_all(self): This does not return any data, it just issues the queries. """ self.log.debug("refresh_all") - for key in LOOKUP: + for key in {k: v for k, v in LOOKUP.items() if k not in self._ignored_commands}: if self.transport is None: self.log.warning("Lost connection to receiver while refreshing device") break self.query(key) + # small delay between command + await asyncio.sleep(0.01) # # asyncio network functions # - def connection_made(self, transport): + def connection_made(self, transport: asyncio.Transport): """Called when asyncio.Protocol establishes the network connection.""" self.log.debug("Connection established to AVR") self.transport = transport @@ -235,8 +273,7 @@ def connection_made(self, transport): limit_low, limit_high = self.transport.get_write_buffer_limits() self.log.debug("Write buffer limits %d to %d", limit_low, limit_high) - self.command("ECH1") - self.refresh_core() + asyncio.run_coroutine_threadsafe(self.refresh_core(), self._loop) def data_received(self, data): """Called when asyncio.Protocol detects received data from network.""" @@ -254,7 +291,9 @@ def connection_lost(self, exc): self.transport = None if self._connection_lost_callback: - self._loop.call_soon(self._connection_lost_callback) + asyncio.run_coroutine_threadsafe( + self._connection_lost_callback(), self._loop + ) def _assemble_buffer(self): """Split up received data from device into individual commands. @@ -285,9 +324,12 @@ def _populate_inputs(self, total): """ total = total + 1 for input_number in range(1, total): - self.query(f"ISN{input_number:02d}") + if self._model_series == "x40": + self.query(f"IS{input_number}IN") + else: + self.query(f"ISN{input_number:02d}") - def _parse_message(self, data): + def _parse_message(self, data: str): """Interpret each message datagram from device and do the needful. This function receives datagrams from _assemble_buffer and inerprets @@ -312,7 +354,7 @@ def _parse_message(self, data): recognized = True else: - for key in LOOKUP: + for key, commands in LOOKUP.items(): if data.startswith(key): recognized = True @@ -324,54 +366,67 @@ def _parse_message(self, data): else: changeindicator = "Unchanged" - if key in LOOKUP: - if "description" in LOOKUP[key]: - if value in LOOKUP[key]: - self.log.debug( - "%s: %s (%s) -> %s (%s)", - changeindicator, - LOOKUP[key]["description"], - key, - LOOKUP[key][value], - value, - ) - else: - self.log.debug( - "%s: %s (%s) -> %s", - changeindicator, - LOOKUP[key]["description"], - key, - value, - ) - else: - self.log.debug("%s: %s -> %s", changeindicator, key, value) + if "description" in commands: + if value in commands: + self.log.debug( + "%s: %s (%s) -> %s (%s)", + changeindicator, + commands["description"], + key, + commands[value], + value, + ) + else: + self.log.debug( + "%s: %s (%s) -> %s", + changeindicator, + commands["description"], + key, + value, + ) setattr(self, "_" + key, value) + if key == "IDM" and value != oldvalue: + self.set_model_command(value) + + if key == "IDM" or key == "IDN" or key == "EMAC" or key == "WMAC": + self._set_device_initialised() + if key == "Z1POW" and value == "1" and oldvalue == "0": self.log.debug("Power on detected, refreshing all attributes") self._poweron_refresh_successful = False - self._loop.call_later(1, self.poweron_refresh) + self._loop.call_later( + 1, + asyncio.run_coroutine_threadsafe, + self.poweron_refresh(), + self._loop, + ) if key == "Z1POW" and value == "0" and oldvalue == "1": self._poweron_refresh_successful = False - if self._Z1POW == "0" and all( - coreKey not in key for coreKey in ATTR_CORE + if ( + self._force_refresh is False + and self._Z1POW == "0" + and all(coreKey not in key for coreKey in ATTR_CORE) ): # AVR doesn't send Power State ON # force refresh power state when receiving any command from a potential powered on AVR + self._force_refresh = True self.log.debug("Force refresh Power State") self.query("Z1POW") + self._loop.call_later(2, setattr, self, "_force_refresh", False) break if data.startswith("ICN"): self.log.debug("ICN update received") + self._poweron_refresh_successful = True recognized = True self._populate_inputs(int(value)) - if data.startswith("ISN"): + if data.startswith("ISN"): # x20 series: ISN01Turntable recognized = True self._poweron_refresh_successful = True @@ -380,6 +435,19 @@ def _parse_message(self, data): oldname = self._input_names.get(input_number, "") + if oldname != value: + self._input_numbers[value] = input_number + self._input_names[input_number] = value + self.log.debug("New Value: Input %d is called %s", input_number, value) + newdata = True + elif data.startswith("IS"): # x40 series, example "IS3INTurntable" + recognized = True + self._poweron_refresh_successful = True + in_position = data.index("IN") + input_number = int(data[2:in_position]) + value = data[in_position + 2 :] + oldname = self._input_names.get(input_number, "") + if oldname != value: self._input_numbers[value] = input_number self._input_names[input_number] = value @@ -395,7 +463,7 @@ def _parse_message(self, data): if not recognized: self.log.debug("Unrecognized response: %s", data) - def query(self, item): + def query(self, item: str): """Issue a raw query to the device for an item. This function is used to request that the device supply the current @@ -418,7 +486,22 @@ def query(self, item): item = item + "?" self.command(item) - def command(self, command): + def set_model_command(self, model: str): + """Add the commands to the model.""" + if "40" in model or "70" in model or "90" in model: + self.log.debug("Set Command to Model x40") + self._ignored_commands = COMMANDS_X20 + self._model_series = "x40" + self.query("EMAC") + self.query("WMAC") + else: + self.log.debug("Set Command to Model x20") + self._ignored_commands = COMMANDS_X40 + self._model_series = "x20" + self.command("ECH1") + self.query("IDN") + + def command(self, command: str): """Issue a raw command to the device. This function is used to update a data item on the device. It's used @@ -438,7 +521,7 @@ def command(self, command): command = command + ";" self.formatted_command(command) - def formatted_command(self, command): + def formatted_command(self, command: str): """Issue a raw, formatted command to the device. This function is invoked by both query and command and is the point @@ -454,15 +537,15 @@ def formatted_command(self, command): >>> formatted_command('Z1VOL-50') """ - command = command command = command.encode() self.log.debug("> %s", command) try: self.transport.write(command) - time.sleep(0.01) - except Exception: - self.log.warning("No transport found, unable to send command") + except Exception as error: + self.log.warning( + "No transport found, unable to send command. error: %s", str(error) + ) # # Volume and Attenuation handlers. The Anthem tracks volume internally as @@ -530,7 +613,7 @@ def attenuation(self): @attenuation.setter def attenuation(self, value): if isinstance(value, int) and -90 <= value <= 0: - self.log.debug("Setting attenuation to " + str(value)) + self.log.debug("Setting attenuation to %s", str(value)) self.command("Z1VOL" + str(value)) @property @@ -704,7 +787,7 @@ def hwversion(self): @property def macaddress(self): """Network MCU MAC address (read-only).""" - return self._IDN or "00:00:00:00:00:00" + return self._IDN or self._EMAC or self._WMAC or EMPTY_MAC @property def audio_input_name(self): @@ -804,7 +887,7 @@ def panel_brightness_text(self): def panel_brightness(self, number): if isinstance(number, int): if 0 <= number <= 3: - self.log.debug("Switching panel brightness to " + str(number)) + self.log.debug("Switching panel brightness to %s", str(number)) self.command("FPB" + str(number)) @property @@ -866,7 +949,7 @@ def dolby_dynamic_range_text(self): def dolby_dynamic_range(self, number): if isinstance(number, int): if 0 <= number <= 2: - self.log.debug("Switching Dolby dynamic range to " + str(number)) + self.log.debug("Switching Dolby dynamic range to %s", str(number)) self.command("Z1DYN" + str(number)) # @@ -944,8 +1027,8 @@ def input_number(self): def input_number(self, number): if isinstance(number, int): if 1 <= number <= 99: - self.log.debug("Switching input to " + str(number)) - self.command("Z1INP" + str(number)) + self.log.debug("Switching input to %s", str(number)) + self.command(f"Z1INP{number}") # # Miscellany diff --git a/anthemav/tools.py b/anthemav/tools.py index 28c7641..3da83ef 100644 --- a/anthemav/tools.py +++ b/anthemav/tools.py @@ -21,7 +21,7 @@ async def console(loop, log): Show debug logging. """ parser = argparse.ArgumentParser(description=console.__doc__) - parser.add_argument("--host", default="192.168.3.16", help="IP or FQDN of AVR") + parser.add_argument("--host", default="127.0.0.1", help="IP or FQDN of AVR") parser.add_argument("--port", default="14999", help="Port of AVR") parser.add_argument("--verbose", "-v", action="count") diff --git a/setup.py b/setup.py index 3afee3b..7191aa3 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def readme(): setup( name="anthemav", - version="1.2.0", + version="1.3.0", author="David McNett", author_email="nugget@macnugget.org", url="https://github.com/nugget/python-anthemav", diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ba62b9f..f9f0926 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -33,8 +33,9 @@ def test_all_alm_matchnumber(): def test_power_on_force_refresh(): avr = AVR() with patch.object(avr, "query") as mock: - avr._parse_message("Z1INP01;") - mock.assert_called_once_with("Z1POW") + with patch.object(avr, "_loop"): + avr._parse_message("Z1INP01;") + mock.assert_called_once_with("Z1POW") def test_mute_force_refresh():