#!/usr/bin/env python
#
# Copyright 2014 Matthew Wall
# See the file LICENSE.txt for your rights.

"""Driver for CC3000 data logger

http://www.rainwise.com/products/attachments/6832/20110518125531.pdf

There are a few variants:

CC-3000_ - __
       |   |
       |   41 = 418 MHz
       |   42 = 433 MHz
       |   __ = 2.4 GHz (LR compatible)
       R = serial (RS232, RS485)
       _ = USB 2.0

The CC3000 communicates using FTDI USB serial bridge.  The CC3000R has both
RS-232 and RS-485 serial ports, only one of which may be used at a time.
A long range (LR) version transmits up to 2 km using 2.4GHz.

The RS232 communicates using 115200 N-8-1

The instrument cluster contains a DIP switch controls with value 0-3 and a
default of 0.  This setting prevents interference when there are multiple
weather stations within radio range.

The CC3000 includes a temperature sensor - that is the source of inTemp.  The
manual indicates that the CC3000 should run for 3 or 4 hours before applying
any calibration to offset the heat generated by CC3000 electronics.

The CC3000 uses 4 AA batteries to maintain its clock.  Use only rechargeable
NiMH batteries.

The logger contains 2MB of memory, with a capacity of 49834 records (over 11
months of data at a 10 minute logging interval).  The exact capacity depends
on the sensors; the basic sensor record is 42 bytes.

The logger does not delete old records when it fills up; once the logger is
full, new data are lost.  So the driver must periodically clear the logger
memory.

This driver does not support hardware record_generation.  It does support
catchup on startup.

If you request many history records then interrupt the receive, the logger will
continue to send history records until it sends all that were requested.  As a
result, any queries made while the logger is still sending will fail.

The rainwise rain bucket measures 0.01 inches per tip.  The logger firmware
automatically converts the bucket tip count to the measure of rain in ENGLISH
or METRIC units.

The historical records appear to track amount of rain since midnight.  If there
is any rainfall in the history, the count resets after midnight.  But the
current rain counter appears to be an absolute count.

Logger uses the following units:
               ENGLISH  METRIC
  wind         mph      m/s
  rain         inch     mm
  pressure     inHg     mbar
  temperature  F        C

Reading the history logger at full speed seems to cause problems on some
systems, specifically with syslog and the USB.  It seems like we have no data
loss, but logging of the history reads is lossy, especially with debug enabled.

This driver was tested with:
  Rainwise CC-3000 Version: 1.3 Build 006 Sep 04 2013
  Rainwise CC-3000 Version: 1.3 Build 016 Aug 21 2014
"""

# FIXME: come up with a way to deal with firmware inconsistencies.  if we do
#        a strict protocol where we wait for an OK response, but one version of
#        the firmware responds whereas another version does not, this leads to
#        comm problems.  specializing the code to handle quirks of each
#        firmware version is not desirable.  maybe just do a flush of the
#        serial buffer before doing any command?

# FIXME: figure out whether logger retains non-fixed interval data

# FIXME: figure out why syslog messages are lost.  when reading from the logger
#        there are many messages to syslog that just do not show up, or msgs
#        that appear in one run but not in a second, identical run.  i suspect
#        that syslog cannot handle the load?  or its buffer is not big enough?
#        but it makes debugging obscenely difficult!

from __future__ import with_statement
import datetime
import serial
import string
import syslog
import time

import weeutil.weeutil
import weewx.drivers

DRIVER_NAME = 'CC3000'
DRIVER_VERSION = '0.19'

def loader(config_dict, engine):
    return CC3000Driver(**config_dict[DRIVER_NAME])

def configurator_loader(config_dict):
    return CC3000Configurator()

def confeditor_loader():
    return CC3000ConfEditor()

DEBUG_SERIAL = 0
DEBUG_CHECKSUM = 0
DEBUG_OPENCLOSE = 0

def logmsg(level, msg):
    syslog.syslog(level, 'cc3000: %s' % msg)

def logdbg(msg):
    logmsg(syslog.LOG_DEBUG, msg)

def loginf(msg):
    logmsg(syslog.LOG_INFO, msg)

def logerr(msg):
    logmsg(syslog.LOG_ERR, msg)

class ChecksumError(weewx.WeeWxIOError):
    def __init__(self, msg):
        weewx.WeeWxIOError.__init__(self, msg)

class ChecksumMismatch(ChecksumError):
    def __init__(self, a, b, buf=None):
        msg = "Checksum mismatch: 0x%04x != 0x%04x" % (a, b)
        if buf is not None:
            msg = "%s (%s)" % (msg, _fmt(buf))
        ChecksumError.__init__(self, msg)

class BadCRC(ChecksumError):
    def __init__(self, a, b, buf=None):
        msg = "Bad CRC: 0x%04x != '%s'" % (a, b)
        if buf is not None:
            msg = "%s (%s)" % (msg, _fmt(buf))
        ChecksumError.__init__(self, msg)


class CC3000Configurator(weewx.drivers.AbstractConfigurator):
    def add_options(self, parser):
        super(CC3000Configurator, self).add_options(parser)
        parser.add_option("--info", dest="info", action="store_true",
                          help="display weather station configuration")
        parser.add_option("--current", dest="current", action="store_true",
                          help="display current weather readings")
        parser.add_option("--history", dest="nrecords", type=int, metavar="N",
                          help="display N records (0 for all records)")
        parser.add_option("--history-since", dest="nminutes", metavar="N",
                          type=int, help="display records since N minutes ago")
        parser.add_option("--clear-memory", dest="clear", action="store_true",
                          help="clear station memory")
        parser.add_option("--reset-rain", dest="reset", action="store_true",
                          help="reset the rain counter")
        parser.add_option("--get-clock", dest="getclock", action="store_true",
                          help="display station clock")
        parser.add_option("--set-clock", dest="setclock", action="store_true",
                          help="set station clock to computer time")
        parser.add_option("--get-interval", dest="getint", action="store_true",
                          help="display logger archive interval, in minutes")
        parser.add_option("--set-interval", dest="interval", metavar="N",
                          type=int,
                          help="set logging interval to N minutes (0-60)")
        parser.add_option("--get-units", dest="getunits", action="store_true",
                          help="show units of logger")
        parser.add_option("--set-units", dest="units", metavar="UNITS",
                          help="set units to METRIC or ENGLISH")
        parser.add_option('--get-dst', dest='getdst', action='store_true',
                          help='display daylight savings settings')
        parser.add_option('--set-dst', dest='dst',
                          metavar='mm/dd HH:MM,mm/dd HH:MM,[MM]M',
                          help='set daylight savings start, end, and amount')
        parser.add_option("--get-channel", dest="getch", action="store_true",
                          help="display the station channel")
        parser.add_option("--set-channel", dest="ch", metavar="CHANNEL",
                          type=int,
                          help="set the station channel")

    def do_options(self, options, parser, config_dict, prompt):
        self.driver = CC3000Driver(**config_dict[DRIVER_NAME])
        if options.current:
            print self.driver.get_current()
        elif options.nrecords is not None:
            for r in self.driver.station.gen_records(nrecords):
                print r
        elif options.clear:
            self.clear_memory(prompt)
        elif options.reset:
            self.reset_rain(prompt)
        elif options.getclock:
            print self.driver.station.get_time()
        elif options.setclock:
            self.set_clock(prompt)
        elif options.getdst:
            print self.driver.station.get_dst()
        elif options.dst is not None:
            self.set_dst(options.setdst, prompt)
        elif options.getint:
            print self.driver.station.get_interval() * 60
        elif options.interval is not None:
            self.set_interval(options.interval / 60, prompt)
        elif options.getunits:
            print self.driver.station.get_units()
        elif options.units is not None:
            self.set_units(options.units, prompt)
        elif options.getch:
            print self.driver.station.get_channel()
        elif options.ch is not None:
            self.set_channel(options.ch, prompt)
        else:
            print "firmware:", self.driver.station.get_version()
            print "time:", self.driver.station.get_time()
            print "dst:", self.driver.station.get_dst()
            print "units:", self.driver.station.get_units()
            print "memory:", self.driver.station.get_memory_status()
            print "interval:", self.driver.station.get_interval() * 60
            print "channel:", self.driver.station.get_channel()
            print "charger:", self.driver.station.get_charger()
            print "baro:", self.driver.station.get_baro()
            print "rain:", self.driver.station.get_rain()
        self.driver.closePort()

    def clear_memory(self, prompt):
        ans = None
        while ans not in ['y', 'n']:
            print self.driver.station.get_memory_status()
            if prompt:
                ans = raw_input("Clear console memory (y/n)? ")
            else:
                print 'Clearing console memory'
                ans = 'y'
            if ans == 'y':
                self.driver.station.clear_memory()
                print self.driver.station.get_memory_status()
            elif ans == 'n':
                print "Clear memory cancelled."

    def reset_rain(self, prompt):
        ans = None
        while ans not in ['y', 'n']:
            print self.driver.station.get_rain()
            if prompt:
                ans = raw_input("Reset rain counter (y/n)? ")
            else:
                print 'Resetting rain counter'
                ans = 'y'
            if ans == 'y':
                self.driver.station.reset_rain()
                print self.driver.station.get_rain()
            elif ans == 'n':
                print "Reset rain cancelled."

    def set_interval(self, interval, prompt):
        if interval < 0 or 60 < interval:
            raise ValueError("Logger interval must be 0-60 minutes")
        ans = None
        while ans not in ['y', 'n']:
            print "Interval is", self.driver.station.get_interval()
            if prompt:
                ans = raw_input("Set interval to %d minutes (y/n)? " % interval)
            else:
                print "Setting interval to %d minutes" % interval
                ans = 'y'
            if ans == 'y':
                self.driver.station.set_interval(interval)
                print "Interval is now", self.driver.station.get_interval()
            elif ans == 'n':
                print "Set interval cancelled."

    def set_clock(self, prompt):
        ans = None
        while ans not in ['y', 'n']:
            print "Station clock is", self.driver.station.get_time()
            now = datetime.datetime.now()
            if prompt:
                ans = raw_input("Set station clock to %s (y/n)? " % now)
            else:
                print "Setting station clock to %s" % now
                ans = 'y'
            if ans == 'y':
                self.driver.station.set_time()
                print "Station clock is now", self.driver.station.get_time()
            elif ans == 'n':
                print "Set clock cancelled."

    def set_units(self, units, prompt):
        if units.lower() not in ['metric', 'english']:
            raise ValueError("Units must be METRIC or ENGLISH")
        ans = None
        while ans not in ['y', 'n']:
            print "Station units is", self.driver.station.get_units()
            if prompt:
                ans = raw_input("Set station units to %s (y/n)? " % units)
            else:
                print "Setting station units to %s" % units
                ans = 'y'
            if ans == 'y':
                self.driver.station.set_units(units)
                print "Station units is now", self.driver.station.get_units()
            elif ans == 'n':
                print "Set units cancelled."

    def set_dst(self, dst, prompt):
        if dst != '0' and len(dst.split(',')) != 3:
            raise ValueError("DST must be 0 (disabled) or start, stop, amount "
                             "with the format mm/dd HH:MM, mm/dd HH:MM, [MM]M")
        ans = None
        while ans not in ['y', 'n']:
            print "Station DST is", self.driver.station.get_dst()
            if prompt:
                ans = raw_input("Set DST to %s (y/n)? " % dst)
            else:
                print "Setting station DST to %s" % dst
                ans = 'y'
            if ans == 'y':
                self.driver.station.set_dst(dst)
                print "Station DST is now", self.driver.station.get_dst()
            elif ans == 'n':
                print "Set DST cancelled."

    def set_channel(self, ch, prompt):
        if ch not in [0, 1, 2, 3]:
            raise ValueError("Channel must be one of 0, 1, 2, or 3")
        ans = None
        while ans not in ['y', 'n']:
            print "Station channel is", self.driver.station.get_channel()
            if prompt:
                ans = raw_input("Set channel to %s (y/n)? " % ch)
            else:
                print "Setting station channel to %s" % ch
                ans = 'y'
            if ans == 'y':
                self.driver.station.set_channel(ch)
                print "Station channel is now", self.driver.station.get_ch()
            elif ans == 'n':
                print "Set channel cancelled."


class CC3000Driver(weewx.drivers.AbstractDevice):
    """weewx driver that communicates with a RainWise CC3000 data logger."""

    # map rainwise names to database schema names
    DEFAULT_SENSOR_MAP = {
        'dateTime': 'TIMESTAMP',
        'outTemp': 'TEMP OUT',
        'outHumidity': 'HUMIDITY',
        'windDir': 'WIND DIRECTION',
        'windSpeed': 'WIND SPEED',
        'windGust': 'WIND GUST',
        'pressure': 'PRESSURE',
        'inTemp': 'TEMP IN',
        'extraTemp1': 'TEMP 1',
        'extraTemp2': 'TEMP 2',
        'day_rain_total': 'RAIN',
        'supplyVoltage': 'STATION BATTERY',
        'consBatteryVoltage': 'BATTERY BACKUP',
        'radiation': 'SOLAR RADIATION',
        'UV': 'UV INDEX',
    }

    def __init__(self, **stn_dict):
        loginf('driver version is %s' % DRIVER_VERSION)

        global DEBUG_SERIAL
        DEBUG_SERIAL = int(stn_dict.get('debug_serial', 0))
        global DEBUG_CHECKSUM
        DEBUG_CHECKSUM = int(stn_dict.get('debug_checksum', 0))
        global DEBUG_OPENCLOSE
        DEBUG_OPENCLOSE = int(stn_dict.get('debug_openclose', 0))

        self.max_tries = int(stn_dict.get('max_tries', 5))
        self.retry_wait = int(stn_dict.get('retry_wait', 60))
        self.model = stn_dict.get('model', 'CC3000')
        port = stn_dict.get('port', CC3000.DEFAULT_PORT)
        loginf('using serial port %s' % port)
        self.polling_interval = float(stn_dict.get('polling_interval', 1))
        loginf('polling interval is %s seconds' % self.polling_interval)
        self.use_station_time = weeutil.weeutil.to_bool(
            stn_dict.get('use_station_time', True))
        loginf('using %s time for loop packets' %
               ('station' if self.use_station_time else 'computer'))
        # start with the default sensormap, then augment with user-specified
        self.sensor_map = dict(self.DEFAULT_SENSOR_MAP)
        if 'sensor_map' in stn_dict:
            self.sensor_map.update(stn_dict['sensor_map'])
        loginf('sensor map is %s' % self.sensor_map)

        # periodically check the logger memory, then clear it if necessary.
        # these track the last time a check was made, and how often to make
        # the checks.  threshold of None indicates do not clear logger.
        self.logger_threshold = weeutil.weeutil.to_int(
            stn_dict.get('logger_threshold', None))
        self.last_mem_check = 0
        self.mem_interval = 7 * 24 * 3600
        if self.logger_threshold is not None:
            loginf('clear logger at %s records' % self.logger_threshold)

        # track the last rain counter value so we can determine deltas
        self.last_rain = None

        self.station = CC3000(port)
        self.station.open()

        # report the station configuration
        settings = self._init_station_with_retries(
            self.station, self.max_tries, self.retry_wait)
        loginf('firmware: %s' % settings['firmware'])
        self.arcint = settings['arcint']
        loginf('archive_interval: %s' % self.arcint)
        self.header = settings['header']
        loginf('header: %s' % self.header)
        self.units = weewx.METRICWX if settings['units'] == 'METRIC' else weewx.US
        loginf('units: %s' % settings['units'])
        loginf('channel: %s' % settings['channel'])
        loginf('charger status: %s' % settings['charger'])
        loginf('memory: %s' % self.station.get_memory_status())

    def genLoopPackets(self):
        cmd_mode = True
        if self.polling_interval == 0:
            self.station.set_auto()
            cmd_mode = False

        logged_nodata = False
        ntries = 0
        while ntries < self.max_tries:
            ntries += 1
            try:
                values = self.station.get_current_data(cmd_mode)
                now = int(time.time())
                ntries = 0
                logdbg("values: %s" % values)
                if values:
                    logged_nodata = False
                    packet = self._parse_current(
                        values, self.header, self.sensor_map)
                    logdbg("parsed: %s" % packet)
                    if packet and 'dateTime' in packet:
                        if not self.use_station_time:
                            packet['dateTime'] = int(time.time() + 0.5)
                        packet['usUnits'] = self.units
                        if 'day_rain_total' in packet:
                            packet['rain'] = self._rain_total_to_delta(
                                packet['day_rain_total'], self.last_rain)
                            self.last_rain = packet['day_rain_total']
                        else:
                            logdbg("no rain in packet: %s" % packet)
                        logdbg("packet: %s" % packet)
                        yield packet
                else:
                    if not logged_nodata:
                        loginf("no data from sensors")
                        logged_nodata = True

                # periodically check memory, clear if necessary
                if time.time() - self.last_mem_check > self.mem_interval:
                    nrec = self.station.get_history_usage()
                    self.last_mem_check = time.time()
                    if nrec is None:
                        loginf("memory check: cannot determine memory usage")
                    else:
                        loginf("logger is at %s records, "
                               "logger clearing threshold is %s" %
                               (nrec, self.logger_threshold))
                        if self.logger_threshold is not None and nrec >= self.logger_threshold:
                            loginf("clearing all records from logger")
                            self.station.clear_memory()

                if self.polling_interval:
                    time.sleep(self.polling_interval)
            except (serial.serialutil.SerialException, weewx.WeeWxIOError), e:
                logerr("Failed attempt %d of %d to get data: %s" %
                       (ntries, self.max_tries, e))
                logdbg("Waiting %d seconds before retry" % self.retry_wait)
                time.sleep(self.retry_wait)
        else:
            msg = "Max retries (%d) exceeded" % self.max_tries
            logerr(msg)
            raise weewx.RetriesExceeded(msg)

    def genStartupRecords(self, since_ts):
        """Return archive records from the data logger.  Download all records
        then return the subset since the indicated timestamp.

        Assumptions:
         - the units are consistent for the entire history.
         - the archive interval is constant for entire history.
         - the HDR for archive records is the same as current HDR
        """
        logdbg("genStartupRecords: since_ts=%s" % since_ts)
        nrec = 0
        # figure out how many records we need to download
        if since_ts is not None:
            delta = int(time.time()) - since_ts
            nrec = int(delta / self.arcint)
            logdbg("genStartupRecords: nrec=%d ts_delta=%d" % (nrec, delta))
            if nrec == 0:
                loginf("no need to read logger records")
                return
        else:
            logdbg("genStartupRecords: nrec=%d" % nrec)

        last_rain = None
        for r in self.station.gen_records(nrec):
            logdbg("values: %s" % r)
            pkt = self._parse_historical(r[1:], self.header, self.sensor_map)
            logdbg("parsed: %s" % pkt)
            if 'dateTime' in pkt and pkt['dateTime'] > since_ts:
                pkt['usUnits'] = self.units
                pkt['interval'] = self.arcint
                if 'day_rain_total' in pkt:
                    pkt['rain'] = self._rain_total_to_delta(
                        pkt['day_rain_total'], last_rain)
                    last_rain = pkt['day_rain_total']
                else:
                    logdbg("no rain in record: %s" % r)
                logdbg("packet: %s" % pkt)
                yield pkt

    @property
    def hardware_name(self):
        return self.model

    @property
    def archive_interval(self):
        return self.arcint

    def getTime(self):
        try:
            v = self.station.get_time()
            return _to_ts(v)
        except ValueError, e:
            logerr("getTime failed: %s" % e)
        return 0

    def setTime(self):
        self.station.set_time()

    @staticmethod
    def _init_station_with_retries(station, max_tries, retry_wait):
        for cnt in xrange(max_tries):
            try:
                return CC3000Driver._init_station(station)
            except (serial.serialutil.SerialException, weewx.WeeWxIOError), e:
                logerr("Failed attempt %d of %d to initialize station: %s" %
                       (cnt + 1, max_tries, e))
                logdbg("Waiting %d seconds before retry" % retry_wait)
                time.sleep(retry_wait)
        else:
            raise weewx.RetriesExceeded("Max retries (%d) exceeded while initializing station" % max_tries)

    @staticmethod
    def _init_station(station):
        station.flush()
        station.wakeup()
        station.set_echo()
        settings = dict()
        settings['firmware'] = station.get_version()
        settings['arcint'] = station.get_interval() * 60 # arcint is in seconds
        settings['header'] = CC3000Driver._parse_header(station.get_header())
        settings['units'] = station.get_units()
        settings['channel'] = station.get_channel()
        settings['charger'] = station.get_charger()
        return settings

    @staticmethod
    def _rain_total_to_delta(rain_total, last_rain):
        # calculate the rain delta from rain total
        # FIXME: replace with wxformulas.calculate_rain
        rain_delta = None
        if last_rain is not None:
            if rain_total >= last_rain:
                rain_delta = (rain_total - last_rain)
            else:
                loginf("rain counter rollover detected: new=%s last=%s" %
                       (rain_total, last_rain))
        else:
            loginf("rain skipped for rain_total=%s: no last rain measurement" %
                   rain_total)
        return rain_delta

    @staticmethod
    def _parse_current(values, header, sensor_map):
        return CC3000Driver._parse_values(values, header, sensor_map,
                                          "%Y/%m/%d %H:%M:%S")

    @staticmethod
    def _parse_historical(values, header, sensor_map):
        return CC3000Driver._parse_values(values, header, sensor_map,
                                          "%Y/%m/%d %H:%M")

    @staticmethod
    def _parse_values(values, header, sensor_map, fmt):
        """parse the values and map them into the schema names.  if there is
        a failure for any one value, then the entire record fails."""
        pkt = dict()
        if len(values) != len(header) + 1:
            loginf("values/header mismatch: %s %s" % (values, header))
            return pkt
        for i, v in enumerate(values):
            if i >= len(header):
                continue
            label = None
            for m in sensor_map:
                if sensor_map[m] == header[i]:
                    label = m
            if label is None:
                continue
            try:
                if header[i] == 'TIMESTAMP':
                    pkt[label] = _to_ts(v, fmt)
                else:
                    pkt[label] = float(v)
            except ValueError, e:
                logerr("parse failed for '%s' '%s': %s (idx=%s values=%s)" %
                       (header[i], v, e, i, values))
                return dict()
        return pkt

    @staticmethod
    def _parse_header(header):
        h = []
        for v in header:
            if v == 'HDR' or v[0:1] == '!':
                continue
            h.append(v.replace('"', ''))
        return h

    def get_current(self):
        data = self.station.get_current_data()
        return self._parse_current(data, self.header, self.sensor_map)

def _to_ts(tstr, fmt="%Y/%m/%d %H:%M:%S"):
    return time.mktime(time.strptime(tstr, fmt))

def _format_bytes(buf):
    return ' '.join(["%0.2X" % ord(c) for c in buf])

def _fmt(buf):
    return filter(lambda x: x in string.printable, buf)

# calculate the crc for a string using CRC-16-CCITT
# http://bytes.com/topic/python/insights/887357-python-check-crc-frame-crc-16-ccitt
def _crc16(data):
    reg = 0x0000
    data += '\x00\x00'
    for byte in data:
        mask = 0x80
        while mask > 0:
            reg <<= 1
            if ord(byte) & mask:
                reg += 1
            mask >>= 1
            if reg > 0xffff:
                reg &= 0xffff
                reg ^= 0x1021
    return reg

def _check_crc(buf):
    idx = buf.find('!')
    if idx < 0:
        return
    a = 0
    b = 0
    cs = ''
    try:
        cs = buf[idx+1:idx+5]
        if DEBUG_CHECKSUM:
            logdbg("found checksum at %d: %s" % (idx, cs))
        a = _crc16(buf[0:idx]) # calculate checksum
        if DEBUG_CHECKSUM:
            logdbg("calculated checksum %x" % a)
        b = int(cs, 16) # checksum provided in data
        if a != b:
            raise ChecksumMismatch(a, b, buf)
    except ValueError, e:
        raise BadCRC(a, cs, buf)

# for some reason we sometimes get null characters randomly mixed in with the
# bytes we receive.  strip them out and let the checksum do the validation of
# the data integrity.
def _strip_unprintables(buf):
    newbuf = ''
    for x in buf:
        if x in string.printable:
            newbuf += x
    return newbuf

class CC3000(object):
    DEFAULT_PORT = '/dev/ttyUSB0'

    def __init__(self, port):
        self.port = port
        self.baudrate = 115200
        self.timeout = 5 # seconds.  clear memory takes 4 seconds.
        self.serial_port = None

    def __enter__(self):
        self.open()
        return self

    def __exit__(self, _, value, traceback):
        self.close()

    def open(self):
        if DEBUG_OPENCLOSE:
            logdbg("open serial port %s" % self.port)
        self.serial_port = serial.Serial(self.port, self.baudrate,
                                         timeout=self.timeout)

    def close(self):
        if self.serial_port is not None:
            if DEBUG_OPENCLOSE:
                logdbg("close serial port %s" % self.port)
            self.serial_port.close()
            self.serial_port = None

    def write(self, data):
        if DEBUG_SERIAL:
            logdbg("write: '%s'" % data)
        n = self.serial_port.write(data)
        if n is not None and n != len(data):
            raise weewx.WeeWxIOError("Write expected %d chars, sent %d" %
                                     (len(data), n))

    def read(self):
        """The station sends CR NL before and after any response.  Some
        responses have a 4-byte CRC checksum at the end, indicated with an
        exclamation.  Not every response has a checksum.
        """
        data = self.serial_port.readline()
        if DEBUG_SERIAL:
            logdbg("read: '%s' (%s)" % (_fmt(data), _format_bytes(data)))
        data = data.strip()
        data = _strip_unprintables(data) # eliminate random NULL characters
        _check_crc(data)
        return data

    def flush(self):
        self.flush_input()
        self.flush_output()

    def flush_input(self):
        logdbg("flush input buffer")
        self.serial_port.flushInput()

    def flush_output(self):
        logdbg("flush output buffer")
        self.serial_port.flushOutput()

    def queued_bytes(self):
        return self.serial_port.inWaiting()

    def send_cmd(self, cmd):
        """Any command must be terminated with a CR"""
        self.write("%s\r" % cmd)

    def command(self, cmd):
        self.send_cmd(cmd)
        data = self.read()
        if data != cmd:
            raise weewx.WeeWxIOError("Command failed: cmd='%s' reply='%s' (%s)"
                                     % (cmd, _fmt(data), _format_bytes(data)))
        return self.read()

    def get_version(self):
        logdbg("get firmware version")
        return self.command("VERSION")

    # give the station some time to wake up.  when we first hit it with a
    # command, it often responds with an empty string.  then subsequent
    # commands get the proper response.  so for a first command, send something
    # innocuous and wait a bit.  hopefully subsequent commands will then work.
    def wakeup(self):
        self.command('ECHO=?')
        time.sleep(1.0)

    def set_echo(self, cmd='ON'):
        logdbg("set echo to %s" % cmd)
        data = self.command('ECHO=%s' % cmd)
        if data != 'OK':
            raise weewx.WeeWxIOError("Set ECHO failed: %s" % _fmt(data))

    def get_header(self):
        logdbg("get header")
        data = self.command("HEADER")
        cols = data.split(',')
        if cols[0] != 'HDR':
            raise weewx.WeeWxIOError("Expected HDR, got %s" % cols[0])
        return cols

    def set_auto(self):
        # auto does not echo the command
        self.send_cmd("AUTO")

    def get_current_data(self, send_now=True):
        data = ''
        if send_now:
            data = self.command("NOW")
        else:
            data = self.read()
        if data == 'NO DATA' or data == 'NO DATA RECEIVED':
            logdbg("No data from sensors")
            return []
        return data.split(',')

    def get_time(self):
        # unlike all of the other accessor methods, the TIME command returns
        # OK after it returns the requested parameter.  so we have to pop the
        # OK off the serial so it does not trip up other commands.
        logdbg("get time")
        tstr = self.command("TIME=?")
        if tstr not in ['ERROR', 'OK']:
            data = self.read()
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to get time: %s" % _fmt(data))
        return tstr

    def set_time(self):
        ts = time.time()
        tstr = time.strftime("%Y/%m/%d %H:%M:%S", time.localtime(ts))
        logdbg("set time to %s (%s)" % (tstr, ts))
        s = "TIME=%s" % tstr
        data = self.command(s)
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to set time to %s: %s" %
                                     (s, _fmt(data)))

    def get_dst(self):
        logdbg("get daylight saving")
        return self.command("DST=?")

    def set_dst(self, dst):
        logdbg("set DST to %s" % dst)
        data = self.command("DST=%s" % dst)
        # FIXME: firmware 1.3 Build 016 Aug 21 2014 does not return OK
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to set DST to %s: %s" %
                                     (dst, _fmt(data)))

    def get_units(self):
        logdbg("get units")
        return self.command("UNITS=?")

    def set_units(self, units):
        logdbg("set units to %s" % units)
        data = self.command("UNITS=%s" % units)
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to set units to %s: %s" %
                                     (units, _fmt(data)))

    def get_interval(self):
        logdbg("get logging interval")
        return int(self.command("LOGINT=?"))

    def set_interval(self, interval=5):
        logdbg("set logging interval to %d minutes" % interval)
        data = self.command("LOGINT=%d" % interval)
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to set logging interval: %s" %
                                     _fmt(data))

    def get_channel(self):
        logdbg("get channel")
        return self.command("STATION")

    def set_channel(self, channel):
        logdbg("set channel to %d" % channel)
        if channel < 0 or 3 < channel:
            raise ValueError("Channel must be 0-3")
        data = self.command("STATION=%d" % channel)
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to set channel: %s" % _fmt(data))

    def get_charger(self):
        logdbg("get charger")
        return self.command("CHARGER")

    def get_baro(self):
        logdbg("get baro")
        return self.command("BARO")

    def set_baro(self, offset):
        logdbg("set barometer offset to %d" % offset)
        if offset != '0':
            parts = offset.split('.')
            if (len(parts) != 2 or
                (not (len(parts[0]) == 2 and len(parts[1]) == 2) and
                 not (len(parts[0]) == 3 and len(parts[1]) == 1))):
                raise ValueError("Offset must be 0, XX.XX (inHg), or XXXX.X (mbar)")
        data = self.command("BARO=%d" % offset)
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to set baro: %s" % _fmt(data))

    def get_memory_status(self):
        # query for logger memory use.  output is something like this:
        # 6438 bytes, 111 records, 0%
        logdbg("get memory status")
        return self.command("MEM=?")

    def get_history_usage(self):
        # return the number of records in the logger
        # FIXME: some firmware returns NOW when you query MEM=?
        s = self.get_memory_status()
        if 'records' in s:
            return int(s.split(',')[1].split()[0])
        return None

    def clear_memory(self):
        logdbg("clear memory")
        data = self.command("MEM=CLEAR")
        # FIXME: firmware 1.3 Build 016 Aug 21 2014 does not return OK
#        if data != 'OK':
#            raise weewx.WeeWxIOError("Failed to clear memory: %s" % _fmt(data))

    def get_rain(self):
        logdbg("get rain total")
        return self.command("RAIN")

    def reset_rain(self):
        logdbg("reset rain counter")
        data = self.command("RAIN=RESET")
        if data != 'OK':
            raise weewx.WeeWxIOError("Failed to reset rain: %s" % _fmt(data))

    def gen_records(self, nrec=0, specify_qty=False):
        """
        Generator function for getting nrec records from the device.  A value
        of 0 indicates all records.
        
        The CC3000 documents say that passing a value such as DOWNLOAD=n
        will download the n latest records.  They lie.

        For firmware 1.3 Build 016, this works only if n<=1130 or n=>N where
        N is the number of records in the logger.

        So we have to download everything (which takes a long time if the
        logger is full) and ignore the records we do not care about.

        If using this driver with firmware that actually behaves as the docs
        describe, then use True for specify_qty.

        We would like to use the logger's record count to help us know which
        records to keep, but that count appears to include MAX/MIN records
        as well as the REC records that we actually want.  So brute force.
        """

        totrec = self.get_history_usage()
        if totrec is None:
            raise weewx.WeeWxIOError("cannot determine logger memory status")
        loginf("requested %d latest of %d records" % (nrec, totrec))
        loginf("this could take awhile...")

        # on some platforms the comms get wedged, so we need a way to reset
        need_cmd = True
        cmd_max = 5
        cmd_cnt = 0
        n = 0
        yielded = 0

        while True:
            if need_cmd:
                cmd_cnt += 1
                if cmd_cnt > cmd_max:
                    msg = "download failed after %d attempts" % cmd_max
                    logerr(msg)
                    raise weewx.WeeWxIOError(msg)
                cmd = "DOWNLOAD"
                if specify_qty and nrec != 0:
                    qty = nrec - n
                    cmd = "DOWNLOAD=%d" % qty
                loginf("%s (attempt %s of %s)" % (cmd, cmd_cnt, cmd_max))
                self.send_cmd(cmd)
                need_cmd = False
            try:
                data = self.read()
                if data == 'OK':
                    loginf("downloaded %d records, yielded %d" % (n, yielded))
                    break
                elif ',' in data:
                    values = data.split(',')
                    if values[0] == 'REC':
                        n += 1
                        cmd_cnt = 0
                        yielded += 1
                        yield values
                    elif (values[0] == 'HDR' or values[0] == 'MSG' or
                          values[0] == 'MIN' or values[0] == 'MAX' or
                          values[0].startswith('DOWNLOAD')):
                        # skip any non-REC responses
                        cmd_cnt = 0
                    elif values[0] == '':
                        # FIXME: observed 'input overrun' on rpi2 with debian 7
                        # so try sending another download command to unhang it.
                        logerr("download hung, initiate another download")
                        need_cmd = True
                    else:
                        cmd_cnt = 0
                        logerr("bad record %s '%s' (%s)" %
                               (n, _fmt(values[0]), _fmt(data)))
                elif 'DOWNLOAD' in data:
                    # some firmware echos the command, but not always
                    pass
                else:
                    logerr("unexpected response to DOWNLOAD: '%s'" % data)
                    need_cmd = True
            except ChecksumError, e:
                logerr("download failed for record %s: %s" % (n, e))


class CC3000ConfEditor(weewx.drivers.AbstractConfEditor):
    @property
    def default_stanza(self):
        return """
[CC3000]
    # This section is for RainWise MarkIII weather stations and CC3000 logger.

    # Serial port such as /dev/ttyS0, /dev/ttyUSB0, or /dev/cuaU0
    port = %s

    # The station model, e.g., CC3000 or CC3000R
    model = CC3000

    # The driver to use:
    driver = weewx.drivers.cc3000
""" % (CC3000.DEFAULT_PORT,)

    def prompt_for_settings(self):
        print "Specify the serial port on which the station is connected, for"
        print "example /dev/ttyUSB0 or /dev/ttyS0."
        port = self._prompt('port', CC3000.DEFAULT_PORT)
        return {'port': port}


# define a main entry point for basic testing.  invoke from the weewx root dir:
#
# PYTHONPATH=bin python bin/weewx/drivers/cc3000.py

if __name__ == '__main__':
    import optparse

    usage = """%prog [options] [--help]"""

    syslog.openlog('cc3000', syslog.LOG_PID | syslog.LOG_CONS)
    syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_INFO))
    parser = optparse.OptionParser(usage=usage)
    parser.add_option('--version', action='store_true',
                      help='display driver version')
    parser.add_option('--debug', action='store_true', default=False,
                      help='emit additional diagnostic information')
    parser.add_option('--test-crc', dest='testcrc', action='store_true',
                      help='test crc')
    parser.add_option('--port', metavar='PORT',
                      help='port to which the station is connected',
                      default=CC3000.DEFAULT_PORT)
    parser.add_option('--get-version', dest='getver', action='store_true',
                      help='display firmware version')
    parser.add_option('--get-status', dest='status', action='store_true',
                      help='display memory status')
    parser.add_option('--get-channel', dest='getch', action='store_true',
                      help='display station channel')
    parser.add_option('--set-channel', dest='setch', metavar='CHANNEL',
                      help='set station channel')
    parser.add_option('--get-battery', dest='getbat', action='store_true',
                      help='display battery status')
    parser.add_option('--get-current', dest='getcur', action='store_true',
                      help='display current data')
    parser.add_option('--get-memory', dest='getmem', action='store_true',
                      help='display memory status')
    parser.add_option('--get-records', dest='getrec', metavar='NUM_RECORDS',
                      help='display records from station memory')
    parser.add_option('--get-header', dest='gethead', action='store_true',
                      help='display data header')
    parser.add_option('--get-units', dest='getunits', action='store_true',
                      help='display units')
    parser.add_option('--set-units', dest='setunits', metavar='UNITS',
                      help='set units to ENGLISH or METRIC')
    parser.add_option('--get-time', dest='gettime', action='store_true',
                      help='display station time')
    parser.add_option('--set-time', dest='settime', action='store_true',
                      help='set station time to computer time')
    parser.add_option('--get-dst', dest='getdst', action='store_true',
                      help='display daylight savings settings')
    parser.add_option('--set-dst', dest='setdst',
                      metavar='mm/dd HH:MM,mm/dd HH:MM,[MM]M',
                      help='set daylight savings start, end, and amount')
    parser.add_option('--get-interval', dest='getint', action='store_true',
                      help='display logging interval, in seconds')
    parser.add_option('--set-interval', dest='setint', metavar='INTERVAL',
                      type=int, help='set logging interval, in seconds')
    parser.add_option('--clear-memory', dest='clear', action='store_true',
                      help='clear logger memory')
    parser.add_option('--reset-rain', dest='reset', action='store_true',
                      help='reset rain counter')
    parser.add_option('--poll', metavar='POLL_INTERVAL', type=int,
                      help='poll interval in seconds')
    (options, args) = parser.parse_args()

    if options.version:
        print "%s driver version %s" % (DRIVER_NAME, DRIVER_VERSION)
        exit(0)

    if options.debug:
        DEBUG_SERIAL = 1
        DEBUG_CHECKSUM = 1
        DEBUG_OPENCLOSE = 1
        syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_DEBUG))

    if options.testcrc:
        _check_crc('OK')
        _check_crc('REC,2010/01/01 14:12, 64.5, 85,29.04,349,  2.4,  4.2,  0.00, 6.21, 0.25, 73.2,!B82C')
        _check_crc('MSG,2010/01/01 20:22,CHARGER ON,!4CED')
        exit(0)

    with CC3000(options.port) as s:
        s.flush()
        s.wakeup()
        s.set_echo()
        if options.getver:
            print s.get_version()
        if options.status:
            print "firmware:", s.get_version()
            print "time:", s.get_time()
            print "dst:", s.get_dst()
            print "units:", s.get_units()
            print "memory:", s.get_memory_status()
            print "interval:", s.get_interval() * 60
            print "channel:", s.get_channel()
            print "charger:", s.get_charger()
            print "baro:", s.get_baro()
            print "rain:", s.get_rain()
        if options.getch:
            print s.get_channel()
        if options.setch is not None:
            s.set_channel(int(options.setch))
        if options.getbat:
            print s.get_charger()
        if options.getcur:
            print s.get_current_data()
        if options.getmem:
            print s.get_memory_status()
        if options.getrec is not None:
            i = 0
            for r in s.gen_records(int(options.getrec)):
                print i, r
                i += 1
        if options.gethead:
            print s.get_header()
        if options.getunits:
            print s.get_units()
        if options.setunits:
            s.set_units(options.setunits)
        if options.gettime:
            print s.get_time()
        if options.settime:
            s.set_time()
        if options.getdst:
            print s.get_dst()
        if options.setdst:
            s.set_dst(options.setdst)
        if options.getint:
            print s.get_interval() * 60
        if options.setint:
            s.set_interval(int(options.setint) / 60)
        if options.clear:
            s.clear_memory()
        if options.reset:
            s.reset_rain()
        if options.poll is not None:
            cmd_mode = True
            if options.poll == 0:
                cmd_mode = False
                s.set_auto()
            while True:
                print s.get_current_data(cmd_mode)
                time.sleep(options.poll)
