blob: a179bca7386bb859d349832b62e5a78db577ddef [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
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100235 def scale(val):
236 if val & 1:
237 return ((val & ~1) - self._coarse_zero) * self._coarse_scale
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700238 else:
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100239 return (val - self._fine_zero) * self._fine_scale
240
241 out_main = []
242 out_usb = []
243 for main, usb, aux, voltage in data:
244 out_main.append(scale(main))
245 out_usb.append(scale(usb))
246 return (out_main, out_usb)
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700247
248 elif type == 1:
249 self._fine_zero = data[0][0]
250 self._coarse_zero = data[1][0]
251 # print >>sys.stderr, "zero calibration: fine 0x%04x, coarse 0x%04x" % (
252 # self._fine_zero, self._coarse_zero)
253
254 elif type == 2:
255 self._fine_ref = data[0][0]
256 self._coarse_ref = data[1][0]
257 # print >>sys.stderr, "ref calibration: fine 0x%04x, coarse 0x%04x" % (
258 # self._fine_ref, self._coarse_ref)
259
260 else:
261 print >>sys.stderr, "discarding data packet type=0x%02x" % type
262 continue
263
264 if self._coarse_ref != self._coarse_zero:
265 self._coarse_scale = 2.88 / (self._coarse_ref - self._coarse_zero)
266 if self._fine_ref != self._fine_zero:
267 self._fine_scale = 0.0332 / (self._fine_ref - self._fine_zero)
268
269
270 def _SendStruct(self, fmt, *args):
271 """ Pack a struct (without length or checksum) and send it. """
272 data = struct.pack(fmt, *args)
273 data_len = len(data) + 1
274 checksum = (data_len + sum(struct.unpack("B" * len(data), data))) % 256
275 out = struct.pack("B", data_len) + data + struct.pack("B", checksum)
276 self.ser.write(out)
277
278
279 def _ReadPacket(self):
280 """ Read a single data record as a string (without length or checksum). """
281 len_char = self.ser.read(1)
282 if not len_char:
283 print >>sys.stderr, "timeout reading from serial port"
284 return None
285
286 data_len = struct.unpack("B", len_char)
287 data_len = ord(len_char)
288 if not data_len: return ""
289
290 result = self.ser.read(data_len)
291 if len(result) != data_len: return None
292 body = result[:-1]
293 checksum = (data_len + sum(struct.unpack("B" * len(body), body))) % 256
294 if result[-1] != struct.pack("B", checksum):
295 print >>sys.stderr, "invalid checksum from serial port"
296 return None
297 return result[:-1]
298
299 def _FlushInput(self):
300 """ Flush all read data until no more available. """
301 self.ser.flush()
302 flushed = 0
303 while True:
304 ready_r, ready_w, ready_x = select.select([self.ser], [], [self.ser], 0)
305 if len(ready_x) > 0:
306 print >>sys.stderr, "exception from serial port"
307 return None
308 elif len(ready_r) > 0:
309 flushed += 1
310 self.ser.read(1) # This may cause underlying buffering.
311 self.ser.flush() # Flush the underlying buffer too.
312 else:
313 break
314 if flushed > 0:
315 print >>sys.stderr, "dropped >%d bytes" % flushed
316
317def main(argv):
318 """ Simple command-line interface for Monsoon."""
319 useful_flags = ["voltage", "status", "usbpassthrough", "samples", "current"]
320 if not [f for f in useful_flags if FLAGS.get(f, None) is not None]:
321 print __doc__.strip()
322 print FLAGS.MainModuleHelp()
323 return
324
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100325 if FLAGS.includeusb:
326 num_channels = 2
327 else:
328 num_channels = 1
329
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700330 if FLAGS.avg and FLAGS.avg < 0:
331 print "--avg must be greater than 0"
332 return
333
334 mon = Monsoon(device=FLAGS.device, serialno=FLAGS.serialno)
335
336 if FLAGS.voltage is not None:
337 if FLAGS.ramp is not None:
338 mon.RampVoltage(mon.start_voltage, FLAGS.voltage)
339 else:
340 mon.SetVoltage(FLAGS.voltage)
341
342 if FLAGS.current is not None:
343 mon.SetMaxCurrent(FLAGS.current)
344
345 if FLAGS.status:
346 items = sorted(mon.GetStatus().items())
347 print "\n".join(["%s: %s" % item for item in items])
348
349 if FLAGS.usbpassthrough:
350 if FLAGS.usbpassthrough == 'off':
351 mon.SetUsbPassthrough(0)
352 elif FLAGS.usbpassthrough == 'on':
353 mon.SetUsbPassthrough(1)
354 elif FLAGS.usbpassthrough == 'auto':
355 mon.SetUsbPassthrough(2)
356 else:
357 sys.exit('bad passthrough flag: %s' % FLAGS.usbpassthrough)
358
359 if FLAGS.samples:
360 # Make sure state is normal
361 mon.StopDataCollection()
362 status = mon.GetStatus()
363 native_hz = status["sampleRate"] * 1000
364
365 # Collect and average samples as specified
366 mon.StartDataCollection()
367
368 # In case FLAGS.hz doesn't divide native_hz exactly, use this invariant:
369 # 'offset' = (consumed samples) * FLAGS.hz - (emitted samples) * native_hz
370 # This is the error accumulator in a variation of Bresenham's algorithm.
371 emitted = offset = 0
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100372 chan_buffers = tuple([] for _ in range(num_channels))
373 # past n samples for rolling average
374 history_deques = tuple(collections.deque() for _ in range(num_channels))
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700375
376 try:
377 last_flush = time.time()
378 while emitted < FLAGS.samples or FLAGS.samples == -1:
379 # The number of raw samples to consume before emitting the next output
380 need = (native_hz - offset + FLAGS.hz - 1) / FLAGS.hz
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100381 if need > len(chan_buffers[0]): # still need more input samples
382 chans_samples = mon.CollectData()
383 if not all(chans_samples): break
384 for chan_buffer, chan_samples in zip(chan_buffers, chans_samples):
385 chan_buffer.extend(chan_samples)
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700386 else:
387 # Have enough data, generate output samples.
388 # Adjust for consuming 'need' input samples.
389 offset += need * FLAGS.hz
390 while offset >= native_hz: # maybe multiple, if FLAGS.hz > native_hz
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100391 this_sample = [sum(chan[:need]) / need for chan in chan_buffers]
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700392
393 if FLAGS.timestamp: print int(time.time()),
394
395 if FLAGS.avg:
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100396 chan_avgs = []
397 for chan_deque, chan_sample in zip(history_deques, this_sample):
398 chan_deque.appendleft(chan_sample)
399 if len(chan_deque) > FLAGS.avg: chan_deque.pop()
400 chan_avgs.append(sum(chan_deque) / len(chan_deque))
401 # Interleave channel rolling avgs with latest channel data
402 data_to_print = [datum
403 for pair in zip(this_sample, chan_avgs)
404 for datum in pair]
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700405 else:
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100406 data_to_print = this_sample
407
408 fmt = ' '.join('%f' for _ in data_to_print)
409 print fmt % tuple(data_to_print)
410
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700411 sys.stdout.flush()
412
413 offset -= native_hz
414 emitted += 1 # adjust for emitting 1 output sample
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100415 chan_buffers = tuple(c[need:] for c in chan_buffers)
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700416 now = time.time()
417 if now - last_flush >= 0.99: # flush every second
418 sys.stdout.flush()
419 last_flush = now
420 except KeyboardInterrupt:
421 print >>sys.stderr, "interrupted"
422
423 mon.StopDataCollection()
424
425
426if __name__ == '__main__':
427 # Define flags here to avoid conflicts with people who use us as a library
428 flags.DEFINE_boolean("status", None, "Print power meter status")
429 flags.DEFINE_integer("avg", None,
430 "Also report average over last n data points")
431 flags.DEFINE_float("voltage", None, "Set output voltage (0 for off)")
432 flags.DEFINE_float("current", None, "Set max output current")
433 flags.DEFINE_string("usbpassthrough", None, "USB control (on, off, auto)")
Brendan Jackman7174bff2017-04-20 11:11:28 +0100434 flags.DEFINE_integer("samples", None,
435 "Collect and print this many samples. "
436 "-1 means collect indefinitely.")
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700437 flags.DEFINE_integer("hz", 5000, "Print this many samples/sec")
438 flags.DEFINE_string("device", None,
439 "Path to the device in /dev/... (ex:/dev/ttyACM1)")
440 flags.DEFINE_integer("serialno", None, "Look for a device with this serial number")
441 flags.DEFINE_boolean("timestamp", None,
442 "Also print integer (seconds) timestamp on each line")
443 flags.DEFINE_boolean("ramp", True, "Gradually increase voltage")
Brendan Jackmanc3520a92017-04-20 11:23:04 +0100444 flags.DEFINE_boolean("includeusb", False, "Include measurements from USB channel")
Etienne Le Grande6124fd2014-05-02 19:54:50 -0700445
446 main(FLAGS(sys.argv))