diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c4120a..b732787e 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 9225605d..be594d28 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 1c525ce8..31750dd9 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: @@ -658,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: @@ -667,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 @@ -976,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. @@ -992,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/echodevice.py b/hardwarelibrary/echodevice.py index 9586b9f1..889dcfc8 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/motion/sutterdevice.py b/hardwarelibrary/motion/sutterdevice.py index dc5a3a6f..295ea95e 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/powermeters/integradevice.py b/hardwarelibrary/powermeters/integradevice.py index 1aeb562d..a1d2bd85 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/sources/cobolt.py b/hardwarelibrary/sources/cobolt.py index 8e6612ee..d02c3ed2 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/testIntegraDevice.py b/hardwarelibrary/tests/testIntegraDevice.py index d991169a..109d2f23 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 e23eac67..60be0de9 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() diff --git a/hardwarelibrary/tests/testProtocolDebugPort.py b/hardwarelibrary/tests/testProtocolDebugPort.py index cad8844f..4f5b6bcf 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 36e9f77d..f3e3f19e 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