blob: ae40fda9f10f7518883706da0a00be06717d41aa [file] [log] [blame]
Brendan Jackman1c36e682017-04-20 11:00:13 +01001#!/usr/bin/python
Etienne Le Grande6124fd2014-05-02 19:54:50 -07002
3# Copyright (C) 2014 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Interface for a USB-connected Monsoon power meter
18(http://msoon.com/LabEquipment/PowerMonitor/).
19This file requires gflags, which requires setuptools.
20To install setuptools: sudo apt-get install python-setuptools
21To install gflags, see http://code.google.com/p/python-gflags/
22To install pyserial, see http://pyserial.sourceforge.net/
23
24Example usages:
25 Set the voltage of the device 7536 to 4.0V
Brendan Jackman1c36e682017-04-20 11:00:13 +010026 python monsoon.py --voltage=4.0 --serialno 7536
Etienne Le Grande6124fd2014-05-02 19:54:50 -070027
28 Get 5000hz data from device number 7536, with unlimited number of samples
Brendan Jackman1c36e682017-04-20 11:00:13 +010029 python monsoon.py --samples -1 --hz 5000 --serialno 7536
Etienne Le Grande6124fd2014-05-02 19:54:50 -070030
31 Get 200Hz data for 5 seconds (1000 events) from default device
Brendan Jackman1c36e682017-04-20 11:00:13 +010032 python monsoon.py --samples 100 --hz 200
Etienne Le Grande6124fd2014-05-02 19:54:50 -070033
34 Get unlimited 200Hz data from device attached at /dev/ttyACM0
Brendan Jackman1c36e682017-04-20 11:00:13 +010035 python monsoon.py --samples -1 --hz 200 --device /dev/ttyACM0
Etienne Le Grande6124fd2014-05-02 19:54:50 -070036"""
37
38import fcntl
39import os
40import select
41import signal
42import stat
43import struct
44import sys
45import time
46import collections
47
48import gflags as flags # http://code.google.com/p/python-gflags/
49
50import serial # http://pyserial.sourceforge.net/
51
52FLAGS = flags.FLAGS
53
54class Monsoon:
55 """
56 Provides a simple class to use the power meter, e.g.
57 mon = monsoon.Monsoon()
58 mon.SetVoltage(3.7)
59 mon.StartDataCollection()
60 mydata = []
61 while len(mydata) < 1000:
62 mydata.extend(mon.CollectData())
63 mon.StopDataCollection()
64 """
65
66 def __init__(self, device=None, serialno=None, wait=1):
67 """
68 Establish a connection to a Monsoon.
69 By default, opens the first available port, waiting if none are ready.
70 A particular port can be specified with "device", or a particular Monsoon
71 can be specified with "serialno" (using the number printed on its back).
72 With wait=0, IOError is thrown if a device is not immediately available.
73 """
74
75 self._coarse_ref = self._fine_ref = self._coarse_zero = self._fine_zero = 0
76 self._coarse_scale = self._fine_scale = 0
77 self._last_seq = 0
78 self.start_voltage = 0
79
80 if device:
81 self.ser = serial.Serial(device, timeout=1)
82 return
83
84 while True: # try all /dev/ttyACM* until we find one we can use
85 for dev in os.listdir("/dev"):
86 if not dev.startswith("ttyACM"): continue
87 tmpname = "/tmp/monsoon.%s.%s" % (os.uname()[0], dev)
88 self._tempfile = open(tmpname, "w")
89 try:
90 os.chmod(tmpname, 0666)
91 except OSError:
92 pass
93 try: # use a lockfile to ensure exclusive access
94 fcntl.lockf(self._tempfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
95 except IOError as e:
96 print >>sys.stderr, "device %s is in use" % dev
97 continue
98
99 try: # try to open the device
100 self.ser = serial.Serial("/dev/%s" % dev, timeout=1)
101 self.StopDataCollection() # just in case
102 self._FlushInput() # discard stale input
103 status = self.GetStatus()
104 except Exception as e:
105 print >>sys.stderr, "error opening device %s: %s" % (dev, e)
106 continue
107
108 if not status:
109 print >>sys.stderr, "no response from device %s" % dev
110 elif serialno and status["serialNumber"] != serialno:
111 print >>sys.stderr, ("Note: another device serial #%d seen on %s" %
112 (status["serialNumber"], dev))
113 else:
114 self.start_voltage = status["voltage1"]
115 return
116
117 self._tempfile = None
118 if not wait: raise IOError("No device found")
119 print >>sys.stderr, "waiting for device..."
120 time.sleep(1)
121
122
123 def GetStatus(self):
124 """ Requests and waits for status. Returns status dictionary. """
125
126 # status packet format
127 STATUS_FORMAT = ">BBBhhhHhhhHBBBxBbHBHHHHBbbHHBBBbbbbbbbbbBH"
128 STATUS_FIELDS = [
129 "packetType", "firmwareVersion", "protocolVersion",
130 "mainFineCurrent", "usbFineCurrent", "auxFineCurrent", "voltage1",
131 "mainCoarseCurrent", "usbCoarseCurrent", "auxCoarseCurrent", "voltage2",
132 "outputVoltageSetting", "temperature", "status", "leds",
133 "mainFineResistor", "serialNumber", "sampleRate",
134 "dacCalLow", "dacCalHigh",
135 "powerUpCurrentLimit", "runTimeCurrentLimit", "powerUpTime",
136 "usbFineResistor", "auxFineResistor",
137 "initialUsbVoltage", "initialAuxVoltage",
138 "hardwareRevision", "temperatureLimit", "usbPassthroughMode",
139 "mainCoarseResistor", "usbCoarseResistor", "auxCoarseResistor",
140 "defMainFineResistor", "defUsbFineResistor", "defAuxFineResistor",
141 "defMainCoarseResistor", "defUsbCoarseResistor", "defAuxCoarseResistor",
142 "eventCode", "eventData", ]
143
144 self._SendStruct("BBB", 0x01, 0x00, 0x00)
145 while True: # Keep reading, discarding non-status packets
146 bytes = self._ReadPacket()
147 if not bytes: return None
148 if len(bytes) != struct.calcsize(STATUS_FORMAT) or bytes[0] != "\x10":
149 print >>sys.stderr, "wanted status, dropped type=0x%02x, len=%d" % (
150 ord(bytes[0]), len(bytes))
151 continue
152
153 status = dict(zip(STATUS_FIELDS, struct.unpack(STATUS_FORMAT, bytes)))
154 assert status["packetType"] == 0x10
155 for k in status.keys():
156 if k.endswith("VoltageSetting"):
157 status[k] = 2.0 + status[k] * 0.01
158 elif k.endswith("FineCurrent"):
159 pass # needs calibration data
160 elif k.endswith("CoarseCurrent"):
161 pass # needs calibration data
162 elif k.startswith("voltage") or k.endswith("Voltage"):
163 status[k] = status[k] * 0.000125
164 elif k.endswith("Resistor"):
165 status[k] = 0.05 + status[k] * 0.0001
166 if k.startswith("aux") or k.startswith("defAux"): status[k] += 0.05
167 elif k.endswith("CurrentLimit"):
168 status[k] = 8 * (1023 - status[k]) / 1023.0
169 return status
170
171 def RampVoltage(self, start, end):
172 v = start
173 if v < 3.0: v = 3.0 # protocol doesn't support lower than this
174 while (v < end):
175 self.SetVoltage(v)
176 v += .1
177 time.sleep(.1)
178 self.SetVoltage(end)
179
180 def SetVoltage(self, v):
181 """ Set the output voltage, 0 to disable. """
182 if v == 0:
183 self._SendStruct("BBB", 0x01, 0x01, 0x00)
184 else:
185 self._SendStruct("BBB", 0x01, 0x01, int((v - 2.0) * 100))
186
187
188 def SetMaxCurrent(self, i):
189 """Set the max output current."""
190 assert i >= 0 and i <= 8
191
192 val = 1023 - int((i/8)*1023)
193 self._SendStruct("BBB", 0x01, 0x0a, val & 0xff)
194 self._SendStruct("BBB", 0x01, 0x0b, val >> 8)
195
196 def SetUsbPassthrough(self, val):
197 """ Set the USB passthrough mode: 0 = off, 1 = on, 2 = auto. """
198 self._SendStruct("BBB", 0x01, 0x10, val)
199
200
201 def StartDataCollection(self):
202 """ Tell the device to start collecting and sending measurement data. """
203 self._SendStruct("BBB", 0x01, 0x1b, 0x01) # Mystery command
204 self._SendStruct("BBBBBBB", 0x02, 0xff, 0xff, 0xff, 0xff, 0x03, 0xe8)
205
206
207 def StopDataCollection(self):
208 """ Tell the device to stop collecting measurement data. """
209 self._SendStruct("BB", 0x03, 0x00) # stop
210
211
212 def CollectData(self):
213 """ Return some current samples. Call StartDataCollection() first. """
214 while True: # loop until we get data or a timeout
215 bytes = self._ReadPacket()
216 if not bytes: return None
217 if len(bytes) < 4 + 8 + 1 or bytes[0] < "\x20" or bytes[0] > "\x2F":
218 print >>sys.stderr, "wanted data, dropped type=0x%02x, len=%d" % (
219 ord(bytes[0]), len(bytes))
220 continue
221
222 seq, type, x, y = struct.unpack("BBBB", bytes[:4])
223 data = [struct.unpack(">hhhh", bytes[x:x+8])
224 for x in range(4, len(bytes) - 8, 8)]
225
226 if self._last_seq and seq & 0xF != (self._last_seq + 1) & 0xF:
227 print >>sys.stderr, "data sequence skipped, lost packet?"
228 self._last_seq = seq
229
230 if type == 0:
231 if not self._coarse_scale or not self._fine_scale:
232 print >>sys.stderr, "waiting for calibration, dropped data packet"
233 continue
234
235 out = []
236 for main, usb, aux, voltage in data:
237 if main & 1:
238 out.append(((main & ~1) - self._coarse_zero) * self._coarse_scale)
239 else:
240 out.append((main - self._fine_zero) * self._fine_scale)
241 return out
242
243 elif type == 1:
244 self._fine_zero = data[0][0]
245 self._coarse_zero = data[1][0]
246 # print >>sys.stderr, "zero calibration: fine 0x%04x, coarse 0x%04x" % (
247 # self._fine_zero, self._coarse_zero)
248
249 elif type == 2:
250 self._fine_ref = data[0][0]
251 self._coarse_ref = data[1][0]
252 # print >>sys.stderr, "ref calibration: fine 0x%04x, coarse 0x%04x" % (
253 # self._fine_ref, self._coarse_ref)
254
255 else:
256 print >>sys.stderr, "discarding data packet type=0x%02x" % type
257 continue
258
259 if self._coarse_ref != self._coarse_zero:
260 self._coarse_scale = 2.88 / (self._coarse_ref - self._coarse_zero)
261 if self._fine_ref != self._fine_zero:
262 self._fine_scale = 0.0332 / (self._fine_ref - self._fine_zero)
263
264
265 def _SendStruct(self, fmt, *args):
266 """ Pack a struct (without length or checksum) and send it. """
267 data = struct.pack(fmt, *args)
268 data_len = len(data) + 1
269 checksum = (data_len + sum(struct.unpack("B" * len(data), data))) % 256
270 out = struct.pack("B", data_len) + data + struct.pack("B", checksum)
271 self.ser.write(out)
272
273
274 def _ReadPacket(self):
275 """ Read a single data record as a string (without length or checksum). """
276 len_char = self.ser.read(1)
277 if not len_char:
278 print >>sys.stderr, "timeout reading from serial port"
279 return None
280
281 data_len = struct.unpack("B", len_char)
282 data_len = ord(len_char)
283 if not data_len: return ""
284
285 result = self.ser.read(data_len)
286 if len(result) != data_len: return None
287 body = result[:-1]
288 checksum = (data_len + sum(struct.unpack("B" * len(body), body))) % 256
289 if result[-1] != struct.pack("B", checksum):
290 print >>sys.stderr, "invalid checksum from serial port"
291 return None
292 return result[:-1]
293
294 def _FlushInput(self):
295 """ Flush all read data until no more available. """
296 self.ser.flush()
297 flushed = 0
298 while True:
299 ready_r, ready_w, ready_x = select.select([self.ser], [], [self.ser], 0)
300 if len(ready_x) > 0:
301 print >>sys.stderr, "exception from serial port"
302 return None
303 elif len(ready_r) > 0:
304 flushed += 1
305 self.ser.read(1) # This may cause underlying buffering.
306 self.ser.flush() # Flush the underlying buffer too.
307 else:
308 break
309 if flushed > 0:
310 print >>sys.stderr, "dropped >%d bytes" % flushed
311
312def main(argv):
313 """ Simple command-line interface for Monsoon."""
314 useful_flags = ["voltage", "status", "usbpassthrough", "samples", "current"]
315 if not [f for f in useful_flags if FLAGS.get(f, None) is not None]:
316 print __doc__.strip()
317 print FLAGS.MainModuleHelp()
318 return
319
320 if FLAGS.avg and FLAGS.avg < 0:
321 print "--avg must be greater than 0"
322 return
323
324 mon = Monsoon(device=FLAGS.device, serialno=FLAGS.serialno)
325
326 if FLAGS.voltage is not None:
327 if FLAGS.ramp is not None:
328 mon.RampVoltage(mon.start_voltage, FLAGS.voltage)
329 else:
330 mon.SetVoltage(FLAGS.voltage)
331
332 if FLAGS.current is not None:
333 mon.SetMaxCurrent(FLAGS.current)
334
335 if FLAGS.status:
336 items = sorted(mon.GetStatus().items())
337 print "\n".join(["%s: %s" % item for item in items])
338
339 if FLAGS.usbpassthrough:
340 if FLAGS.usbpassthrough == 'off':
341 mon.SetUsbPassthrough(0)
342 elif FLAGS.usbpassthrough == 'on':
343 mon.SetUsbPassthrough(1)
344 elif FLAGS.usbpassthrough == 'auto':
345 mon.SetUsbPassthrough(2)
346 else:
347 sys.exit('bad passthrough flag: %s' % FLAGS.usbpassthrough)
348
349 if FLAGS.samples:
350 # Make sure state is normal
351 mon.StopDataCollection()
352 status = mon.GetStatus()
353 native_hz = status["sampleRate"] * 1000
354
355 # Collect and average samples as specified
356 mon.StartDataCollection()
357
358 # In case FLAGS.hz doesn't divide native_hz exactly, use this invariant:
359 # 'offset' = (consumed samples) * FLAGS.hz - (emitted samples) * native_hz
360 # This is the error accumulator in a variation of Bresenham's algorithm.
361 emitted = offset = 0
362 collected = []
363 history_deque = collections.deque() # past n samples for rolling average
364
365 try:
366 last_flush = time.time()
367 while emitted < FLAGS.samples or FLAGS.samples == -1:
368 # The number of raw samples to consume before emitting the next output
369 need = (native_hz - offset + FLAGS.hz - 1) / FLAGS.hz
370 if need > len(collected): # still need more input samples
371 samples = mon.CollectData()
372 if not samples: break
373 collected.extend(samples)
374 else:
375 # Have enough data, generate output samples.
376 # Adjust for consuming 'need' input samples.
377 offset += need * FLAGS.hz
378 while offset >= native_hz: # maybe multiple, if FLAGS.hz > native_hz
379 this_sample = sum(collected[:need]) / need
380
381 if FLAGS.timestamp: print int(time.time()),
382
383 if FLAGS.avg:
384 history_deque.appendleft(this_sample)
385 if len(history_deque) > FLAGS.avg: history_deque.pop()
386 print "%f %f" % (this_sample,
387 sum(history_deque) / len(history_deque))
388 else:
389 print "%f" % this_sample
390 sys.stdout.flush()
391
392 offset -= native_hz
393 emitted += 1 # adjust for emitting 1 output sample
394 collected = collected[need:]
395 now = time.time()
396 if now - last_flush >= 0.99: # flush every second
397 sys.stdout.flush()
398 last_flush = now
399 except KeyboardInterrupt:
400 print >>sys.stderr, "interrupted"
401
402 mon.StopDataCollection()
403
404
405if __name__ == '__main__':
406 # Define flags here to avoid conflicts with people who use us as a library
407 flags.DEFINE_boolean("status", None, "Print power meter status")
408 flags.DEFINE_integer("avg", None,
409 "Also report average over last n data points")
410 flags.DEFINE_float("voltage", None, "Set output voltage (0 for off)")
411 flags.DEFINE_float("current", None, "Set max output current")
412 flags.DEFINE_string("usbpassthrough", None, "USB control (on, off, auto)")
Brendan Jackman7174bff2017-04-20 11:11:28 +0100413 flags.DEFINE_integer("samples", None,
414 "Collect and print this many samples. "
415 "-1 means collect indefinitely.")
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700416 flags.DEFINE_integer("hz", 5000, "Print this many samples/sec")
417 flags.DEFINE_string("device", None,
418 "Path to the device in /dev/... (ex:/dev/ttyACM1)")
419 flags.DEFINE_integer("serialno", None, "Look for a device with this serial number")
420 flags.DEFINE_boolean("timestamp", None,
421 "Also print integer (seconds) timestamp on each line")
422 flags.DEFINE_boolean("ramp", True, "Gradually increase voltage")
423
424 main(FLAGS(sys.argv))