1#!/usr/bin/python2.6
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  python2.6 monsoon.py --voltage=4.0 --serialno 7536
27
28  Get 5000hz data from device number 7536, with unlimited number of samples
29  python2.6 monsoon.py --samples -1 --hz 5000 --serialno 7536
30
31  Get 200Hz data for 5 seconds (1000 events) from default device
32  python2.6 monsoon.py --samples 100 --hz 200
33
34  Get unlimited 200Hz data from device attached at /dev/ttyACM0
35  python2.6 monsoon.py --samples -1 --hz 200 --device /dev/ttyACM0
36"""
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)")
413  flags.DEFINE_integer("samples", None, "Collect and print this many samples")
414  flags.DEFINE_integer("hz", 5000, "Print this many samples/sec")
415  flags.DEFINE_string("device", None,
416                      "Path to the device in /dev/... (ex:/dev/ttyACM1)")
417  flags.DEFINE_integer("serialno", None, "Look for a device with this serial number")
418  flags.DEFINE_boolean("timestamp", None,
419                       "Also print integer (seconds) timestamp on each line")
420  flags.DEFINE_boolean("ramp", True, "Gradually increase voltage")
421
422  main(FLAGS(sys.argv))
423