Skip to content

Refactor Device API to allow binding device's registers - #16

Closed
bruno-f-cruz wants to merge 22 commits into
mainfrom
refactor-register-binding
Closed

Refactor Device API to allow binding device's registers#16
bruno-f-cruz wants to merge 22 commits into
mainfrom
refactor-register-binding

Conversation

@bruno-f-cruz

@bruno-f-cruz bruno-f-cruz commented Jul 26, 2026

Copy link
Copy Markdown
Member

Closes #10 (register-binding half). Replaces the address-keyed REGISTER_MAP with a name-addressable register namespace that works for both runtime-generated (create_device) and statically generated devices, and gives static devices real editor autocomplete and payload-aware types. It also partially addresses a previous concern in #5 (comment) by making registers discoverable from the device itself.

What this adds

A device exposes its registers by name through device.registers, on the class or an instance:

Behavior.registers.AnalogData          # -> type[AnalogData], by name
Behavior.registers.WhoAmI              # common Harp registers, merged in automatically
Behavior.registers.by_address[44]      # {address: register_class} when you need the map
Behavior.registers.by_name["AnalogData"]   # {name: register_class}
  • Single source of truth. A device declares its own registers once, as assignments on a CoreRegisters subclass, and names that class as Device's type parameter. Subclassing CoreRegisters is what merges in the common Harp registers, through ordinary inheritance (the device's register wins on a name clash).
  • RegisterMap. Attribute access, by_name / by_address views, iteration, in, len, and __dir__ for REPL discovery. It builds those maps by introspecting the class for register attributes — which is why members are assignments (AnalogData = AnalogData) and not AnalogData: type[AnalogData] annotations: a bare annotation carries no runtime value. Static typing is unaffected; each member still infers as type[AnalogData]. Undeclared attribute access falls back to __getattr__ typed type[RegisterBase[Any]], so a register that only exists at runtime still type-checks.
  • REGISTER_MAP is gone. No address dict to hand-build, no **_CORE_REGISTER_MAP spread. Address lookups move to device.registers.by_address.

Static type hints

CoreRegisters is a typed namespace for the common Harp registers. A statically generated device subclasses it, adds its own registers, and passes the result as Device's type parameter:

from harp.device import CoreRegisters, Device


class BehaviorRegisters(CoreRegisters):
    DigitalInputs = DigitalInputs
    AnalogData = AnalogData
    # ... one line per device register ...


class Behavior(Device[BehaviorRegisters]):
    __whoami__ = 1216
    registers = BehaviorRegisters()

With this, device.registers.AnalogData autocompletes and read / write infer the payload type.

Device is generic over its register-namespace type precisely so this needs no reportIncompatibleVariableOverride suppression: a mutable attribute is invariant under override, so re-annotating registers in the subclass would be an error, whereas specializing a type parameter is not an override at all. It also means a type checker verifies the assigned instance matches the parameter, so the two can't drift. The parameter has a PEP 696 default (CoreRegisters), so a bare Device still means Device[CoreRegisters] — that default is why harp-device now depends on typing-extensions (typing.TypeVar only grew defaults in 3.13).

Runtime devices (create_device) get the same device.registers surface for free — without static autocomplete, since the names come from the schema at runtime and resolve as type[RegisterBase[Any]].

What downstream code generators must emit

A statically generated device package emits, per device:

  1. Each register as a RegisterBase subclass (unchanged), at module scope — the namespace's Name = Name assignments resolve their right-hand side through module globals.
  2. A namespace subclassing CoreRegisters, one Name = Name assignment per device register. Do not include the common Harp registers; they come from the base.
  3. A Device[<Namespace>] subclass setting only __whoami__ (when known) and registers = <Namespace>().

The reference shape is tests/device/expected_device.py and the hand-written docs/examples/static_device/static_device.py. example.py is a runnable tour of all four cases (root, static, runtime, data loading).

Generators must not: emit REGISTER_MAP, spread the core registers into the namespace, declare members as Name: type[Name] annotations, or re-annotate registers instead of passing the namespace as the type parameter.

Dataset reader

harp-data's DatasetReader is generic over the same namespace type and surfaces it via reader.registers (e.g. reader.registers.by_address), so demultiplexed files can be matched to registers by name or address without a separate map.

Narrow the Any-typed inputs of format_bulk / to_buffer / to_file to
PayloadBase | ArrayLike, ArrayLike | None, and MessageType | ArrayLike.
Give Device a base REGISTER_MAP ClassVar default ({}) that generated
subclasses override, and tidy the create_device docstring.
Add a "Generating a Device from a Schema" example walking through
`create_device`, including a win/loss discussion of runtime generation
vs. pre-generated device packages. Wire it into the examples index and
nav, surface `create_device`/`parse_device_schema`/`ConverterContext` in
the device API reference, and add a Quickstart to the root README.
@bruno-f-cruz
bruno-f-cruz force-pushed the refactor-register-binding branch from a31e01e to 7261bbf Compare July 27, 2026 02:13
@bruno-f-cruz
bruno-f-cruz requested a review from glopesdev August 2, 2026 07:12
_Register = type[RegisterBase[Any]]


class RegisterNamespace:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename to RegisterMap?

precise types.
"""

def __init__(self, registers: Iterable[_Register]) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be possible to make this take no argument other than self, and extract all available registers by class introspection.

)


class CoreRegistersNamespace(RegisterNamespace):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe rename to CoreRegisters?

Comment on lines +127 to +132
merged: dict[int, type[RegisterBase[Any]]] = dict(cls.registers.by_address)
for register in cls.__dict__.get("__REGISTERS__", ()):
merged[register.address] = register
# ``type.__setattr__`` (not ``cls.registers = ...``) keeps pyright treating
# ``registers`` as read-only; it shadows the base descriptor on the subclass.
type.__setattr__(cls, "registers", CoreRegistersNamespace(merged.values()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we redesign the register map to work with class introspection we should not need the device class to emit __REGISTERS__.

Comment on lines +3 to +4
TODO: hand-maintained for now. Auto-generating it from the upstream
``harp-tech/protocol`` JSON schema is deferred until that schema stabilises.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I would defer this until we have a unified C# + Python repo.


# -- enums ------------------------------------------------------------
def _build_enums(self) -> dict[str, Any]:
# Enum names and members are kept verbatim from the yml.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to convert them to SCREAMING_SNAKE to align with the typed generated code.

return _NO_DEFAULT # custom domain interfaceType: no numeric default

# -- fields -----------------------------------------------------------
def _build_field(self, key: str, member: PayloadMember, reg: Register) -> tuple[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to convert the names of fields to camelCase to align with the typed interface.

def create_registers(
source: Union[str, DeviceModel, Registers],
*,
converters: Optional[Mapping[str, ConverterValue]] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to pass a mapping to resolve custom converters, this is runtime emission after all.

@bruno-f-cruz

Copy link
Copy Markdown
Member Author

Closing in favor of #17

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add device-driven reader factory for harp.data

2 participants