blob: 6efaf98182c0fee3479e9f245bc191f23c0de7a5 [file] [log] [blame]
Ryan Mitchelle35c1f22017-05-18 11:27:42 -07001#!/usr/bin/env 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
Brendan Jackmanf11b8b72017-04-20 11:29:59 +010036
37Output columns for collection with --samples, separated by space:
38
39 TIMESTAMP OUTPUT OUTPUT_AVG USB USB_AVG
40 | | | |
41 | | | ` (if --includeusb and --avg)
42 | | ` (if --includeusb)
43 | ` (if --avg)
44 ` (if --timestamp)
Etienne Le Grande6124fd2014-05-02 19:54:50 -070045"""
46
47import fcntl
48import os
49import select
50import signal
51import stat
52import struct
53import sys
54import time
55import collections
56
57import gflags as flags # http://code.google.com/p/python-gflags/
58
59import serial # http://pyserial.sourceforge.net/
60
61FLAGS = flags.FLAGS
62
63class Monsoon:
64 """
65 Provides a simple class to use the power meter, e.g.
66 mon = monsoon.Monsoon()
67 mon.SetVoltage(3.7)
68 mon.StartDataCollection()
69 mydata = []
70 while len(mydata) < 1000:
71 mydata.extend(mon.CollectData())
72 mon.StopDataCollection()
73 """
74
75 def __init__(self, device=None, serialno=None, wait=1):
76 """
77 Establish a connection to a Monsoon.
78 By default, opens the first available port, waiting if none are ready.
79 A particular port can be specified with "device", or a particular Monsoon
80 can be specified with "serialno" (using the number printed on its back).
81 With wait=0, IOError is thrown if a device is not immediately available.
82 """
83
84 self._coarse_ref = self._fine_ref = self._coarse_zero = self._fine_zero = 0
85 self._coarse_scale = self._fine_scale = 0
86 self._last_seq = 0
87 self.start_voltage = 0
88
89 if device:
90 self.ser = serial.Serial(device, timeout=1)
91 return
92
93 while True: # try all /dev/ttyACM* until we find one we can use
94 for dev in os.listdir("/dev"):
95 if not dev.startswith("ttyACM"): continue
96 tmpname = "/tmp/monsoon.%s.%s" % (os.uname()[0], dev)
97 self._tempfile = open(tmpname, "w")
98 try:
99 os.chmod(tmpname, 0666)
100 except OSError:
101 pass
102 try: # use a lockfile to ensure exclusive access
103 fcntl.lockf(self._tempfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
104 except IOError as e:
105 print >>sys.stderr, "device %s is in use" % dev
106 continue
107
108 try: # try to open the device
109 self.ser = serial.Serial("/dev/%s" % dev, timeout=1)
110 self.StopDataCollection() # just in case
111 self._FlushInput() # discard stale input
112 status = self.GetStatus()
113 except Exception as e:
114 print >>sys.stderr, "error opening device %s: %s" % (dev, e)
115 continue
116
117 if not status:
118 print >>sys.stderr, "no response from device %s" % dev
119 elif serialno and status["serialNumber"] != serialno:
120 print >>sys.stderr, ("Note: another device serial #%d seen on %s" %
121 (status["serialNumber"], dev))
122 else:
123 self.start_voltage = status["voltage1"]
124 return
125
126 self._tempfile = None
127 if not wait: raise IOError("No device found")
128 print >>sys.stderr, "waiting for device..."
129 time.sleep(1)
130
131
132 def GetStatus(self):
133 """ Requests and waits for status. Returns status dictionary. """
134
135 # status packet format
136 STATUS_FORMAT = ">BBBhhhHhhhHBBBxBbHBHHHHBbbHHBBBbbbbbbbbbBH"
137 STATUS_FIELDS = [
138 "packetType", "firmwareVersion", "protocolVersion",
139 "mainFineCurrent", "usbFineCurrent", "auxFineCurrent", "voltage1",
140 "mainCoarseCurrent", "usbCoarseCurrent", "auxCoarseCurrent", "voltage2",
141 "outputVoltageSetting", "temperature", "status", "leds",
142 "mainFineResistor", "serialNumber", "sampleRate",
143 "dacCalLow", "dacCalHigh",
144 "powerUpCurrentLimit", "runTimeCurrentLimit", "powerUpTime",
145 "usbFineResistor", "auxFineResistor",
146 "initialUsbVoltage", "initialAuxVoltage",
147 "hardwareRevision", "temperatureLimit", "usbPassthroughMode",
148 "mainCoarseResistor", "usbCoarseResistor", "auxCoarseResistor",
149 "defMainFineResistor", "defUsbFineResistor", "defAuxFineResistor",
150 "defMainCoarseResistor", "defUsbCoarseResistor", "defAuxCoarseResistor",
151 "eventCode", "eventData", ]
152
153 self._SendStruct("BBB", 0x01, 0x00, 0x00)
154 while True: # Keep reading, discarding non-status packets
155 bytes = self._ReadPacket()
156 if not bytes: return None
157 if len(bytes) != struct.calcsize(STATUS_FORMAT) or bytes[0] != "\x10":
158 print >>sys.stderr, "wanted status, dropped type=0x%02x, len=%d" % (
159 ord(bytes[0]), len(bytes))
160 continue
161
162 status = dict(zip(STATUS_FIELDS, struct.unpack(STATUS_FORMAT, bytes)))
163 assert status["packetType"] == 0x10
164 for k in status.keys():
165 if k.endswith("VoltageSetting"):
166 status[k] = 2.0 + status[k] * 0.01
167 elif k.endswith("FineCurrent"):
168 pass # needs calibration data
169 elif k.endswith("CoarseCurrent"):
170 pass # needs calibration data
171 elif k.startswith("voltage") or k.endswith("Voltage"):
172 status[k] = status[k] * 0.000125
173 elif k.endswith("Resistor"):
174 status[k] = 0.05 + status[k] * 0.0001
175 if k.startswith("aux") or k.startswith("defAux"): status[k] += 0.05
176 elif k.endswith("CurrentLimit"):
177 status[k] = 8 * (1023 - status[k]) / 1023.0
178 return status
179
180 def RampVoltage(self, start, end):
181 v = start
182 if v < 3.0: v = 3.0 # protocol doesn't support lower than this
183 while (v < end):
184 self.SetVoltage(v)
185 v += .1
186 time.sleep(.1)
187 self.SetVoltage(end)
188
189 def SetVoltage(self, v):
190 """ Set the output voltage, 0 to disable. """
191 if v == 0:
192 self._SendStruct("BBB", 0x01, 0x01, 0x00)
193 else:
194 self._SendStruct("BBB", 0x01, 0x01, int((v - 2.0) * 100))
195
196
197 def SetMaxCurrent(self, i):
198 """Set the max output current."""
199 assert i >= 0 and i <= 8
200
201 val = 1023 - int((i/8)*1023)
202 self._SendStruct("BBB", 0x01, 0x0a, val & 0xff)
203 self._SendStruct("BBB", 0x01, 0x0b, val >> 8)
204
205 def SetUsbPassthrough(self, val):
206 """ Set the USB passthrough mode: 0 = off, 1 = on, 2 = auto. """
207 self._SendStruct("BBB", 0x01, 0x10, val)
208
209
210 def StartDataCollection(self):
211 """ Tell the device to start collecting and sending measurement data. """
212 self._SendStruct("BBB", 0x01, 0x1b, 0x01) # Mystery command
213 self._SendStruct("BBBBBBB", 0x02, 0xff, 0xff, 0xff, 0xff, 0x03, 0xe8)
214
215
216 def StopDataCollection(self):
217 """ Tell the device to stop collecting measurement data. """
218 self._SendStruct("BB", 0x03, 0x00) # stop
219
220
221 def CollectData(self):
222 """ Return some current samples. Call StartDataCollection() first. """
223 while True: # loop until we get data or a timeout
224 bytes = self._ReadPacket()
225 if not bytes: return None
226 if len(bytes) < 4 + 8 + 1 or bytes[0] < "\x20" or bytes[0] > "\x2F":
227 print >>sys.stderr, "wanted data, dropped type=0x%02x, len=%d" % (
228 ord(bytes[0]), len(bytes))
229 continue
230
231 seq, type, x, y = struct.unpack("BBBB", bytes[:4])
232 data = [struct.unpack(">hhhh", bytes[x:x+8])
233 for x in range(4, len(bytes) - 8, 8)]
234
235 if self._last_seq and seq & 0xF != (self._last_seq + 1) & 0xF:
236 print >>sys.stderr, "data sequence skipped, lost packet?"
237 self._last_seq = seq
238
239 if type == 0:
240 if not self._coarse_scale or not self._fine_scale:
241 print >>sys.stderr, "waiting for calibration, dropped data packet"
242 continue
243
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100244 def scale(val):
245 if val & 1:
246 return ((val & ~1) - self._coarse_zero) * self._coarse_scale
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700247 else:
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100248 return (val - self._fine_zero) * self._fine_scale
249
250 out_main = []
251 out_usb = []
252 for main, usb, aux, voltage in data:
253 out_main.append(scale(main))
254 out_usb.append(scale(usb))
255 return (out_main, out_usb)
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700256
257 elif type == 1:
258 self._fine_zero = data[0][0]
259 self._coarse_zero = data[1][0]
260 # print >>sys.stderr, "zero calibration: fine 0x%04x, coarse 0x%04x" % (
261 # self._fine_zero, self._coarse_zero)
262
263 elif type == 2:
264 self._fine_ref = data[0][0]
265 self._coarse_ref = data[1][0]
266 # print >>sys.stderr, "ref calibration: fine 0x%04x, coarse 0x%04x" % (
267 # self._fine_ref, self._coarse_ref)
268
269 else:
270 print >>sys.stderr, "discarding data packet type=0x%02x" % type
271 continue
272
273 if self._coarse_ref != self._coarse_zero:
274 self._coarse_scale = 2.88 / (self._coarse_ref - self._coarse_zero)
275 if self._fine_ref != self._fine_zero:
276 self._fine_scale = 0.0332 / (self._fine_ref - self._fine_zero)
277
278
279 def _SendStruct(self, fmt, *args):
280 """ Pack a struct (without length or checksum) and send it. """
281 data = struct.pack(fmt, *args)
282 data_len = len(data) + 1
283 checksum = (data_len + sum(struct.unpack("B" * len(data), data))) % 256
284 out = struct.pack("B", data_len) + data + struct.pack("B", checksum)
285 self.ser.write(out)
286
287
288 def _ReadPacket(self):
289 """ Read a single data record as a string (without length or checksum). """
290 len_char = self.ser.read(1)
291 if not len_char:
292 print >>sys.stderr, "timeout reading from serial port"
293 return None
294
295 data_len = struct.unpack("B", len_char)
296 data_len = ord(len_char)
297 if not data_len: return ""
298
299 result = self.ser.read(data_len)
300 if len(result) != data_len: return None
301 body = result[:-1]
302 checksum = (data_len + sum(struct.unpack("B" * len(body), body))) % 256
303 if result[-1] != struct.pack("B", checksum):
304 print >>sys.stderr, "invalid checksum from serial port"
305 return None
306 return result[:-1]
307
308 def _FlushInput(self):
309 """ Flush all read data until no more available. """
310 self.ser.flush()
311 flushed = 0
312 while True:
313 ready_r, ready_w, ready_x = select.select([self.ser], [], [self.ser], 0)
314 if len(ready_x) > 0:
315 print >>sys.stderr, "exception from serial port"
316 return None
317 elif len(ready_r) > 0:
318 flushed += 1
319 self.ser.read(1) # This may cause underlying buffering.
320 self.ser.flush() # Flush the underlying buffer too.
321 else:
322 break
323 if flushed > 0:
324 print >>sys.stderr, "dropped >%d bytes" % flushed
325
326def main(argv):
327 """ Simple command-line interface for Monsoon."""
328 useful_flags = ["voltage", "status", "usbpassthrough", "samples", "current"]
329 if not [f for f in useful_flags if FLAGS.get(f, None) is not None]:
330 print __doc__.strip()
331 print FLAGS.MainModuleHelp()
332 return
333
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100334 if FLAGS.includeusb:
335 num_channels = 2
336 else:
337 num_channels = 1
338
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700339 if FLAGS.avg and FLAGS.avg < 0:
340 print "--avg must be greater than 0"
341 return
342
343 mon = Monsoon(device=FLAGS.device, serialno=FLAGS.serialno)
344
345 if FLAGS.voltage is not None:
346 if FLAGS.ramp is not None:
347 mon.RampVoltage(mon.start_voltage, FLAGS.voltage)
348 else:
349 mon.SetVoltage(FLAGS.voltage)
350
351 if FLAGS.current is not None:
352 mon.SetMaxCurrent(FLAGS.current)
353
354 if FLAGS.status:
355 items = sorted(mon.GetStatus().items())
356 print "\n".join(["%s: %s" % item for item in items])
357
358 if FLAGS.usbpassthrough:
359 if FLAGS.usbpassthrough == 'off':
360 mon.SetUsbPassthrough(0)
361 elif FLAGS.usbpassthrough == 'on':
362 mon.SetUsbPassthrough(1)
363 elif FLAGS.usbpassthrough == 'auto':
364 mon.SetUsbPassthrough(2)
365 else:
366 sys.exit('bad passthrough flag: %s' % FLAGS.usbpassthrough)
367
368 if FLAGS.samples:
369 # Make sure state is normal
370 mon.StopDataCollection()
371 status = mon.GetStatus()
372 native_hz = status["sampleRate"] * 1000
373
374 # Collect and average samples as specified
375 mon.StartDataCollection()
376
377 # In case FLAGS.hz doesn't divide native_hz exactly, use this invariant:
378 # 'offset' = (consumed samples) * FLAGS.hz - (emitted samples) * native_hz
379 # This is the error accumulator in a variation of Bresenham's algorithm.
380 emitted = offset = 0
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100381 chan_buffers = tuple([] for _ in range(num_channels))
382 # past n samples for rolling average
383 history_deques = tuple(collections.deque() for _ in range(num_channels))
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700384
385 try:
386 last_flush = time.time()
387 while emitted < FLAGS.samples or FLAGS.samples == -1:
388 # The number of raw samples to consume before emitting the next output
389 need = (native_hz - offset + FLAGS.hz - 1) / FLAGS.hz
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100390 if need > len(chan_buffers[0]): # still need more input samples
391 chans_samples = mon.CollectData()
392 if not all(chans_samples): break
393 for chan_buffer, chan_samples in zip(chan_buffers, chans_samples):
394 chan_buffer.extend(chan_samples)
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700395 else:
396 # Have enough data, generate output samples.
397 # Adjust for consuming 'need' input samples.
398 offset += need * FLAGS.hz
399 while offset >= native_hz: # maybe multiple, if FLAGS.hz > native_hz
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100400 this_sample = [sum(chan[:need]) / need for chan in chan_buffers]
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700401
402 if FLAGS.timestamp: print int(time.time()),
403
404 if FLAGS.avg:
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100405 chan_avgs = []
406 for chan_deque, chan_sample in zip(history_deques, this_sample):
407 chan_deque.appendleft(chan_sample)
408 if len(chan_deque) > FLAGS.avg: chan_deque.pop()
409 chan_avgs.append(sum(chan_deque) / len(chan_deque))
410 # Interleave channel rolling avgs with latest channel data
411 data_to_print = [datum
412 for pair in zip(this_sample, chan_avgs)
413 for datum in pair]
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700414 else:
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100415 data_to_print = this_sample
416
417 fmt = ' '.join('%f' for _ in data_to_print)
418 print fmt % tuple(data_to_print)
419
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700420 sys.stdout.flush()
421
422 offset -= native_hz
423 emitted += 1 # adjust for emitting 1 output sample
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100424 chan_buffers = tuple(c[need:] for c in chan_buffers)
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700425 now = time.time()
426 if now - last_flush >= 0.99: # flush every second
427 sys.stdout.flush()
428 last_flush = now
429 except KeyboardInterrupt:
430 print >>sys.stderr, "interrupted"
431
432 mon.StopDataCollection()
433
434
435if __name__ == '__main__':
436 # Define flags here to avoid conflicts with people who use us as a library
437 flags.DEFINE_boolean("status", None, "Print power meter status")
438 flags.DEFINE_integer("avg", None,
439 "Also report average over last n data points")
440 flags.DEFINE_float("voltage", None, "Set output voltage (0 for off)")
441 flags.DEFINE_float("current", None, "Set max output current")
442 flags.DEFINE_string("usbpassthrough", None, "USB control (on, off, auto)")
Brendan Jackman7174bff2017-04-20 11:11:28 +0100443 flags.DEFINE_integer("samples", None,
444 "Collect and print this many samples. "
445 "-1 means collect indefinitely.")
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700446 flags.DEFINE_integer("hz", 5000, "Print this many samples/sec")
447 flags.DEFINE_string("device", None,
448 "Path to the device in /dev/... (ex:/dev/ttyACM1)")
449 flags.DEFINE_integer("serialno", None, "Look for a device with this serial number")
450 flags.DEFINE_boolean("timestamp", None,
451 "Also print integer (seconds) timestamp on each line")
452 flags.DEFINE_boolean("ramp", True, "Gradually increase voltage")
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100453 flags.DEFINE_boolean("includeusb", False, "Include measurements from USB channel")
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700454
455 main(FLAGS(sys.argv))