1#!/usr/bin/env python
2
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
26  python monsoon.py --voltage=4.0 --serialno 7536
27
28  Get 5000hz data from device number 7536, with unlimited number of samples
29  python monsoon.py --samples -1 --hz 5000 --serialno 7536
30
31  Get 200Hz data for 5 seconds (1000 events) from default device
32  python monsoon.py --samples 100 --hz 200
33
34  Get unlimited 200Hz data from device attached at /dev/ttyACM0
35  python monsoon.py --samples -1 --hz 200 --device /dev/ttyACM0
36
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)
45"""
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
244        def scale(val):
245          if val & 1:
246            return ((val & ~1) - self._coarse_zero) * self._coarse_scale
247          else:
248            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)
256
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
334  if FLAGS.includeusb:
335    num_channels = 2
336  else:
337    num_channels = 1
338
339  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
381    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))
384
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
390        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)
395        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
400            this_sample = [sum(chan[:need]) / need for chan in chan_buffers]
401
402            if FLAGS.timestamp: print int(time.time()),
403
404            if FLAGS.avg:
405              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]
414            else:
415              data_to_print = this_sample
416
417            fmt = ' '.join('%f' for _ in data_to_print)
418            print fmt % tuple(data_to_print)
419
420            sys.stdout.flush()
421
422            offset -= native_hz
423            emitted += 1              # adjust for emitting 1 output sample
424          chan_buffers = tuple(c[need:] for c in chan_buffers)
425          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)")
443  flags.DEFINE_integer("samples", None,
444                       "Collect and print this many samples. "
445                       "-1 means collect indefinitely.")
446  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")
453  flags.DEFINE_boolean("includeusb", False, "Include measurements from USB channel")
454
455  main(FLAGS(sys.argv))
456