Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,54 @@ API changes can land even when the minor version is unchanged.

## [Unreleased]

### Added
- **A protocol is described, not performed** (`communication/protocol.py`). A
`Frame` turns named values into the bytes to write and the bytes read back into
named values; it owns no port and stores nothing of what happened. `TextFrame`
states a `str.format` template and the regular expression that reads it back;
`BinaryFrame` states one struct format, whose constants are written on the way
out and *required* on the way in. A `Command` pairs two frames, a
`CommandDictionary` holds a device's commands and reads them from JSON. Nothing
is inferred anywhere: a byte order must be explicit, and so must both notations
of a text line.
- Both directions come out of the same objects. A driver writes the request and
reads the reply; a debug port reads the request and writes the reply. That is
what the old `DataCommand` needed a second encoder and decoder for, and those
could -- and did -- drift apart.
- `CommandDictionary.usage()` prints every command, its arguments and what it
answers, with types read off the description rather than a comment.
- `CommandDictionary.validate()` refuses a description that is wrong about
itself: a template and an expression that describe different lines, a struct
that packs a different number of values than it names, a command whose request
another command answers to first. It writes each command out with specimen
values made from the declared types and reads it straight back.
- **`ProtocolDebugPort`** (`communication/debugport.py`), a debug port with no code
of its own: hand it a `CommandDictionary` and it stands in for the instrument.
Whatever a request carries is remembered by name and whatever a reply carries is
answered from that memory. A command may add a `"sets"` clause for a state change
its bytes cannot express -- `HOME` carries nothing and still moves a stage.
- **`PhysicalDevice.protocol`** (default `None`) and **`PhysicalDevice.performTransaction()`**,
which performs one command of that protocol under the port's `transactionLock`,
reading by `readLength` for a binary reply and up to the terminator for a line.
A driver that sets `protocol` needs no send-and-receive code of its own.

### Changed
- **`SutterDevice` speaks through a description.** Its protocol is data, it sends
through `performTransaction`, and it no longer checks acknowledgements by hand:
the description says `MOVE`, `HOME` and `WORK` answer `b"\r"`. Its nested
`DebugSerialPort` is gone; `serialNumber="debug"` now gets a `ProtocolDebugPort`.
Callers building the debug port themselves should use
`ProtocolDebugPort(SutterDevice.protocol)`.

### Removed
- **`PhysicalDevice.sendCommand()`**. It looked a command up in `self.commands`,
sent it through `self.port` and handed the `Command` object back so a caller
could read the reply off it -- the pattern the description above exists to
replace. No driver in the library called it; the two tests that did now send
through the `Command` itself, which is all it ever did. Use
`performTransaction()` with a `protocol`, or a `Command`'s own `send()` while a
device still carries a `commands` dict.

## [2.1.0] - 2026-08-04

### Added
Expand Down
82 changes: 81 additions & 1 deletion hardwarelibrary/communication/debugport.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,84 @@ def process_command(self, name, params, endPointIndex):
- bytes/str: written to the output buffer as-is
- None: no response is sent
"""
raise NotImplementedError("Subclasses must implement process_command")
raise NotImplementedError("Subclasses must implement process_command")

class ProtocolDebugPort(DebugPort):
"""An instrument stood in for entirely by its own protocol description.

Where TableDrivenDebugPort needs a subclass with a process_command full of
branches, this needs nothing at all: hand it a CommandDictionary and it
answers every command that dictionary describes. Two rules do the whole job,
and both fall out of the fact that a command's two halves name their values
the same way.

- whatever a request carries is remembered, by name. MOVE arrives with x, y
and z, so x, y and z are what the instrument now holds.
- whatever a reply carries is answered from that memory. GET_POSITION asks
for x, y and z, and gets back what MOVE left there.

A value never set reads as 0, which is the state of an instrument that has
just been switched on. A reply that cannot pack a 0 -- one carrying text, say
-- will say so rather than invent something.

The one thing no description of bytes can express is a command that changes
the instrument without carrying anything: HOME takes no arguments and yet
moves the stage to its origin. A command may therefore state a "sets" clause,
which is applied on arrival exactly as a request's own values are. That
clause is the only part of a description that talks about the instrument
rather than the wire, and a driver never reads it.

Recognising a request is the dictionary's work, not this class's: recognize()
asks each command in turn, and the header constants settle it. So there is no
second table of prefixes to keep in step with the driver.
"""

def __init__(self, protocol, delay=0, numberOfEndPoints=1):
"""Stand in for the instrument a description describes.

Args:
protocol: the CommandDictionary to answer from
delay: seconds of jitter to add to a read, as DebugPort applies it
numberOfEndPoints: how many endpoints to pretend to have
"""
super().__init__(delay=delay, numberOfEndPoints=numberOfEndPoints)
self.protocol = protocol
self.values = {}

def processInputBuffers(self, endPointIndex):
"""Answer whatever complete request has arrived.

Args:
endPointIndex: which endpoint was written to

Raises:
RequestDidNotMatch: when the bytes are no command of this protocol,
which means whatever wrote them is at fault -- a debug port that
quietly dropped them would hide the bug it exists to find.
"""
received = bytes(self.inputBuffers[endPointIndex])
if len(received) == 0:
return

command, arguments = self.protocol.recognize(received)
self.inputBuffers[endPointIndex] = bytearray()

self.values.update(arguments)
self.values.update(command.sets)
if command.expectsReply:
self.writeToOutputBuffer(command.encodeReply(**self.answerFor(command)),
endPointIndex)

def answerFor(self, command) -> dict:
"""The values to answer one command with, taken from what is remembered.

Args:
command: the command that was recognized

Returns:
{field name: value} for every value its reply carries, 0 for one that
was never set. Empty for a reply that is nothing but a fixed
acknowledgement, which the description supplies on its own.
"""
return {name: self.values.get(name, 0)
for name, _ in command.reply.parameters}
Loading