1# Copyright 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Interface for a USB-connected Monsoon power meter.
6
7http://msoon.com/LabEquipment/PowerMonitor/
8Currently Unix-only. Relies on fcntl, /dev, and /tmp.
9"""
10
11import collections
12import logging
13import os
14import select
15import struct
16import time
17
18from telemetry.core import util
19
20util.AddDirToPythonPath(util.GetTelemetryDir(), 'third_party', 'pyserial')
21import serial  # pylint: disable=F0401
22import serial.tools.list_ports  # pylint: disable=F0401,E0611
23
24
25Power = collections.namedtuple('Power', ['amps', 'volts'])
26
27
28class Monsoon:
29  """Provides a simple class to use the power meter.
30
31  mon = monsoon.Monsoon()
32  mon.SetVoltage(3.7)
33  mon.StartDataCollection()
34  mydata = []
35  while len(mydata) < 1000:
36    mydata.extend(mon.CollectData())
37  mon.StopDataCollection()
38  """
39
40  def __init__(self, device=None, serialno=None, wait=True):
41    """Establish a connection to a Monsoon.
42
43    By default, opens the first available port, waiting if none are ready.
44    A particular port can be specified with 'device', or a particular Monsoon
45    can be specified with 'serialno' (using the number printed on its back).
46    With wait=False, IOError is thrown if a device is not immediately available.
47    """
48    assert float(serial.VERSION) >= 2.7, \
49     'Monsoon requires pyserial v2.7 or later. You have %s' % serial.VERSION
50
51    self._coarse_ref = self._fine_ref = self._coarse_zero = self._fine_zero = 0
52    self._coarse_scale = self._fine_scale = 0
53    self._last_seq = 0
54    self._voltage_multiplier = None
55
56    if device:
57      self.ser = serial.Serial(device, timeout=1)
58      return
59
60    while 1:
61      for (port, desc, _) in serial.tools.list_ports.comports():
62        if not desc.lower().startswith('mobile device power monitor'):
63          continue
64        tmpname = '/tmp/monsoon.%s.%s' % (os.uname()[0], os.path.basename(port))
65        self._tempfile = open(tmpname, 'w')
66        try:  # Use a lockfile to ensure exclusive access.
67          # Put the import in here to avoid doing it on unsupported platforms.
68          import fcntl
69          fcntl.lockf(self._tempfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
70        except IOError:
71          logging.error('device %s is in use', port)
72          continue
73
74        try:  # Try to open the device.
75          self.ser = serial.Serial(port, timeout=1)
76          self.StopDataCollection()  # Just in case.
77          self._FlushInput()  # Discard stale input.
78          status = self.GetStatus()
79        except IOError, e:
80          logging.error('error opening device %s: %s', port, e)
81          continue
82
83        if not status:
84          logging.error('no response from device %s', port)
85        elif serialno and status['serialNumber'] != serialno:
86          logging.error('device %s is #%d', port, status['serialNumber'])
87        else:
88          if status['hardwareRevision'] == 1:
89            self._voltage_multiplier = 62.5 / 10**6
90          else:
91            self._voltage_multiplier = 125.0 / 10**6
92          return
93
94      self._tempfile = None
95      if not wait:
96        raise IOError('No device found')
97      logging.info('waiting for device...')
98      time.sleep(1)
99
100  def GetStatus(self):
101    """Requests and waits for status.  Returns status dictionary."""
102
103    # status packet format
104    STATUS_FORMAT = '>BBBhhhHhhhHBBBxBbHBHHHHBbbHHBBBbbbbbbbbbBH'
105    STATUS_FIELDS = [
106        'packetType', 'firmwareVersion', 'protocolVersion',
107        'mainFineCurrent', 'usbFineCurrent', 'auxFineCurrent', 'voltage1',
108        'mainCoarseCurrent', 'usbCoarseCurrent', 'auxCoarseCurrent', 'voltage2',
109        'outputVoltageSetting', 'temperature', 'status', 'leds',
110        'mainFineResistor', 'serialNumber', 'sampleRate',
111        'dacCalLow', 'dacCalHigh',
112        'powerUpCurrentLimit', 'runTimeCurrentLimit', 'powerUpTime',
113        'usbFineResistor', 'auxFineResistor',
114        'initialUsbVoltage', 'initialAuxVoltage',
115        'hardwareRevision', 'temperatureLimit', 'usbPassthroughMode',
116        'mainCoarseResistor', 'usbCoarseResistor', 'auxCoarseResistor',
117        'defMainFineResistor', 'defUsbFineResistor', 'defAuxFineResistor',
118        'defMainCoarseResistor', 'defUsbCoarseResistor', 'defAuxCoarseResistor',
119        'eventCode', 'eventData',
120    ]
121
122    self._SendStruct('BBB', 0x01, 0x00, 0x00)
123    while 1:  # Keep reading, discarding non-status packets.
124      data = self._ReadPacket()
125      if not data:
126        return None
127      if len(data) != struct.calcsize(STATUS_FORMAT) or data[0] != '\x10':
128        logging.debug('wanted status, dropped type=0x%02x, len=%d',
129                      ord(data[0]), len(data))
130        continue
131
132      status = dict(zip(STATUS_FIELDS, struct.unpack(STATUS_FORMAT, data)))
133      assert status['packetType'] == 0x10
134      for k in status.keys():
135        if k.endswith('VoltageSetting'):
136          status[k] = 2.0 + status[k] * 0.01
137        elif k.endswith('FineCurrent'):
138          pass  # Needs calibration data.
139        elif k.endswith('CoarseCurrent'):
140          pass  # Needs calibration data.
141        elif k.startswith('voltage') or k.endswith('Voltage'):
142          status[k] = status[k] * 0.000125
143        elif k.endswith('Resistor'):
144          status[k] = 0.05 + status[k] * 0.0001
145          if k.startswith('aux') or k.startswith('defAux'):
146            status[k] += 0.05
147        elif k.endswith('CurrentLimit'):
148          status[k] = 8 * (1023 - status[k]) / 1023.0
149      return status
150
151
152  def SetVoltage(self, v):
153    """Set the output voltage, 0 to disable."""
154    if v == 0:
155      self._SendStruct('BBB', 0x01, 0x01, 0x00)
156    else:
157      self._SendStruct('BBB', 0x01, 0x01, int((v - 2.0) * 100))
158
159
160  def SetMaxCurrent(self, i):
161    """Set the max output current."""
162    assert i >= 0 and i <= 8
163
164    val = 1023 - int((i/8)*1023)
165    self._SendStruct('BBB', 0x01, 0x0a, val & 0xff)
166    self._SendStruct('BBB', 0x01, 0x0b, val >> 8)
167
168  def SetUsbPassthrough(self, val):
169    """Set the USB passthrough mode: 0 = off, 1 = on,  2 = auto."""
170    self._SendStruct('BBB', 0x01, 0x10, val)
171
172
173  def StartDataCollection(self):
174    """Tell the device to start collecting and sending measurement data."""
175    self._SendStruct('BBB', 0x01, 0x1b, 0x01)  # Mystery command.
176    self._SendStruct('BBBBBBB', 0x02, 0xff, 0xff, 0xff, 0xff, 0x03, 0xe8)
177
178
179  def StopDataCollection(self):
180    """Tell the device to stop collecting measurement data."""
181    self._SendStruct('BB', 0x03, 0x00)  # Stop.
182
183
184  def CollectData(self):
185    """Return some current samples.  Call StartDataCollection() first."""
186    while 1:  # Loop until we get data or a timeout.
187      data = self._ReadPacket()
188      if not data:
189        return None
190      if len(data) < 4 + 8 + 1 or data[0] < '\x20' or data[0] > '\x2F':
191        logging.debug('wanted data, dropped type=0x%02x, len=%d',
192            ord(data[0]), len(data))
193        continue
194
195      seq, packet_type, x, _ = struct.unpack('BBBB', data[:4])
196      data = [struct.unpack(">hhhh", data[x:x+8])
197              for x in range(4, len(data) - 8, 8)]
198
199      if self._last_seq and seq & 0xF != (self._last_seq + 1) & 0xF:
200        logging.info('data sequence skipped, lost packet?')
201      self._last_seq = seq
202
203      if packet_type == 0:
204        if not self._coarse_scale or not self._fine_scale:
205          logging.info('waiting for calibration, dropped data packet')
206          continue
207
208        out = []
209        for main, usb, _, voltage in data:
210          main_voltage_v = self._voltage_multiplier * (voltage & ~3)
211          sample = 0.0
212          if main & 1:
213            sample += ((main & ~1) - self._coarse_zero) * self._coarse_scale
214          else:
215            sample += (main - self._fine_zero) * self._fine_scale
216          if usb & 1:
217            sample += ((usb & ~1) - self._coarse_zero) * self._coarse_scale
218          else:
219            sample += (usb - self._fine_zero) * self._fine_scale
220          out.append(Power(sample, main_voltage_v))
221        return out
222
223      elif packet_type == 1:
224        self._fine_zero = data[0][0]
225        self._coarse_zero = data[1][0]
226
227      elif packet_type == 2:
228        self._fine_ref = data[0][0]
229        self._coarse_ref = data[1][0]
230
231      else:
232        logging.debug('discarding data packet type=0x%02x', packet_type)
233        continue
234
235      if self._coarse_ref != self._coarse_zero:
236        self._coarse_scale = 2.88 / (self._coarse_ref - self._coarse_zero)
237      if self._fine_ref != self._fine_zero:
238        self._fine_scale = 0.0332 / (self._fine_ref - self._fine_zero)
239
240
241  def _SendStruct(self, fmt, *args):
242    """Pack a struct (without length or checksum) and send it."""
243    data = struct.pack(fmt, *args)
244    data_len = len(data) + 1
245    checksum = (data_len + sum(struct.unpack('B' * len(data), data))) % 256
246    out = struct.pack('B', data_len) + data + struct.pack('B', checksum)
247    self.ser.write(out)
248
249
250  def _ReadPacket(self):
251    """Read a single data record as a string (without length or checksum)."""
252    len_char = self.ser.read(1)
253    if not len_char:
254      logging.error('timeout reading from serial port')
255      return None
256
257    data_len = struct.unpack('B', len_char)
258    data_len = ord(len_char)
259    if not data_len:
260      return ''
261
262    result = self.ser.read(data_len)
263    if len(result) != data_len:
264      return None
265    body = result[:-1]
266    checksum = (data_len + sum(struct.unpack('B' * len(body), body))) % 256
267    if result[-1] != struct.pack('B', checksum):
268      logging.error('invalid checksum from serial port')
269      return None
270    return result[:-1]
271
272  def _FlushInput(self):
273    """Flush all read data until no more available."""
274    self.ser.flush()
275    flushed = 0
276    while True:
277      ready_r, _, ready_x = select.select([self.ser], [], [self.ser], 0)
278      if len(ready_x) > 0:
279        logging.error('exception from serial port')
280        return None
281      elif len(ready_r) > 0:
282        flushed += 1
283        self.ser.read(1)  # This may cause underlying buffering.
284        self.ser.flush()  # Flush the underlying buffer too.
285      else:
286        break
287    if flushed > 0:
288      logging.debug('dropped >%d bytes', flushed)
289