diff --git a/htd_client/__init__.py b/htd_client/__init__.py index ab2d74b..d4680e9 100644 --- a/htd_client/__init__.py +++ b/htd_client/__init__.py @@ -28,6 +28,7 @@ async def async_get_client( serial_address: str = None, network_address: Tuple[str, int] = None, loop: asyncio.AbstractEventLoop = None, + retry_attempts: int = HtdConstants.DEFAULT_RETRY_ATTEMPTS, ) -> BaseClient: """ Create a new client object. @@ -36,6 +37,7 @@ async def async_get_client( network_address (str): The address to communicate with over TCP. serial_address (str): The location of the serial port. loop (asyncio.AbstractEventLoop): The event loop to use. + retry_attempts (int): Number of times to retry a command before failing. Returns: HtdClient: The new client object. @@ -53,6 +55,7 @@ async def async_get_client( model_info, network_address=network_address, serial_address=serial_address, + retry_attempts=retry_attempts, ) elif model_info["kind"] == HtdDeviceKind.lync: @@ -61,10 +64,11 @@ async def async_get_client( model_info, network_address=network_address, serial_address=serial_address, + retry_attempts=retry_attempts, ) else: - raise ValueError(f"Unknown Device Kind: {model_info["kind"]}") + raise ValueError(f"Unknown Device Kind: {model_info['kind']}") await client.async_connect() diff --git a/htd_client/base_client.py b/htd_client/base_client.py index 4f9d8ad..53f1eb0 100644 --- a/htd_client/base_client.py +++ b/htd_client/base_client.py @@ -186,7 +186,11 @@ async def _async_reconnect(self): async def async_wait_until_ready(self): - pass + start_time = time.time() + while not self._ready: + if time.time() - start_time > self._socket_timeout_sec: + raise Exception("Timed out waiting for device to be ready") + await asyncio.sleep(0.1) def has_zone_data(self, zone: int): return zone in self._zone_data @@ -284,7 +288,6 @@ def _process_next_command(self, data: bytes): def _parse_command(self, zone, cmd, data): if cmd == HtdCommonCommands.KEYPAD_EXISTS_RECEIVE_COMMAND: - print(f"DEBUG: inside _parse_command _zone_data id: {id(self._zone_data)}") # if len(self._zone_data) == 0: # this is zone 0 with all zone data # second byte is zone 1 - 8 @@ -569,6 +572,10 @@ async def async_power_on(self, zone: int): @abstractmethod async def async_power_off(self, zone: int): pass + + @abstractmethod + async def async_set_bass(self, zone: int, bass: int): + pass @abstractmethod async def async_bass_up(self, zone: int): @@ -578,6 +585,10 @@ async def async_bass_up(self, zone: int): async def async_bass_down(self, zone: int): pass + @abstractmethod + async def async_set_treble(self, zone: int, treble: int): + pass + @abstractmethod async def async_treble_up(self, zone: int): pass diff --git a/htd_client/constants.py b/htd_client/constants.py index b0b0e02..35540a8 100644 --- a/htd_client/constants.py +++ b/htd_client/constants.py @@ -82,14 +82,26 @@ class HtdConstants: VOLUME_OFFSET = MAX_RAW_VOLUME - MAX_VOLUME - MIN_BASS = -10 - MAX_BASS = 10 - - MIN_TREBLE = -10 - MAX_TREBLE = 10 - - MIN_BALANCE = -18 - MAX_BALANCE = 18 + # Lync Constants + LYNC_MIN_BASS = -10 + LYNC_MAX_BASS = 10 + LYNC_MIN_TREBLE = -10 + LYNC_MAX_TREBLE = 10 + LYNC_MIN_BALANCE = -18 + LYNC_MAX_BALANCE = 18 + + # MCA Constants + # Raw values exposed to the user (-12 to 12) + MCA_MIN_BASS = -12 + MCA_MAX_BASS = 12 + MCA_MIN_TREBLE = -12 + MCA_MAX_TREBLE = 12 + MCA_MIN_BALANCE = -12 + MCA_MAX_BALANCE = 12 + + # Step size exposed to user + MCA_BASS_TREBLE_STEP = 4 + MCA_BALANCE_STEP = 6 # each message we get is chunked at 14 bytes MESSAGE_CHUNK_SIZE = 14 @@ -214,6 +226,9 @@ class HtdLyncConstants: BASS_COMMAND_OFFSET = 0x80 TREBLE_COMMAND_OFFSET = 0x80 + + STATUS_REFRESH_CODE = 0x1F + class HtdMcaCommands: diff --git a/htd_client/lync_client.py b/htd_client/lync_client.py index f8f6069..1adc643 100644 --- a/htd_client/lync_client.py +++ b/htd_client/lync_client.py @@ -237,7 +237,7 @@ async def async_bass_up(self, zone: int): current_zone = self.get_zone(zone) new_bass = current_zone.bass + 1 - if new_bass >= HtdConstants.MAX_BASS: + if new_bass > HtdConstants.LYNC_MAX_BASS: return await self.async_set_bass(zone, new_bass) @@ -253,11 +253,12 @@ async def async_bass_down(self, zone: int): current_zone = self.get_zone(zone) new_bass = current_zone.bass - 1 - if new_bass < HtdConstants.MIN_BASS: + if new_bass < HtdConstants.LYNC_MIN_BASS: return await self.async_set_bass(zone, new_bass) + async def async_set_bass(self, zone: int, bass: int): """ Set the bass of a zone. @@ -266,17 +267,27 @@ async def async_set_bass(self, zone: int, bass: int): zone (int): the zone bass (int): the bass value to set """ + + logging.debug(f"Setting bass for zone {zone} to {bass}") + zone_info = self.get_zone(zone) if zone_info.bass == bass: return - return await self._async_send_and_validate( + encoded_bass = bass & 0xFF + + await self._send_cmd( + zone, + HtdLyncCommands.BASS_SETTING_CONTROL_COMMAND_CODE, + encoded_bass + ) + + await self._async_send_and_validate( lambda z: z.bass == bass, zone, HtdLyncCommands.COMMON_COMMAND_CODE, - HtdLyncCommands.BASS_SETTING_CONTROL_COMMAND_CODE, - bytearray([bass]) + HtdLyncConstants.STATUS_REFRESH_CODE ) async def async_treble_up(self, zone: int): @@ -290,7 +301,7 @@ async def async_treble_up(self, zone: int): current_zone = self.get_zone(zone) new_treble = current_zone.treble + 1 - if new_treble >= HtdConstants.MAX_TREBLE: + if new_treble > HtdConstants.LYNC_MAX_TREBLE: return await self.async_set_treble(zone, new_treble) @@ -306,7 +317,7 @@ async def async_treble_down(self, zone: int): current_zone = self.get_zone(zone) new_treble = current_zone.treble - 1 - if new_treble < HtdConstants.MIN_TREBLE: + if new_treble < HtdConstants.LYNC_MIN_TREBLE: return await self.async_set_treble(zone, new_treble) @@ -325,12 +336,19 @@ async def async_set_treble(self, zone: int, treble: int): if treble == zone_info.treble: return - return await self._async_send_and_validate( + encoded_treble = treble & 0xFF + + await self._send_cmd( + zone, + HtdLyncCommands.TREBLE_SETTING_CONTROL_COMMAND_CODE, + encoded_treble + ) + + await self._async_send_and_validate( lambda z: z.treble == treble, zone, HtdLyncCommands.COMMON_COMMAND_CODE, - HtdLyncCommands.TREBLE_SETTING_CONTROL_COMMAND_CODE, - bytearray([treble]) + HtdLyncConstants.STATUS_REFRESH_CODE ) async def async_balance_left(self, zone: int): @@ -344,7 +362,7 @@ async def async_balance_left(self, zone: int): current_zone = self.get_zone(zone) new_balance = current_zone.balance - 1 - if new_balance < HtdConstants.MIN_BALANCE: + if new_balance < HtdConstants.LYNC_MIN_BALANCE: return await self.async_set_balance(zone, new_balance) @@ -360,7 +378,7 @@ async def async_balance_right(self, zone: int): current_zone = self.get_zone(zone) new_balance = current_zone.balance + 1 - if new_balance > HtdConstants.MAX_BALANCE: + if new_balance > HtdConstants.LYNC_MAX_BALANCE: return await self.async_set_balance(zone, new_balance) @@ -379,11 +397,19 @@ async def async_set_balance(self, zone: int, balance: int): if balance == current_zone.balance: return - return await self._async_send_and_validate( - lambda z: z.balance == balance, + encoded_balance = balance & 0xFF + + await self._send_cmd( zone, HtdLyncCommands.BALANCE_SETTING_CONTROL_COMMAND_CODE, - balance + encoded_balance + ) + + await self._async_send_and_validate( + lambda z: z.balance == balance, + zone, + HtdLyncCommands.COMMON_COMMAND_CODE, + HtdLyncConstants.STATUS_REFRESH_CODE ) # def query_zone_name(self, zone: int) -> str: diff --git a/htd_client/mca_client.py b/htd_client/mca_client.py index 3d320da..67bf435 100644 --- a/htd_client/mca_client.py +++ b/htd_client/mca_client.py @@ -24,11 +24,14 @@ class HtdMcaClient(BaseClient): _target_volumes: Dict[int, int | None] = None + _target_bass: Dict[int, int | None] = None + _target_treble: Dict[int, int | None] = None + _target_balance: Dict[int, int | None] = None _subscribed: bool = None def __init__( self, - loop: asyncio.EventLoop, + loop: asyncio.AbstractEventLoop, model_info: HtdModelInfo, network_address: Tuple[str, int] = None, serial_address: str = None, @@ -65,6 +68,9 @@ def __init__( # we'll re-run _set_volume to get to the target self._subscribed = False self._target_volumes = {key: None for key in range(1, self._model_info["sources"] + 1)} + self._target_bass = {key: None for key in range(1, self._model_info["sources"] + 1)} + self._target_treble = {key: None for key in range(1, self._model_info["sources"] + 1)} + self._target_balance = {key: None for key in range(1, self._model_info["sources"] + 1)} async def async_connect(self): @@ -85,6 +91,24 @@ def _on_zone_update(self, zone: int = None): else: asyncio.run_coroutine_threadsafe(self._async_set_volume(zone), self._loop) + if self._target_bass[zone] is not None: + if self._zone_data[zone].bass == self._target_bass[zone]: + self._target_bass[zone] = None + else: + asyncio.run_coroutine_threadsafe(self._async_set_bass(zone), self._loop) + + if self._target_treble[zone] is not None: + if self._zone_data[zone].treble == self._target_treble[zone]: + self._target_treble[zone] = None + else: + asyncio.run_coroutine_threadsafe(self._async_set_treble(zone), self._loop) + + if self._target_balance[zone] is not None: + if self._zone_data[zone].balance == self._target_balance[zone]: + self._target_balance[zone] = None + else: + asyncio.run_coroutine_threadsafe(self._async_set_balance(zone), self._loop) + async def async_mute(self, zone: int): if self._zone_data[zone].mute: return @@ -100,6 +124,15 @@ async def async_unmute(self, zone: int): def has_volume_target(self, zone: int): return self._target_volumes[zone] is not None + def has_bass_target(self, zone: int): + return self._target_bass[zone] is not None + + def has_treble_target(self, zone: int): + return self._target_treble[zone] is not None + + def has_balance_target(self, zone: int): + return self._target_balance[zone] is not None + async def async_set_volume(self, zone: int, volume: int): existing = False @@ -311,17 +344,79 @@ async def async_bass_up(self, zone: int): zone_info = self._zone_data[zone] - new_bass = zone_info.bass + 1 - if new_bass > HtdConstants.MAX_BASS: + if not zone_info.power: + await self.async_power_on(zone) + + new_bass = zone_info.bass + HtdConstants.MCA_BASS_TREBLE_STEP + if new_bass > HtdConstants.MCA_MAX_BASS: return await self._async_send_and_validate( - lambda z: z.bass >= zone_info.bass + 1, + lambda z: z.bass >= zone_info.bass + HtdConstants.MCA_BASS_TREBLE_STEP, zone, HtdMcaCommands.COMMON_COMMAND_CODE, HtdMcaCommands.BASS_UP_COMMAND ) + async def async_set_bass(self, zone: int, bass: int): + # Clip to limits + if bass > HtdConstants.MCA_MAX_BASS: + bass = HtdConstants.MCA_MAX_BASS + elif bass < HtdConstants.MCA_MIN_BASS: + bass = HtdConstants.MCA_MIN_BASS + + # Round to nearest step + if bass % HtdConstants.MCA_BASS_TREBLE_STEP != 0 and bass != 0: + bass = HtdConstants.MCA_BASS_TREBLE_STEP * round(bass / HtdConstants.MCA_BASS_TREBLE_STEP) + + existing = False + + if self._target_bass[zone] is not None: + existing = True + + self._target_bass[zone] = bass + + if existing: + return + + zone_info = self._zone_data[zone] + + if not zone_info.power: + await self.async_power_on(zone) + + return await self._async_set_bass(zone) + + async def _async_set_bass(self, zone: int): + """ + Resume setting the bass of a zone. + + Args: + zone (int): the zone + """ + + zone_info = self._zone_data[zone] + + if not zone_info.power: + self._target_bass[zone] = None + return + + diff = self._target_bass[zone] - zone_info.bass + + if diff == 0: + return + + if diff < 0: + bass_command = HtdMcaCommands.BASS_DOWN_COMMAND + else: + bass_command = HtdMcaCommands.BASS_UP_COMMAND + + await self._async_send_and_validate( + lambda z: z.bass != zone_info.bass, + zone, + HtdMcaCommands.COMMON_COMMAND_CODE, + bass_command + ) + async def async_bass_down(self, zone: int): """ Decrease the bass of a zone. @@ -332,12 +427,15 @@ async def async_bass_down(self, zone: int): zone_info = self._zone_data[zone] - new_bass = zone_info.bass - 1 - if new_bass < HtdConstants.MIN_BASS: + if not zone_info.power: + await self.async_power_on(zone) + + new_bass = zone_info.bass - HtdConstants.MCA_BASS_TREBLE_STEP + if new_bass < HtdConstants.MCA_MIN_BASS: return await self._async_send_and_validate( - lambda z: z.bass <= zone_info.bass - 1, + lambda z: z.bass <= zone_info.bass - HtdConstants.MCA_BASS_TREBLE_STEP, zone, HtdMcaCommands.COMMON_COMMAND_CODE, HtdMcaCommands.BASS_DOWN_COMMAND @@ -353,12 +451,15 @@ async def async_treble_up(self, zone: int): zone_info = self._zone_data[zone] - new_treble = zone_info.treble + 1 - if new_treble > HtdConstants.MAX_TREBLE: + if not zone_info.power: + await self.async_power_on(zone) + + new_treble = zone_info.treble + HtdConstants.MCA_BASS_TREBLE_STEP + if new_treble > HtdConstants.MCA_MAX_TREBLE: return await self._async_send_and_validate( - lambda z: z.treble >= zone_info.treble + 1, + lambda z: z.treble >= zone_info.treble + HtdConstants.MCA_BASS_TREBLE_STEP, zone, HtdMcaCommands.COMMON_COMMAND_CODE, HtdMcaCommands.TREBLE_UP_COMMAND @@ -374,17 +475,79 @@ async def async_treble_down(self, zone: int): zone_info = self._zone_data[zone] - new_treble = zone_info.treble - 1 - if new_treble < HtdConstants.MIN_TREBLE: + if not zone_info.power: + await self.async_power_on(zone) + + new_treble = zone_info.treble - HtdConstants.MCA_BASS_TREBLE_STEP + if new_treble < HtdConstants.MCA_MIN_TREBLE: return await self._async_send_and_validate( - lambda z: z.treble <= zone_info.treble - 1, + lambda z: z.treble <= zone_info.treble - HtdConstants.MCA_BASS_TREBLE_STEP, zone, HtdMcaCommands.COMMON_COMMAND_CODE, HtdMcaCommands.TREBLE_DOWN_COMMAND ) + async def async_set_treble(self, zone: int, treble: int): + # Clip to limits + if treble > HtdConstants.MCA_MAX_TREBLE: + treble = HtdConstants.MCA_MAX_TREBLE + elif treble < HtdConstants.MCA_MIN_TREBLE: + treble = HtdConstants.MCA_MIN_TREBLE + + # Round to nearest step + if treble % HtdConstants.MCA_BASS_TREBLE_STEP != 0 and treble != 0: + treble = HtdConstants.MCA_BASS_TREBLE_STEP * round(treble / HtdConstants.MCA_BASS_TREBLE_STEP) + + existing = False + + if self._target_treble[zone] is not None: + existing = True + + self._target_treble[zone] = treble + + if existing: + return + + zone_info = self._zone_data[zone] + + if not zone_info.power: + await self.async_power_on(zone) + + return await self._async_set_treble(zone) + + async def _async_set_treble(self, zone: int): + """ + Resume setting the treble of a zone. + + Args: + zone (int): the zone + """ + + zone_info = self._zone_data[zone] + + if not zone_info.power: + self._target_treble[zone] = None + return + + diff = self._target_treble[zone] - zone_info.treble + + if diff == 0: + return + + if diff < 0: + treble_command = HtdMcaCommands.TREBLE_DOWN_COMMAND + else: + treble_command = HtdMcaCommands.TREBLE_UP_COMMAND + + await self._async_send_and_validate( + lambda z: z.treble != zone_info.treble, + zone, + HtdMcaCommands.COMMON_COMMAND_CODE, + treble_command + ) + async def async_balance_left(self, zone: int): """ Increase the balance toward the left for a zone. @@ -395,12 +558,15 @@ async def async_balance_left(self, zone: int): zone_info = self._zone_data[zone] - new_balance = zone_info.balance - 1 - if new_balance < HtdConstants.MIN_BALANCE: + if not zone_info.power: + await self.async_power_on(zone) + + new_balance = zone_info.balance - HtdConstants.MCA_BALANCE_STEP + if new_balance < HtdConstants.MCA_MIN_BALANCE: return await self._async_send_and_validate( - lambda z: z.balance <= zone_info.balance - 1, + lambda z: z.balance <= zone_info.balance - HtdConstants.MCA_BALANCE_STEP, zone, HtdMcaCommands.COMMON_COMMAND_CODE, HtdMcaCommands.BALANCE_LEFT_COMMAND @@ -416,17 +582,79 @@ async def async_balance_right(self, zone: int): zone_info = self._zone_data[zone] - new_balance = zone_info.balance + 1 - if new_balance > HtdConstants.MAX_BALANCE: + if not zone_info.power: + await self.async_power_on(zone) + + new_balance = zone_info.balance + HtdConstants.MCA_BALANCE_STEP + if new_balance > HtdConstants.MCA_MAX_BALANCE: return await self._async_send_and_validate( - lambda z: z.balance >= zone_info.balance + 1, + lambda z: z.balance >= zone_info.balance + HtdConstants.MCA_BALANCE_STEP, zone, HtdMcaCommands.COMMON_COMMAND_CODE, HtdMcaCommands.BALANCE_RIGHT_COMMAND ) + async def async_set_balance(self, zone: int, balance: int): + # Clip to limits + if balance > HtdConstants.MCA_MAX_BALANCE: + balance = HtdConstants.MCA_MAX_BALANCE + elif balance < HtdConstants.MCA_MIN_BALANCE: + balance = HtdConstants.MCA_MIN_BALANCE + + # Round to nearest step + if balance % HtdConstants.MCA_BALANCE_STEP != 0 and balance != 0: + balance = HtdConstants.MCA_BALANCE_STEP * round(balance / HtdConstants.MCA_BALANCE_STEP) + + existing = False + + if self._target_balance[zone] is not None: + existing = True + + self._target_balance[zone] = balance + + if existing: + return + + zone_info = self._zone_data[zone] + + if not zone_info.power: + await self.async_power_on(zone) + + return await self._async_set_balance(zone) + + async def _async_set_balance(self, zone: int): + """ + Resume setting the balance of a zone. + + Args: + zone (int): the zone + """ + + zone_info = self._zone_data[zone] + + if not zone_info.power: + self._target_balance[zone] = None + return + + diff = self._target_balance[zone] - zone_info.balance + + if diff == 0: + return + + if diff < 0: + balance_command = HtdMcaCommands.BALANCE_LEFT_COMMAND + else: + balance_command = HtdMcaCommands.BALANCE_RIGHT_COMMAND + + await self._async_send_and_validate( + lambda z: z.balance != zone_info.balance, + zone, + HtdMcaCommands.COMMON_COMMAND_CODE, + balance_command + ) + # def get_source_names(self): # """ # Query a zone and return `ZoneDetail` diff --git a/pyproject.toml b/pyproject.toml index f049566..c563dba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "htd-client" -version = "0.0.25" +version = "0.0.26" description = "A client supporting Home Theater Direct's gateway device." authors = [ "Adam Kirschner " diff --git a/tests/test_base_client_gap_coverage.py b/tests/test_base_client_gap_coverage.py new file mode 100644 index 0000000..7979ea3 --- /dev/null +++ b/tests/test_base_client_gap_coverage.py @@ -0,0 +1,58 @@ +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock, patch +from htd_client.base_client import BaseClient +from htd_client.constants import HtdModelInfo + +class ConcreteClient(BaseClient): + pass + +@pytest.fixture +def base_client(): + mock_loop = MagicMock() + model_info = {"zones": 6, "sources": 6, "kind": "lync", "name": "Lync6"} + client = ConcreteClient(mock_loop, model_info) + return client + +@pytest.mark.asyncio +async def test_wait_until_ready_success(base_client): + base_client._ready = False + + # Simulate ready becoming true after delay + async def make_ready(): + await asyncio.sleep(0.05) + base_client._ready = True + + asyncio.create_task(make_ready()) + + await base_client.async_wait_until_ready() + assert base_client._ready is True + +@pytest.mark.asyncio +async def test_wait_until_ready_timeout(base_client): + base_client._ready = False + base_client._socket_timeout_sec = 0.1 + + with pytest.raises(Exception, match="Timed out waiting for device to be ready"): + await base_client.async_wait_until_ready() + +@pytest.mark.asyncio +async def test_connect_already_connected(base_client): + base_client._connected = True + result = await base_client.async_connect() + assert result is None + +@pytest.mark.asyncio +async def test_connect_no_address(base_client): + base_client._connected = False + base_client._serial_address = None + base_client._network_address = None + + with pytest.raises(ValueError, match="No address provided"): + await base_client.async_connect() + +def test_ready_property(base_client): + base_client._ready = True + assert base_client.ready is True + base_client._ready = False + assert base_client.ready is False diff --git a/tests/test_bass_treble.py b/tests/test_bass_treble.py new file mode 100644 index 0000000..b210078 --- /dev/null +++ b/tests/test_bass_treble.py @@ -0,0 +1,209 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock +from htd_client.mca_client import HtdMcaClient +from htd_client.lync_client import HtdLyncClient +from htd_client.constants import HtdConstants, HtdDeviceKind, HtdMcaCommands, HtdLyncCommands, HtdLyncConstants +from htd_client.models import ZoneDetail +import asyncio + +# --- Fixtures --- + +@pytest.fixture +def mca_client(): + loop = MagicMock() + model_info = { + "zones": 6, + "sources": 6, + "friendly_name": "MCA66", + "name": "MCA66", + "kind": HtdDeviceKind.mca, + "identifier": b'Wangine_MCA66' + } + client = HtdMcaClient(loop, model_info) + client._connection = MagicMock() + client._socket_lock = asyncio.Lock() + client._zone_data = { + i: ZoneDetail(i, enabled=True, power=True, volume=30, bass=0, treble=0) + for i in range(1, 7) + } + return client + +@pytest.fixture +def lync_client(): + loop = MagicMock() + model_info = { + "zones": 6, + "sources": 6, + "friendly_name": "Lync6", + "name": "Lync6", + "kind": HtdDeviceKind.lync, + "identifier": b'Wangine_Lync6' + } + client = HtdLyncClient(loop, model_info) + client._connection = MagicMock() + client._socket_lock = asyncio.Lock() + client._zone_data = { + i: ZoneDetail(i, enabled=True, power=True, volume=30, bass=0, treble=0) + for i in range(1, 7) + } + return client + +# --- MCA Tests --- + +@pytest.mark.asyncio +async def test_mca_bass_up(mca_client): + mca_client._async_send_and_validate = AsyncMock() + mca_client._zone_data[1].bass = 0 + + await mca_client.async_bass_up(1) + + args = mca_client._async_send_and_validate.call_args[0] + # args: validate, zone, cmd, code + assert args[1] == 1 + assert args[3] == HtdMcaCommands.BASS_UP_COMMAND + +@pytest.mark.asyncio +async def test_mca_bass_down(mca_client): + mca_client._async_send_and_validate = AsyncMock() + mca_client._zone_data[1].bass = 0 + + await mca_client.async_bass_down(1) + + args = mca_client._async_send_and_validate.call_args[0] + assert args[1] == 1 + assert args[3] == HtdMcaCommands.BASS_DOWN_COMMAND + +@pytest.mark.asyncio +async def test_mca_treble_up(mca_client): + mca_client._async_send_and_validate = AsyncMock() + mca_client._zone_data[1].treble = 0 + + await mca_client.async_treble_up(1) + + args = mca_client._async_send_and_validate.call_args[0] + assert args[1] == 1 + assert args[3] == HtdMcaCommands.TREBLE_UP_COMMAND + +@pytest.mark.asyncio +async def test_mca_treble_down(mca_client): + mca_client._async_send_and_validate = AsyncMock() + mca_client._zone_data[1].treble = 0 + + await mca_client.async_treble_down(1) + + args = mca_client._async_send_and_validate.call_args[0] + assert args[1] == 1 + assert args[3] == HtdMcaCommands.TREBLE_DOWN_COMMAND + +@pytest.mark.asyncio +async def test_mca_limits(mca_client): + mca_client._async_send_and_validate = AsyncMock() + + # Bass Max + mca_client._zone_data[1].bass = HtdConstants.MCA_MAX_BASS + await mca_client.async_bass_up(1) + mca_client._async_send_and_validate.assert_not_called() + + # Bass Min + mca_client._zone_data[1].bass = HtdConstants.MCA_MIN_BASS + await mca_client.async_bass_down(1) + mca_client._async_send_and_validate.assert_not_called() + + # Treble Max + mca_client._zone_data[1].treble = HtdConstants.MCA_MAX_TREBLE + await mca_client.async_treble_up(1) + mca_client._async_send_and_validate.assert_not_called() + + # Treble Min + mca_client._zone_data[1].treble = HtdConstants.MCA_MIN_TREBLE + await mca_client.async_treble_down(1) + mca_client._async_send_and_validate.assert_not_called() + +# --- Lync Tests --- + +@pytest.mark.asyncio +async def test_lync_set_bass(lync_client): + lync_client._send_cmd = AsyncMock() # Ensure this is mocked + lync_client._async_send_and_validate = AsyncMock() + + target_bass = 5 + await lync_client.async_set_bass(1, target_bass) + + args_send = lync_client._send_cmd.call_args[0] + # args: zone, command, data_code + assert args_send[0] == 1 + assert args_send[1] == HtdLyncCommands.BASS_SETTING_CONTROL_COMMAND_CODE + assert args_send[2] == target_bass & 0xFF + + args_validate = lync_client._async_send_and_validate.call_args[0] + # args: validate, zone, command, data_code + assert args_validate[1] == 1 + # Commit Command + assert args_validate[2] == HtdLyncCommands.COMMON_COMMAND_CODE + assert args_validate[3] == HtdLyncConstants.STATUS_REFRESH_CODE + +@pytest.mark.asyncio +async def test_lync_set_treble(lync_client): + lync_client._send_cmd = AsyncMock() # Mock _send_cmd as well + lync_client._async_send_and_validate = AsyncMock() + + target_treble = -5 + await lync_client.async_set_treble(1, target_treble) + + args_send = lync_client._send_cmd.call_args[0] + assert args_send[0] == 1 + assert args_send[1] == HtdLyncCommands.TREBLE_SETTING_CONTROL_COMMAND_CODE + assert args_send[2] == target_treble & 0xFF + + args_validate = lync_client._async_send_and_validate.call_args[0] + assert args_validate[1] == 1 + assert args_validate[2] == HtdLyncCommands.COMMON_COMMAND_CODE + assert args_validate[3] == HtdLyncConstants.STATUS_REFRESH_CODE + + +@pytest.mark.asyncio +async def test_lync_bass_up(lync_client): + lync_client.async_set_bass = AsyncMock() + lync_client._zone_data[1].bass = 0 + + await lync_client.async_bass_up(1) + lync_client.async_set_bass.assert_awaited_with(1, 1) + +@pytest.mark.asyncio +async def test_lync_bass_down(lync_client): + lync_client.async_set_bass = AsyncMock() + lync_client._zone_data[1].bass = 0 + + await lync_client.async_bass_down(1) + lync_client.async_set_bass.assert_awaited_with(1, -1) + +@pytest.mark.asyncio +async def test_lync_treble_up(lync_client): + lync_client.async_set_treble = AsyncMock() + lync_client._zone_data[1].treble = 0 + + await lync_client.async_treble_up(1) + lync_client.async_set_treble.assert_awaited_with(1, 1) + +@pytest.mark.asyncio +async def test_lync_treble_down(lync_client): + lync_client.async_set_treble = AsyncMock() + lync_client._zone_data[1].treble = 0 + + await lync_client.async_treble_down(1) + lync_client.async_set_treble.assert_awaited_with(1, -1) + +@pytest.mark.asyncio +async def test_lync_limits(lync_client): + lync_client.async_set_bass = AsyncMock() + lync_client.async_set_treble = AsyncMock() + + # Bass Max + lync_client._zone_data[1].bass = HtdConstants.LYNC_MAX_BASS + await lync_client.async_bass_up(1) + lync_client.async_set_bass.assert_not_called() + + # Bass Min + lync_client._zone_data[1].bass = HtdConstants.LYNC_MIN_BASS + await lync_client.async_bass_down(1) + lync_client.async_set_bass.assert_not_called() diff --git a/tests/test_lync_client_coverage.py b/tests/test_lync_client_coverage.py index c28be19..dea3a01 100644 --- a/tests/test_lync_client_coverage.py +++ b/tests/test_lync_client_coverage.py @@ -88,17 +88,17 @@ async def test_bass_treble_balance_limits(lync_client): lync_client.async_set_balance = AsyncMock() # Bass limit - lync_client._zone_data[1].bass = HtdConstants.MAX_BASS + lync_client._zone_data[1].bass = HtdConstants.LYNC_MAX_BASS await lync_client.async_bass_up(1) lync_client.async_set_bass.assert_not_called() # Treble limit - lync_client._zone_data[1].treble = HtdConstants.MAX_TREBLE + lync_client._zone_data[1].treble = HtdConstants.LYNC_MAX_TREBLE await lync_client.async_treble_up(1) lync_client.async_set_treble.assert_not_called() # Balance limit - lync_client._zone_data[1].balance = HtdConstants.MAX_BALANCE + lync_client._zone_data[1].balance = HtdConstants.LYNC_MAX_BALANCE await lync_client.async_balance_right(1) lync_client.async_set_balance.assert_not_called() @@ -180,31 +180,42 @@ async def test_audio_controls_success(lync_client): @pytest.mark.asyncio async def test_set_audio_values(lync_client): + lync_client._send_cmd = AsyncMock() lync_client._async_send_and_validate = AsyncMock() - lync_client._zone_data[1].bass = 0 # != 5 - lync_client._zone_data[1].treble = 0 - lync_client._zone_data[1].balance = 0 # Set bass await lync_client.async_set_bass(1, 5) - args = lync_client._async_send_and_validate.call_args[0] - # args: validate, zone, cmd, code, extra - assert args[2] == HtdLyncCommands.COMMON_COMMAND_CODE - assert args[3] == HtdLyncCommands.BASS_SETTING_CONTROL_COMMAND_CODE - assert args[4] == bytearray([5]) + + # Check 0x18 sent + args_send = lync_client._send_cmd.call_args[0] + assert args_send[1] == HtdLyncCommands.BASS_SETTING_CONTROL_COMMAND_CODE + assert args_send[2] == 5 + + # Check Commit + args_val = lync_client._async_send_and_validate.call_args[0] + assert args_val[2] == HtdLyncCommands.COMMON_COMMAND_CODE + assert args_val[3] == HtdLyncConstants.STATUS_REFRESH_CODE # Set treble await lync_client.async_set_treble(1, 5) - args = lync_client._async_send_and_validate.call_args[0] - assert args[3] == HtdLyncCommands.TREBLE_SETTING_CONTROL_COMMAND_CODE - assert args[4] == bytearray([5]) + args_send = lync_client._send_cmd.call_args[0] + assert args_send[1] == HtdLyncCommands.TREBLE_SETTING_CONTROL_COMMAND_CODE + assert args_send[2] == 5 + + args_val = lync_client._async_send_and_validate.call_args[0] + assert args_val[2] == HtdLyncCommands.COMMON_COMMAND_CODE + assert args_val[3] == HtdLyncConstants.STATUS_REFRESH_CODE # Set balance await lync_client.async_set_balance(1, 5) - # Lync balance: send_and_validate(..., zone, BALANCE_SETTING_CONTROL, balance) - args = lync_client._async_send_and_validate.call_args[0] - assert args[2] == HtdLyncCommands.BALANCE_SETTING_CONTROL_COMMAND_CODE - assert args[3] == 5 + + args_send = lync_client._send_cmd.call_args[0] + assert args_send[1] == HtdLyncCommands.BALANCE_SETTING_CONTROL_COMMAND_CODE + assert args_send[2] == 5 + + args_val = lync_client._async_send_and_validate.call_args[0] + assert args_val[2] == HtdLyncCommands.COMMON_COMMAND_CODE + assert args_val[3] == HtdLyncConstants.STATUS_REFRESH_CODE @pytest.mark.asyncio async def test_set_source_high(lync_client): diff --git a/tests/test_lync_options_coverage.py b/tests/test_lync_options_coverage.py new file mode 100644 index 0000000..966c782 --- /dev/null +++ b/tests/test_lync_options_coverage.py @@ -0,0 +1,135 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock +from htd_client.lync_client import HtdLyncClient +from htd_client.constants import HtdConstants + +@pytest.fixture +def lync_client(): + mock_loop = MagicMock() + model_info = {"zones": 6, "sources": 6, "kind": "lync", "name": "Lync6"} + client = HtdLyncClient(mock_loop, model_info) + client._connection = MagicMock() + client._socket_lock = AsyncMock() + client._zone_data = {} + return client + +@pytest.mark.asyncio +async def test_lync_volume_down_boundary(lync_client): + # Test volume down when already 0 + lync_client._zone_data[1] = MagicMock(volume=0) + lync_client.async_set_volume = AsyncMock() + + await lync_client.async_volume_down(1) + + # Should not call set_volume + lync_client.async_set_volume.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_set_bass_no_change(lync_client): + # Test setting bass to same value + lync_client._zone_data[1] = MagicMock(bass=0) + lync_client._async_send_and_validate = AsyncMock() + + await lync_client.async_set_bass(1, 0) + + # Should not send command + lync_client._async_send_and_validate.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_set_treble_no_change(lync_client): + # Test setting treble to same value + lync_client._zone_data[1] = MagicMock(treble=0) + lync_client._async_send_and_validate = AsyncMock() + + await lync_client.async_set_treble(1, 0) + + # Should not send command + lync_client._async_send_and_validate.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_set_balance_no_change(lync_client): + # Test setting balance to same value + lync_client._zone_data[1] = MagicMock(balance=0) + lync_client._async_send_and_validate = AsyncMock() + + await lync_client.async_set_balance(1, 0) + + # Should not send command + lync_client._async_send_and_validate.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_bass_up_boundary(lync_client): + # Test bass up when already max + lync_client._zone_data[1] = MagicMock(bass=HtdConstants.LYNC_MAX_BASS) + lync_client.async_set_bass = AsyncMock() + + await lync_client.async_bass_up(1) + + # Should not call set_bass + lync_client.async_set_bass.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_bass_down_boundary(lync_client): + # Test bass down when already min + lync_client._zone_data[1] = MagicMock(bass=HtdConstants.LYNC_MIN_BASS) + lync_client.async_set_bass = AsyncMock() + + await lync_client.async_bass_down(1) + + # Should not call set_bass + lync_client.async_set_bass.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_treble_up_boundary(lync_client): + # Test treble up when already max + lync_client._zone_data[1] = MagicMock(treble=HtdConstants.LYNC_MAX_TREBLE) + lync_client.async_set_treble = AsyncMock() + + await lync_client.async_treble_up(1) + + # Should not call set_treble + lync_client.async_set_treble.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_treble_down_boundary(lync_client): + # Test treble down when already min + lync_client._zone_data[1] = MagicMock(treble=HtdConstants.LYNC_MIN_TREBLE) + lync_client.async_set_treble = AsyncMock() + + await lync_client.async_treble_down(1) + + # Should not call set_treble + lync_client.async_set_treble.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_balance_left_boundary(lync_client): + # Test balance left when already min + lync_client._zone_data[1] = MagicMock(balance=HtdConstants.LYNC_MIN_BALANCE) + lync_client.async_set_balance = AsyncMock() + + await lync_client.async_balance_left(1) + + # Should not call set_balance + lync_client.async_set_balance.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_balance_right_boundary(lync_client): + # Test balance right when already max + lync_client._zone_data[1] = MagicMock(balance=HtdConstants.LYNC_MAX_BALANCE) + lync_client.async_set_balance = AsyncMock() + + await lync_client.async_balance_right(1) + + # Should not call set_balance + lync_client.async_set_balance.assert_not_called() + +@pytest.mark.asyncio +async def test_lync_volume_down_success(lync_client): + # Test volume down when > 0 + lync_client._zone_data[1] = MagicMock(volume=10) + lync_client.async_set_volume = AsyncMock() + + await lync_client.async_volume_down(1) + + # Should call set_volume with 9 + lync_client.async_set_volume.assert_called_once_with(1, 9) diff --git a/tests/test_mca_bass_treble_set.py b/tests/test_mca_bass_treble_set.py new file mode 100644 index 0000000..7dbeb54 --- /dev/null +++ b/tests/test_mca_bass_treble_set.py @@ -0,0 +1,167 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, call +from htd_client.mca_client import HtdMcaClient +from htd_client.constants import HtdConstants + +@pytest.fixture +def mca_client(): + mock_loop = MagicMock() + model_info = {"zones": 6, "sources": 6, "kind": "mca", "name": "MCA66"} + client = HtdMcaClient(mock_loop, model_info) + client._connection = MagicMock() + client._socket_lock = AsyncMock() + client._zone_data = {} + + # Mock methods to avoid actual network calls + client._async_send_and_validate = AsyncMock() + client.async_power_on = AsyncMock() + + return client + +@pytest.mark.asyncio +async def test_set_bass_target(mca_client): + zone = 1 + target_bass = 4 # Use a valid step (4 on wire) + current_bass = 0 + + # Setup initial state + mca_client._zone_data[zone] = MagicMock(bass=current_bass, power=True) + + # Call set_bass + await mca_client.async_set_bass(zone, target_bass) + + # Check target is set + assert mca_client._target_bass[zone] == target_bass + assert mca_client.has_bass_target(zone) + + # Check _async_set_bass logic trigger (should send UP command) + # The first call interacts with _async_set_bass which calls _async_send_and_validate + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_set_treble_logic(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(treble=0, power=True) + mca_client._target_treble[zone] = 4 # Step of 4 + + # Test _async_set_treble directly + await mca_client._async_set_treble(zone) + + # Should send UP command + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_target_cleared_when_reached(mca_client): + zone = 1 + target_bass = 4 + mca_client._target_bass[zone] = target_bass + mca_client._zone_data[zone] = MagicMock(bass=target_bass, power=True) + + # Trigger update with matching value + # We need to simulate _on_zone_update logic without the threadsafe call for simplicity + if mca_client._zone_data[zone].bass == mca_client._target_bass[zone]: + mca_client._target_bass[zone] = None + + assert mca_client._target_bass[zone] is None + assert not mca_client.has_bass_target(zone) + +@pytest.mark.asyncio +async def test_auto_power_on_bass(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(bass=0, power=False) + + # calling bass up should trigger power on + await mca_client.async_bass_up(zone) + + mca_client.async_power_on.assert_called_with(zone) + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_auto_power_on_treble(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(treble=0, power=False) + + await mca_client.async_treble_up(zone) + + mca_client.async_power_on.assert_called_with(zone) + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_auto_power_on_balance(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(balance=0, power=False) + + await mca_client.async_balance_right(zone) + + mca_client.async_power_on.assert_called_with(zone) + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_set_balance_target(mca_client): + zone = 1 + target_balance = 6 + mca_client._zone_data[zone] = MagicMock(balance=0, power=True) + + await mca_client.async_set_balance(zone, target_balance) + + assert mca_client._target_balance[zone] == target_balance + assert mca_client.has_balance_target(zone) + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_set_balance_logic_resume(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(balance=0, power=True) + mca_client._target_balance[zone] = 6 + + await mca_client._async_set_balance(zone) + + # Should be RIGHT command since target > current (1 > 0) + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_set_balance_logic_left(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(balance=6, power=True) + mca_client._target_balance[zone] = 0 + + await mca_client._async_set_balance(zone) + + # Should be LEFT command + mca_client._async_send_and_validate.assert_called() + +@pytest.mark.asyncio +async def test_mca_rounding(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(bass=0, power=True) + + # Test round up: 3 -> 4 + await mca_client.async_set_bass(zone, 3) + assert mca_client._target_bass[zone] == 4 + + # Test round down: 1 -> 0 + await mca_client.async_set_bass(zone, 1) + assert mca_client._target_bass[zone] == 0 + + # Test exact: 4 -> 4 + await mca_client.async_set_bass(zone, 4) + assert mca_client._target_bass[zone] == 4 + +@pytest.mark.asyncio +async def test_mca_balance_rounding(mca_client): + zone = 1 + mca_client._zone_data[zone] = MagicMock(balance=0, power=True) + + # Test round up: 4 -> 6 (step is 6) + # 4 / 6 = 0.66 -> round to 1 -> 1 * 6 = 6 + await mca_client.async_set_balance(zone, 4) + assert mca_client._target_balance[zone] == 6 + + # Test round down: 2 -> 0 + # 2 / 6 = 0.33 -> round to 0 -> 0 * 6 = 0 + await mca_client.async_set_balance(zone, 2) + assert mca_client._target_balance[zone] == 0 + + # Test exact: 6 -> 6 + await mca_client.async_set_balance(zone, 6) + assert mca_client._target_balance[zone] == 6 diff --git a/tests/test_mca_client_coverage.py b/tests/test_mca_client_coverage.py index a6f0242..a3e4e01 100644 --- a/tests/test_mca_client_coverage.py +++ b/tests/test_mca_client_coverage.py @@ -149,25 +149,25 @@ async def test_audio_limits_mca(mca_client): mca_client._async_send_and_validate = AsyncMock() # Bass Down limit - mca_client._zone_data[1].bass = HtdConstants.MIN_BASS + mca_client._zone_data[1].bass = HtdConstants.MCA_MIN_BASS await mca_client.async_bass_down(1) mca_client._async_send_and_validate.assert_not_called() # Treble limits - mca_client._zone_data[1].treble = HtdConstants.MAX_TREBLE + mca_client._zone_data[1].treble = HtdConstants.MCA_MAX_TREBLE await mca_client.async_treble_up(1) mca_client._async_send_and_validate.assert_not_called() - mca_client._zone_data[1].treble = HtdConstants.MIN_TREBLE + mca_client._zone_data[1].treble = HtdConstants.MCA_MIN_TREBLE await mca_client.async_treble_down(1) mca_client._async_send_and_validate.assert_not_called() # Balance limits - mca_client._zone_data[1].balance = HtdConstants.MAX_BALANCE + mca_client._zone_data[1].balance = HtdConstants.MCA_MAX_BALANCE await mca_client.async_balance_right(1) mca_client._async_send_and_validate.assert_not_called() - mca_client._zone_data[1].balance = HtdConstants.MIN_BALANCE + mca_client._zone_data[1].balance = HtdConstants.MCA_MIN_BALANCE await mca_client.async_balance_left(1) mca_client._async_send_and_validate.assert_not_called() @@ -183,29 +183,21 @@ async def test_set_source(mca_client): @pytest.mark.asyncio async def test_volume_limits_mca(mca_client): - mca_client._async_send_and_validate = AsyncMock() - - # Max volume - mca_client._zone_data[1].volume = HtdConstants.MAX_VOLUME - await mca_client.async_volume_up(1) - mca_client._async_send_and_validate.assert_not_called() - - # Min volume? mca_client has no check for min volume in async_volume_down? - # Let's check code. Code: - # await self._async_send_and_validate(lambda z: z.volume >= zone_info.volume - 1, ...) - # It assumes hardware limit or validate failure? - # But async_volume_up HAS check: code line 226: if zone_info.volume == HtdConstants.MAX_VOLUME: return. + mca_client._async_send_and_validate = AsyncMock() - # async_volume_down has NO check in code provided in Step 492. + # Max volume + mca_client._zone_data[1].volume = HtdConstants.MAX_VOLUME + await mca_client.async_volume_up(1) + mca_client._async_send_and_validate.assert_not_called() - # Treble/Bass limits? - mca_client._zone_data[1].treble = HtdConstants.MAX_TREBLE - await mca_client.async_treble_up(1) - mca_client._async_send_and_validate.assert_not_called() + # Treble/Bass limits? + mca_client._zone_data[1].treble = HtdConstants.MCA_MAX_TREBLE + await mca_client.async_treble_up(1) + mca_client._async_send_and_validate.assert_not_called() - mca_client._zone_data[1].treble = HtdConstants.MIN_TREBLE - await mca_client.async_treble_down(1) - mca_client._async_send_and_validate.assert_not_called() + mca_client._zone_data[1].treble = HtdConstants.MCA_MIN_TREBLE + await mca_client.async_treble_down(1) + mca_client._async_send_and_validate.assert_not_called() def test_on_zone_update(mca_client): diff --git a/tests/test_mca_options_coverage.py b/tests/test_mca_options_coverage.py new file mode 100644 index 0000000..4016998 --- /dev/null +++ b/tests/test_mca_options_coverage.py @@ -0,0 +1,99 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock +from htd_client.mca_client import HtdMcaClient +from htd_client.constants import HtdConstants, HtdMcaCommands + +@pytest.fixture +def mca_client(): + mock_loop = MagicMock() + model_info = {"zones": 6, "sources": 6, "kind": "mca", "name": "MCA66"} + client = HtdMcaClient(mock_loop, model_info) + client._connection = MagicMock() + client._socket_lock = AsyncMock() + # Initialize zone data + client._zone_data = {1: MagicMock(volume=30, mute=False, power=True)} + client._target_volumes = {key: None for key in range(1, 7)} + return client + +@pytest.mark.asyncio +async def test_mca_on_zone_update_none(mca_client): + # Test _on_zone_update with None or 0 + mca_client._on_zone_update(None) + mca_client._on_zone_update(0) + # Should not crash + +@pytest.mark.asyncio +async def test_mca_unmute_already_unmuted(mca_client): + # Test unmute when already unmuted + mca_client._zone_data[1].mute = False + mca_client._async_toggle_mute = AsyncMock() + + await mca_client.async_unmute(1) + + mca_client._async_toggle_mute.assert_not_called() + +@pytest.mark.asyncio +async def test_mca_has_volume_target(mca_client): + mca_client._target_volumes[1] = 50 + assert mca_client.has_volume_target(1) is True + + mca_client._target_volumes[1] = None + assert mca_client.has_volume_target(1) is False + +@pytest.mark.asyncio +async def test_mca_set_volume_existing_target(mca_client): + # Test setting volume when a target already exists + mca_client._target_volumes[1] = 40 + mca_client._async_set_volume = AsyncMock() + + await mca_client.async_set_volume(1, 50) + + # Target should be updated, but _async_set_volume should not be called again immediately? + # Actually logic says: if existing: return. So _async_set_volume NOT called. + assert mca_client._target_volumes[1] == 50 + mca_client._async_set_volume.assert_not_called() + +@pytest.mark.asyncio +async def test_mca_async_set_volume_power_off(mca_client): + # Test _async_set_volume when power is off + mca_client._zone_data[1].power = False + mca_client._target_volumes[1] = 50 + + await mca_client._async_set_volume(1) + + assert mca_client._target_volumes[1] is None + +@pytest.mark.asyncio +async def test_mca_async_set_volume_no_diff(mca_client): + # Test _async_set_volume when current volume equals target + mca_client._target_volumes[1] = 30 + mca_client._zone_data[1].volume = 30 + mca_client._async_send_and_validate = AsyncMock() + + await mca_client._async_set_volume(1) + + mca_client._async_send_and_validate.assert_not_called() + +@pytest.mark.asyncio +async def test_mca_async_set_volume_down(mca_client): + # Test _async_set_volume when target is lower (diff < 0) + mca_client._target_volumes[1] = 20 + mca_client._zone_data[1].volume = 30 + mca_client._async_send_and_validate = AsyncMock() + + await mca_client._async_set_volume(1) + + mca_client._async_send_and_validate.assert_called_once() + # Check command arg + args, _ = mca_client._async_send_and_validate.call_args + assert args[3] == HtdMcaCommands.VOLUME_DOWN_COMMAND + +@pytest.mark.asyncio +async def test_mca_volume_down_boundary(mca_client): + # Test volume down when at 0 + mca_client._zone_data[1].volume = 0 + mca_client._async_send_and_validate = AsyncMock() + + await mca_client.async_volume_down(1) + + mca_client._async_send_and_validate.assert_not_called() diff --git a/tests/test_utils_coverage.py b/tests/test_utils_coverage.py index 937d649..6a74035 100644 --- a/tests/test_utils_coverage.py +++ b/tests/test_utils_coverage.py @@ -32,10 +32,10 @@ def test_stringify_bytes(): def test_convert_volume_to_raw(): # MAX_RAW_VOLUME = 256, MAX_VOLUME = 60 - assert convert_volume_to_raw(0) == 0 + assert convert_volume_to_raw(0) == 196 # MAX_RAW_VOLUME - (MAX_VOLUME - volume) - # 60 -> 256 - (60 - 60) = 256 - assert convert_volume_to_raw(60) == 256 + # 60 -> 0 (Special case in utils.py) + assert convert_volume_to_raw(60) == 0 # 30 -> 256 - (60 - 30) = 226 assert convert_volume_to_raw(30) == 226 diff --git a/tests/test_utils_coverage_gap.py b/tests/test_utils_coverage_gap.py new file mode 100644 index 0000000..9e5552f --- /dev/null +++ b/tests/test_utils_coverage_gap.py @@ -0,0 +1,40 @@ +import pytest +import htd_client.utils +from htd_client.constants import HtdConstants +from unittest.mock import AsyncMock, patch + +def test_build_command_with_extra_data(): + # Test build_command with extra_data (line 34) + zone = 1 + command = 2 + data = 3 + extra = bytearray([4, 5]) + + cmd = htd_client.utils.build_command(zone, command, data, extra) + + # Header (2) + Zone (1) + Command (1) + Data (1) + Extra (2) + Checksum (1) = 8 bytes + assert len(cmd) == 8 + assert cmd[0] == HtdConstants.HEADER_BYTE + assert cmd[4] == data + assert cmd[5] == 4 + assert cmd[6] == 5 + +@pytest.mark.asyncio +async def test_async_send_command_no_header(): + # Test async_send_command when response has no header (line 104) + loop = AsyncMock() + # Mock open_connection + reader = AsyncMock() + writer = AsyncMock() + + # Return data without header + reader.read.return_value = b'\x00\x00\x00\x00' + + with patch('asyncio.open_connection', return_value=(reader, writer)): + response = await htd_client.utils.async_send_command( + loop, + b'cmd', + network_address=('localhost', 1234) + ) + + assert response == b'\x00\x00\x00\x00'