Home | History | Annotate | Download | only in utils
      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/).
     19 This file requires gflags, which requires setuptools.
     20 To install setuptools: sudo apt-get install python-setuptools
     21 To install gflags, see http://code.google.com/p/python-gflags/
     22 To install pyserial, see http://pyserial.sourceforge.net/
     23 
     24 Example 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 
     37 Output 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 
     47 import fcntl
     48 import os
     49 import select
     50 import signal
     51 import stat
     52 import struct
     53 import sys
     54 import time
     55 import collections
     56 
     57 import gflags as flags  # http://code.google.com/p/python-gflags/
     58 
     59 import serial           # http://pyserial.sourceforge.net/
     60 
     61 FLAGS = flags.FLAGS
     62 
     63 class 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 
    326 def 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 
    435 if __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