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
33 changes: 11 additions & 22 deletions pylabrobot/hamilton/transport/tcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
"""Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.)."""

from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand
from pylabrobot.hamilton.transport.tcp.introspection import HamiltonIntrospection
from pylabrobot.hamilton.transport.tcp.messages import (
CommandMessage,
CommandResponse,
HoiParams,
HoiParamsParser,
InitMessage,
InitResponse,
RegistrationMessage,
RegistrationResponse,
)
from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket
from pylabrobot.hamilton.transport.tcp.protocol import (
HamiltonDataType,
HamiltonProtocol,
HarpTransportableProtocol,
Hoi2Action,
HoiRequestId,
RegistrationActionCode,
RegistrationOptionType,
)
from pylabrobot.hamilton.transport.tcp.commands import TCPCommand
from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError
from pylabrobot.hamilton.transport.tcp.packets import Address
from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient

__all__ = [
"Address",
"HamiltonTCPClient",
"HoiError",
"TCPCommand",
]
158 changes: 122 additions & 36 deletions pylabrobot/hamilton/transport/tcp/commands.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,50 @@
"""Hamilton command architecture using new simplified TCP stack.
"""Command layer for Hamilton TCP.

This module provides the HamiltonCommand base class that uses the new refactored
architecture: Wire -> HoiParams -> Packets -> Messages -> Commands.
TCPCommand base: build_parameters() returns HoiParams; interpret_response()
auto-decodes success responses via nested Response dataclasses (wire-type
annotations and parse_into_struct). Wire → HoiParams → Packets → Messages → Commands.
"""

from __future__ import annotations

import inspect
from typing import Optional
from dataclasses import fields, is_dataclass
from typing import Any, Optional

from pylabrobot.hamilton.transport.tcp.messages import (
CommandMessage,
CommandResponse,
HoiParams,
interpret_hoi_success_payload,
log_hoi_result_entries,
split_hoi_params_after_warning_prefix,
)
from pylabrobot.hamilton.transport.tcp.packets import Address
from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol
from pylabrobot.hamilton.transport.tcp.wire_types import HcResultEntry


class HamiltonCommand:
"""Base class for Hamilton commands using new simplified architecture.
class TCPCommand:
"""Base class for Hamilton TCP commands.

This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design:
- Explicitly uses CommandMessage for building packets
- build_parameters() returns HoiParams object (not bytes)
- Uses Address instead of ObjectAddress
- Cleaner separation of concerns
Preferred usage: define commands as ``@dataclass`` subclasses with
``Annotated`` wire-type fields. ``build_parameters()`` and
``interpret_response()`` are handled automatically by the base class.

Example:
class MyCommand(HamiltonCommand):
Example::

@dataclass
class MyCommand(TCPCommand):
protocol = HamiltonProtocol.OBJECT_DISCOVERY
interface_id = 0
command_id = 42

def __init__(self, dest: Address, value: int):
super().__init__(dest)
self.value = value

def build_parameters(self) -> HoiParams:
return HoiParams().i32(self.value)
dest: Address # infrastructure field — not serialised
value: Annotated[int, I32] # wire field — serialised in order

@classmethod
def parse_response_parameters(cls, data: bytes) -> dict:
parser = HoiParamsParser(data)
_, result = parser.parse_next()
return {'result': result}
@dataclass
class Response:
result: Annotated[int, U32]
"""

# Class-level attributes that subclasses must override
Expand All @@ -58,7 +58,7 @@ def parse_response_parameters(cls, data: bytes) -> dict:
ip_protocol: int = 6 # Default: OBJECT_DISCOVERY

def __init__(self, dest: Address):
"""Initialize Hamilton command.
"""Initialize TCP command.

Args:
dest: Destination address for this command
Expand All @@ -78,12 +78,17 @@ def __init__(self, dest: Address):
def build_parameters(self) -> HoiParams:
"""Build HOI parameters for this command.

Override this method in subclasses to provide command-specific parameters.
Return a HoiParams object (not bytes!).
Default: serializes all ``Annotated`` wire-type fields on ``self`` via
``HoiParams.from_struct``. On non-dataclass subclasses ``from_struct``
finds no fields and returns an empty ``HoiParams``, preserving the old
behaviour. Override only when the wire layout cannot be expressed with
``Annotated`` field declarations.

Returns:
HoiParams object with command parameters
"""
if is_dataclass(self):
return HoiParams.from_struct(self)
return HoiParams()

def get_log_params(self) -> dict:
Expand Down Expand Up @@ -150,19 +155,84 @@ def build(
# Build final packet
return msg.build(source, sequence, harp_response_required=response_required)

def interpret_response(self, response: CommandResponse) -> Optional[dict]:
"""Interpret success response.
def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]:
"""Map a ``HcResultEntry`` to a 0-indexed PLR channel, or ``None`` to skip.

This is the new interface used by the backend. Default implementation
directly calls parse_response_parameters for efficiency.
Default: the entry's position in the HoiResult — firmware populates arrays
in active-channel order. ``NimbusCommand`` / ``PrepCommand`` override this
to translate the active-channel ordinal into the caller's 0-indexed channel
via ``channels_involved`` bitmask or per-channel struct-array reflection.
"""
return entry_index

Args:
response: CommandResponse from network
def error_entries_use_physical_channels(self) -> bool:
"""Whether ``STATUS_EXCEPTION`` entries should be mapped to PLR channel indices.

Returns:
Dictionary with parsed response data, or None if no data to extract
Returns ``True`` when the command carries per-channel wire parameters:
Prep ``StructArray`` elements with a ``channel`` field, or Nimbus
``channels_involved`` parallel arrays. Void MLPrep / status queries return
``False`` so the client raises :class:`~pylabrobot.hamilton.transport.tcp.hoi_error.HoiError`
instead of attributing errors to synthetic ``ch0``.
"""
return self.parse_response_parameters(response.hoi.params)
if not is_dataclass(self):
return False
for f in fields(self):
if f.name == "channels_involved":
return True
value = getattr(self, f.name, None)
if isinstance(value, list) and value:
if getattr(value[0], "channel", None) is not None:
return True
return False

def interpret_response(self, response: CommandResponse) -> Any:
"""Pure decoder for a success response — never raises on channel errors.

For ``STATUS_WARNING`` / ``COMMAND_WARNING`` frames, strips the leading
summary + formatted-string prefix (per ``SystemController.SendAndReceive``)
and logs entries parsed via ``HoiDecoder2.GetHcResults``. For plain
``STATUS_RESPONSE`` / ``COMMAND_RESPONSE`` frames, decodes the Response
dataclass directly — the firmware emits exactly the fields declared in
the interface yaml, with no HoiResult trailer. HoiResult only rides on
warning (prefix) or exception (separate payload, handled in
``send_command``) frames.

Fatal (non-success, non-warning) entries from a warning frame surface
through ``fatal_entries_by_channel`` and are lifted into a
``ChannelizedError`` by the backend — this decoder stays pure.
"""
eff, _prefix = self._strip_warning_prefix(response)
return interpret_hoi_success_payload(self, eff)

def fatal_entries_by_channel(self, response: CommandResponse) -> dict[int, HcResultEntry]:
"""Return fatal entries keyed by 0-indexed PLR channel.

Only non-success, non-warning entries from a warning-frame prefix are
included; warnings remain log-only. Exception frames are handled
separately in ``send_command`` via :func:`~pylabrobot.hamilton.transport.tcp.hoi_error.parse_hamilton_error_entry`.

``entry_index`` passed to ``_channel_index_for_entry`` is the position of
the entry in the *original* entries list (i.e. active-channel ordinal),
not among fatal entries only — so bitmask / struct-array overrides can
map ordinal → channel correctly even when earlier channels warned.
"""
_eff, prefix_entries = self._strip_warning_prefix(response)
per_channel: dict[int, HcResultEntry] = {}
for i, entry in enumerate(prefix_entries):
if entry.is_success:
continue
ch = self._channel_index_for_entry(i, entry)
if ch is None:
continue
per_channel[ch] = entry
return per_channel

def _strip_warning_prefix(self, response: CommandResponse) -> tuple[bytes, list[HcResultEntry]]:
"""Strip the warning-frame HoiResult prefix, if present. Logs entries."""
raw = response.hoi.params
eff, prefix_entries = split_hoi_params_after_warning_prefix(response.hoi.action_code, raw)
log_hoi_result_entries(type(self).__name__, prefix_entries, source="HOI prefix")
return eff, prefix_entries

@classmethod
def parse_response_parameters(cls, data: bytes) -> Optional[dict]:
Expand All @@ -177,3 +247,19 @@ def parse_response_parameters(cls, data: bytes) -> Optional[dict]:
Dictionary with parsed response data, or None if no data to extract
"""
return None


def hamilton_error_for_entry(entry: HcResultEntry, description: str) -> Exception:
"""Wrap an ``HcResultEntry`` in a ``RuntimeError`` using a pre-resolved description.

``description`` is sourced from the device itself via Interface 0 method 5
(``EnumInfo``) — see ``HamiltonTCPClient._describe_entry``. The returned
exception has ``.entry`` attached so callers can dispatch on
``entry.result`` / ``entry.interface_id`` / ``entry.address``.
"""
err = RuntimeError(
f"{description} (HcResult=0x{entry.result:04X}) "
f"at {entry.address} iface={entry.interface_id} action={entry.action_id}"
)
err.entry = entry # type: ignore[attr-defined]
return err
Loading
Loading