Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
156 changes: 131 additions & 25 deletions htd_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,55 +18,128 @@
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

_LOGGER = logging.getLogger(__name__)


def get_model_info(key: str | None) -> HtdModelInfo | None:
"""
Look up a model definition by its persistable key, without touching the device.

This is the counterpart to `async_get_model_info`: a consumer records
`model_info["key"]` once, and can rebuild the same client on later runs while the
device is powered off.

Args:
key (str | None): a key from `HtdConstants.SUPPORTED_MODELS`, e.g. "mca66".

Returns:
HtdModelInfo | None: the model definition, or None if the key is unknown or missing.
"""
if key is None:
return None

return HtdConstants.SUPPORTED_MODELS.get(key)


def build_client(
model_info: HtdModelInfo,
*,
serial_address: str = None,
network_address: Tuple[str, int] = None,
loop: asyncio.AbstractEventLoop = None,
retry_attempts: int = HtdConstants.DEFAULT_RETRY_ATTEMPTS,
) -> BaseClient:
"""
Create a client from a known model definition, without touching the device.

No I/O happens here — nothing is opened, probed or connected. Call `async_connect()`
or `async_start()` afterwards.

Args:
model_info (HtdModelInfo): the model definition, from `get_model_info` or
`async_get_model_info`.
network_address (Tuple[str, int]): 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:
BaseClient: an unconnected client of the class matching the model's kind.

Raises:
ValueError: the model's kind is not recognized.
"""
resolved_loop = loop if loop is not None else asyncio.get_running_loop()

if model_info["kind"] == HtdDeviceKind.mca:
client_class = HtdMcaClient
elif model_info["kind"] == HtdDeviceKind.lync:
client_class = HtdLyncClient
else:
raise ValueError(f"Unknown Device Kind: {model_info['kind']}")

return client_class(
resolved_loop,
model_info,
network_address=network_address,
serial_address=serial_address,
retry_attempts=retry_attempts,
)


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.
Probe the device for its model, then create and connect a client.

Args:
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}"

if model_info["kind"] == HtdDeviceKind.mca:
client = HtdMcaClient(
try:
model_info = await async_get_model_info(
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,
)
except OSError as e:
raise HtdConnectionError(f"Unable to connect to HTD device ({address}): {e}") from e

elif model_info["kind"] == HtdDeviceKind.lync:
client = HtdLyncClient(
loop if loop is not None else asyncio.get_running_loop(),
model_info,
network_address=network_address,
serial_address=serial_address,
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."
)

else:
raise ValueError(f"Unknown Device Kind: {model_info["kind"]}")
client = build_client(
model_info,
serial_address=serial_address,
network_address=network_address,
loop=loop,
retry_attempts=retry_attempts,
)

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 +148,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 +162,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
Loading