Parameterize HarpMessage by its payload type - #30
Merged
Conversation
HarpMessage is now generic over the type of its payload, and the ParsedHarpMessage subclass is removed. A message read from the wire is a HarpMessage[Any] and decoding it with a register yields a HarpMessage[P], so read, write and subscribe all deliver one type carrying both the frame and the decoded value. payload is now that decoded value, replacing parsed, and the byte view it displaces becomes raw_payload. Reading payload before a register has decoded the message raises rather than falling back to the bytes, and has_payload reports which state a message is in. with_payload attaches a decoded value to a copy, so decoding never mutates a frame the dispatch loop has already handed elsewhere.
bruno-f-cruz
requested changes
Aug 19, 2026
The undecoded payload marker is a typing_extensions Sentinel rather than a bare object, so _payload is typed as P | _UNDECODED and narrows on the identity check instead of being hidden behind Any. payload_bytes replaces raw_payload, pairing with the existing bytes property that returns the whole frame. typing-extensions rises to 4.14, the version that introduces Sentinel, which three modules already import under the previous lower bound.
HarpMessage.decode takes anything satisfying the new PayloadDecoder protocol, which every register does, and returns a copy carrying the decoded payload. The payload is derived from the frame in the same call, so the two cannot disagree. Decoding checks the payload type and the byte count, which parse does not, so a U32 frame read through a U16 register and a 4-byte payload read through a scalar register both raise instead of returning the leading bytes. The address is not checked, so a frame can be decoded by any register describing the same layout. The protocol is structural, keeping registers out of the message imports.
bruno-f-cruz
self-requested a review
August 19, 2026 20:19
bruno-f-cruz
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HarpMessageis now generic over its payload type andParsedHarpMessageis retired. A frame off the wire is aHarpMessage[Any], and decoding it with a register yields aHarpMessage[P], soread,writeandsubscribeall deliver one type carrying the frame and the decoded value together. That is what keeps the timestamp, the message type and the error flag reachable from whatever a read returns, instead of having to be recovered separately.payload names the typed contract
payloadis now the decoded value andpayload_bytesis the frame bytes, inverting the previous split wherepayloadwas the bytes andparsedwas the value.Two things support reading payload as the typed contract rather than the encoding.
Bonsai.Harpalready uses it that way through itsGetPayload()family, and Harp's own schema usespayloadSpecandpayloadMemberto describe a structured contract rather than bytes.parsednamed how the value was obtained rather than what it is.payload_bytesalso pairs with the existingbytesproperty, whole frame against the payload portion of it, which the alternative did not.One generic class rather than a subclass
Python generics are erased, so
HarpMessage[Any]expresses the untyped case on the same class and no second type is needed. A subclass would have needed a name for something with no role in the protocol, since a typed message is not specifically a reply and not specifically an event.The cost this accepts is that
HarpMessage[Any].payloadisAny, so an unsafe access on the raw path fromparseorsubscribe_allis not caught statically. Readingpayloadbefore a register has decoded a message raises rather than falling back to the bytes, andhas_payloadreports which state a message is in, so an explicit check is available where the static type cannot help.Decoding is bound to the message it came from
The new
msg.decode(register)method is the only way to attach a typed payload. It derives the payload from the frame in the same call, so the two cannot disagree. Decoding also checks whatparsedoes not. AU32frame read through aU16register, and a four-byte payload read through a scalar register, both raise instead of silently returning the leading bytes. The address is deliberately not checked, since the payload type and byte count decide whether these bytes can be read as this payload at all, while the address says which register the device meant. That keeps a frame decodable by any register describing the same layout.This also closes a hole that was open in two places.
ParsedHarpMessagecould be constructed with anyparsedvalue, andParsedHarpMessage.from_message(msg, parsed)would wrap any message with any value, so nothing checked that a payload came from the bytes beside it.Note
typing-extensionsrises to>=4.14sinceSentinelarrives in 4.14 and three modules onmainalready import it.