Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 67 additions & 14 deletions htd_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import htd_client.utils
from .base_client import BaseClient
from .constants import HtdCommonCommands, HtdModelInfo, HtdDeviceKind, HtdConstants
from .exceptions import HtdConnectionError
from .lync_client import HtdLyncClient
from .mca_client import HtdMcaClient

Expand All @@ -28,6 +29,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.
Expand All @@ -36,23 +38,37 @@ 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.
"""

model_info = await async_get_model_info(
loop if loop is not None else asyncio.get_running_loop(),
network_address=network_address,
serial_address=serial_address
)
address = f"serial: {serial_address}" if serial_address is not None else f"network: {network_address}"

try:
model_info = await async_get_model_info(
loop if loop is not None else asyncio.get_running_loop(),
network_address=network_address,
serial_address=serial_address,
retry_attempts=retry_attempts,
)
except OSError as e:
raise HtdConnectionError(f"Unable to connect to HTD device ({address}): {e}") from e

if model_info is None:
raise HtdConnectionError(
f"Unable to detect HTD device model ({address}). "
f"Verify the device is powered on and the path/address is correct."
)

if model_info["kind"] == HtdDeviceKind.mca:
client = HtdMcaClient(
loop if loop is not None else asyncio.get_running_loop(),
model_info,
network_address=network_address,
serial_address=serial_address,
retry_attempts=retry_attempts,
)

elif model_info["kind"] == HtdDeviceKind.lync:
Expand All @@ -61,12 +77,16 @@ 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()
try:
await client.async_connect()
except OSError as e:
raise HtdConnectionError(f"Unable to connect to HTD device ({address}): {e}") from e

return client

Expand All @@ -75,6 +95,7 @@ async def async_get_model_info(
loop: asyncio.AbstractEventLoop = None,
network_address: Tuple[str, int] = None,
serial_address:str=None,
retry_attempts: int = HtdConstants.DEFAULT_RETRY_ATTEMPTS,
) -> HtdModelInfo | None:
"""
Get the model information from the gateway.
Expand All @@ -88,16 +109,48 @@ async def async_get_model_info(
1, HtdCommonCommands.MODEL_QUERY_COMMAND_CODE, 0
)

model_id = await htd_client.utils.async_send_command(
def find_model(data: bytes) -> HtdModelInfo | None:
for model_name in HtdConstants.SUPPORTED_MODELS:
model = HtdConstants.SUPPORTED_MODELS[model_name]
if model["identifier"] in data:
return model
return None

# open the connection once and retry on it: every serial port open can
# toggle DTR and reset the gateway, so re-opening per attempt would keep
# resetting the device we are trying to probe
reader, writer = await htd_client.utils.async_open_connection(
loop if loop is not None else asyncio.get_running_loop(),
cmd,
network_address=network_address,
serial_address=serial_address
serial_address=serial_address,
settle_delay=HtdConstants.SERIAL_SETTLE_DELAY if serial_address is not None else 0,
)

for model_name in HtdConstants.SUPPORTED_MODELS:
model = HtdConstants.SUPPORTED_MODELS[model_name]
if model["identifier"] in model_id:
return model
try:
for attempt in range(retry_attempts):
writer.write(cmd)
await writer.drain()

data = await htd_client.utils.async_read_response(
reader,
response_complete=lambda d: find_model(d) is not None,
timeout=HtdConstants.RESPONSE_TIMEOUT,
quiet_window=HtdConstants.RESPONSE_QUIET_WINDOW,
)

model = find_model(data)
if model is not None:
return model

if attempt < retry_attempts - 1:
_LOGGER.warning(
"Model probe attempt %d/%d failed to match a known device, retrying",
attempt + 1,
retry_attempts,
)
await asyncio.sleep(HtdConstants.DEFAULT_COMMAND_RETRY_TIMEOUT)
finally:
writer.close()
await writer.wait_closed()

return None
95 changes: 83 additions & 12 deletions htd_client/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -127,6 +128,11 @@ def connection_made(self, transport: Transport):


async def _heartbeat(self):
# opening a serial port can toggle DTR and reset the gateway; wait for
# it to come back or the first refresh command is lost
if self._serial_address is not None:
await asyncio.sleep(HtdConstants.SERIAL_SETTLE_DELAY)

while self._connected:
await self.refresh()
await asyncio.sleep(60)
Expand Down Expand Up @@ -186,7 +192,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
Expand Down Expand Up @@ -280,11 +290,17 @@ def _process_next_command(self, data: bytes):
else:
_LOGGER.info("Bad checksum %02x != %02x", frame_sum_checksum, checksum)

# the checksum mismatch means this frame's declared length can't
# be trusted (zone/command may be a coincidental match on
# misaligned or corrupted bytes), so don't skip the full
# presumed frame or a genuinely desynced buffer never recovers.
# Resync minimally, same as the unknown-command path above.
return None, start_message_index + HtdConstants.MESSAGE_HEADER_LENGTH

return zone, chunk_length

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
Expand Down Expand Up @@ -330,14 +346,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'
Expand Down Expand Up @@ -389,7 +408,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
Expand Down Expand Up @@ -497,6 +516,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`
Expand Down Expand Up @@ -569,6 +596,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):
Expand All @@ -578,6 +609,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
Expand All @@ -593,3 +628,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
Loading