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

1import copy 

2import re 

3from collections import namedtuple 

4from functools import partial 

5 

6import numpy 

7 

8__version__ = '0.2.0' 

9 

10 

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) 

14 

15 

16def __decode_err(s): 

17 code, desc = map(str.strip, s.split(",", 1)) 

18 return dict(code=int(code), desc=desc[1:-1]) 

19 

20 

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 

30 

31 

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)) 

40 

41 

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)) 

49 

50 

51__decode_int_array = partial(numpy.fromstring, dtype=int, sep=",") 

52__decode_float_array = partial(numpy.fromstring, dtype=float, sep=",") 

53 

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 

65 

66FuncCmd = partial(Cmd, set=None) 

67 

68IntCmd = partial(Cmd, get=int, set=str) 

69IntCmdR = partial(Cmd, get=int) 

70IntCmdW = partial(Cmd, set=str) 

71 

72FloatCmd = partial(Cmd, get=float, set=str) 

73FloatCmdR = partial(Cmd, get=float) 

74FloatCmdW = partial(Cmd, set=str) 

75 

76StrCmd = partial(Cmd, get=str, set=str) 

77StrCmdR = partial(Cmd, get=str) 

78StrCmdW = partial(Cmd, set=str) 

79 

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(",")) 

84 

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 

91 

92IDNCmd = partial(Cmd, get=decode_idn, doc="identification query") 

93 

94ErrCmd = partial(Cmd, get=__decode_err) 

95ErrArrayCmd = partial(Cmd, get=__decode_err_array) 

96 

97 

98def min_max_cmd(cmd_expr): 

99 """ 

100 Find the shortest and longest version of an SCPI command expression. 

101 

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 

122 

123 

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 

151 

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 += ")?" 

155 

156 return reg_expr + "$" 

157 

158 

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) 

165 

166 

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: 

171 

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 

179 

180 *kwargs* should also be SCPI command expressions; 

181 

182 The same way, assignment keys should be SCPI command expressions and 

183 assignment values can be any python object. 

184 

185 Examples:: 

186 

187 from egse.scpi import FuncCmd, ErrCmd, IntCmd, Commands 

188 

189 # c1 will only have *CLS command 

190 c1 = Commands({'*CLS': FuncCmd(doc='clear status'), 

191 '*RST': FuncCmd(doc='reset')}) 

192 

193 # c2 will have *CLS and VOLTage commands 

194 c2 = Commands(c1, VOLTage=IntCmd()) 

195 

196 # add error command to c2 

197 c2['SYSTem:ERRor[:NEXT]'] = ErrCmd() 

198 

199 Access to a command will return the same python object for any text 

200 that resolves to the same SCPI command:: 

201 

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 """ 

209 

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) 

216 

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 

230 

231 def __getitem__(self, cmd_name): 

232 return self.get_command(cmd_name)['value'] 

233 

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] 

240 

241 def __contains__(self, cmd_name): 

242 return self.get(cmd_name) is not None 

243 

244 def __iter__(self): 

245 return self.command_expressions.__iter__() 

246 

247 def __len__(self): 

248 return len(self.command_expressions) 

249 

250 def deepcopy(self): 

251 return copy.deepcopy(self.command_expressions) 

252 

253 def clear(self): 

254 self.command_expressions.clear() 

255 self._command_cache.clear() 

256 

257 def keys(self): 

258 return self.command_expressions.keys() 

259 

260 def values(self): 

261 return {k:v['value'] for k, v in self.command_expressions.items()}.values() 

262 

263 def get_command(self, cmd_name): 

264 cmd_expr = self.get_command_expression(cmd_name) 

265 return self.command_expressions[cmd_expr] 

266 

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) 

278 

279 def get(self, cmd_name, default=None): 

280 try: 

281 return self[cmd_name] 

282 except KeyError: 

283 return default 

284 

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 

295 

296 

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) 

316 

317 

318class SCPIError(Exception): 

319 """ 

320 Base :term:`SCPI` error 

321 """ 

322 

323 

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>): 

328 

329 if strict_query=True, sep=';', eol='\n' (default): 

330 msgs = ('*rst', '*idn?;*cls') => 

331 (['*RST', '*IDN?', '*CLS'], ['*IDN?'], '*RST\n*IDN?\n*CLS') 

332 

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 

360 

361 

362Request = namedtuple('Request', 'name args query') 

363 

364 

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