diff --git a/MKS_ASCII_Protocol.py b/MKS_ASCII_Protocol.py index 3564581..d73aa34 100644 --- a/MKS_ASCII_Protocol.py +++ b/MKS_ASCII_Protocol.py @@ -3,6 +3,7 @@ Implementation of the MKS ASCII Protocol as specified in "ASCII Protocol V1.8.pdf" +The """ from __future__ import absolute_import @@ -17,39 +18,20 @@ import sys import os #import numpy as np import logging +import threading import queue import socket __softwarename__ = "MKS ASCII server on " + socket.gethostname() from message import ParseMessage, ComposeMessage +## basic config logging.basicConfig(level=logging.INFO) +assert (sys.version_info[0] + sys.version_info[1]*0.1 ) >= 3.7, "Minimum python requirement not met: "+str(sys.version_info[0] + sys.version_info[1]*0.1)+". We need 3.7 or above for fstrings and queue features." #### composer = ComposeMessage() class MKSListener(): - # networking - _remote_port = 10014 # port on remote device - _local_port = 10000 # local port - _s = None # socket object - - # connection state - _connected = False # connection state - _target_state = "disconnected" # target connection state - device_sn = None # device SN currently connected to - - # protocol versioning and formatting - _remote_min_protocol_version = None # remote requirement of min protocol, retrieved from remote device - _min_protocol_version = 1.6 # assumed minimum protocol version - _protocol_version = __protocol_version__ - format_with_tab = False # whether to request FormatWithTab (reduces networkload slighlty) - - # program flow control - loop_min_time = 0.01 # minimum duration of a listener loop in seconds - - # Mass spectrometer properties - filament = None # number of filament in use - def __init__(self, ip, up_queue, down_queue, port=10014, local_port=10000, target_state="connected"): """ @@ -63,10 +45,31 @@ class MKSListener(): """ self.up_queue = up_queue # queue of messages towards the RGA device self.down_queue = down_queue # queue of messages from the RGA device + self._target_state = target_state + + # networking self._remote_ip = ip # self._remote_port = port self._local_port = local_port - self._target_state = target_state + self._s = None # socket object + + # connection state + self._connected = False # connection state + self._target_state = "disconnected" # target connection state + self.device_sn = None # device SN currently connected to + + # protocol versioning and formatting + self._remote_min_protocol_version = None # remote requirement of min protocol, retrieved from remote device + self._min_protocol_version = 1.6 # assumed minimum protocol version + self._protocol_version = __protocol_version__ + self.format_with_tab = False # whether to request FormatWithTab (reduces networkload slighlty) + + # program flow control + self.loop_min_time = 0.01 # minimum duration of a listener loop in seconds + + # Mass spectrometer properties + self.filament = None # number of filament in use + def turn_on(self): self._target_state = "connected" @@ -184,8 +187,6 @@ class MKSListener(): self.down_queue.put(ParseMessage(rawinput[i])) class MKSOperator(): - _target_state = "run" - _current_protocol_version = 1 _commands = { # controlling message formatting "FormatWithTab": {"type": "Controlling message formatting", "description": "", "action": NotImplementedError, "expected_response": "", "from_version": 1}, @@ -340,8 +341,12 @@ class MKSOperator(): "DiagnosticInput": {"type": "Asynchronous Sensor Notifications", "description": "", "action": NotImplementedError, "expected_response": "", "from_version": 1.6}, } - def __init__(self, up_queue:queue.SimpleQueue, down_queue:queue.SimpleQueue, command_actions:dict = {}): + def __init__(self, up_queue:queue.SimpleQueue, down_queue:queue.SimpleQueue, command_actions:dict={}, target_state="run"): #raise NotImplementedError() + + self._target_state = target_state + self._current_protocol_version = 1 + self.up_queue = up_queue self.down_queue = down_queue # default pre-set actions @@ -370,7 +375,7 @@ class MKSOperator(): if self._commands[command]["from_version"] > self._current_protocol_version: logging.warning(f"Unsupported command {command} for our version {self._current_protocol_version}. Ignoring.") continue - assert hasattr(command, "__call__") + assert hasattr(self._commands[command]['action'], "__call__"), f"{command} callable has not __call__" self._commands[command]["action"] = actions[command] def _digest_command(self, message): @@ -401,19 +406,34 @@ class MKSOperator(): raise NotImplementedError() def _message_tab_formatting(self, message): + # no action needed - all code is invariant to tabs vs spaces + # using spaces saves a few bits of bandwith due to less padding spaces, so use it anyways + pass + + def _available_sensors(self, message): + raise NotImplementedError() + class ProtocolVersionError(Exception): - raise NotImplementedError("TODO") # TODO + def __init__(self, version, message="Version mismatch: "): + self.message = message + str(version) + super().__init__(self.message) + +def run(obj): + obj.run() if __name__ == "__main__": - print(sys.version_info) - # use https://docs.python.org/3/library/queue.html#queue.SimpleQueue - q = queue.SimpleQueue() - # producer: MKS_ASCII_Protocol instance - # consumer: yet-to-write - # launch both in separate threads (https://docs.python.org/3/library/threading.html) and have them communicating via queue - p = MKSListener(ip="127.0.0.1") - - p.run() \ No newline at end of file + # communicating queues: up -> towards MKS instrument, down -> from MKS intrument + upq = queue.Queue() + downq = queue.Queue() + # create instances of listener and operator + l = MKSListener(ip="127.0.0.1", up_queue=upq, down_queue=downq) + o = MKSOperator(up_queue=upq, down_queue=downq) + # create thread for each + listenerThread = threading.Thread(target=run, args=(l,)) + operatorThread = threading.Thread(target=run, args=(o,)) + # start threads + operatorThread.start() + listenerThread.start() \ No newline at end of file diff --git a/message.py b/message.py index 6fbcd0b..1d2575e 100644 --- a/message.py +++ b/message.py @@ -1,33 +1,72 @@ import time +import logging class ParseMessage(): - parms = {} - raw=None + + table_commands = ["Sensors", "EGains", "InletInfo", "DetectorInfo", "AnalogInputInfo", + "AnalogOutputInfo", "DigitalInfo", "SourceAlignmentInfo", "SourceResolutionInfo", + "DiagnosticInputInfo", "RunDiagnostics", ""] + verbose_commands = ["DiagnosticInput", "DigitalPortChange", "AnalogInput", "MassReading", "ZeroReading", + "StartingScan", "FilamentStatus", ""] # commands that feel special and don't submit to the usual pattern of command - status def __init__(self, raw): - + """ + Parse incoming binary message + :param raw: raw string + """ self.time = time.time() - + self.is_table = False + self.type = None + self.status = None + self.parms = {} + self.table_head = None + self.table_body = None self.raw = raw - self.parse() + self.parse(raw) - def parse(self): + def parse(self, raw): """ Parser for incoming messages. :return: """ - for i, line in enumerate(self.raw.split("\r\n")): - entries = line.split() - if entries == []: - continue - if i == 0: - self.type = entries[0] - if len(entries) < 2: - raise NotImplementedError() - elif len(entries) == 2: - self.parms[entries[0]] = entries[1] - else: - self.parms[entries[0]] = entries[1:] + # split message at newline indicators and get rid of empty entries + linebyline = list(filter(None, raw.split("\r\n"))) + + # first line contains command and status or more + line1 = linebyline[0].split() + self.type = line1[0] + if self.type in self.table_commands: + self.is_table = True + if len(line1) == 1: + pass + elif len(line1) == 2: + self.type = line1[0] + self.status = line1[1] + else: + assert self.type in self.verbose_commands + self.parms[self.type] = line1[1:] + + # terminate here for one-line commands + if len(linebyline) == 1: + return + + # following lines + if self.is_table: + # for table commands we split table head and body + thead = linebyline[1].split() + try: + tbody = [linebyline[i].split() for i in range(len(linebyline[2:]))] + except IndexError: + logging.warning(f"Digesting message {self.type}: no table body found") + tbody = [] + self.table_head = thead + self.table_body = tbody + else: + # for other commands we make a dictionary with key [values, ..] + try: + self.parms.update({line.split()[0]: line.split()[1:] for line in linebyline[1:]}) + except IndexError: + raise NotImplementedError("this should not happen") class ComposeMessage(): def __init__(self): @@ -65,14 +104,19 @@ if __name__ == "__main__": "InletInfo": 'InletInfo OK\r\n Automatic Yes \r\nActiveInlet 0\r\n Factor Fixed CanCalibrate DefaultFactor TypeName\r\n 1 Yes No 1 "Process Chamber direct"\r\n', "Release": "Release Ok\r\n", #"AcceptProtocol": - "initiate": "MKSRGA Single\r\n Protocol_Revision 1.1\r\n Min_Compatibility 1.1\r\n\r\n\r\r", + "initiate": "MKSRGA Single\r\n Protocol_Revision 1.1\r\n Min_Compatibility 1.1\r\n\r\n", "Error": 'command ERROR\r\n Number 200\r\n Description "err description"\r\n\r\n', + "Sensors": "Sensors OK\r\n State SerialNumber Name\r\n Ready LM70-00197021 “Chamber A”\r\n Ready LM70-00198021 “Chamber B”\r\n\r\n", + "DiagnosticInput": "DiagnosticInput 0 3.2745\r\n\r\n", + "FilamentStatus": "FilamentStatus 1 OFF\r\n Trip None\r\n Drive Off\r\n EmissionTripState OK\r\n ExternalTripState OK\r\n RVCTripState OK", } cm = ComposeMessage() #bmess = cm(mtype="Select", args=["LM70-00197021"]) #smess = bmess.decode("ASCII") - pm = ParseMessage(sample_messages["InletInfo"]) + for m in sample_messages: + pm = ParseMessage(sample_messages[m]) + print(pm.type, pm.status, pm.is_table) - print(pm.type, pm.parms, pm.time) + #print(pm.type, pm.parms, pm.time)