Updated driver, added --current and --format args to wee_device

-- 
You received this message because you are subscribed to the Google Groups 
"weewx-development" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion on the web visit 
https://groups.google.com/d/msgid/weewx-development/67b4a770-34a6-4ab4-97f0-5054f045468e%40googlegroups.com.
#!/usr/bin/env python
#
# Copyright 2014 Matthew Wall
# Copyright 2019 Scott Shambarger

"""weewx driver for WeatherLink
   Retries weather data from weatherlink.com for use in weewx

To use this driver:

1) copy this file to the weewx user directory, eg:

   cp wlink.py {weewx root}/user/

2) (optional) install pytz python module to correctly handle station timezone

   pip install pytz

3) configure weewx.conf

   wee_config --reconfigure --driver=user.wlink

4) test configuraion

   wee_device --info
"""

from __future__ import print_function
import httplib
import socket
import struct
import sys
import syslog
import time
import calendar
import email.utils
import datetime
import urllib2
import json

import weewx
import weewx.drivers
import weewx.engine

DRIVER_NAME = "WeatherLink"
DRIVER_VERSION = "0.15"

def loader(config_dict, engine):
    return WeatherLinkService(engine, config_dict)

def configurator_loader(config_dict):  # @UnusedVariable
    return WeatherLinkConfigurator()

def confeditor_loader():
    return WeatherLinkConfEditor()

DRIVER_CONSOLE_DEBUG = False

if weewx.__version__ < "3":
    raise weewx.UnsupportedFeature("weewx 3 is required, found %s"
                                   % weewx.__version__)

def logmsg(dst, msg):
    if DRIVER_CONSOLE_DEBUG:
        print(msg)
    else:
        syslog.syslog(dst, 'wlink: %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)

# =============================================================================
#                      Decoding routines
# =============================================================================

def _big_val(v):
    return float(v) if v != 0x7fff else None

def _big_val10(v):
    return float(v)/10.0 if v != 0x7fff else None

def _val100(v):
    return float(v)/100.0

def _val1000(v):
    return float(v)/1000.0

def _val1000Zero(v):
    return float(v)/1000.0 if v != 0 else None

def _little_val(v):
    return float(v) if v != 0x00ff else None

def _little_val10(v):
    return float(v)/10.0 if v != 0x00ff else None

def _little_temp(v):
    return float(v-90) if v != 0x00ff else None

def _null(v):
    return v

def _null_float(v):
    return float(v)

def _windDir(v):
    return float(v) * 22.5 if v!= 0x00ff else None

# Rain bucket type "1", a 0.2 mm bucket
def _bucket_1(v):
    return float(v)*0.00787401575

# Rain bucket type "2", a 0.1 mm bucket
def _bucket_2(v):
    return float(v)*0.00393700787

class WeatherLink(weewx.drivers.AbstractDevice):
    """weewx driver to download data from weatherlink.com.

    Retrieves station configuration from the WeatherLink Station Status API.

    Downloads archive records from the Davis "Web Download" protocol.

    Parse 'loop' packets from the WeatherLink Current Conditions API.

    """

    rain_bucket_dict = {0:'0.01 inches', 1:'0.2 mm', 2:'0.1 mm'}

    def __init__(self, **stn_dict):
        loginf("version is %s" % DRIVER_VERSION)
        self._username = stn_dict['username']
        self._password = stn_dict['password']
        self._apitoken = stn_dict.get('apitoken')
        self._debug = stn_dict.get('debug')
        self._setup_rain_bucket(int(stn_dict.get('rain_bucket_type', -1)))
        self._archive_interval = int(stn_dict.get('archive_interval', 0))
        if self._archive_interval > 0:
            loginf("archive interval is %d seconds" % self._archive_interval)
        self._setup_tz(stn_dict.get('station_tz'),
                       stn_dict.get('station_dst', 1))
        self._poll_interval = float(stn_dict.get('poll_interval', 60))
        loginf("loop polling interval is %d secs" % self._poll_interval)
        self._max_tries = int(stn_dict.get('max_tries', 5))
        self._retry_wait = int(stn_dict.get('retry_wait', 30))
        # state values
        self._monthRain = None
        self._status_cache = None

    @property
    def hardware_name(self):
        """weewx api."""
        return "WeatherLink"

    @property
    def archive_interval(self):
        """weewx api.  Time in seconds between archive intervals."""
        if self._archive_interval <= 0:
            self._check_station_status()
        if self._archive_interval <= 0:
            raise NotImplementedError("Failed to query 'archive_interval' "
                                      "from StationStatus")
        return self._archive_interval

    def genLoopPackets(self):
        """Main generator function that continuously returns loop packets

        weewx api to return live records.

        yields: a sequence of dictionaries containing the data
        """
        while True:
            packet = self.get_readings()
            if packet:
                # calc rain based on monthRain changes
                monthRain = packet.get('monthRain')
                if self._monthRain is None:
                    self._monthRain = monthRain
                elif monthRain is not None:
                    delta = monthRain - self._monthRain
                    self._monthRain = monthRain
                    # will be negative at start of month
                    if delta >= 0: packet['rain'] = delta
                yield packet
            time.sleep(self._poll_interval)

    def genArchiveRecords(self, since_ts):
        """A generator function to return archive packets from weatherlink.com

        weewx api to return archive records.

        since_ts: A timestamp. All data since (but not including) this
          time will be returned. Pass in None for all data

        yields: a sequence of dictionaries containing the data
        """
        if not self._check_station_status():
            return
        ts = self._epoch_to_timestamp(since_ts)
        data = self._get_url("http://weatherlink.com/webdl.php";
                             "?timestamp=%s&user=%s&pass=%s&action=data"
                             % (ts, self._username, self._password))
        if data is None:
            return
        logdbg("found %s archive records since %s"
               % ((len(data) / 52), since_ts))
        sidx = 0
        while sidx < len(data):
            record = self._unpack_archive_packet(data[sidx:sidx + 52])
            if record is not None:
                logdbg("yielding: %s" % record)
                yield record
            sidx += 52

    def _setup_tz(self, station_tz, station_dst):
        """Set _station_tzinfo based on station_tz and station_dst
        station_tz can be 'localtime' to use server timezone,
        an fixed-offset (in mins, eg '-480'), or a named timezone
        (eg 'US/Pacific').  If a named timezone is used and station_dst = 0,
        then a fixed-offset timezone based on the non-DST offset for the
        timezone is applied"""
        self._station_tz = station_tz
        if station_tz is None: return
        try:
            tzoffset = int(station_tz)
        except ValueError:
            tzoffset = None
        if tzoffset:
            # fixed-offset tzinfo
            tz = FixedOffsetTZ(tzoffset)
        elif station_tz == 'localtime':
            tz = None
        else:
            try:
                # named timezone requires pytz
                import pytz
                tz = pytz.timezone(station_tz)
                if station_dst == 0:
                    # create fixed-offset based on non-dst delta
                    dt = datetime.datetime(2019, 1, 1)
                    tzoffset = tz.utcoffset(dt) + tz.dst(dt)
                    tzmins = (tzoffset.days * 1440) + (tzoffset.seconds / 60)
                    tz = FixedOffsetTZ(tzmins)
            except ImportError:
                logerr("pytz required to setup named timezone '%s' "
                       "- falling back to 'localtime'" % station_tz)
                self._station_tz = 'localtime'
                tz = None
            except:
                logerr("station timezone '%s' not recognized by pytz"
                       % station_tz)
                logerr("Set station_tz to 'localtime', or OFFSET_MINS "
                       "if daylight savings is not used")
                self._station_tz = None
                raise weewx.UnsupportedFeature("Unable to use station_tz '%s'"
                                               % station_tz)
        self._station_tzinfo = tz
        loginf("station timezone is '%s'" % self._station_tz)

    def _setup_rain_bucket(self, rain_bucket_type):
        """Adjust _archive_map based on rain_bucket_type"""
        # if unset/invalid, retrieve from StationStatus
        if rain_bucket_type < 0:
            self._rain_bucket_type = -1
            return
        if rain_bucket_type == 0:
            self._archive_map['rain'] = self._archive_map['rainRate'] \
                = _val100
        elif rain_bucket_type == 1:
            self._archive_map['rain'] = self._archive_map['rainRate'] \
                = _bucket_1
        elif rain_bucket_type == 2:
            self._archive_map['rain'] = self._archive_map['rainRate'] \
                = _bucket_2
        else:
            raise weewx.ViolatedPrecondition("Unknown rain_bucket_type: %d" % rain_bucket_type)
        self._rain_bucket_type = rain_bucket_type
        loginf("rain bucket type is %d (%s)"
               % (rain_bucket_type, self.rain_bucket_dict[rain_bucket_type]))

    def _query_api(self, mode):
        """Fetches json values from WeatherLink API, returns parsed dict"""
        url = "https://api.weatherlink.com/v1/%s.json?user=%s&pass=%s"; \
            % (mode, self._username, self._password)
        # apiToken really only required for accounts with 2+ stations
        if self._apitoken:
            url += "&apiToken=%s" % self._apitoken
        data = self._get_url(url)
        if data is None or data == "Invalid Request!":
            return None
        try:
            jdata = json.loads(data)
        except (TypeError, ValueError):
            raise weewx.WeeWxIOError("failed to parse %s json values" % mode)
        if self._debug:
            logdbg('%s' % _json_pretty(jdata))
        return jdata

    def _check_station_status(self):
        """Retrieve StationStatus from WeatherLink API, and set any
        missing config values.
        Returns True if config values are set, False if not."""
        # skip if all status values valid
        if (self._archive_interval > 0 and self._rain_bucket_type >= 0 and
            self._station_tz):
            return True
        jdata = self.get_status()
        if jdata is None:
            return False

        if self._archive_interval <= 0:
            arc_int_mins = int(jdata.get("station_archive_interval", 0))
            if arc_int_mins <= 0:
                raise weewx.ViolatedPrecondition(
                    "station_archive_interval missing in StationStatus")
            self._archive_interval = arc_int_mins * 60
            loginf("archive interval is %d seconds" % self._archive_interval)

        if self._rain_bucket_type < 0:
            rain_collector = jdata.get("station_rain_collector")
            if not rain_collector:
                raise weewx.ViolatedPrecondition(
                    "station_rain_collector missing in StationStatus")
            if rain_collector == "0.2 mm":
                rain_bucket_type = 1
            elif rain_collector == "0.1 mm":
                rain_bucket_type = 2
            elif rain_collector == "0.01 inches":
                rain_bucket_type = 0
            else:
                raise weewx.ViolatedPrecondition(
                    "Unknown station_rain_collector in StationStatus: '%s'"
                    % rain_collector)
            self._setup_rain_bucket(rain_bucket_type)

        if not self._station_tz:
            station_tz = jdata.get("station_timezone")
            station_dst_yn = jdata.get("station_daylight_observed", "no")
            station_dst = 1
            if station_dst_yn == "no":
                station_dst = 0
            if not station_tz:
                station_offset = jdata.get("station_time_offset")
                if not station_offset:
                    raise weewx.ViolatedPrecondition(
                        "Unable to obtain station timezone from StationStatus")
                if station_dst:
                    raise weewx.ViolatedPrecondition(
                        "Unable to use station_time_offset from StationStatus "
                        "when station_daylight_observed='yes'")
                offset = int(station_offset.split(' ')[0])
                offset_mins = (abs(offset) / 100 * 60) + abs(offset) % 100
                if offset < 0:
                    offset_mins = -offset_mins
                station_tz = str(offset_mins)
            self._setup_tz(station_tz, station_dst)
        return True

    def _get_url(self, url):
        """Returns content from url, with retries on network failures."""
        for count in range(self._max_tries):
            try:
                if self._debug:
                    logdbg('get_url: %s' % url)
                response = urllib2.urlopen(url)
                return response.read()
            except (urllib2.URLError, socket.error,
                    httplib.BadStatusLine, httplib.IncompleteRead), e:
                logerr('download failed attempt %d of %d: %s'
                       % (count + 1, self._max_tries, e))
                time.sleep(self._retry_wait)
        # network failures happen... don't throw exception as it kills engine
        logerr('download failed after %d tries' % self._max_tries)
        return None

    def _archive_datetime(self, datestamp, timestamp):
        """Returns the epoch time of the archive packet, adjusted
        for the station timezone."""
        if datestamp == 0xffff or timestamp == 0xffff:
            return None
        try:
            time_tuple = (2000 + ((0xfe00 & datestamp) >> 9), # year
                          (0x01e0 & datestamp) >> 5,          # month
                          (0x001f & datestamp),               # day
                          timestamp // 100,                   # hour
                          timestamp % 100,                    # minute
                          0)                                  # second
            if self._debug:
                logdbg("archive timestamp: %s" % repr(time_tuple))
            if self._station_tz == 'localtime':
                ts = int(time.mktime(time_tuple + (0, 0, -1)))
            else:
                dt = datetime.datetime(*time_tuple)
                # ambiguous dates are converted with dst off...
                dz = self._station_tzinfo.localize(dt)
                ts = calendar.timegm(dz.utctimetuple())
        except (OverflowError, ValueError, TypeError):
            logerr("cannot make timestamp: ds=%s ts=%s, tz=%s"
                   % (datestamp, timestamp, self._station_tz))
            ts = None
        return ts

    # adapted from the implementation in the vantage driver
    def _unpack_archive_packet(self, raw_record_string):
        """Unpack a binary archive packet"""
        packet_type = ord(raw_record_string[42])
        if packet_type == 0xff:
            archive_format = _rec_fmt_A
            data_types = _rec_types_A
        elif packet_type == 0x00:
            archive_format = _rec_fmt_B
            data_types = _rec_types_B
        else:
            logerr("skipping packet of unknown archive type 0x%x"
                   % packet_type);
            return None
        data_tuple = archive_format.unpack(raw_record_string)
        raw_record = dict(zip(data_types, data_tuple))
        packet = {
            'dateTime': self._archive_datetime(raw_record['date_stamp'],
                                               raw_record['time_stamp']),
            'usUnits': weewx.US,
        }
        if packet['dateTime'] is None:
            return None
        for type_ in raw_record:
            func = self._archive_map.get(type_)
            if func:
                v = func(raw_record[type_])
                if v is not None:
                    packet[type_] = v
        packet['interval'] = self._archive_interval / 60
        return packet

    def _epoch_to_timestamp(self, epoch):
        """convert unix timestamp to davis timestamp in station's timezone"""
        if not epoch:
            return 0
        if self._station_tz == 'localtime':
            tt = time.localtime(epoch)
        else:
            dt = datetime.datetime.fromtimestamp(epoch, self._station_tzinfo)
            tt = dt.timetuple()
        ds = tt[2] + (tt[1] << 5) + ((tt[0] - 2000) << 9)
        ts = tt[3] * 100 + tt[4]
        x = (ds << 16) | ts
        return x

    _archive_map = {
        'barometer'      : _val1000Zero,
        'inTemp'         : _big_val10,
        'outTemp'        : _big_val10,
        'highOutTemp'    : lambda v : float(v/10.0) if v != -32768 else None,
        'lowOutTemp'     : _big_val10,
        'inHumidity'     : _little_val,
        'outHumidity'    : _little_val,
        'windSpeed'      : _little_val,
        'windDir'        : _windDir,
        'windGust'       : _null_float,
        'windGustDir'    : _windDir,
        'rain'           : _val100,
        'rainRate'       : _val100,
        'ET'             : _val1000,
        'radiation'      : _big_val,
        'highRadiation'  : _big_val,
        'UV'             : _little_val10,
        'highUV'         : _little_val10,
        'extraTemp1'     : _little_temp,
        'extraTemp2'     : _little_temp,
        'extraTemp3'     : _little_temp,
        'soilTemp1'      : _little_temp,
        'soilTemp2'      : _little_temp,
        'soilTemp3'      : _little_temp,
        'soilTemp4'      : _little_temp,
        'leafTemp1'      : _little_temp,
        'leafTemp2'      : _little_temp,
        'extraHumid1'    : _little_val,
        'extraHumid2'    : _little_val,
        'soilMoist1'     : _little_val,
        'soilMoist2'     : _little_val,
        'soilMoist3'     : _little_val,
        'soilMoist4'     : _little_val,
        'leafWet1'       : _little_val,
        'leafWet2'       : _little_val,
        'leafWet3'       : _little_val,
        'leafWet4'       : _little_val,
        'forecastRule'   : _null,
        'readClosed'     : _null,
        'readOpened'     : _null,
    }

    def get_status(self):
        """Return StationStatus as dict
        Return None if status not available"""
        if self._status_cache is None:
            self._status_cache = self._query_api('StationStatus')
        return self._status_cache

    def get_readings(self):
        """Return LOOP packet"""
        if not self._check_station_status():
            return None
        jdata = self._query_api('NoaaExt')
        packet = None
        if jdata:
            packet = _parse_loop(jdata)
        return packet

class FixedOffsetTZ(datetime.tzinfo):
    """Fixed offset in minutes east from UTC."""

    def __init__(self, offset):
        self.__mins = offset
        self.__offset = datetime.timedelta(minutes = offset)
        self.__name = "%+03d%02d" % (offset / 60, offset % 60)

    def utcoffset(self, dt):
        return self.__offset

    def tzname(self, dt):
        return self.__name

    def dst(self, dt):
        return datetime.timedelta(0)

    def __repr__(self):
        return 'FixedOffsetTZ(%d)' % self.__mins

    def localize(self, dt, is_dst=False):
        '''Convert naive time to local time'''
        if dt.tzinfo is not None:
            raise ValueError('Not naive datetime (tzinfo is already set)')
        return dt.replace(tzinfo=self)


_rec_format_A =[
    ('date_stamp',  'H'), ('time_stamp',    'H'), ('outTemp',        'h'),
    ('highOutTemp', 'h'), ('lowOutTemp',    'h'), ('rain',           'H'),
    ('rainRate',    'H'), ('barometer',     'H'), ('radiation',      'H'),
    ('number_of_wind_samples', 'H'), ('inTemp', 'h'), ('inHumidity', 'B'),
    ('outHumidity', 'B'), ('windSpeed',     'B'), ('windGust',       'B'),
    ('windGustDir', 'B'), ('windDir',       'B'), ('UV',             'B'),
    ('ET',          'B'), ('invalid_data',  'B'), ('soilMoist1',     'B'),
    ('soilMoist2',  'B'), ('soilMoist3',    'B'), ('soilMoist4',     'B'),
    ('soilTemp1',   'B'), ('soilTemp2',     'B'), ('soilTemp3',      'B'),
    ('soilTemp4',   'B'), ('leafWet1',      'B'), ('leafWet2',       'B'),
    ('leafWet3',    'B'), ('leafWet4',      'B'), ('extraTemp1',     'B'),
    ('extraTemp2',  'B'), ('extraHumid1',   'B'), ('extraHumid2',    'B'),
    ('readClosed',  'H'), ('readOpened',    'H'), ('unused',         'B')
]

_rec_format_B = [
    ('date_stamp',  'H'), ('time_stamp',    'H'), ('outTemp',        'h'),
    ('highOutTemp', 'h'), ('lowOutTemp',    'h'), ('rain',           'H'),
    ('rainRate',    'H'), ('barometer',     'H'), ('radiation',      'H'),
    ('number_of_wind_samples', 'H'), ('inTemp','h'), ('inHumidity',  'B'),
    ('outHumidity', 'B'), ('windSpeed',     'B'), ('windGust',       'B'),
    ('windGustDir', 'B'), ('windDir',       'B'), ('UV',             'B'),
    ('ET',          'B'), ('highRadiation', 'H'), ('highUV',         'B'),
    ('forecastRule','B'), ('leafTemp1',     'B'), ('leafTemp2',      'B'),
    ('leafWet1',    'B'), ('leafWet2',      'B'), ('soilTemp1',      'B'),
    ('soilTemp2',   'B'), ('soilTemp3',     'B'), ('soilTemp4',      'B'),
    ('download_record_type', 'B'), ('extraHumid1', 'B'), ('extraHumid2', 'B'),
    ('extraTemp1',  'B'), ('extraTemp2',    'B'), ('extraTemp3',     'B'),
    ('soilMoist1',  'B'), ('soilMoist2',    'B'), ('soilMoist3',     'B'),
    ('soilMoist4',  'B')
]

_rec_types_A, _fmt_A = zip(*_rec_format_A)
_rec_types_B, _fmt_B = zip(*_rec_format_B)
_rec_fmt_A = struct.Struct('<' + ''.join(_fmt_A))
_rec_fmt_B = struct.Struct('<' + ''.join(_fmt_B))

# entries in current conditions
_cur_con_to_loop_float = {
    'pressure_in'        : 'barometer',
    'temp_f'             : 'outTemp',
    'wind_mph'           : 'windSpeed',
    'wind_degrees'       : 'windDir',
    'relative_humidity'  : 'outHumidity',
}
# entries in davis_current_observation
_cur_obs_to_loop_float = {
    'temp_in_f'             : 'inTemp',
    'relative_humidity_in'  : 'inHumidity',
    'wind_ten_min_avg_mph'  : 'windSpeed10',
    'temp_extra_1'          : 'extraTemp1',
    'temp_extra_2'          : 'extraTemp2',
    'temp_extra_3'          : 'extraTemp3',
    'temp_extra_4'          : 'extraTemp4',
    'temp_extra_5'          : 'extraTemp5',
    'temp_extra_6'          : 'extraTemp6',
    'temp_extra_7'          : 'extraTemp7',
    'temp_soil_1'           : 'soilTemp1',
    'temp_soil_2'           : 'soilTemp2',
    'temp_soil_3'           : 'soilTemp3',
    'temp_soil_4'           : 'soilTemp4',
    'temp_leaf_1'           : 'leafTemp1',
    'temp_leaf_2'           : 'leafTemp2',
    'temp_leaf_3'           : 'leafTemp3',
    'temp_leaf_4'           : 'leafTemp4',
    'relative_humidity_1'   : 'extraHumid1',
    'relative_humidity_2'   : 'extraHumid2',
    'relative_humidity_3'   : 'extraHumid3',
    'relative_humidity_4'   : 'extraHumid4',
    'relative_humidity_5'   : 'extraHumid5',
    'relative_humidity_6'   : 'extraHumid6',
    'relative_humidity_7'   : 'extraHumid7',
    'rain_rate_in_per_hr'   : 'rainRate',
    'uv_index'              : 'UV',
    'solar_radiation'       : 'radiation',
    'rain_storm_in'         : 'stormRain',
    'rain_day_in'           : 'dayRain',
    'rain_month_in'         : 'monthRain',
    'rain_year_in'          : 'yearRain',
    'et_day'                : 'dayET',
    'et_month'              : 'monthET',
    'et_year'               : 'yearET',
    'soil_moisture_1'       : 'soilMoist1',
    'soil_moisture_2'       : 'soilMoist2',
    'soil_moisture_3'       : 'soilMoist3',
    'soil_moisture_4'       : 'soilMoist4',
    'leaf_wetness_1'        : 'leafWet1',
    'leaf_wetness_2'        : 'leafWet2',
    'leaf_wetness_3'        : 'leafWet3',
    'leaf_wetness_4'        : 'leafWet4',
    'wind_ten_min_gust_mph' : 'windGust',
}

def _parse_date(dstr, fmt, tz_offset):
    """Parse dstr using strptime fmt, returns unix timestamp adjusted for
    tz_offset"""
    try:
        ts = calendar.timegm(time.strptime(dstr, fmt))
        return ts - tz_offset
    except (ValueError, TypeError):
        return None

def _parse_rfc822(dstr):
    """Parses rfc822 date string, returns timestamp and timezone offset"""
    try:
        tt = email.utils.parsedate_tz(dstr)
        return calendar.timegm(tt[:6]) - int(tt[9]), int(tt[9])
    except (ValueError, TypeError):
        return None, 0

def _parse_time(tstr, ts, tz_offset):
    """Parses HH:MM(am/pm) string, and then offsets it from start of day
    based on timestamp and tz_offset"""
    try:
        tlist = tstr[:-2].split(':')
        hour = int(tlist[0])
        if hour == 12: hour = 0
        if tstr[-2:] == "pm": hour += 12
        secs = hour * 3600 + int(tlist[1]) * 60
        tt = time.gmtime(ts+tz_offset)
        utc_start = calendar.timegm((tt.tm_year, tt.tm_mon, tt.tm_mday,
                                     0, 0, 0))
        return utc_start - tz_offset + secs
    except (ValueError, TypeError):
        return None

def _parse_loop(jdata):
    """Parse loop values from WeatherLink API current conditions"""
    ts, tz_offset = _parse_rfc822(jdata.get('observation_time_rfc822'))
    if ts is None: ts = int(time.time() + 0.5)
    packet = {
        'dateTime': ts,
        'usUnits': weewx.US, # type 2 is US imperial
    }
    packet.update(_map_floats(jdata, _cur_con_to_loop_float))
    obs = jdata.get('davis_current_observation', {})
    packet.update(_map_floats(obs, _cur_obs_to_loop_float))
    storm_start = _parse_date(obs.get('rain_storm_start_date'), "%m/%d/%Y",
                              tz_offset)
    if storm_start is not None: packet['stormStart'] = storm_start
    sunrise = _parse_time(obs.get('sunrise'), ts, tz_offset)
    if sunrise is not None: packet['sunrise'] = sunrise
    sunset = _parse_time(obs.get('sunset'), ts, tz_offset)
    if sunset is not None: packet['sunset'] = sunset
    return packet

def _map_floats(jdata, mapping):
    """Map keys to new names and convert to float"""
    values = {}
    for k1,k2 in mapping.items():
        v = jdata.get(k1)
        if v:
            values[k2] = float(v)
    return values

def _json_pretty(jdata):
    """Return dict as indent-formatted json"""
    return json.dumps(jdata, indent=4, skipkeys=True)

#==============================================================================
#                      class WeatherLinkService
#==============================================================================

# This class uses multiple inheritance:

class WeatherLinkService(WeatherLink, weewx.engine.StdService):
    """Weewx service for WeatherLink."""

    def __init__(self, engine, config_dict):
        WeatherLink.__init__(self, **config_dict[DRIVER_NAME])
        weewx.engine.StdService.__init__(self, engine, config_dict)

        self.bind(weewx.STARTUP, self._init_state)
        self.bind(weewx.NEW_LOOP_PACKET,    self._new_loop_packet)
        self.bind(weewx.END_ARCHIVE_PERIOD, self._init_state)

    def _init_state(self, event):  # @UnusedVariable
        self._max_loop_gust = 0.0
        self._max_loop_gustdir = None

    def _new_loop_packet(self, event):
        """Calculate the max gust seen since the last archive record."""

        # Calculate the max gust seen since the start of this archive record
        # and put it in the packet.
        windSpeed = event.packet.get('windSpeed')
        windDir   = event.packet.get('windDir')
        if windSpeed is not None and windSpeed > self._max_loop_gust:
            self._max_loop_gust = windSpeed
            self._max_loop_gustdir = windDir
        event.packet['windGust'] = self._max_loop_gust
        event.packet['windGustDir'] = self._max_loop_gustdir

#==============================================================================
#                      Class WeatherLinkConfigurator
#==============================================================================

class WeatherLinkConfigurator(weewx.drivers.AbstractConfigurator):
    @property
    def description(self):
        return "Queries weatherlink.com for StationStatus"

    @property
    def usage(self):
        return """%prog [config_file] [--help] [--info]"""

    def add_options(self, parser):
        super(WeatherLinkConfigurator, self).add_options(parser)
        parser.add_option("--info", dest="info", action="store_true",
                          help="print device info")
        parser.add_option("--current", dest="current", action="store_true",
                          help="get the current weather conditions")
        parser.add_option("--format", dest="format",
                          type=str, metavar="FORMAT", default='table',
                          help="formats include: table, dict")

    def do_options(self, options, parser, config_dict, prompt):  # @UnusedVariable
        options.format = options.format.lower()
        if (options.format != 'table' and
            options.format != 'dict'):
            parser.error("Unknown format '%s'.  Known formats include 'table' and 'dict'." % options.format)
        station = WeatherLink(**config_dict[DRIVER_NAME])
        if options.info is not None:
            self.show_info(station, fmt=options.format)
        elif options.current is not None:
            self.show_current(station, fmt=options.format)

    @staticmethod
    def _print_data(data, fmt):
        if fmt == 'table':
            WeatherLinkConfigurator._print_table(data)
        else:
            print("%s" % data)

    @staticmethod
    def _print_table(data):
        for key in sorted(data):
            print("%s: %s" % (key.rjust(16), data[key]))

    @staticmethod
    def show_info(station, fmt='dict'):
        """Display StationStatus"""

        print("Querying...")
        data = station.get_status()
        if data is None:
            print("Unable to retrieve station status")
            return
        if fmt == 'dict':
            print("%s" % data)
            return
        print("""WeatherLink Station Status:

    STATION NAME:                   %s
    CONSOLE TYPE:                   %s
    DEVICE ID:                      %s
    HARDWARE VERSION:               %s
    FIRMWARE VERSION:               %s

    CONSOLE SETTINGS:
      Archive interval:             %d (seconds)
      Altitude:                     %s (feet)
      Latitude:                     %s
      Longitude:                    %s
      Rain bucket type:             %s
      Timezone:                     %s
      Daylight Savings Observed:    %s
      Time Offset:                  %s
        """ % (data.get('station_name'),
               data.get('station_type'),
               data.get('station_did'),
               data.get('station_hardware'),
               data.get('station_firmware'),
               (int(data.get('station_archive_interval', 0)) * 60),
               data.get('station_elevation'),
               data.get('station_latitude'),
               data.get('station_longitude'),
               data.get('station_rain_collector'),
               data.get('station_timezone'),
               data.get('station_daylight_observed'),
               data.get('station_time_offset')))

    @staticmethod
    def show_current(station, fmt='dict'):
        """Display LOOP packet"""
        print("Querying the current weather data...")
        data = station.get_readings()
        if data is None:
            print("Unable to retrieve current conditions")
            return
        WeatherLinkConfigurator._print_data(data, fmt)

# =============================================================================
#                      Class WeatherLinkConfEditor
# =============================================================================

class WeatherLinkConfEditor(weewx.drivers.AbstractConfEditor):
    @property
    def default_stanza(self):
        return """
[WeatherLink]
    # This section is for retrieving data from weatherlink.com.

    # Device ID
    username = 001D0A00DE6A

    # WeatherLink password
    password = DEMO

    # The following are optional:

    # apiToken, if needed (not required as of Aug 2019)
    apitoken = ""

    # extra debug logging
    #debug = None

    ######################################################
    # These values are retrieved from the StationStatus
    # API, and should only be set if those values are
    # incorrect.
    ######################################################

    # Rain bucket size: 0 = 0.01in, 1 = 0.2mm, 2 = 0.1mm
    #rain_bucket_type = <FROM API>

    # Station archive interval (secs)
    #archive_interval = <FROM API>

    # Station timezone.  This can be named (eg 'US/Pacific'),
    # an fixed offset (eg '-480'), or 'localtime' to use the
    # servers timezone.
    # NOTE: named timezones, if set here or retrieved from the API
    #       require the pytz python module, and if not present,
    #       will fallback to 'localtime' to be compatible with
    #       wlink's original behavior.
    #station_tz = <FROM API>

    # Station uses daylight savings on dates (1 = yes, 0 = no)
    # NOTE: this is only used if station uses a named timezone.
    #station_dst = 1

    ######################################################
    # The rest of this section rarely needs any attention.
    # You can safely leave it "as is."
    ######################################################

    # LOOP packet poll interval (secs)
    #poll_interval = 60

    # Network connection retries (secs)
    #max_tries = 5

    # Delays between connect retries (secs)
    #retry_wait = 30

    # The driver to use:
    driver = user.wlink
"""

    def prompt_for_settings(self):
        settings = dict()
        print("Enter the Device ID")
        print(" - This can be found on the WeatherLink Device Info page")
        settings['username'] = self._prompt('username')
        print("Enter the weatherlink.com account password")
        settings['password'] = self._prompt('password')
        print("Enter the weatherlink.com account apiToken (optional)")
        print(" - This can be found on the WeatherLink Account Information page")
        settings['apitoken'] = self._prompt('apitoken')
        return settings

# Invoke this as follows from the weewx user directory:
#
# PYTHONPATH=../ python -m user.wlink

if __name__ == "__main__":
    import optparse

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

    parser = optparse.OptionParser(usage=usage)
    # values for driver
    parser.add_option('--version', action='store_true',
                      help='Display driver version')
    parser.add_option('--username', dest='username', default='001D0A00DE6A',
                      help='Device ID')
    parser.add_option('--password', dest='password', default='DEMO',
                      help='WeatherLink account password')
    parser.add_option('--apitoken', dest='apitoken',
                      help='WeatherLink account apiToken')
    parser.add_option('--debug', dest='debug', action='store_true',
                      help='print debug output to console')
    parser.add_option('--rain-bucket-type', dest='rain_bucket_type',
                      default=-1, type='int', help='rain bucket type')
    parser.add_option('--archive-interval', dest='archive_interval',
                      type='int', default=0, help='archive interval')
    parser.add_option('--station-tz', dest='station_tz',
                      help='station timezone')
    parser.add_option('--station-dst', dest='station_dst', type='int',
                      default=1, help='station uses daylight savings')
    parser.add_option('--poll-interval', dest='poll_interval', default=2.0,
                      type='float', help='loop poll interval in secs')
    # options for testing
    parser.add_option('--test-parser', dest='test_parser', action='store_true',
                      help='test the parser')
    parser.add_option('--filename', dest='filename', default='testfile.json',
                      help='name of file for test-parser')
    parser.add_option('--test-driver', dest='test_driver', action='store_true',
                      help='test the driver (LOOP, --since-ts for archive)')
    parser.add_option('--since-ts', dest='since_ts', type='int', default=-1,
                      help='query archive records since this epoch timestamp')
    parser.add_option('--record-limit', dest='record_limit', default=0,
                      type='int', help='limit # of driver records to display')
    (options, args) = parser.parse_args()

    if options.debug:
        DRIVER_CONSOLE_DEBUG = True
    else:
        syslog.openlog('wlink', syslog.LOG_PID | syslog.LOG_CONS)
        syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_DEBUG))

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

    if options.test_parser:
        data = []
        with open(options.filename) as f:
            for line in f:
                data.append(line)
        jdata = json.loads(''.join(data))
        if options.debug:
            print(("%s: " % options.filename) + _json_pretty(jdata))
        packet = _parse_loop(jdata)
        print("parsed loop: " + _json_pretty(packet))
    elif options.test_driver:
        import weeutil.weeutil
        station = WeatherLink(**(vars(options)))
        if options.since_ts >= 0:
            func = station.genArchiveRecords
            args = [options.since_ts]
        else:
            func = station.genLoopPackets
            args=[]
        n = 0
        for p in func(*args):
            if station._station_tz == 'localtime':
                print(weeutil.weeutil.timestamp_to_string(p['dateTime']) +
                      ' ' + _json_pretty(p))
            else:
                dt = datetime.datetime.fromtimestamp(int(p['dateTime']),
                                                     station._station_tzinfo)
                print(dt.strftime("%Y-%m-%d %H:%M:%S %Z")
                      + (" (%d) " % p['dateTime']) + _json_pretty(p))
            n += 1
            if options.record_limit and n >= options.record_limit:
                break

Reply via email to