Coverage for src/cgse_dummy/scpi.py: 82%
203 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-04 11:22 +0200
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-04 11:22 +0200
1import copy
2import re
3from collections import namedtuple
4from functools import partial
6import numpy
8__version__ = '0.2.0'
11def decode_idn(s):
12 manuf, model, serial, version = map(str.strip, s.split(","))
13 return dict(manufacturer=manuf, model=model, serial=serial, version=version)
16def __decode_err(s):
17 code, desc = map(str.strip, s.split(",", 1))
18 return dict(code=int(code), desc=desc[1:-1])
21def __decode_err_array(s):
22 msgs = list(map(str.strip, s.split(",")))
23 result = []
24 for i in range(0, len(msgs), 2):
25 code, desc = int(msgs[i]), msgs[i + 1][1:-1]
26 if code == 0:
27 continue
28 result.append(dict(code=code, desc=desc))
29 return result
32def __decode_on_off(s):
33 su = s.upper()
34 if su in ("1", "ON"):
35 return True
36 elif su in ("0", "OFF"):
37 return False
38 else:
39 raise ValueError("Cannot decode OnOff value {0}".format(s))
42def __encode_on_off(s):
43 if s in (0, False, "off", "OFF"):
44 return "OFF"
45 elif s in (1, True, "on", "ON"):
46 return "ON"
47 else:
48 raise ValueError("Cannot encode OnOff value {0}".format(s))
51__decode_int_array = partial(numpy.fromstring, dtype=int, sep=",")
52__decode_float_array = partial(numpy.fromstring, dtype=float, sep=",")
54#: SCPI command
55#: accepts the following keys:
56#:
57#: - get - translation function called on the result of a query.
58#: If not present means command cannot be queried.
59#: If present and is None means ignore query result
60#: - set - translation function called before a write call.
61#: If not present means command cannot be written.
62#: If present and is None means it doesn't receive any argument
63#: - doc - command documentation (str, optional)
64Cmd = dict
66FuncCmd = partial(Cmd, set=None)
68IntCmd = partial(Cmd, get=int, set=str)
69IntCmdR = partial(Cmd, get=int)
70IntCmdW = partial(Cmd, set=str)
72FloatCmd = partial(Cmd, get=float, set=str)
73FloatCmdR = partial(Cmd, get=float)
74FloatCmdW = partial(Cmd, set=str)
76StrCmd = partial(Cmd, get=str, set=str)
77StrCmdR = partial(Cmd, get=str)
78StrCmdW = partial(Cmd, set=str)
80IntArrayCmdR = partial(Cmd, get=__decode_int_array)
81FloatArrayCmdR = partial(Cmd, get=__decode_float_array)
82StrArrayCmd = partial(Cmd, get=lambda x: x.split(","), set=lambda x: ",".join(x))
83StrArrayCmdR = partial(Cmd, get=lambda x: x.split(","))
85OnOffCmd = partial(Cmd, get=__decode_on_off, set=__encode_on_off)
86OnOffCmdR = partial(Cmd, get=__decode_on_off)
87OnOffCmdW = partial(Cmd, set=__encode_on_off)
88BoolCmd = OnOffCmd
89BoolCmdR = OnOffCmdR
90BoolCmdW = OnOffCmdW
92IDNCmd = partial(Cmd, get=decode_idn, doc="identification query")
94ErrCmd = partial(Cmd, get=__decode_err)
95ErrArrayCmd = partial(Cmd, get=__decode_err_array)
98def min_max_cmd(cmd_expr):
99 """
100 Find the shortest and longest version of an SCPI command expression.
102 Example:
103 >>> min_max_cmd('SYSTem:ERRor[:NEXT]')
104 ('SYST:ERR', 'SYSTEM:ERROR:NEXT')
105 """
106 result_min, optional = "", 0
107 for c in cmd_expr:
108 if c.islower():
109 continue
110 if c == "[":
111 optional += 1
112 continue
113 if c == "]":
114 optional -= 1
115 continue
116 if optional:
117 continue
118 result_min += c
119 result_min = result_min.lstrip(":")
120 result_max = cmd_expr.replace("[", "").replace("]", "").upper().lstrip(":")
121 return result_min, result_max
124def cmd_expr_to_reg_expr_str(cmd_expr):
125 """
126 Return a regular expression string from the given SCPI command expression.
127 """
128 # Basicaly we replace [] -> ()?, and LOWercase -> LOW(ercase)?
129 # Also we add :? optional to the start and $ to the end to make sure
130 # we have an exact match
131 reg_expr, low_zone = r"\:?", False
132 for c in cmd_expr:
133 cl = c.islower()
134 if not cl:
135 if low_zone:
136 reg_expr += ")?"
137 low_zone = False
138 if c == "[":
139 reg_expr += "("
140 elif c == "]":
141 reg_expr += ")?"
142 elif cl:
143 if not low_zone:
144 reg_expr += "("
145 low_zone = True
146 reg_expr += c.upper()
147 elif c in "*:":
148 reg_expr += "\\" + c
149 else:
150 reg_expr += c
152 # if cmd expr ends in lower case we close the optional zone 'by hand'
153 if low_zone: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 reg_expr += ")?"
156 return reg_expr + "$"
159def cmd_expr_to_reg_expr(cmd_expr):
160 """
161 Return a compiled regular expression object from the given SCPI command
162 expression.
163 """
164 return re.compile(cmd_expr_to_reg_expr_str(cmd_expr), re.IGNORECASE)
167class Commands:
168 """
169 A dict like container for SCPI commands. Construct a Commands object like a
170 dict. When creating a Commands object, *args* must either:
172 * another *Commands* object
173 * a dict where keys should be SCPI command expressions
174 (ex: `SYSTem:ERRor[:NEXT]`). Values can be any python objects
175 Common use-case is instance of *Cmd* which contain information on how to
176 handle a get/set operation and optional docstrings
177 * a sequence of pairs where first element must be SCPI command expression
178 and second element is any python object
180 *kwargs* should also be SCPI command expressions;
182 The same way, assignment keys should be SCPI command expressions and
183 assignment values can be any python object.
185 Examples::
187 from egse.scpi import FuncCmd, ErrCmd, IntCmd, Commands
189 # c1 will only have *CLS command
190 c1 = Commands({'*CLS': FuncCmd(doc='clear status'),
191 '*RST': FuncCmd(doc='reset')})
193 # c2 will have *CLS and VOLTage commands
194 c2 = Commands(c1, VOLTage=IntCmd())
196 # add error command to c2
197 c2['SYSTem:ERRor[:NEXT]'] = ErrCmd()
199 Access to a command will return the same python object for any text
200 that resolves to the same SCPI command::
202 >>> c2['SYST:ERR']
203 <ErrCmd at 0x7f3f663bf390>
204 >>> c2[':system:error:next']
205 <ErrCmd at 0x7f3f663bf390>
206 >>> c2[':system:error:next'] == c2['SYST:ERR']
207 True
208 """
210 def __init__(self, *args, **kwargs):
211 self.command_expressions = {}
212 self._command_cache = {}
213 for arg in args:
214 self.update(arg)
215 self.update(kwargs)
217 def __setitem__(self, cmd_expr, command):
218 min_cmd, max_cmd = min_max_cmd(cmd_expr)
219 cmd_info = dict(
220 value=command,
221 re=cmd_expr_to_reg_expr(cmd_expr),
222 min_command=min_cmd,
223 max_command=max_cmd,
224 )
225 self.command_expressions[cmd_expr] = cmd_info
226 # update cache with short and long version
227 self.get_command_expression(min_cmd)
228 self.get_command_expression(max_cmd)
229 return cmd_info
231 def __getitem__(self, cmd_name):
232 return self.get_command(cmd_name)['value']
234 def __delitem__(self, cmd_expr):
235 reg_expr = self.command_expressions.pop(cmd_expr)['re']
236 del_cache = {k for k, v in self._command_cache.items()
237 if reg_expr.match(k)}
238 for k in del_cache:
239 del self._command_cache[k]
241 def __contains__(self, cmd_name):
242 return self.get(cmd_name) is not None
244 def __iter__(self):
245 return self.command_expressions.__iter__()
247 def __len__(self):
248 return len(self.command_expressions)
250 def deepcopy(self):
251 return copy.deepcopy(self.command_expressions)
253 def clear(self):
254 self.command_expressions.clear()
255 self._command_cache.clear()
257 def keys(self):
258 return self.command_expressions.keys()
260 def values(self):
261 return {k:v['value'] for k, v in self.command_expressions.items()}.values()
263 def get_command(self, cmd_name):
264 cmd_expr = self.get_command_expression(cmd_name)
265 return self.command_expressions[cmd_expr]
267 def get_command_expression(self, cmd_name):
268 cmd_name_u = cmd_name.upper()
269 try:
270 return self._command_cache[cmd_name_u]
271 except KeyError:
272 for cmd_expr, cmd_info in self.command_expressions.items():
273 reg_expr = cmd_info["re"]
274 if reg_expr.match(cmd_name):
275 self._command_cache[cmd_name.upper()] = cmd_expr
276 return cmd_expr
277 raise KeyError(cmd_name)
279 def get(self, cmd_name, default=None):
280 try:
281 return self[cmd_name]
282 except KeyError:
283 return default
285 def update(self, commands):
286 if isinstance(commands, Commands): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true
287 self.command_expressions.update(commands.command_expressions)
288 self._command_cache.update(commands._command_cache)
289 elif isinstance(commands, dict): 289 ↛ 293line 289 didn't jump to line 293 because the condition on line 289 was always true
290 for cmd_expr, cmd in commands.items():
291 self[cmd_expr] = cmd
292 else:
293 for cmd_expr, cmd in commands:
294 self[cmd_expr] = cmd
297COMMANDS = Commands(
298 {
299 "*CLS": FuncCmd(doc="clear status"),
300 "*ESE": IntCmd(doc="standard event status enable register"),
301 "*ESR": IntCmdR(doc="standard event event status register"),
302 "*IDN": IDNCmd(),
303 "*OPC": IntCmdR(set=None, doc="operation complete"),
304 "*OPT": IntCmdR(doc="return model number of any installed options"),
305 "*RCL": IntCmdW(set=int, doc="return to user saved setup"),
306 "*RST": FuncCmd(doc="reset"),
307 "*SAV": IntCmdW(doc="save the preset setup as the user-saved setup"),
308 "*SRE": IntCmdW(doc="service request enable register"),
309 "*STB": StrCmdR(doc="status byte register"),
310 "*TRG": FuncCmd(doc="bus trigger"),
311 "*TST": Cmd(get=lambda x: not __decode_on_off(x), doc="self-test query"),
312 "*WAI": FuncCmd(doc="wait to continue"),
313 "SYSTem:ERRor[:NEXT]": ErrCmd(doc="return and clear oldest system error"),
314 }
315)
318class SCPIError(Exception):
319 """
320 Base :term:`SCPI` error
321 """
324def sanitize_msgs(*msgs, eol='\n', sep=';', strict_query=True):
325 """
326 Transform a tuple of messages into a list of
327 (<individual commands>, <individual queries>, <full_message>):
329 if strict_query=True, sep=';', eol='\n' (default):
330 msgs = ('*rst', '*idn?;*cls') =>
331 (['*RST', '*IDN?', '*CLS'], ['*IDN?'], '*RST\n*IDN?\n*CLS')
333 if strict_query=False, sep=';', eol='\n' (default):
334 msgs = ('*rst', '*idn?;*cls') =>
335 (['*RST', '*IDN?', '*CLS'], ['*IDN?'], '*RST\n*IDN?;*CLS')
336 """
337 # in case a single message comes with several eol separated commands
338 msgs = eol.join(msgs).split(eol)
339 result, commands, queries = [], [], []
340 for msg in msgs:
341 sub_result = []
342 for cmd in msg.split(sep):
343 cmd = cmd.strip()
344 if not cmd: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 continue
346 commands.append(cmd)
347 is_query = "?" in cmd
348 if is_query:
349 queries.append(cmd)
350 if is_query and strict_query:
351 if sub_result: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 result.append(sep.join(sub_result))
353 sub_result = []
354 result.append(cmd)
355 else:
356 sub_result.append(cmd)
357 if sub_result: 357 ↛ 340line 357 didn't jump to line 340 because the condition on line 357 was always true
358 result.append(sep.join(sub_result))
359 return commands, queries, eol.join(result) + eol
362Request = namedtuple('Request', 'name args query')
365def split_line(line, sep=';'):
366 line = line.strip().strip(';')
367 requests = []
368 for req_str in line.split(sep):
369 if not req_str:
370 continue
371 query = '?' in req_str
372 req_str = req_str.replace('?', '')
373 name, _, args = req_str.partition(' ')
374 name, args = name.strip(), args.strip()
375 requests.append(Request(name, args, query))
376 return requests