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..0188bfe 100644 --- a/htd_client/base_client.py +++ b/htd_client/base_client.py @@ -95,7 +95,8 @@ async def async_connect(self): self._buffer = bytearray() self._zone_data = {} self._zones_loaded = 0 - self._zone_data = {} + self._source_names = {} + self._zone_names = {} self._connection = None self._disconnected = False @@ -186,7 +187,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 +289,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 @@ -330,14 +334,17 @@ def _parse_command(self, zone, cmd, data): # remove the extra null bytes elif cmd == HtdCommonCommands.ZONE_NAME_RECEIVE_COMMAND: - name = str(data[0:11].decode().rstrip('\0')).lower() - self._zone_data[zone].name = name - - elif cmd == HtdCommonCommands.SOURCE_NAME_RECEIVE_COMMAND: - source = data[11] - name = str(data[0:10].decode().rstrip('\0')).lower() - # self.zone_info[zone]['source_list'][source] = name - # self.source_info[zone][name] = source + name = str(data[0:11].decode(errors="ignore").rstrip('\0')).lower() + self._zone_names[zone] = name + if self.has_zone_data(zone): + self._zone_data[zone].name = name + + elif cmd == HtdCommonCommands.SOURCE_NAME_RECEIVE_COMMAND or cmd == HtdCommonCommands.ZONE_SOURCE_NAME_RECEIVE_COMMAND_LYNC: + source = data[11] + 1 + name = str(data[0:10].decode(errors="ignore").rstrip('\0')).lower() + self._source_names[source] = name + if self.has_zone_data(zone): + self._zone_data[zone].source_name = name # # elif cmd == HtdCommonCommands.MP3_ON_RECEIVE_COMMAND: # self.mp3_status['state'] = 'on' @@ -389,7 +396,7 @@ def _parse_zone(self, zone_number: int, zone_data: bytearray) -> ZoneDetail | No HtdConstants.POWER_STATE_TOGGLE_INDEX ) zone.mute = htd_client.utils.is_bit_on(state_toggles, HtdConstants.MUTE_STATE_TOGGLE_INDEX) - zone.mode = htd_client.utils.is_bit_on(state_toggles, HtdConstants.MODE_STATE_TOGGLE_INDEX) + zone.dnd = htd_client.utils.is_bit_on(state_toggles, HtdConstants.DND_STATE_TOGGLE_INDEX) zone.source = zone_data[HtdConstants.SOURCE_ZONE_DATA_INDEX] + HtdConstants.SOURCE_QUERY_OFFSET zone.volume = volume @@ -497,6 +504,14 @@ def get_source_count(self) -> int: """ return self._model_info['sources'] + def get_source_name(self, source: int) -> str: + """Get the name of a source if it has been fetched.""" + return self._source_names.get(source, f"Source {source}") + + def get_zone_name(self, zone: int) -> str | None: + """Get the cached zone name, or None if not yet queried.""" + return self._zone_names.get(zone) + def get_zone(self, zone: int): """ Query a zone and return `ZoneDetail` @@ -569,6 +584,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 +597,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 @@ -593,3 +616,39 @@ async def async_balance_left(self, zone: int): @abstractmethod async def async_balance_right(self, zone: int): pass + + @abstractmethod + async def async_set_balance(self, zone: int, balance: int): + pass + + @abstractmethod + async def async_set_dnd(self, zone: int, dnd: bool): + pass + + @abstractmethod + async def async_set_echo(self, echo: bool): + pass + + @abstractmethod + async def async_query_id(self): + pass + + @abstractmethod + async def async_query_all_zone_status(self): + pass + + @abstractmethod + async def async_query_zone_name(self, zone: int): + pass + + @abstractmethod + async def async_query_source_name(self, source: int): + pass + + @abstractmethod + async def async_set_zone_name(self, zone: int, name: str): + pass + + @abstractmethod + async def async_set_source_name(self, source: int, name: str): + pass diff --git a/htd_client/constants.py b/htd_client/constants.py index b0b0e02..97304d2 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 @@ -105,7 +117,7 @@ class HtdConstants: # indexes of each state toggle POWER_STATE_TOGGLE_INDEX = 0 MUTE_STATE_TOGGLE_INDEX = 1 - MODE_STATE_TOGGLE_INDEX = 2 + DND_STATE_TOGGLE_INDEX = 2 # the byte index for where to locate the corresponding setting SOURCE_ZONE_DATA_INDEX = 4 @@ -134,6 +146,7 @@ class HtdCommonCommands: MP3_ARTIST_NAME_RECEIVE_COMMAND = 0x12 MP3_ON_RECEIVE_COMMAND = 0x13 MP3_OFF_RECEIVE_COMMAND = 0x14 + QUERY_ID_CODE_RECEIVE_COMMAND = 0x08 ERROR_RECEIVE_COMMAND = 0x1b EXPECTED_MESSAGE_LENGTH_MAP = { @@ -150,6 +163,7 @@ class HtdCommonCommands: MP3_ON_RECEIVE_COMMAND: 1, MP3_OFF_RECEIVE_COMMAND: 17, ERROR_RECEIVE_COMMAND: 9, + QUERY_ID_CODE_RECEIVE_COMMAND: 1, } class HtdMcaConstants: @@ -171,8 +185,10 @@ class HtdLyncCommands: BALANCE_SETTING_CONTROL_COMMAND_CODE = 0x16 TREBLE_SETTING_CONTROL_COMMAND_CODE = 0x17 BASS_SETTING_CONTROL_COMMAND_CODE = 0x18 + SET_ECHO_COMMAND_CODE = 0x19 SET_AUDIO_TO_DEFAULT_COMMAND_CODE = 0x1c SET_NAME_TO_DEFAULT_COMMAND_CODE = 0x1e + QUERY_ID_CODE = 0x08 MP3_FAST_FORWARD_COMMAND_CODE = 0x0a MP3_PLAY_PAUSE_COMMAND_CODE = 0x0b @@ -214,6 +230,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..adb72dd 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,106 +397,94 @@ 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: - # """ - # Query a zone and return `ZoneDetail` - # - # Args: - # zone (int): the zone - # - # Returns: - # ZoneDetail: a ZoneDetail instance representing the zone requested - # - # Raises: - # Exception: zone X is invalid - # """ - # - # # htd_client.utils.validate_zone(zo+ne) - # - # self._send_and_validate( - # zone, - # HtdLyncCommands.QUERY_ZONE_NAME_COMMAND_CODE, - # 0 - # ) - - # def query_source_name(self, source: int, zone: int) -> str: - # source_offset = source - 1 - # - # self._send_and_validate( - # zone, HtdLyncCommands.QUERY_SOURCE_NAME_COMMAND_CODE, source_offset - # ) - # - # source_name_bytes = response[4:14].strip(b'\x00') - # source_name = htd_client.utils.decode_response(source_name_bytes) - # - # return source_name - - # def set_source_name(self, source: int, zone: int, name: str): - # """ - # Query a zone and return `ZoneDetail` - # - # Args: - # source (int): the source - # zone: (int): the zone - # name (str): the name of the source (max length of 7) - # - # Returns: - # bytes: a ZoneDetail instance representing the zone requested - # - # Raises: - # Exception: zone X is invalid - # """ - # - # # htd_client.utils.validate_zone(zone) - # - # extra_data = bytes( - # [ord(char) for char in name] + [0] * (11 - len(name)) - # ) - # - # self._send_and_validate( - # zone, - # HtdLyncCommands.SET_SOURCE_NAME_COMMAND_CODE, - # source) - # extra_data - # ) - # - # def get_zone_names(self): - # self._send_cmd( - # 1, - # HtdLyncCommands.QUERY_ZONE_NAME_COMMAND_CODE, - # 1 - # ) - # def set_zone_name(self, zone: int, name: str): - # """ - # Query a zone and return `ZoneDetail` - # - # Args: - # zone: (int): the zone - # name (str): the name of the source (max length of 7) - # - # Returns: - # bytes: a ZoneDetail instance representing the zone requested - # - # Raises: - # Exception: zone X is invalid - # """ - # - # # htd_client.utils.validate_zone(zone) - # - # extra_data = bytes( - # [ord(char) for char in name] + [0] * (11 - len(name)) - # ) - # - # self._send_and_validate( - # zone, - # HtdLyncCommands.SET_ZONE_NAME_COMMAND_CODE, - # 0) - # extra_data - # ) + async def async_query_all_zone_status(self): + """Query status of all zones""" + return await self._send_cmd( + 0, + HtdLyncCommands.QUERY_COMMAND_CODE, + 0 + ) + + async def async_set_dnd(self, zone: int, dnd: bool): + """Set Do Not Disturb state on/off.""" + return await self._async_send_and_validate( + lambda z: z.dnd == dnd, + zone, + HtdLyncCommands.COMMON_COMMAND_CODE, + HtdLyncCommands.DND_ON_COMMAND_CODE if dnd else HtdLyncCommands.DND_OFF_COMMAND_CODE + ) + + async def async_set_echo(self, echo: bool): + """Set whether the unit should echo commands back.""" + return await self._send_cmd( + 0, + HtdLyncCommands.SET_ECHO_COMMAND_CODE, + 0xFF if echo else 0x00 + ) + + async def async_query_id(self): + """Query device ID.""" + return await self._send_cmd( + 0, + HtdLyncCommands.QUERY_ID_CODE, + 0x00 + ) + + async def async_query_zone_name(self, zone: int): + """Query the name of a zone.""" + await self._send_cmd( + zone, + HtdLyncCommands.QUERY_ZONE_NAME_COMMAND_CODE, + 0 + ) + + async def async_query_source_name(self, source: int): + """Query the name of a source.""" + await self._send_cmd( + 1, + HtdLyncCommands.QUERY_SOURCE_NAME_COMMAND_CODE, + source + ) + + async def async_set_source_name(self, source: int, name: str): + """Set the name of a source (max 10 chars).""" + trimmed = name[:10] + encoded = list(trimmed.encode("ascii", errors="ignore")) + encoded.extend([0] * (10 - len(encoded))) + extra_data = bytearray(encoded + [0]) + + await self._send_cmd( + 0, + HtdLyncCommands.SET_SOURCE_NAME_COMMAND_CODE, + source, + extra_data + ) + + async def async_set_zone_name(self, zone: int, name: str): + """Set the name of a zone (max 10 chars).""" + trimmed = name[:10] + encoded = list(trimmed.encode("ascii", errors="ignore")) + encoded.extend([0] * (10 - len(encoded))) + extra_data = bytearray(encoded + [0]) + + await self._send_cmd( + zone, + HtdLyncCommands.SET_ZONE_NAME_COMMAND_CODE, + 0, + extra_data + ) diff --git a/htd_client/mca_client.py b/htd_client/mca_client.py index 3d320da..3670c4d 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,59 +582,113 @@ 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 ) - # def get_source_names(self): - # """ - # Query a zone and return `ZoneDetail` - # - # Returns: - # Dict[int, str]: a dictionary where each zone has a string value - # of the source name - # """ - # - # self._send_cmd( - # 0, - # HtdMcaCommands.QUERY_SOURCE_NAME_COMMAND_CODE, - # 0 - # ) - # - # def set_source_name(self, source: int, name: str): - # """ - # Query a zone and return `ZoneDetail` - # - # Args: - # source (int): the source - # name (str): the name of the source (max length of 7) - # - # Returns: - # ZoneDetail: a ZoneDetail instance representing the zone requested - # - # Raises: - # Exception: zone X is invalid - # """ - # - # # htd_client.utils.validate_zone(zone) - # - # extra_data = bytearray( - # [ord(char) for char in name] + [0] * (7 - len(name)) + [0x00] - # ) - # - # self._send_cmd( - # 0, - # HtdMcaCommands.SET_SOURCE_NAME_COMMAND_CODE, - # source, - # extra_data - # ) - # - # def get_zone_names(self): - # pass + 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 + ) + + async def async_set_dnd(self, zone: int, dnd: bool): + raise NotImplementedError("MCA does not support DND.") + + async def async_set_echo(self, echo: bool): + raise NotImplementedError("MCA does not support echo setting.") + + async def async_query_id(self): + raise NotImplementedError("MCA does not support ID query.") + + async def async_query_all_zone_status(self): + raise NotImplementedError("MCA does not support global status query.") + + async def async_query_zone_name(self, zone: int): + raise NotImplementedError("MCA does not support querying zone names.") + + async def async_set_zone_name(self, zone: int, name: str): + raise NotImplementedError("MCA does not support setting zone names.") + + async def async_query_source_name(self, source: int): + """Query a source name.""" + await self._send_cmd( + 0, + HtdMcaCommands.QUERY_SOURCE_NAME_COMMAND_CODE, + 0 + ) + + async def async_set_source_name(self, source: int, name: str): + """Set the name of a source (max 7 chars).""" + extra_data = bytearray( + [ord(char) for char in name[:7]] + [0] * (7 - len(name[:7])) + [0x00] + ) + await self._send_cmd( + 0, + HtdMcaCommands.SET_SOURCE_NAME_COMMAND_CODE, + source, + extra_data + ) diff --git a/htd_client/models.py b/htd_client/models.py index 17ffdfe..2ce698c 100644 --- a/htd_client/models.py +++ b/htd_client/models.py @@ -6,26 +6,28 @@ class ZoneDetail: enabled: bool = True power: bool = None mute: bool = None - mode: bool = None + dnd: bool = None source: int = None volume: int = None treble: int = None bass: int = None balance: int = None name: str = None + source_name: str = None def __str__(self): return ( - "zone_number = %s, enabled = %s, name = %s, power = %s, " - "mute = %s, mode = %s, source = %s, volume = %s, " + "zone_number = %s, enabled = %s, name = %s, source_name = %s, power = %s, " + "mute = %s, dnd = %s, source = %s, volume = %s, " "treble = %s, bass = %s, balance = %s" % ( self.number, self.enabled, self.name, + self.source_name, self.power, self.mute, - self.mode, + self.dnd, self.source, self.volume, self.treble, 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_additional.py b/tests/test_base_client_additional.py index ab3a6c2..11f2917 100644 --- a/tests/test_base_client_additional.py +++ b/tests/test_base_client_additional.py @@ -23,6 +23,7 @@ def client(): c._connection = MagicMock() c._subscribers = set() c._zone_data = {} + c._source_names = {} c._socket_lock = asyncio.Lock() c._callback_lock = asyncio.Lock() c._connected = True diff --git a/tests/test_base_client_edge_cases.py b/tests/test_base_client_edge_cases.py index c8a345d..aef97a4 100644 --- a/tests/test_base_client_edge_cases.py +++ b/tests/test_base_client_edge_cases.py @@ -43,6 +43,7 @@ def client(): c._connection = MagicMock() c._subscribers = set() c._zone_data = {} + c._zone_names = {} return c def test_process_next_command_invalid_header(client): 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_models_coverage.py b/tests/test_models_coverage.py index 29de7c7..7641662 100644 --- a/tests/test_models_coverage.py +++ b/tests/test_models_coverage.py @@ -2,7 +2,7 @@ def test_zone_detail_str(): zone = ZoneDetail(1, enabled=True, name="Kitchen", power=True, mute=False, - mode=True, source=1, volume=30, treble=0, bass=0, balance=0) + dnd=True, source=1, volume=30, treble=0, bass=0, balance=0) s = str(zone) assert "zone_number = 1" in s assert "name = Kitchen" in s diff --git a/tests/test_utils_coverage.py b/tests/test_utils_coverage.py index 937d649..c01d530 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(60) == 0 # MAX_RAW_VOLUME - (MAX_VOLUME - volume) - # 60 -> 256 - (60 - 60) = 256 - assert convert_volume_to_raw(60) == 256 + # 0 -> 256 - (60 - 0) = 196 + assert convert_volume_to_raw(0) == 196 # 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' diff --git a/tests/test_zone_name_cache.py b/tests/test_zone_name_cache.py new file mode 100644 index 0000000..4dd0a51 --- /dev/null +++ b/tests/test_zone_name_cache.py @@ -0,0 +1,49 @@ +import asyncio +import pytest +from unittest.mock import MagicMock +from htd_client.lync_client import HtdLyncClient +from htd_client.constants import HtdDeviceKind + + +@pytest.fixture +def lync_client(): + loop = MagicMock() + model_info = { + "zones": 6, "sources": 12, "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_names = {} + client._zone_data = {} + client._source_names = {} + return client + + +def test_get_zone_name_returns_none_before_query(lync_client): + assert lync_client.get_zone_name(1) is None + + +def test_get_zone_name_returns_none_for_unknown_zone(lync_client): + lync_client._zone_names[1] = "Living Room" + assert lync_client.get_zone_name(99) is None + + +def test_get_zone_name_returns_cached_name(lync_client): + lync_client._zone_names[1] = "Living Room" + assert lync_client.get_zone_name(1) == "Living Room" + + +def test_zone_name_cached_independently_of_zone_data(lync_client): + # Zone data does NOT exist for zone 2 yet + # Simulate _handle_message processing a ZONE_NAME_RECEIVE_COMMAND for zone 2 + lync_client._zone_names[2] = "office" + # zone_data should NOT be required for get_zone_name to work + assert 2 not in lync_client._zone_data + assert lync_client.get_zone_name(2) == "office" + + +def test_get_zone_name_reads_from_zone_names_dict(lync_client): + lync_client._zone_names[1] = "living room" + assert lync_client.get_zone_name(1) == "living room"