1#!/usr/bin/env python3 2# 3# Copyright 2019 - 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"""The interface for a USB-connected Monsoon power meter. 17 18Details on the protocol can be found at 19(http://msoon.com/LabEquipment/PowerMonitor/) 20 21Based on the original py2 script of kens@google.com. 22""" 23import collections 24import fcntl 25import logging 26import os 27import select 28import struct 29import sys 30import time 31 32import errno 33import serial 34 35from acts.controllers.monsoon_lib.api.common import MonsoonError 36 37 38class LvpmStatusPacket(object): 39 """The data received from asking an LVPM Monsoon for its status. 40 41 Attributes names with the same values as HVPM match those defined in 42 Monsoon.Operations.statusPacket. 43 """ 44 45 def __init__(self, values): 46 iter_value = iter(values) 47 self.packetType = next(iter_value) 48 self.firmwareVersion = next(iter_value) 49 self.protocolVersion = next(iter_value) 50 self.mainFineCurrent = next(iter_value) 51 self.usbFineCurrent = next(iter_value) 52 self.auxFineCurrent = next(iter_value) 53 self.voltage1 = next(iter_value) 54 self.mainCoarseCurrent = next(iter_value) 55 self.usbCoarseCurrent = next(iter_value) 56 self.auxCoarseCurrent = next(iter_value) 57 self.voltage2 = next(iter_value) 58 self.outputVoltageSetting = next(iter_value) 59 self.temperature = next(iter_value) 60 self.status = next(iter_value) 61 self.leds = next(iter_value) 62 self.mainFineResistor = next(iter_value) 63 self.serialNumber = next(iter_value) 64 self.sampleRate = next(iter_value) 65 self.dacCalLow = next(iter_value) 66 self.dacCalHigh = next(iter_value) 67 self.powerupCurrentLimit = next(iter_value) 68 self.runtimeCurrentLimit = next(iter_value) 69 self.powerupTime = next(iter_value) 70 self.usbFineResistor = next(iter_value) 71 self.auxFineResistor = next(iter_value) 72 self.initialUsbVoltage = next(iter_value) 73 self.initialAuxVoltage = next(iter_value) 74 self.hardwareRevision = next(iter_value) 75 self.temperatureLimit = next(iter_value) 76 self.usbPassthroughMode = next(iter_value) 77 self.mainCoarseResistor = next(iter_value) 78 self.usbCoarseResistor = next(iter_value) 79 self.auxCoarseResistor = next(iter_value) 80 self.defMainFineResistor = next(iter_value) 81 self.defUsbFineResistor = next(iter_value) 82 self.defAuxFineResistor = next(iter_value) 83 self.defMainCoarseResistor = next(iter_value) 84 self.defUsbCoarseResistor = next(iter_value) 85 self.defAuxCoarseResistor = next(iter_value) 86 self.eventCode = next(iter_value) 87 self.eventData = next(iter_value) 88 89 90class MonsoonProxy(object): 91 """Class that directly talks to monsoon over serial. 92 93 Provides a simple class to use the power meter. 94 See http://wiki/Main/MonsoonProtocol for information on the protocol. 95 """ 96 97 # The format of the status packet. 98 STATUS_FORMAT = '>BBBhhhHhhhHBBBxBbHBHHHHBbbHHBBBbbbbbbbbbBH' 99 100 # The list of fields that appear in the Monsoon status packet. 101 STATUS_FIELDS = [ 102 'packetType', 103 'firmwareVersion', 104 'protocolVersion', 105 'mainFineCurrent', 106 'usbFineCurrent', 107 'auxFineCurrent', 108 'voltage1', 109 'mainCoarseCurrent', 110 'usbCoarseCurrent', 111 'auxCoarseCurrent', 112 'voltage2', 113 'outputVoltageSetting', 114 'temperature', 115 'status', 116 'leds', 117 'mainFineResistorOffset', 118 'serialNumber', 119 'sampleRate', 120 'dacCalLow', 121 'dacCalHigh', 122 'powerupCurrentLimit', 123 'runtimeCurrentLimit', 124 'powerupTime', 125 'usbFineResistorOffset', 126 'auxFineResistorOffset', 127 'initialUsbVoltage', 128 'initialAuxVoltage', 129 'hardwareRevision', 130 'temperatureLimit', 131 'usbPassthroughMode', 132 'mainCoarseResistorOffset', 133 'usbCoarseResistorOffset', 134 'auxCoarseResistorOffset', 135 'defMainFineResistor', 136 'defUsbFineResistor', 137 'defAuxFineResistor', 138 'defMainCoarseResistor', 139 'defUsbCoarseResistor', 140 'defAuxCoarseResistor', 141 'eventCode', 142 'eventData', 143 ] 144 145 def __init__(self, device=None, serialno=None, connection_timeout=600): 146 """Establish a connection to a Monsoon. 147 148 By default, opens the first available port, waiting if none are ready. 149 150 Args: 151 device: The particular device port to be used. 152 serialno: The Monsoon's serial number. 153 connection_timeout: The number of seconds to wait for the device to 154 connect. 155 156 Raises: 157 TimeoutError if unable to connect to the device. 158 """ 159 self.start_voltage = 0 160 self.serial = serialno 161 162 if device: 163 self.ser = serial.Serial(device, timeout=1) 164 return 165 # Try all devices connected through USB virtual serial ports until we 166 # find one we can use. 167 self._tempfile = None 168 self.obtain_dev_port(connection_timeout) 169 self.log = logging.getLogger() 170 171 def obtain_dev_port(self, timeout=600): 172 """Obtains the device port for this Monsoon. 173 174 Args: 175 timeout: The time in seconds to wait for the device to connect. 176 177 Raises: 178 TimeoutError if the device was unable to be found, or was not 179 available. 180 """ 181 start_time = time.time() 182 183 while start_time + timeout > time.time(): 184 for dev in os.listdir('/dev'): 185 prefix = 'ttyACM' 186 # Prefix is different on Mac OS X. 187 if sys.platform == 'darwin': 188 prefix = 'tty.usbmodem' 189 if not dev.startswith(prefix): 190 continue 191 tmpname = '/tmp/monsoon.%s.%s' % (os.uname()[0], dev) 192 self._tempfile = open(tmpname, 'w') 193 if not os.access(tmpname, os.R_OK | os.W_OK): 194 try: 195 os.chmod(tmpname, 0o666) 196 except OSError as e: 197 if e.errno == errno.EACCES: 198 raise ValueError( 199 'Unable to set permissions to read/write to ' 200 '%s. This file is owned by another user; ' 201 'please grant o+wr access to this file, or ' 202 'run as that user.') 203 raise 204 205 try: # Use a lock file to ensure exclusive access. 206 fcntl.flock(self._tempfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 207 except IOError: 208 logging.error('Device %s is in use.', repr(dev)) 209 continue 210 211 try: # try to open the device 212 self.ser = serial.Serial('/dev/%s' % dev, timeout=1) 213 self.stop_data_collection() # just in case 214 self._flush_input() # discard stale input 215 status = self.get_status() 216 except Exception as e: 217 logging.warning('Error opening device %s: %s', dev, e, 218 exc_info=True) 219 continue 220 221 if not status: 222 logging.error('No response from device %s.', dev) 223 elif self.serial and status.serialNumber != self.serial: 224 logging.error('Another device serial #%d seen on %s', 225 status.serialNumber, dev) 226 else: 227 self.start_voltage = status.voltage1 228 return 229 230 self._tempfile = None 231 logging.info('Waiting for device...') 232 time.sleep(1) 233 raise TimeoutError( 234 'Unable to connect to Monsoon device with ' 235 'serial "%s" within %s seconds.' % (self.serial, timeout)) 236 237 def release_dev_port(self): 238 """Releases the dev port used to communicate with the Monsoon device.""" 239 fcntl.flock(self._tempfile, fcntl.LOCK_UN) 240 self._tempfile.close() 241 self.ser.close() 242 243 def get_status(self): 244 """Requests and waits for status. 245 246 Returns: 247 status dictionary. 248 """ 249 self._send_struct('BBB', 0x01, 0x00, 0x00) 250 read_bytes = self._read_packet() 251 252 if not read_bytes: 253 raise MonsoonError('Failed to read Monsoon status') 254 expected_size = struct.calcsize(self.STATUS_FORMAT) 255 if len(read_bytes) != expected_size or read_bytes[0] != 0x10: 256 raise MonsoonError('Wanted status, dropped type=0x%02x, len=%d', 257 read_bytes[0], len(read_bytes)) 258 259 status = collections.OrderedDict( 260 zip(self.STATUS_FIELDS, 261 struct.unpack(self.STATUS_FORMAT, read_bytes))) 262 p_type = status['packetType'] 263 if p_type != 0x10: 264 raise MonsoonError('Packet type %s is not 0x10.' % p_type) 265 266 for k in status.keys(): 267 if k.endswith('VoltageSetting'): 268 status[k] = 2.0 + status[k] * 0.01 269 elif k.endswith('FineCurrent'): 270 pass # needs calibration data 271 elif k.endswith('CoarseCurrent'): 272 pass # needs calibration data 273 elif k.startswith('voltage') or k.endswith('Voltage'): 274 status[k] = status[k] * 0.000125 275 elif k.endswith('Resistor'): 276 status[k] = 0.05 + status[k] * 0.0001 277 if k.startswith('aux') or k.startswith('defAux'): 278 status[k] += 0.05 279 elif k.endswith('CurrentLimit'): 280 status[k] = 8 * (1023 - status[k]) / 1023.0 281 return LvpmStatusPacket(status.values()) 282 283 def set_voltage(self, voltage): 284 """Sets the voltage on the device to the specified value. 285 286 Args: 287 voltage: Either 0 or a value between 2.01 and 4.55 inclusive. 288 289 Raises: 290 struct.error if voltage is an invalid value. 291 """ 292 # The device has a range of 255 voltage values: 293 # 294 # 0 is "off". Note this value not set outputVoltageSetting to 295 # zero. The previous outputVoltageSetting value is 296 # maintained. 297 # 1 is 2.01V. 298 # 255 is 4.55V. 299 voltage_byte = max(0, round((voltage - 2.0) * 100)) 300 self._send_struct('BBB', 0x01, 0x01, voltage_byte) 301 302 def get_voltage(self): 303 """Get the output voltage. 304 305 Returns: 306 Current Output Voltage (in unit of V). 307 """ 308 return self.get_status().outputVoltageSetting 309 310 def set_max_current(self, i): 311 """Set the max output current.""" 312 if i < 0 or i > 8: 313 raise MonsoonError(('Target max current %sA, is out of acceptable ' 314 'range [0, 8].') % i) 315 val = 1023 - int((i / 8) * 1023) 316 self._send_struct('BBB', 0x01, 0x0a, val & 0xff) 317 self._send_struct('BBB', 0x01, 0x0b, val >> 8) 318 319 def set_max_initial_current(self, current): 320 """Sets the maximum initial current, in mA.""" 321 if current < 0 or current > 8: 322 raise MonsoonError(('Target max current %sA, is out of acceptable ' 323 'range [0, 8].') % current) 324 val = 1023 - int((current / 8) * 1023) 325 self._send_struct('BBB', 0x01, 0x08, val & 0xff) 326 self._send_struct('BBB', 0x01, 0x09, val >> 8) 327 328 def set_usb_passthrough(self, passthrough_mode): 329 """Set the USB passthrough mode. 330 331 Args: 332 passthrough_mode: The mode used for passthrough. Must be the integer 333 value. See common.PassthroughModes for a list of values and 334 their meanings. 335 """ 336 self._send_struct('BBB', 0x01, 0x10, passthrough_mode) 337 338 def get_usb_passthrough(self): 339 """Get the USB passthrough mode: 0 = off, 1 = on, 2 = auto. 340 341 Returns: 342 The mode used for passthrough, as an integer. See 343 common.PassthroughModes for a list of values and their meanings. 344 """ 345 return self.get_status().usbPassthroughMode 346 347 def start_data_collection(self): 348 """Tell the device to start collecting and sending measurement data.""" 349 self._send_struct('BBB', 0x01, 0x1b, 0x01) # Mystery command 350 self._send_struct('BBBBBBB', 0x02, 0xff, 0xff, 0xff, 0xff, 0x03, 0xe8) 351 352 def stop_data_collection(self): 353 """Tell the device to stop collecting measurement data.""" 354 self._send_struct('BB', 0x03, 0x00) # stop 355 356 def _send_struct(self, fmt, *args): 357 """Pack a struct (without length or checksum) and send it.""" 358 # Flush out the input buffer before sending data 359 self._flush_input() 360 data = struct.pack(fmt, *args) 361 data_len = len(data) + 1 362 checksum = (data_len + sum(bytearray(data))) % 256 363 out = struct.pack('B', data_len) + data + struct.pack('B', checksum) 364 self.ser.write(out) 365 366 def _read_packet(self): 367 """Returns a single packet as a string (without length or checksum).""" 368 len_char = self.ser.read(1) 369 if not len_char: 370 raise MonsoonError('Reading from serial port timed out') 371 372 data_len = ord(len_char) 373 if not data_len: 374 return '' 375 result = self.ser.read(int(data_len)) 376 result = bytearray(result) 377 if len(result) != data_len: 378 raise MonsoonError( 379 'Length mismatch, expected %d bytes, got %d bytes.', data_len, 380 len(result)) 381 body = result[:-1] 382 checksum = (sum(struct.unpack('B' * len(body), body)) + data_len) % 256 383 if result[-1] != checksum: 384 raise MonsoonError( 385 'Invalid checksum from serial port! Expected %s, got %s', 386 hex(checksum), hex(result[-1])) 387 return result[:-1] 388 389 def _flush_input(self): 390 """Flushes all read data until the input is empty.""" 391 self.ser.reset_input_buffer() 392 while True: 393 ready_r, ready_w, ready_x = select.select([self.ser], [], 394 [self.ser], 0) 395 if len(ready_x) > 0: 396 raise MonsoonError('Exception from serial port.') 397 elif len(ready_r) > 0: 398 self.ser.read(1) # This may cause underlying buffering. 399 # Flush the underlying buffer too. 400 self.ser.reset_input_buffer() 401 else: 402 break 403