Skip to content

Commit fec46b0

Browse files
authored
Fix decoding of multi-element register payloads (#44)
decode now sizes the payload from payload_class rather than from the register length, which only an array register declares, so a payload spanning several elements is no longer rejected. That covers every struct, string and mask register, DeviceName included. An array register declaring length in its class body now sizes its payload too, where before only the call form did and it read the first element only. A declared length of 1 is distinct from an absent one, so it emits a one-element array rather than a scalar, and length: 0 is rejected for a register and a payload member. length is no longer a member of every register, moving to the array register metaclass, and ConverterContext.length is never zero, reporting the effective element count. Closes #36
1 parent 368a623 commit fec46b0

9 files changed

Lines changed: 268 additions & 53 deletions

File tree

src/packages/harp-device/src/harp/device/schema/_emit.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,14 @@ class ConverterContext:
104104
name: str # yml field key ("__value__" for a whole-register value)
105105
interface_type: Optional[str] # the DSL interfaceType (None = raw/native)
106106
mask: Optional[int] # bit mask, when the value is bit-packed
107-
length: int # element count this value spans (0 = unset -> scalar)
107+
length: int # register elements this value spans, at least one
108108
element: np.dtype # base element dtype of the register, from PayloadType
109109
element_size: int # base element byte size of the register
110110

111111
@property
112112
def span(self) -> int:
113-
"""Byte span of the value (element count * element size)."""
114-
return max(1, self.length) * self.element_size
113+
"""Byte span of the value, its element count times the element size."""
114+
return self.length * self.element_size
115115

116116
@property
117117
def member_dtype(self) -> np.dtype:
@@ -343,7 +343,7 @@ def _extension(self, symbol: str, ctx: ConverterContext) -> Converter[Any]:
343343
def _default(self, member: PayloadMember, type_name: str, ctx: ConverterContext) -> Any:
344344
"""The typed default value of the field, or ``_NO_DEFAULT`` when it has none."""
345345
_default_value = member.defaultValue if member.defaultValue is not None else member.minValue
346-
if _default_value is None or (member.length or 0) > 1:
346+
if _default_value is None or ctx.length > 1:
347347
return _NO_DEFAULT
348348
value = float(_default_value.root)
349349
group_mask = self._find_mask(type_name)
@@ -367,17 +367,17 @@ def _build_field(self, key: str, member: PayloadMember, reg: Register) -> Any:
367367
# ``key`` stays the verbatim yml name: it feeds ``ConverterContext.name``, and
368368
# a custom converter symbol is derived from the pre-rename key ("Data" ->
369369
# "DataConverter"). The renamed attribute name is applied by the caller.
370-
elem_np = _ELEMENT[reg.type]
371-
elem_size = np.dtype(elem_np).itemsize
370+
elem = np.dtype(_ELEMENT[reg.type])
371+
elem_size = elem.itemsize
372372
offset = member.offset or 0
373373
it = member.interfaceType.root if member.interfaceType else None
374374
type_name = it or (member.maskType.root if member.maskType else "")
375375
ctx = ConverterContext(
376376
name=key,
377377
interface_type=it,
378378
mask=member.mask,
379-
length=member.length or 0,
380-
element=np.dtype(elem_np),
379+
length=member.length or 1,
380+
element=elem,
381381
element_size=elem_size,
382382
)
383383
default = self._default(member, type_name, ctx)
@@ -416,16 +416,16 @@ def _build_payload(self, name: str, reg: Register) -> type:
416416

417417
def _new_payload(self, class_name: str, owner: str, reg: Register) -> type:
418418
elem_np = _ELEMENT[reg.type]
419-
elem_size = np.dtype(elem_np).itemsize
420-
length = reg.length or 1
419+
elem = np.dtype(elem_np)
420+
elem_size = elem.itemsize
421421

422422
if reg.payloadSpec is not None:
423423
renamed = self._rename("field", owner, reg.payloadSpec, field_name, reserved=True)
424424
namespace = {
425425
renamed[key]: self._build_field(key, member, reg)
426426
for key, member in reg.payloadSpec.items()
427427
}
428-
kwds = {"length": length} if length > 1 else {}
428+
kwds = {"length": reg.length}
429429
return _new_class(class_name, (StructPayload[elem_np],), namespace, kwds)
430430

431431
# anonymous single-value payload
@@ -451,8 +451,8 @@ def _new_payload(self, class_name: str, owner: str, reg: Register) -> type:
451451
name="__value__",
452452
interface_type=it,
453453
mask=None,
454-
length=length,
455-
element=np.dtype(elem_np),
454+
length=reg.length or 1,
455+
element=elem,
456456
element_size=elem_size,
457457
)
458458
descriptor = Field(self._resolve_converter(ctx))
@@ -464,7 +464,6 @@ def _class_name(self, name: str, reg: Register) -> str:
464464
return f"_{name}" if reg.visibility is Visibility.private else name
465465

466466
def _build_register(self, name: str, class_name: str, reg: Register) -> type[RegisterBase[Any]]:
467-
length = reg.length or 1
468467
it = reg.interfaceType.root if reg.interfaceType else None
469468

470469
# A plain scalar/array register needs no payload wrapper: its whole value is a
@@ -475,8 +474,8 @@ def _build_register(self, name: str, class_name: str, reg: Register) -> type[Reg
475474
and reg.converter is None
476475
and _is_native(it)
477476
):
478-
if length > 1: # plain array register
479-
cls = _ARRAY_REGISTER[reg.type](reg.address, length=length)
477+
if reg.length is not None: # plain array register
478+
cls = _ARRAY_REGISTER[reg.type](reg.address, length=reg.length)
480479
cls.__name__ = cls.__qualname__ = class_name
481480
return cls
482481
return _new_class(class_name, (_SCALAR_REGISTER[reg.type],), {"address": reg.address})

src/packages/harp-device/src/harp/device/schema/_model.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,10 @@ class PayloadMember(BaseModel):
126126
None,
127127
description="The zero-based index at which encoding of this payload member starts.",
128128
)
129-
length: Optional[int] = Field(
130-
None, description="The number of elements used to encode this payload member."
131-
)
129+
length: Annotated[
130+
Optional[int],
131+
Field(ge=1, description="The number of elements used to encode this payload member."),
132+
] = None
132133
description: Optional[str] = Field(
133134
None, description="A summary description of this payload member."
134135
)
@@ -164,8 +165,8 @@ class Register(BaseModel):
164165
address: Annotated[int, Field(le=255, description="The unique 8-bit address of the register.")]
165166
type: PayloadType = Field(..., description="The type of the register payload.")
166167
length: Annotated[
167-
Optional[int], Field(ge=1, default=1, description="The length of the register payload.")
168-
]
168+
Optional[int], Field(ge=1, description="The length of the register payload.")
169+
] = None
169170
access: Union[Access, List[Access]] = Field(
170171
..., description="The expected use of the register."
171172
)

src/packages/harp-protocol/src/harp/protocol/_message.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import struct
44
from typing import Any, ClassVar, Generic, Protocol, TypeVar, cast
55

6-
import numpy as np
76
from typing_extensions import Sentinel
87

98
from ._builder import build_message_frame
@@ -18,6 +17,7 @@
1817
_TIMESTAMPED_PAYLOAD_OFFSET,
1918
)
2019
from ._message_type import MessageType, _message_type_from_byte_safe
20+
from ._payload import PayloadBase
2121
from ._payload_type import PayloadType, decode_payload_type
2222

2323
P = TypeVar("P")
@@ -38,14 +38,14 @@ class PayloadDecoder(Protocol[_P_co]):
3838
"""Reads a payload of type ``_P_co`` out of a message.
3939
4040
Structural rather than nominal, so a message never has to know about registers, and
41-
anything declaring a payload type, a length and a ``parse`` satisfies it. Every
42-
``RegisterBase`` does. ``length`` is the element count, or ``None`` for a single
43-
value, and together with ``payload_type`` it fixes how many payload bytes the
44-
decoder consumes.
41+
anything declaring a payload type, a payload class and a ``parse`` satisfies it. Every
42+
``RegisterBase`` does. ``payload_class.payload_dtype`` is what fixes how many payload
43+
bytes the decoder consumes, and it is the same quantity ``parse`` reads the frame
44+
with, so the two cannot disagree about the extent of a payload.
4545
"""
4646

4747
payload_type: ClassVar["PayloadType"]
48-
length: ClassVar[int | None]
48+
payload_class: ClassVar[type[PayloadBase[Any]]]
4949

5050
@classmethod
5151
def parse(cls, value: Any) -> _P_co: ...
@@ -187,7 +187,7 @@ def decode(self, decoder: type[PayloadDecoder[_P]]) -> "HarpMessage[_P]":
187187
f"{decoder.__name__} declares {decoder.payload_type!r} but this "
188188
f"message declares {self.payload_type!r}."
189189
)
190-
expected = (decoder.length or 1) * np.dtype(decoder.payload_type.value).itemsize
190+
expected = decoder.payload_class.payload_dtype.itemsize
191191
actual = len(self.payload_bytes)
192192
if actual != expected:
193193
raise HarpParseError(

src/packages/harp-protocol/src/harp/protocol/_register.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ._message import HarpMessage, HarpParseError
1919
from ._message_type import MessageType, message_type_to_byte
2020
from ._payload import (
21+
AnonymousPayload,
2122
Batch,
2223
PayloadBase,
2324
PayloadFloat,
@@ -148,14 +149,13 @@ class RegisterBase(ABC, Generic[U], metaclass=_RegisterBaseMeta):
148149
or ``RegisterBase[ClockConfigurationFlags]`` for a whole-register
149150
``BitMask`` or ``GroupMask``, even though each still has a ``payload_class``.
150151
151-
Subclasses must define ``address``, ``payload_type``, and
152-
``payload_class`` as ``ClassVar``s.
152+
Subclasses must define ``address``, ``payload_type``, and ``payload_class`` as
153+
``ClassVar``s. The extent of a payload is always read from ``payload_class``.
153154
"""
154155

155156
address: ClassVar[int]
156157
payload_type: ClassVar[PayloadType]
157158
payload_class: ClassVar[type[PayloadBase[Any]]]
158-
length: ClassVar[int | None] = None
159159

160160
@classmethod
161161
def parse(cls, value: HarpMessage | bytes | bytearray | memoryview) -> U:
@@ -435,32 +435,40 @@ class RegisterFloat(RegisterBase[np.float32], metaclass=_ScalarRegisterMeta):
435435

436436

437437
class _ArrayRegisterMeta(_RegisterBaseMeta):
438-
"""A base metaclass for array registers. Calling with address and length creates a concrete subclass: ``RegisterU16Array(0x28, length=3)``."""
438+
"""A declared ``length`` sizes the payload, and calling a register base with an address
439+
and a length creates a one-off subclass: ``RegisterU16Array(0x28, length=3)``.
439440
440-
def __call__(cls: "type[_AR]", address: int, *, length: int) -> "type[_AR]": # type: ignore[override, misc]
441-
_require_no_address(cls)
442-
base_payload = cls.payload_class # type: ignore[attr-defined]
443-
# Anonymous payloads carry a plain (non-structured) dtype. The array
444-
# variant uses a sub-dtype (inner_dtype, (length,)) so a single buffer
445-
# element decodes directly to an ndarray of shape (length,).
446-
inner = base_payload.payload_dtype
447-
sub_dtype = np.dtype((inner, (length,)))
448-
concrete_payload = type(
441+
``length`` is declared here rather than on ``RegisterBase``, so only an array register
442+
carries one. It is the element count, and nothing reads it to size a payload.
443+
"""
444+
445+
length: int
446+
payload_class: type[AnonymousPayload[Any]]
447+
448+
def __init__(
449+
cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any
450+
) -> None:
451+
super().__init__(name, bases, namespace, **kwargs)
452+
# The namespace holds this class body only, not inherited values, so a plain
453+
# subclass reads None and keeps the payload already sized by its base.
454+
length = namespace.get("length")
455+
if length is None:
456+
return
457+
base_payload = cls.payload_class
458+
if base_payload.payload_dtype.subdtype is not None:
459+
raise TypeError(f"{name} redeclares a length already applied by its base class.")
460+
# A sub-array dtype, so reading one buffer element gives an ndarray of that shape.
461+
cls.payload_class = type(
449462
f"{base_payload.__name__}_{length}",
450463
(base_payload,),
451-
{"payload_dtype": sub_dtype},
464+
{"payload_dtype": np.dtype((base_payload.payload_dtype, (length,)))},
452465
)
466+
467+
def __call__(cls: "type[_AR]", address: int, *, length: int) -> "type[_AR]": # type: ignore[override, misc]
468+
_require_no_address(cls)
453469
return cast(
454470
"type[_AR]",
455-
type(
456-
f"{cls.__name__}_{address:#04x}",
457-
(cls,),
458-
{
459-
"address": address,
460-
"length": length,
461-
"payload_class": concrete_payload,
462-
},
463-
),
471+
type(f"{cls.__name__}_{address:#04x}", (cls,), {"address": address, "length": length}),
464472
)
465473

466474

tests/device/test_device.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,21 @@ def test_error_reply_returned_when_not_raising():
176176
reply = device.read(core.WhoAmI)
177177
assert reply.has_error
178178
assert int(reply.payload) == 7
179+
180+
181+
def test_read_multi_element_register_returns_payload():
182+
# read decodes the reply through the register, so a payload of several elements has to
183+
# survive that step.
184+
transport = _ScriptedTransport()
185+
transport.on_write = lambda _: (
186+
core.DeviceName.format("Behavior", message_type=MessageType.Read),
187+
)
188+
with _ShortTimeoutDevice(transport) as device:
189+
assert device.read(core.DeviceName).payload == "Behavior"
190+
191+
192+
def test_write_multi_element_register_returns_payload():
193+
transport = _ScriptedTransport()
194+
transport.on_write = lambda data: (data,) # a device echoing the write
195+
with _ShortTimeoutDevice(transport) as device:
196+
assert device.write(core.DeviceName, "Behavior").payload == "Behavior"

tests/device/test_emit.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,62 @@ def test_enum_names_match_generator_for_every_enum(device_registers):
136136
}
137137

138138

139+
# ---------------------------------------------------------------------------
140+
# The declared length decides whether a register is an array
141+
# ---------------------------------------------------------------------------
142+
143+
_LENGTH_YML = """
144+
registers:
145+
Absent: {address: 32, type: U16, access: Read}
146+
One: {address: 33, type: U16, length: 1, access: Read}
147+
Three: {address: 34, type: U16, length: 3, access: Read}
148+
"""
149+
150+
151+
def test_absent_length_emits_scalar_register():
152+
# A register with no declared length holds a single value, and carries no length at all.
153+
reg = create_registers(_LENGTH_YML)["Absent"]
154+
assert not hasattr(reg, "length")
155+
assert reg.payload_class.payload_dtype.shape == ()
156+
157+
158+
@pytest.mark.parametrize("name, count", [("One", 1), ("Three", 3)])
159+
def test_declared_length_emits_array_register(name, count):
160+
# Any declared length means an array, 1 included. Reading a declared 1 as a single value
161+
# gives the same type as declaring nothing, and the generator would then disagree.
162+
reg = create_registers(_LENGTH_YML)[name]
163+
assert reg.length == count
164+
assert reg.payload_class.payload_dtype.shape == (count,)
165+
values = np.arange(count, dtype=np.uint16)
166+
np.testing.assert_array_equal(reg.parse(HarpMessage.parse(reg.format(values))), values)
167+
168+
169+
def test_multi_element_struct_register_decodes_through_message():
170+
# A struct register declares no length, so decode has to size its payload from the
171+
# payload class.
172+
reg = create_registers(
173+
"registers:\n"
174+
" Settings:\n"
175+
" address: 32\n"
176+
" type: U16\n"
177+
" length: 3\n"
178+
" access: Write\n"
179+
" payloadSpec:\n"
180+
" Gain: {offset: 0}\n"
181+
" Offset: {offset: 1}\n"
182+
" Threshold: {offset: 2}\n"
183+
)["Settings"]
184+
payload = reg.payload_class(gain=1, offset=2, threshold=3)
185+
decoded = HarpMessage.parse(reg.format(payload)).decode(reg)
186+
assert (int(decoded.payload.gain), int(decoded.payload.threshold)) == (1, 3)
187+
188+
189+
def test_core_string_register_decodes_through_message():
190+
# DeviceName spans 25 U8 elements, so the same defect reached every Harp device.
191+
frame = core.DeviceName.format("Behavior")
192+
assert HarpMessage.parse(frame).decode(core.DeviceName).payload == "Behavior"
193+
194+
139195
# ---------------------------------------------------------------------------
140196
# Core registers: the emitter and the generated package, from the same core.yml
141197
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)