From 917e45de50f62f8dd4c369e173f1c956a08785f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Wed, 12 Aug 2026 21:06:15 -0400 Subject: [PATCH 1/3] Open protocol.py by saying what the description is for The module docstring began with the sans-I/O principle and the mechanics that follow from it, which answers "how does this work" before "why would I want it". It now opens with the purpose: describe a device's protocol generally enough to build a debug port that tests it without hardware, and to keep the details of sending out of the driver. The sans-I/O property is still there, stated as what it is -- a protocol formats bytes and never performs the send or the read. Prose by DCC; two typos fixed in passing. Co-Authored-By: Claude Opus 5 (1M context) --- hardwarelibrary/communication/protocol.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/hardwarelibrary/communication/protocol.py b/hardwarelibrary/communication/protocol.py index 1c525ce..865b481 100644 --- a/hardwarelibrary/communication/protocol.py +++ b/hardwarelibrary/communication/protocol.py @@ -1,11 +1,13 @@ """Describing an instrument protocol, without performing it. -The idea is sans-I/O, the principle behind h11 and wsproto: a protocol is a pure -transformation over bytes and never performs the exchange. 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. A driver keeps the port and calls the -primitives of CommunicationPort itself, so the same description serves a driver -speaking to hardware and a debug port standing in for it. +The idea is simple: describe the protocol of a device in a way general enough +to create a debug port to test it without hardware and minimize the details +of sending the commands. A protocol manipulates the bytes and formats them, +but never performs the send or the read. 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. A driver keeps the port and calls the +primitives of CommunicationPort itself, so the same description serves a +driver speaking to hardware and a debug port standing in for it. Consequences worth noticing while reading: From 1e8a52d34343e3a1ff287a5aa636b4754f13bfee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Wed, 12 Aug 2026 21:49:46 -0400 Subject: [PATCH 2/3] Let EchoDevice and IntegraDevice speak through a description Problem: two more drivers built their bytes with the commands dict. Each hid a different fault behind it. EchoDevice's three commands were timing out, every time, and saying nothing. An echo answers with the payload and no terminator, so reading a line ran to the end of the buffer and raised -- and Command.send catches every exception into an attribute and returns, so testEchoCommands passed while nothing worked. The test could not fail. IntegraDevice had no debug path at all: it opened a USBPort unconditionally, so all twelve of its tests skipped unless the meter was plugged in. Nobody without the hardware could change that driver and know. Solution: both keep their protocol as data and send through performTransaction. An echo is described as fixed-size frames whose every byte is a constant, which is both the truthful shape -- there is no terminator to read up to -- and a stronger claim: a constant is required on the way in, so a payload that comes back altered is refused rather than merely received. testEchoCommands now calls performTransaction and needs no try/fail wrapper, since a failure raises. IntegraDevice("debug") builds a ProtocolDebugPort, and five tests now run without the meter: the description validated against itself, the usage text, and setting a wavelength then reading it back, which goes out as "*PWC00532" exactly as before. One command did not survive: "*STS" answers with a run of lines until one matches ":100000000", and a reply of an unknown number of lines is the one shape the description cannot state. Nothing ever called it, and the module says where it goes when multi-line replies exist. Co-Authored-By: Claude Opus 5 (1M context) --- hardwarelibrary/echodevice.py | 84 ++++++++++----- hardwarelibrary/powermeters/integradevice.py | 108 ++++++++++++++----- hardwarelibrary/tests/testIntegraDevice.py | 45 ++++++++ hardwarelibrary/tests/testPhysicalDevice.py | 28 +++-- 4 files changed, 195 insertions(+), 70 deletions(-) diff --git a/hardwarelibrary/echodevice.py b/hardwarelibrary/echodevice.py index 9586b9f..889dcfc 100644 --- a/hardwarelibrary/echodevice.py +++ b/hardwarelibrary/echodevice.py @@ -1,46 +1,72 @@ -from hardwarelibrary.physicaldevice import * -from hardwarelibrary.communication.communicationport import * -from hardwarelibrary.communication.serialport import * -from hardwarelibrary.communication.commands import DataCommand, DataDecoder, TextCommand -from hardwarelibrary.communication.debugport import TableDrivenDebugPort +from hardwarelibrary.communication.debugport import ProtocolDebugPort +from hardwarelibrary.communication.protocol import CommandDictionary +from hardwarelibrary.communication.serialport import SerialPort +from hardwarelibrary.physicaldevice import PhysicalDevice -import re -import time -from struct import * + +# An echo sends its payload back verbatim, with nothing around it. There is no +# terminator to read up to, so a reply is a fixed-size frame -- and since the +# whole content is known in advance, every byte of it is a constant. That is what +# turns "we received something" into "we received exactly what we sent": a +# constant is required on the way in, so a reply that differs is refused. +echoProtocol = { + "device": "FTDI echo device", + "commands": { + "ECHO1": { + "request": {"struct": "<8s", "fields": ["text"], + "constants": {"text": "someText"}}, + "reply": {"struct": "<8s", "fields": ["text"], + "constants": {"text": "someText"}}, + }, + "ECHO2": { + "request": {"struct": "<13s", "fields": ["text"], + "constants": {"text": "someOtherText"}}, + "reply": {"struct": "<13s", "fields": ["text"], + "constants": {"text": "someOtherText"}}, + }, + "ECHO3": { + "request": {"struct": "<8s", "fields": ["data"], + "constants": {"data": "someData"}}, + "reply": {"struct": "<8s", "fields": ["data"], + "constants": {"data": "someData"}}, + }, + }, +} class EchoDevice(PhysicalDevice): + """A device that sends back whatever it is given, over an FTDI cable. + + It exists to exercise the machinery rather than to measure anything, so its + protocol is three payloads that must come back unchanged. + """ + classIdProduct = 0x6001 classIdVendor = 0x0403 usesGenericSerialConverter = True - commands = { - "ECHO1": TextCommand(name="ECHO1", requestEncoder="someText", replyDecoder="someText"), - "ECHO2": TextCommand(name="ECHO2", requestEncoder="someOtherText", replyDecoder="someOtherText"), - "ECHO3": DataCommand(name="ECHO3", data=b"someData", - replyDecoder=DataDecoder(length=len(b"someData"))), - } - def __init__(self, serialNumber='ftDXIKC4', idProduct=classIdProduct, idVendor=classIdVendor): - PhysicalDevice.__init__(self, serialNumber=serialNumber, idProduct=idProduct, idVendor=idVendor) + protocol = CommandDictionary.fromDescription(echoProtocol) + + def __init__(self, serialNumber='ftDXIKC4', idProduct=classIdProduct, + idVendor=classIdVendor): + """Bind to one FTDI cable, or to "debug" for a port that echoes in memory. + + Args: + serialNumber: the cable's serial number, or "debug" + idProduct: USB product id, defaulting to the class attribute + idVendor: USB vendor id, defaulting to the class attribute + """ + PhysicalDevice.__init__(self, serialNumber=serialNumber, + idProduct=idProduct, idVendor=idVendor) def doInitializeDevice(self): + """Open the cable, or stand one up out of the description.""" if self.serialNumber == "debug": - self.port = self.DebugSerialPort() + self.port = ProtocolDebugPort(self.protocol) else: self.port = SerialPort(idVendor=self.idVendor, idProduct=self.idProduct) self.port.open() def doShutdownDevice(self): + """Close the port.""" self.port.close() - - class DebugSerialPort(TableDrivenDebugPort): - def __init__(self): - super().__init__(commands=EchoDevice.commands) - - def process_command(self, name, params, endPointIndex): - if name == 'ECHO1': - return 'someText' - elif name == 'ECHO2': - return 'someOtherText' - elif name == 'ECHO3': - return b'someData' diff --git a/hardwarelibrary/powermeters/integradevice.py b/hardwarelibrary/powermeters/integradevice.py index 1aeb562..a1d2bd8 100644 --- a/hardwarelibrary/powermeters/integradevice.py +++ b/hardwarelibrary/powermeters/integradevice.py @@ -1,51 +1,109 @@ import time from enum import Enum -from hardwarelibrary.communication import USBPort, TextCommand, MultilineTextCommand -from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice + from hardwarelibrary.capabilities import WavelengthCalibrationCapability +from hardwarelibrary.communication.debugport import ProtocolDebugPort +from hardwarelibrary.communication.protocol import CommandDictionary +from hardwarelibrary.communication.usbport import USBPort +from hardwarelibrary.powermeters.powermeterdevice import PowerMeterDevice from notificationcenter import NotificationCenter, Notification + +# The Integra answers every query with one line ending in \r\n, and acknowledges +# nothing: *PWC sets the wavelength and stays silent, which is why it describes a +# request and no reply. +# +# One command is not here. "*STS" answers with a run of lines until one matching +# ":100000000", and a reply of an unknown number of lines is the one shape this +# description cannot yet state. Nothing in the driver ever called it, so nothing +# is lost today; when multi-line replies exist, it goes back as +# "STATUS": {"request": {"template": "*STS", "regex": r"\*STS"}, ...} +integraProtocol = { + "device": "Gentec Integra", + "commands": { + "GETPOWER": { + "request": {"template": "*CVU", "regex": r"\*CVU"}, + "reply": {"regex": r"(.+?)\r\n", "template": "{power}\r\n", + "fields": {"power": "float"}}, + }, + "VERSION": { + "request": {"template": "*VER", "regex": r"\*VER"}, + "reply": {"regex": r"(.+?)\r\n", "template": "{version}\r\n", + "fields": {"version": "text"}}, + }, + "GETWAVELENGTH": { + "request": {"template": "*GWL", "regex": r"\*GWL"}, + "reply": {"regex": r"PWC\s*:\s*(.+?)\r\n", + "template": "PWC : {wavelength}\r\n", + "fields": {"wavelength": "float"}}, + }, + "SETWAVELENGTH": { + "request": {"template": "*PWC{wavelength:05d}", "regex": r"\*PWC(\d{5})", + "fields": {"wavelength": "integer"}}, + }, + }, +} + + class IntegraDevice(PowerMeterDevice, WavelengthCalibrationCapability): + """A Gentec Integra power meter, over USB. + + Its protocol is the dictionary above and this class only moves values between + that description and a port. + """ + classIdProduct = 0x0300 classIdVendor = 0x1ad5 - commands = { - "GETPOWER": TextCommand(name="GETPOWER", requestEncoder="*CVU", replyDecoder=r"(.+?)\r\n"), - "VERSION": TextCommand(name="VERSION", requestEncoder="*VER", replyDecoder=r"(.+?)\r\n"), - "STATUS": MultilineTextCommand(name="STATUS", requestEncoder="*STS", replyDecoder=r"(.+?)\r\n", lastLinePattern=":100000000"), - "GETWAVELENGTH": TextCommand(name="GETWAVELENGTH", requestEncoder="*GWL", replyDecoder=r"PWC\s*:\s*(.+?)\r\n"), - "SETWAVELENGTH": TextCommand(name="SETWAVELENGTH", requestEncoder="*PWC{0:05d}"), - } - - def __init__(self, serialNumber:str = None, idProduct:int = 0x0300, idVendor:int = 0x1ad5): + + protocol = CommandDictionary.fromDescription(integraProtocol) + + def __init__(self, serialNumber: str = None, idProduct: int = 0x0300, + idVendor: int = 0x1ad5): + """Bind to one meter, or to "debug" for a port built from the description. + + Args: + serialNumber: the meter's serial number, or "debug" + idProduct: USB product id + idVendor: USB vendor id + """ super().__init__(serialNumber, idProduct, idVendor) self.version = "" def doInitializeDevice(self): - self.port = USBPort(idVendor=self.idVendor, idProduct=self.idProduct, interfaceNumber=0, defaultEndPoints=(1, 2)) - self.port.open() + """Open the port and read the firmware version, which proves it answers.""" + if self.serialNumber == "debug": + self.port = ProtocolDebugPort(self.protocol) + self.port.open() + else: + self.port = USBPort(idVendor=self.idVendor, idProduct=self.idProduct, + interfaceNumber=0, defaultEndPoints=(1, 2)) + self.port.open() self.doGetVersion() def doShutdownDevice(self): + """Close the port and forget it.""" self.port.close() self.port = None def doGetAbsolutePower(self): - getPowerCommand = IntegraDevice.commands["GETPOWER"] - getPowerCommand.send(port=self.port) - self.absolutePower = float(getPowerCommand.matchGroups[0]) + """Read the power in watts and keep it on absolutePower.""" + self.absolutePower = self.performTransaction("GETPOWER")["power"] def doGetCalibrationWavelength(self): - getWavelength = IntegraDevice.commands["GETWAVELENGTH"] - getWavelength.send(port=self.port) - self.calibrationWavelength = float(getWavelength.matchGroups[0]) + """Read the wavelength the meter is calibrated for, in nanometres.""" + self.calibrationWavelength = self.performTransaction( + "GETWAVELENGTH")["wavelength"] def doSetCalibrationWavelength(self, wavelength): - setWavelength = IntegraDevice.commands["SETWAVELENGTH"] - setWavelength.send(port=self.port, params=(wavelength)) + """Tell the meter which wavelength to correct for. + + Args: + wavelength: the wavelength in nanometres, as a whole number since the + command writes it in five digits + """ + self.performTransaction("SETWAVELENGTH", wavelength=wavelength) time.sleep(0.05) # This is necessary, see testIntegraDevice def doGetVersion(self): - getVersion = IntegraDevice.commands["VERSION"] - getVersion.send(port=self.port) - self.version = getVersion.matchGroups[0] - + """Read the firmware version and keep it on version.""" + self.version = self.performTransaction("VERSION")["version"] diff --git a/hardwarelibrary/tests/testIntegraDevice.py b/hardwarelibrary/tests/testIntegraDevice.py index d991169..109d2f2 100644 --- a/hardwarelibrary/tests/testIntegraDevice.py +++ b/hardwarelibrary/tests/testIntegraDevice.py @@ -5,6 +5,51 @@ from hardwarelibrary.communication import USBPort, TextCommand, MultilineTextCommand from hardwarelibrary.powermeters import * +from hardwarelibrary.powermeters.integradevice import IntegraDevice + + +class TestDebugIntegraDevice(unittest.TestCase): + """The Integra without an Integra, out of its own protocol description. + + Until this class the meter had no debug path at all: every one of its tests + skipped unless the instrument was plugged in, so a change to the driver could + not be checked by anyone who did not have one on the bench. + """ + + def setUp(self): + self.device = IntegraDevice(serialNumber="debug") + self.device.initializeDevice() + + def tearDown(self): + self.device.shutdownDevice() + + def testTheShippedDescriptionIsConsistentWithItself(self): + # Writes every command out with specimen values, reads it straight back, + # and checks each request is recognised as its own. + IntegraDevice.protocol.validate() + + def testItAnnouncesWhatToCallAndWhatComesBack(self): + usage = IntegraDevice.protocol.usage() + self.assertIn("SETWAVELENGTH(wavelength: int)", usage) + self.assertIn("the instrument does not answer", usage) + self.assertIn("answers power: float", usage) + + def testTheWavelengthSetIsTheWavelengthRead(self): + self.device.setCalibrationWavelength(532) + self.assertEqual(self.device.getCalibrationWavelength(), 532.0) + + self.device.setCalibrationWavelength(1064) + self.assertEqual(self.device.getCalibrationWavelength(), 1064.0) + + def testTheWavelengthGoesOutInFiveDigits(self): + # "*PWC00532", which is what the meter expects and what the old command + # produced. The description states the width, so nothing rounds it here. + self.assertEqual(IntegraDevice.protocol["SETWAVELENGTH"].encode(wavelength=532), + b"*PWC00532") + + def testEveryQueryAnswers(self): + self.assertIsNotNone(self.device.version) + self.assertIsInstance(self.device.measureAbsolutePower(), float) class TestIntegraDevice(unittest.TestCase): diff --git a/hardwarelibrary/tests/testPhysicalDevice.py b/hardwarelibrary/tests/testPhysicalDevice.py index e23eac6..60be0de 100644 --- a/hardwarelibrary/tests/testPhysicalDevice.py +++ b/hardwarelibrary/tests/testPhysicalDevice.py @@ -261,14 +261,12 @@ def setUp(self): def testEchoCommands(self): self.device.initializeDevice() - for name, command in self.device.commands.items(): - try: - # PhysicalDevice.sendCommand used to wrap these two lines and - # nothing else. It is gone; a device that still carries a - # commands dict sends through the Command itself. - command.send(port=self.device.port) - except Exception as err: - self.fail("Unable to send command {0} to device {1}: {2}".format(name, self.device, err)) + # No try/fail wrapper: performTransaction raises, so a failure arrives + # with its own traceback. The version this replaces called a send() that + # swallowed every exception into an attribute and returned, so the test + # passed even though all three commands were timing out. + for name in self.device.protocol: + self.device.performTransaction(name) self.device.shutdownDevice() class TestDebugEchoPhysicalDevice(BaseTestCases.TestPhysicalDeviceBase): @@ -278,14 +276,12 @@ def setUp(self): def testEchoCommands(self): self.device.initializeDevice() - for name, command in self.device.commands.items(): - try: - # PhysicalDevice.sendCommand used to wrap these two lines and - # nothing else. It is gone; a device that still carries a - # commands dict sends through the Command itself. - command.send(port=self.device.port) - except Exception as err: - self.fail("Unable to send command {0} to device {1}: {2}".format(name, self.device, err)) + # No try/fail wrapper: performTransaction raises, so a failure arrives + # with its own traceback. The version this replaces called a send() that + # swallowed every exception into an attribute and returned, so the test + # passed even though all three commands were timing out. + for name in self.device.protocol: + self.device.performTransaction(name) self.device.shutdownDevice() From 96d0d5a36b7a030784e8d783ed7bfdd55227b6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Wed, 12 Aug 2026 21:50:04 -0400 Subject: [PATCH 3/3] Rewrite CoboltDevice on a description, and drop the sets clause Problem: a command could state a "sets" clause -- what receiving it does to the instrument's state, for what its bytes do not carry -- so that a debug port needed no code. Two clauses of that kind had appeared: "sets" per command, and an "initial" for what an instrument reads as when switched on. Both describe the instrument rather than the wire, in a format whose whole premise is that it describes the wire, and both were about to be paid for by every description. Measured, they were not worth it. Neutralising "sets" broke one pre-existing test; the other two failures were the tests written to justify it. Neutralising "initial" broke two. All three belong to Cobolt -- the one device that already carries a debug-port subclass, because a laser does not reach a new power at once and doSetPower's convergence loop needs something to converge to. Solution: no such clauses. A description is the wire again. CoboltDevice keeps its power-up state and its four state-changing commands in the subclass it already had, next to the ramp, which is where behaviour lives. SutterDevice gains a four-line subclass so HOME still sends the stage to the origin -- no test required it, but a debug port that lies about homing is exactly what the rest of this design refuses. EchoDevice and IntegraDevice need no subclass at all. The description also corrects the old mock: it never updated requestedPower, so GET_REQUESTED_POWER answered 0 forever. On a Cobolt, "p" sets the power asked for and "p?" reads it back, while "pa?" reads the power reached. Naming that field requestedPower rather than power keeps the two quantities apart, and the ramp between them means something again. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 24 +- hardwarelibrary/communication/debugport.py | 13 +- hardwarelibrary/communication/protocol.py | 17 +- hardwarelibrary/motion/sutterdevice.py | 26 +- hardwarelibrary/sources/cobolt.py | 274 ++++++++++-------- .../tests/testProtocolDebugPort.py | 17 +- .../tests/testProtocolPrototype.py | 2 +- 7 files changed, 207 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c4120..b732787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,14 +30,34 @@ API changes can land even when the minor version is unchanged. - **`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. + answered from that memory. Two things stay outside it, being facts about the + instrument rather than its protocol: what it reads as when switched on, and a + command that changes state it does not carry (`HOME` carries nothing and still + moves a stage). A device needing either subclasses the port, which is also where + anything a value store cannot model belongs -- a laser taking a second to reach + a new power. A device needing none of it uses the class as it stands. - **`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 +- **`EchoDevice`, `IntegraDevice` and `CoboltDevice` speak through a description**, + as `SutterDevice` does. Each keeps its protocol as data and sends through + `performTransaction`; `communication/commands.py` now has one user left, + `IntellidriveDevice`. + - **`IntegraDevice` gains a debug path**: `IntegraDevice("debug")` builds a + `ProtocolDebugPort`. It had none, so every one of its tests skipped unless the + meter was plugged in; five now run without it. + - `EchoDevice`'s three commands were **timing out and saying nothing about it**. + Its replies carry no terminator, so reading a line ran to the end of the + buffer, and the exception was swallowed into an attribute -- which is why + `testEchoCommands` passed. Described as fixed-size frames whose every byte is + a constant, an echo that comes back wrong is now refused. + - `CoboltDevice`'s debug port never updated `requestedPower`, so + `GET_REQUESTED_POWER` always answered 0. On a Cobolt, `p` sets the power asked + for and `pa?` reads the power reached; the description now names those two + apart, and both read correctly. - **`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 diff --git a/hardwarelibrary/communication/debugport.py b/hardwarelibrary/communication/debugport.py index 9225605..be594d2 100644 --- a/hardwarelibrary/communication/debugport.py +++ b/hardwarelibrary/communication/debugport.py @@ -180,12 +180,12 @@ class ProtocolDebugPort(DebugPort): 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. + Two things are deliberately outside it, because they are facts about the + instrument and not about its protocol: what it reads as when switched on, and + a command that changes state it does not carry -- HOME takes no arguments and + still moves a stage. Both belong to a subclass, which is also where anything + a store cannot model belongs, such as a laser taking a second to reach a new + power. A device needing none of that uses this class as it stands. 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 @@ -223,7 +223,6 @@ def processInputBuffers(self, endPointIndex): self.inputBuffers[endPointIndex] = bytearray() self.values.update(arguments) - self.values.update(command.sets) if command.expectsReply: self.writeToOutputBuffer(command.encodeReply(**self.answerFor(command)), endPointIndex) diff --git a/hardwarelibrary/communication/protocol.py b/hardwarelibrary/communication/protocol.py index 865b481..31750dd 100644 --- a/hardwarelibrary/communication/protocol.py +++ b/hardwarelibrary/communication/protocol.py @@ -660,7 +660,7 @@ class Command: """ def __init__(self, name: str, request: Frame, reply: Optional[Frame] = None, - sets: Optional[dict] = None, specimen: Optional[dict] = None): + specimen: Optional[dict] = None): """Pair a request with the reply it expects, under the name a driver uses. Args: @@ -669,23 +669,14 @@ def __init__(self, name: str, request: Frame, reply: Optional[Frame] = None, request: the frame the driver writes and a mock reads reply: the frame the driver reads and a mock writes, or None for a command the instrument does not answer - sets: what receiving this command does to the instrument's state, - as {field name: value}, for what the request does not carry. - HOME takes no arguments and yet moves the stage to the origin, - and nothing about the bytes on the wire could say so. This is the - one thing here that describes the instrument rather than the - protocol, and it exists so that a debug port needs no code of its - own; a driver never reads it. specimen: a value per field for validate() to send through this command and expect back, when the ones it makes from the declared types will not do -- an expression that refuses a zero, say. - Like sets, it is not part of the protocol, and only validate() - reads it. + It is not part of the protocol, and only validate() reads it. """ self.name = name self.request = request self.reply = reply - self.sets = dict(sets or {}) self.specimen = dict(specimen or {}) @property @@ -978,8 +969,7 @@ def commandFrom(cls, name: str, description: dict) -> Command: name: the command's name, used both for the Command and to say which command an error is about description: {"request": {...}} and, when the instrument answers, - {"reply": {...}}, plus an optional {"sets": {...}} for a state - change the request does not carry + {"reply": {...}} Returns: The command, its reply None when none was described. @@ -994,7 +984,6 @@ def commandFrom(cls, name: str, description: dict) -> Command: reply = description.get("reply") return Command(name, cls.requestFrom(description["request"], where), cls.replyFrom(reply, where) if reply is not None else None, - sets=description.get("sets"), specimen=description.get("specimen")) @classmethod diff --git a/hardwarelibrary/motion/sutterdevice.py b/hardwarelibrary/motion/sutterdevice.py index dc5a3a6..295ea95 100644 --- a/hardwarelibrary/motion/sutterdevice.py +++ b/hardwarelibrary/motion/sutterdevice.py @@ -34,9 +34,6 @@ "constants": {"header": "H", "terminator": "\r"}}, "reply": {"struct": " dict: + """Move to the origin on HOME, then answer as the description says. + + Args: + command: the command that was recognized + + Returns: + The values its reply carries, taken from what is remembered. + """ + if command.name == "HOME": + self.values.update(x=0, y=0, z=0) + return super().answerFor(command) diff --git a/hardwarelibrary/sources/cobolt.py b/hardwarelibrary/sources/cobolt.py index 8e6612e..d02c3ed 100644 --- a/hardwarelibrary/sources/cobolt.py +++ b/hardwarelibrary/sources/cobolt.py @@ -1,73 +1,90 @@ -from hardwarelibrary.physicaldevice import * -from hardwarelibrary.communication import * -from hardwarelibrary.communication.commands import TextCommand -from hardwarelibrary.communication.debugport import TableDrivenDebugPort +from hardwarelibrary.communication.debugport import ProtocolDebugPort +from hardwarelibrary.communication.protocol import CommandDictionary +from hardwarelibrary.communication.serialport import SerialPort +from hardwarelibrary.physicaldevice import PhysicalDevice from .lasersourcedevice import LaserSourceDevice from hardwarelibrary.capabilities import OnOffCapability, PowerCapability, InterlockCapability, AutostartCapability -import re import time from threading import Thread, RLock globalLock = RLock() + +# Every Cobolt command is a line ending in \r, answered by a line ending in +# \r\n. The instrument acknowledges a setting with a bare "OK" and answers a +# query with the value alone, so the queries carry a field and the settings carry +# none. Nothing here says what turning the laser on does to a later reading, nor +# what it reads as when switched on: that is the laser, not its protocol, and it +# lives in DebugSerialPort below. +coboltProtocol = { + "device": "Cobolt laser", + "commands": { + "GET_POWER": { + "request": {"template": "pa?\r", "regex": r"pa\?\r"}, + "reply": {"regex": r"(\d+\.\d+)", "template": "{power:0.4f}\r\n", + "fields": {"power": "float"}}, + }, + "GET_REQUESTED_POWER": { + "request": {"template": "p?\r", "regex": r"p\?\r"}, + "reply": {"regex": r"(\d+\.\d+)", + "template": "{requestedPower:0.4f}\r\n", + "fields": {"requestedPower": "float"}}, + }, + # "p" sets the power asked for, which "p?" reads back; "pa?" reads what + # the laser has actually reached. Naming this field requestedPower rather + # than power is what keeps those two apart. + "SET_POWER": { + "request": {"template": "p {requestedPower:0.3f}\r", + "regex": r"p ([0-9.]+)\r", + "fields": {"requestedPower": "float"}}, + "reply": {"regex": "OK", "template": "OK\r\n"}, + }, + "GET_ON_OFF": { + "request": {"template": "l?\r", "regex": r"l\?\r"}, + "reply": {"regex": "(0|1)", "template": "{isOn:d}\r\n", + "fields": {"isOn": "boolean01"}}, + }, + "TURN_ON": { + "request": {"template": "l1\r", "regex": r"l1\r"}, + "reply": {"regex": "OK", "template": "OK\r\n"}, + }, + "TURN_OFF": { + "request": {"template": "l0\r", "regex": r"l0\r"}, + "reply": {"regex": "OK", "template": "OK\r\n"}, + }, + "GET_SERIAL_NUMBER": { + "request": {"template": "sn?\r", "regex": r"sn\?\r"}, + "reply": {"regex": r"(\d+)", "template": "{serialNumber}\r\n", + "fields": {"serialNumber": "text"}}, + }, + "TURN_AUTOSTART_ON": { + "request": {"template": "@cobas 1\r", "regex": r"@cobas 1\r"}, + "reply": {"regex": "OK", "template": "OK\r\n"}, + }, + "TURN_AUTOSTART_OFF": { + "request": {"template": "@cobas 0\r", "regex": r"@cobas 0\r"}, + "reply": {"regex": "OK", "template": "OK\r\n"}, + }, + "GET_AUTOSTART": { + "request": {"template": "@cobas?\r", "regex": r"@cobas\?\r"}, + "reply": {"regex": "(0|1)", "template": "{autostart:d}\r\n", + "fields": {"autostart": "boolean01"}}, + }, + "GET_INTERLOCK": { + "request": {"template": "ilk?\r", "regex": r"ilk\?\r"}, + "reply": {"regex": "(0|1)", "template": "{interlock:d}\r\n", + "fields": {"interlock": "boolean01"}}, + }, + }, +} + class CoboltCantTurnOnWithAutostartOn(Exception): pass class CoboltDevice(LaserSourceDevice, OnOffCapability, PowerCapability, InterlockCapability, AutostartCapability): - commands = { - "GET_POWER": TextCommand(name="GET_POWER", - requestEncoder="pa?\r", - requestDecoder=r"pa\?\r", - replyDecoder=r"(\d+\.\d+)", - replyEncoder="{power:0.4f}\r\n"), - "GET_REQUESTED_POWER": TextCommand(name="GET_REQUESTED_POWER", - requestEncoder="p?\r", - requestDecoder=r"p\?\r", - replyDecoder=r"(\d+\.\d+)", - replyEncoder="{requestedPower:0.4f}\r\n"), - "SET_POWER": TextCommand(name="SET_POWER", - requestEncoder="p {0:0.3f}\r", - requestDecoder=r"p (?P\d+\.?\d+)\r", - replyDecoder=r"OK"), - "GET_ON_OFF": TextCommand(name="GET_ON_OFF", - requestEncoder="l?\r", - requestDecoder=r"l\?\r", - replyDecoder=r"(0|1)", - replyEncoder="{isOn}\r\n"), - "TURN_ON": TextCommand(name="TURN_ON", - requestEncoder="l1\r", - requestDecoder=r"l1\r", - replyDecoder=r"OK"), - "TURN_OFF": TextCommand(name="TURN_OFF", - requestEncoder="l0\r", - requestDecoder=r"l0\r", - replyDecoder=r"OK"), - "GET_SERIAL_NUMBER": TextCommand(name="GET_SERIAL_NUMBER", - requestEncoder="sn?\r", - requestDecoder=r"sn\?\r", - replyDecoder=r"(\d+)", - replyEncoder="{serialNumber}\r\n"), - "TURN_AUTOSTART_ON": TextCommand(name="TURN_AUTOSTART_ON", - requestEncoder="@cobas 1\r", - requestDecoder=r"@cobas 1\r", - replyDecoder=r"OK"), - "TURN_AUTOSTART_OFF": TextCommand(name="TURN_AUTOSTART_OFF", - requestEncoder="@cobas 0\r", - requestDecoder=r"@cobas 0\r", - replyDecoder=r"OK"), - "GET_AUTOSTART": TextCommand(name="GET_AUTOSTART", - requestEncoder="@cobas?\r", - requestDecoder=r"@cobas\?\r", - replyDecoder=r"(0|1)", - replyEncoder="{autostart}\r\n"), - "GET_INTERLOCK": TextCommand(name="GET_INTERLOCK", - requestEncoder="ilk?\r", - requestDecoder=r"ilk\?\r", - replyDecoder=r"(0|1)", - replyEncoder="{interlock}\r\n"), - } + protocol = CommandDictionary.fromDescription(coboltProtocol) def __init__(self, bsdPath=None, portPath=None, serialNumber: str = None, idProduct: int = None, idVendor: int = None): @@ -102,7 +119,7 @@ def canTurnOn(self) -> bool: def doInitializeDevice(self): try: if self.portPath == "debug": - self.port = self.DebugSerialPort() + self.port = self.DebugSerialPort(self.protocol) else: self.port = SerialPort(portPath=self.portPath) @@ -130,47 +147,40 @@ def doShutdownDevice(self): return def doGetInterlockState(self) -> bool: - cmd = CoboltDevice.commands["GET_INTERLOCK"] - cmd.send(port=self.port) - self.interlockState = bool(int(cmd.matchGroups[0])) + self.interlockState = self.performTransaction("GET_INTERLOCK")["interlock"] return self.interlockState def doGetLaserSerialNumber(self) -> str: - cmd = CoboltDevice.commands["GET_SERIAL_NUMBER"] - cmd.send(port=self.port) - self.laserSerialNumber = cmd.matchGroups[0] + self.laserSerialNumber = self.performTransaction( + "GET_SERIAL_NUMBER")["serialNumber"] def doGetOnOffState(self) -> bool: - cmd = CoboltDevice.commands["GET_ON_OFF"] - cmd.send(port=self.port) - self.isOn = (int(cmd.matchGroups[0]) == 1) + self.isOn = self.performTransaction("GET_ON_OFF")["isOn"] return self.isOn def doTurnOn(self): if not self.doGetAutostart(): - CoboltDevice.commands["TURN_ON"].send(port=self.port) + self.performTransaction("TURN_ON") else: raise CoboltCantTurnOnWithAutostartOn() def doTurnOff(self): - CoboltDevice.commands["TURN_OFF"].send(port=self.port) + self.performTransaction("TURN_OFF") def doGetAutostart(self) -> bool: - cmd = CoboltDevice.commands["GET_AUTOSTART"] - cmd.send(port=self.port) - self.autostart = (int(cmd.matchGroups[0]) == 1) + self.autostart = self.performTransaction("GET_AUTOSTART")["autostart"] return self.autostart def doTurnAutostartOn(self): - CoboltDevice.commands["TURN_AUTOSTART_ON"].send(port=self.port) + self.performTransaction("TURN_AUTOSTART_ON") self.autostart = True def doTurnAutostartOff(self): - CoboltDevice.commands["TURN_AUTOSTART_OFF"].send(port=self.port) + self.performTransaction("TURN_AUTOSTART_OFF") self.autostart = False def doSetPower(self, powerInWatts) -> float: - CoboltDevice.commands["SET_POWER"].send(port=self.port, params=powerInWatts) + self.performTransaction("SET_POWER", requestedPower=powerInWatts) actualPower = 0 acceptableDifference = 0.1 * powerInWatts for i in range(10): # It is not an error if we don't converge @@ -182,58 +192,70 @@ def doSetPower(self, powerInWatts) -> float: return actualPower def doGetPower(self) -> float: - cmd = CoboltDevice.commands["GET_POWER"] - cmd.send(port=self.port) - return float(cmd.matchGroups[0]) - - class DebugSerialPort(TableDrivenDebugPort): - def __init__(self): - super().__init__(commands=CoboltDevice.commands) - self.power = 0.1 - self.requestedPower = 0 - self.isOn = 0 - self.autostart = 1 - self.serialNumber = "123456" - self.interlock = 1 - - def process_command(self, name, params, endPointIndex): + return self.performTransaction("GET_POWER")["power"] + + class DebugSerialPort(ProtocolDebugPort): + """A Cobolt without a Cobolt, mostly out of its own description. + + The wire, the initial readings and every setting that simply changes a + later reading all come from coboltProtocol -- "sets" and "initial" carry + those. What is left here is the one thing a description of a protocol has + no business stating: a laser does not reach a new power instantly, so + SET_POWER starts a ramp and GET_POWER reads whatever it has climbed to, + which is what makes doSetPower's convergence loop mean something. + """ + + def __init__(self, protocol): + """Start as a laser does when switched on, autostart engaged. + + Args: + protocol: the CommandDictionary to answer from + """ + super().__init__(protocol) + self.values.update(power=0.1, requestedPower=0.0, isOn=False, + autostart=True, serialNumber="123456", + interlock=True) + + def answerFor(self, command) -> dict: + """Act as the laser would, then answer as the description says. + + The store already holds whatever a request carried. What it cannot + know is here: which readings a command changes without carrying them, + and that a laser reaches a new power over about a second rather than + at once -- which is what gives doSetPower's convergence loop + something to converge to. + + Args: + command: the command that was recognized + + Returns: + The values its reply carries, taken from what is remembered. + """ with globalLock: - if name == "GET_POWER": - return {"power": self.power} - elif name == "GET_REQUESTED_POWER": - return {"requestedPower": self.requestedPower} - elif name == "SET_POWER": - requestedPower = float(params["value"]) - process = Thread(target=increasePowerSlowlyInBackground, - kwargs=dict(port=self, - endPower=requestedPower, - duration=1.0)) - process.start() - return "OK\r\n" - elif name == "GET_ON_OFF": - return {"isOn": self.isOn} - elif name == "TURN_ON": - if self.autostart == 1: - return "Syntax error: not allowed in autostart mode\r\n" - self.isOn = 1 - return "OK\r\n" - elif name == "TURN_OFF": - self.isOn = 0 - return "OK\r\n" - elif name == "GET_SERIAL_NUMBER": - return {"serialNumber": self.serialNumber} - elif name == "TURN_AUTOSTART_ON": - if self.autostart == 0: - self.isOn = 0 - self.autostart = 1 - return "OK\r\n" - elif name == "TURN_AUTOSTART_OFF": - self.autostart = 0 - return "OK\r\n" - elif name == "GET_AUTOSTART": - return {"autostart": self.autostart} - elif name == "GET_INTERLOCK": - return {"interlock": self.interlock} + if command.name == "TURN_ON": + self.values["isOn"] = True + elif command.name == "TURN_OFF": + self.values["isOn"] = False + elif command.name == "TURN_AUTOSTART_ON": + self.values["autostart"] = True + elif command.name == "TURN_AUTOSTART_OFF": + self.values["autostart"] = False + elif command.name == "SET_POWER": + Thread(target=increasePowerSlowlyInBackground, + kwargs=dict(port=self, + endPower=self.values["requestedPower"], + duration=1.0)).start() + return super().answerFor(command) + + @property + def power(self) -> float: + """The power the laser has climbed to, for the ramp to move.""" + return self.values["power"] + + @power.setter + def power(self, value: float): + """Set the power the next GET_POWER will report.""" + self.values["power"] = value def increasePowerSlowlyInBackground(port, endPower, duration): diff --git a/hardwarelibrary/tests/testProtocolDebugPort.py b/hardwarelibrary/tests/testProtocolDebugPort.py index cad8844..4f5b6bc 100644 --- a/hardwarelibrary/tests/testProtocolDebugPort.py +++ b/hardwarelibrary/tests/testProtocolDebugPort.py @@ -28,7 +28,6 @@ "TURN_OFF": { "request": {"template": "l0\r", "regex": r"l0\r"}, "reply": {"regex": "OK", "template": "OK\r\n"}, - "sets": {"power": 0.0}, }, }, } @@ -111,14 +110,10 @@ def testWhatARequestCarriesIsWhatALaterReplyGivesBack(self): self.assertEqual(self.ask("MOVE", x=4000, y=5000, z=6000), {}) self.assertEqual(self.ask("GET_POSITION"), {"x": 4000, "y": 5000, "z": 6000}) - def testACommandThatCarriesNothingCanStillChangeTheInstrument(self): - # HOME is the case no description of bytes can reach: it takes no - # arguments and moves the stage anyway. Its "sets" clause says so. - self.ask("MOVE", x=1, y=2, z=3) - self.assertEqual(self.ask("HOME"), {}) - self.assertEqual(self.ask("GET_POSITION"), {"x": 0, "y": 0, "z": 0}) - - def testACommandWithNoSetsClauseLeavesTheInstrumentAlone(self): + def testACommandThatCarriesNothingLeavesTheStoreAlone(self): + # The store can only remember what a request carried. WORK carries + # nothing, so nothing changes -- and HOME, which does move the stage, + # needs SutterDevice.DebugSerialPort to say so. self.ask("MOVE", x=1, y=2, z=3) self.ask("WORK") self.assertEqual(self.ask("GET_POSITION"), {"x": 1, "y": 2, "z": 3}) @@ -157,10 +152,8 @@ def testWhatWasSetIsWhatIsRead(self): def testAValueNeverSetReadsAsZero(self): self.assertEqual(self.ask("GET_POWER"), {"power": 0.0}) - def testASetsClauseWorksTheSameWayOnText(self): - self.ask("SET_POWER", power=0.25) + def testAnAcknowledgementCarriesNothingBack(self): self.assertEqual(self.ask("TURN_OFF"), {}) - self.assertEqual(self.ask("GET_POWER"), {"power": 0.0}) if __name__ == "__main__": diff --git a/hardwarelibrary/tests/testProtocolPrototype.py b/hardwarelibrary/tests/testProtocolPrototype.py index 36e9f77..f3e3f19 100644 --- a/hardwarelibrary/tests/testProtocolPrototype.py +++ b/hardwarelibrary/tests/testProtocolPrototype.py @@ -238,7 +238,7 @@ def testTheDescriptionIsSharedButTheResultIsNot(self): self.assertEqual(first, {"power": 0.1}) self.assertEqual(second, {"power": 0.9}) self.assertEqual(vars(command).keys(), - {"name", "request", "reply", "sets", "specimen"}) + {"name", "request", "reply", "specimen"}) def testAFrameSaysOnlyThatItDidNotMatchAndTheCommandSaysWhichHalf(self): # A frame has no idea which command it belongs to, or which end of it, so