From 05a0afd7eb3be11d60aa764be28f80e5e8462496 Mon Sep 17 00:00:00 2001 From: Eric Fetty Date: Fri, 27 Mar 2026 00:43:18 -0500 Subject: [PATCH] feat: Implement initial HTD client architecture including base, MCA, and Lync clients, data models, constants, and tests. --- .lync_clone | 1 + htd_client/base_client.py | 58 +++++++-- htd_client/constants.py | 6 +- htd_client/lync_client.py | 172 ++++++++++++--------------- htd_client/mca_client.py | 82 ++++++------- htd_client/models.py | 10 +- tests/test_base_client_additional.py | 1 + tests/test_models_coverage.py | 2 +- tests/test_utils_coverage.py | 6 +- 9 files changed, 178 insertions(+), 160 deletions(-) create mode 160000 .lync_clone diff --git a/.lync_clone b/.lync_clone new file mode 160000 index 0000000..527a4bf --- /dev/null +++ b/.lync_clone @@ -0,0 +1 @@ +Subproject commit 527a4bfc85386f27424ca4b2869d35aecd6a6cfe diff --git a/htd_client/base_client.py b/htd_client/base_client.py index 4f9d8ad..915d2ab 100644 --- a/htd_client/base_client.py +++ b/htd_client/base_client.py @@ -95,7 +95,7 @@ async def async_connect(self): self._buffer = bytearray() self._zone_data = {} self._zones_loaded = 0 - self._zone_data = {} + self._source_names = {} self._connection = None self._disconnected = False @@ -330,14 +330,16 @@ 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() + 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 +391,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 +499,10 @@ 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(self, zone: int): """ Query a zone and return `ZoneDetail` @@ -593,3 +599,35 @@ async def async_balance_left(self, zone: int): @abstractmethod async def async_balance_right(self, zone: 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..faaf868 100644 --- a/htd_client/constants.py +++ b/htd_client/constants.py @@ -105,7 +105,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 +134,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 +151,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 +173,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 diff --git a/htd_client/lync_client.py b/htd_client/lync_client.py index f8f6069..159d6b5 100644 --- a/htd_client/lync_client.py +++ b/htd_client/lync_client.py @@ -386,99 +386,79 @@ async def async_set_balance(self, zone: int, balance: int): balance ) - # 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 if hasattr(HtdLyncCommands, "SET_ECHO_COMMAND_CODE") else 0x19, + 0xFF if echo else 0x00 + ) + + async def async_query_id(self): + """Query device ID.""" + return await self._send_cmd( + 0, + HtdLyncCommands.QUERY_ID_CODE if hasattr(HtdLyncCommands, "QUERY_ID_CODE") else 0x08, + 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..682dc53 100644 --- a/htd_client/mca_client.py +++ b/htd_client/mca_client.py @@ -427,48 +427,40 @@ async def async_balance_right(self, zone: int): 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_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/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_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