Coverage for src/cgse_dummy/scpi.py: 82%

1 import copy

2 import re

3 from collections import namedtuple

4 from functools import partial

5

6 import numpy

7

8 __version__ = '0.2.0'

9

10

11 def 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

16 def __decode_err(s):

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

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

19

20

21 def __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

32 def __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

42 def __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)

64 Cmd = dict

65

66 FuncCmd = partial(Cmd, set=None)

67

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

69 IntCmdR = partial(Cmd, get=int)

70 IntCmdW = partial(Cmd, set=str)

71

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

73 FloatCmdR = partial(Cmd, get=float)

74 FloatCmdW = partial(Cmd, set=str)

75

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

77 StrCmdR = partial(Cmd, get=str)

78 StrCmdW = partial(Cmd, set=str)

79

80 IntArrayCmdR = partial(Cmd, get=__decode_int_array)

81 FloatArrayCmdR = partial(Cmd, get=__decode_float_array)

82 StrArrayCmd = partial(Cmd, get=lambda x: x.split(","), set=lambda x: ",".join(x))

83 StrArrayCmdR = partial(Cmd, get=lambda x: x.split(","))

84

85 OnOffCmd = partial(Cmd, get=__decode_on_off, set=__encode_on_off)

86 OnOffCmdR = partial(Cmd, get=__decode_on_off)

87 OnOffCmdW = partial(Cmd, set=__encode_on_off)

88 BoolCmd = OnOffCmd

89 BoolCmdR = OnOffCmdR

90 BoolCmdW = OnOffCmdW

91

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

93

94 ErrCmd = partial(Cmd, get=__decode_err)

95 ErrArrayCmd = partial(Cmd, get=__decode_err_array)

96

97

98 def 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

124 def 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

159 def 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

167 class 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

297 COMMANDS = 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

318 class SCPIError(Exception):

319 """

320 Base :term:`SCPI` error

321 """

322

323

324 def 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

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

363

364

365 def 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