diff --git a/bin/user/netatmo.py b/bin/user/netatmo.py index 74f5cf2..6ecc3b4 100644 --- a/bin/user/netatmo.py +++ b/bin/user/netatmo.py @@ -1,11 +1,29 @@ #!/usr/bin/python +# $Id: netatmo.py 1489 2016-04-27 09:58:13Z mwall $ # Copyright 2015 Matthew Wall # # Thanks to phillippe larduinat for publishing lnetatmo.py # https://github.com/philippelt/netatmo-api-python +# The netatmo API has changed quite a bit since phillippe wrote the code, but +# the implementation helps. # # Shame on netatmo for making it very difficult to get data from the hardware # without going through their servers. +# +# More shame on netatmo for encrypting the communication between the netatmo +# station and the netatmo servers, without providing end-users a way to access +# their data locally. They used to send data in the clear up to firmware 101, +# then instead of fixing a stupid decision to send the wifi network password +# to the netatmo servers, they chose to encrypt the communication altogether. +# Beware that netatmo might still be receiving your network passwords. + +"""To use cloud mode you must obtain a client_id and client_secret from +the netatmo development web servers https://dev.netatmo.com + +This driver supports multiple devices, and all known modules on each device. +As of april 2016 that means the base station, 'outside' T/H, additional T/H, +rain, and wind. +""" from __future__ import with_statement import Queue @@ -21,9 +39,19 @@ import urllib2 import weewx.drivers import weewx.engine import weewx.units +import weewx.wxformulas + +DRIVER_NAME = 'netatmo' +DRIVER_VERSION = "X" + +INHG_PER_MBAR = 0.0295299830714 +CM_PER_IN = 2.54 +MPH_TO_KPH = 1.60934 +MPS_TO_KPH = 3.6 +BEAFORT_TO_KPH = {0: 1, 1: 3.0, 2: 9.0, 3: 15.0, 4: 24.0, 5: 34.0, 6: 43.0, + 7: 55.0, 8: 68.0, 9: 82.0, 10: 95.0, 11: 110.0, 12: 120.0} +KNOT_TO_KPH = 1.852 -DRIVER_NAME = 'Netatmo' -DRIVER_VERSION = "0.2" def logmsg(level, msg): syslog.syslog(level, 'netatmo: %s: %s' % @@ -38,44 +66,105 @@ def loginf(msg): def logerr(msg): logmsg(syslog.LOG_ERR, msg) + def loader(config_dict, engine): return NetatmoDriver(**config_dict[DRIVER_NAME]) +def confeditor_loader(config_dict): + return NetatmoConfEditor() + + +class NetatmoConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[netatmo] + # This section is for the netatmo station. + + # The mode specifies how driver should obtain data. The 'cloud' mode will + # retrieve data from the netatmo.com servers. The 'sniff' mode will parse + # packets from the netatmo station on the local network. + mode = cloud + + # The cloud mode requires credentials: + username = INSERT_USERNAME_HERE + password = INSERT_PASSWORD_HERE + client_id = INSERT_CLIENT_ID_HERE + client_secret = INSERT_CLIENT_SECRET_HERE + + # The driver itself + driver = user.netatmo +""" + + def prompt_for_settings(self): + settings = dict() + print "Specify the mode for obtaining data, either 'cloud' or 'sniff'" + settings['mode'] = self._prompt('mode', 'cloud', ['cloud', 'sniff']) + if settings['mode'] == 'cloud': + print "Specify the username for netatmo.com" + self._prompt('username') + print "Specify the password for netatmo.com" + self._prompt('password') + print "Specify the client_id from dev.netatmo.com" + self._prompt('client_id') + print "Specify the client_secret from dev.netatmo.com" + self._prompt('client_secret') + return settings + class NetatmoDriver(weewx.drivers.AbstractDevice): + DEFAULT_PORT = 80 + DEFAULT_HOST = '' # map from netatmo names to database schema names DEFAULT_SENSOR_MAP = { - 'pressure': 'pressure', - 'temperature_in': 'inTemp', - 'humidity_in': 'inHumidity', - 'temperature_out': 'outTemp', - 'humidity_out': 'outHumidity', - 'temperature_1': 'extraTemp1', - 'humidity_1': 'extraHumid1', - 'temperature_2': 'extraTemp2', - 'humidity_2': 'extraHumid2', - 'temperature_3': 'extraTemp3', - 'humidity_3': 'extraHumid3', - 'wind_speed': 'windSpeed', - 'wind_dir': 'windDir', - 'rain': 'rain', - 'co2': 'co2', - 'noise': 'noise'} + '*.NAMain.AbsolutePressure': 'pressure', + '*.NAMain.Temperature': 'inTemp', + '*.NAMain.Humidity': 'inHumidity', + '*.NAMain.CO2': 'co2', + '*.NAMain.Noise': 'noise', + '*.NAMain.wifi_status': 'wifi_status', + '*.NAModule1.Temperature': 'outTemp', + '*.NAModule1.Humidity': 'outHumidity', + '*.NAModule1.rf_status': 'rf_status', + '*.NAModule1.battery_vp': 'battery_vp', + '*.NAModule4.Temperature': 'extraTemp1', + '*.NAModule4.Humidity': 'extraHumid1', + '*.NAModule4.rf_status': 'rf_status', + '*.NAModule4.battery_vp': 'battery_vp', + '*.NAModule2.WindStrength': 'windSpeed', + '*.NAModule2.WindAngle': 'windDir', + '*.NAModule2.GustStrength': 'windGust', + '*.NAModule2.GustAngle': 'windGustDir', + '*.NAModule2.rf_status': 'rf_status', + '*.NAModule2.battery_vp': 'battery_vp', + '*.NAModule3.Rain': 'rain', + '*.NAModule3.rf_status': 'rf_status', + '*.NAModule3.battery_vp': 'battery_vp'} def __init__(self, **stn_dict): loginf("driver version is %s" % DRIVER_VERSION) - self.sensor_map = stn_dict.get('sensor_map', NetatmoDriver.DEFAULT_SENSOR_MAP) + self.sensor_map = stn_dict.get( + 'sensor_map', NetatmoDriver.DEFAULT_SENSOR_MAP) + device_id = stn_dict.get('device_id', None) mode = stn_dict.get('mode', 'cloud') - self.max_tries = int(stn_dict.get('max_tries', 5)) - self.retry_wait = int(stn_dict.get('retry_wait', 10)) # seconds - self.poll_interval = int(stn_dict.get('poll_interval', 600)) # seconds - timeout = int(stn_dict.get('timeout', 3)) - port = int(stn_dict.get('port', 4200)) - addr = stn_dict.get('host', '') - if mode == 'sniff': - self.collector = PacketSniffer(addr, port) + if mode.lower() == 'sniff': + port = int(stn_dict.get('port', NetatmoDriver.DEFAULT_PORT)) + addr = stn_dict.get('host', NetatmoDriver.DEFAULT_HOST) + self.collector = PacketSniffer((addr, port)) + elif mode.lower() == 'cloud': + max_tries = int(stn_dict.get('max_tries', 5)) + retry_wait = int(stn_dict.get('retry_wait', 10)) # seconds + poll_interval = int(stn_dict.get('poll_interval', 600)) # seconds + username = stn_dict['username'] + password = stn_dict['password'] + client_id = stn_dict['client_id'] + client_secret = stn_dict['client_secret'] + self.collector = CloudClient( + username, password, client_id, client_secret, + device_id=device_id, poll_interval=poll_interval, + max_tries=max_tries, retry_wait=retry_wait) else: - self.collector = CloudClient() + raise ValueError("unsupported mode '%s'" % mode) self.collector.startup() def closePort(self): @@ -89,20 +178,47 @@ class NetatmoDriver(weewx.drivers.AbstractDevice): while True: try: data = self.collector.queue.get(True, 10) + logdbg('data: %s' % data) pkt = self.data_to_packet(data) + logdbg('packet: %s' % pkt) if pkt: yield pkt except Queue.Empty: logdbg('empty queue') - def data_to_packet(data): + def data_to_packet(self, data): # convert netatmo data to format, units, and scaling for database - packet = {'dateTime': int(time.time() + 0.5), 'usUnits': weewx.METRIC} - for n in data: - if n in self.sensor_map: - packet[sensor_map[n]] = data[n] + packet = dict() + packet['dateTime'] = data.pop('dateTime', int(time.time() + 0.5)) + packet['usUnits'] = data.pop('usUnits', weewx.US) + + for n in self.sensor_map: + label = self._find_match(n, data.keys()) + if label: + packet[self.sensor_map[n]] = data[label] return packet + @staticmethod + def _find_match(pattern, keylist): + pparts = pattern.split('.') + if len(pparts) != 3: + return None + for k in keylist: + kparts = k.split('.') + if (NetatmoDriver._part_match(pparts[0], kparts[0]) and + NetatmoDriver._part_match(pparts[1], kparts[1]) and + NetatmoDriver._part_match(pparts[2], kparts[2])): + return k + return None + + @staticmethod + def _part_match(pattern, value): + if pattern == value: + return True + if pattern == '*' and value: + return True + return False + class Collector(object): queue = Queue.Queue() @@ -117,169 +233,278 @@ class Collector(object): class CloudClient(Collector): """Poll the netatmo servers for data. Put the result on the queue. - The netatmo server provides the following data: - + noise is measured in dB + co2 is measured in ppm + rain is measured in mm (or inch?) not specified in docs! + temperatures are measured in C or F + + the user object indicates the units of the download + unit: 0: metric, 1: imperial + windunit: 0: kph, 1: mph, 2: m/s, 3: beafort, 4: knot + pressureunit: 0: mbar, 1: inHg, 2: mmHg + lang: user locale + reg_locale: regional preferences for date + feel_like_algo: 0: humidex, 1: heat-index + rf_status is a mapping to rssi (+dB) + 0: 90, 1: 80, 2: 70, 3: 60 + wifi_status is a mapping to rssi (+dB) + 0: 86, 1: 71, 2: 56 + battery_vp measures battery capacity + indoor: 6000-4200; 0: 5640, 1: 5280, 2: 4920, 3: 4560 + rain/outdoor: 6000-3600; 0: 5500, 1: 5000, 2: 4500, 3: 4000 + wind: 6000-3950; 0: 5590, 1: 5180, 2: 4770, 3: 4360 """ - NETATMO_URL = 'https://api.netatmo.net/' - AUTH_URL = NETATMO_URL + 'oauth2/token' - GETUSER_URL = NETATMO_URL + 'api/getuser' - DEVICELIST_URL = NETATMO_URL + 'api/devicelist' - GETMEASURE_URL = NETATMO_URL + 'api/getmeasure' - - def __init__(self, poll_interval): + # endpoints for the cloud queries + NETATMO_URL = 'https://api.netatmo.net' + AUTH_URL = '/oauth2/token' + DATA_URL = '/api/getstationsdata' + + # mapping between observation name and function used to convert it + CONVERSIONS = { + 'Temperature': '_cvt_temperature', + 'AbsolutePressure': '_cvt_pressure', + 'Pressure': '_cvt_pressure', + 'WindStrength': '_cvt_speed', + 'GustStrength': '_cvt_speed', + 'Rain': '_cvt_rain'} + + # list of source units we need to watch for + UNITS = ['unit', 'windunit', 'pressureunit'] + + # these items are tracked from every module and every device + DASHBOARD_ITEMS = [ + 'Temperature', 'Humidity', 'AbsolutePressure', 'Pressure', + 'CO2', 'Noise', 'Rain', + 'WindStrength', 'WindAngle', 'GustStrength', 'GustAngle'] + META_ITEMS = [ + 'wifi_status', 'rf_status', 'battery_vp', 'co2_calibrating', + '_id', 'module_name', 'last_status_store', 'last_seen', + 'firmware', 'last_setup', 'last_upgrade', 'date_setup'] + + def __init__(self, username, password, client_id, client_secret, + device_id=None, poll_interval=600, max_tries=3, retry_wait=30): self._poll_interval = poll_interval + self._max_tries = max_tries + self._retry_wait = retry_wait + self._device_id = device_id + self._auth = CloudClient.ClientAuth( + username, password, client_id, client_secret) + self._sd = CloudClient.StationData(self._auth) + self._thread = None + self._collect_data = False + + def collect_data(self): + """Loop forever, wake up periodically to see if it is time to quit.""" + last_poll = 0 + while self._collect_data: + now = int(time.time()) + if now - last_poll > self._poll_interval: + for tries in range(self._max_tries): + try: + CloudClient.get_data(self._sd, self._device_id) + break + except urllib2.HTTPError, e: + logerr("failed attempt %s of %s to get data: %s" % + (tries + 1, self._max_tries, e)) + logdbg("waiting %s seconds before retry" % + self.retry_wait) + else: + logerr("failed to get data after %d attempts" % + self._max_tries) + last_poll = now + logdbg('next update in %s seconds' % self._poll_interval) + time.sleep(1) + + @staticmethod + def get_data(sd, device_id): + """Query the server for each device and module, put data on the queue""" + raw_data = sd.get_data(device_id) + units_dict = dict((x, raw_data['user']['administrative'][x]) + for x in CloudClient.UNITS) + logdbg('cloud units: %s' % units_dict) + for d in raw_data['devices']: + data = CloudClient.extract_data(d, units_dict) + data = CloudClient.apply_labels(data, d['_id'], d['type']) + Collector.queue.put(data) + for m in d['modules']: + data = CloudClient.extract_data(m, units_dict) + data = CloudClient.apply_labels(data, m['_id'], m['type']) + Collector.queue.put(data) + + @staticmethod + def extract_data(x, units_dict): + """Extract data we care about from a device or module""" + data = {'dateTime': x['dashboard_data']['time_utc'], + 'usUnits': weewx.METRIC} + for n in CloudClient.META_ITEMS: + if n in x: + data[n] = x[n] + for n in CloudClient.DASHBOARD_ITEMS: + if n in x['dashboard_data']: + data[n] = x['dashboard_data'][n] + # do any unit conversions - everything converts to weewx.METRIC + for n in data: + try: + func = CloudClient.CONVERSIONS.get(n) + if func: + data[n] = getattr(CloudClient, func)(data[n], units_dict) + except ValueError, e: + logerr("unit conversion failed for %s: %s" % (data[n], e)) + data[n] = None + return data + + @staticmethod + def apply_labels(data, xid, xtype): + """Copy the data dict but use fully-qualified keys""" + new_data = dict() + for n in data: + # do not touch the timestamp or units + if n in ['dateTime', 'usUnits']: + new_data[n] = data[n] + else: + new_data["%s.%s.%s" % (xid, xtype, n)] = data[n] + return new_data + + @staticmethod + def _cvt_pressure(x, from_unit_dict): + # pressureunit: 0: mbar, 1: inHg, 2: mmHg + if from_unit_dict['pressureunit'] == 1: + x /= INHG_PER_MBAR + elif from_unit_dict['pressureunit'] == 2: + x /= INHG_PER_MBAR * 25.4 + return x + + @staticmethod + def _cvt_speed(x, from_unit_dict): + # windunit: 0: kph, 1: mph, 2: m/s, 3: beafort, 4: knot + if from_unit_dict['windunit'] == 1: + x *= MPH_TO_KPH + elif from_unit_dict['windunit'] == 2: + x *= MPS_TO_KPH + elif from_unit_dict['windunit'] == 3: + x = BEAFORT_TO_KPH.get(x) + elif from_unit_dict['windunit'] == 4: + x *= KNOT_TO_KPH + return x + + @staticmethod + def _cvt_temperature(x, from_unit_dict): + if from_unit_dict['unit'] == 1: + x = weewx.wxformulas.FtoC(x) + return x + + @staticmethod + def _cvt_rain(x, from_unit_dict): + # FIXME: verify that 0 is cm and 1 is inch + if from_unit_dict['unit'] == 1: + x *= CM_PER_IN + return x def startup(self): - auth = ClientAuth(client_id, client_secret, username, password) - devices = DeviceList(auth) - while True: - latest = devices.last_data() - logdbg('latest: %s' % latest) - Collector.queue.put(latest) - time.sleep(self._poll_interval) + """Start a thread that collects data from the netatmo servers.""" + self._thread = CloudClient.CollectorThread(self) + self._collect_data = True + self._thread.start() + + def shutdown(self): + """Tell the thread to stop, then wait for it to finish.""" + if self._thread: + self._collect_data = False + self._thread.join() + self._thread = None + + class CollectorThread(threading.Thread): + def __init__(self, client): + threading.Thread.__init__(self) + self.client = client + self.name = 'netatmo-client' + + def run(self): + self.client.collect_data() class ClientAuth(object): - def __init__(self, client_id, client_secret, username, password): + """Encapsulate the client authentication data and protocols. This + object contains the username, password, client_id, and client_secret + that are required to obtain authorization tokens from netatmo.com. + + It will re-query the netatmo server whenever the tokens have expired""" + + def __init__(self, username, password, client_id, client_secret): + self._username = username + self._password = password + self._client_id = client_id + self._client_secret = client_secret + self._access_token = None + self._refresh_token = None + self._scope = None + self._expiration = None + + def obtain_token(self): params = { 'grant_type': 'password', - 'client_id': client_id, - 'client_secret': client_secret, - 'username': username, - 'password': password, + 'client_id': self._client_id, + 'client_secret': self._client_secret, + 'username': self._username, + 'password': self._password, 'scope': 'read_station'} - resp = CloudClient.post_request(self.AUTH_URL, params) - self._client_id = client_id - self._client_secret = client_secret + resp = CloudClient.post_request(CloudClient.AUTH_URL, params) self._access_token = resp['access_token'] self._refresh_token = resp['refresh_token'] self._scope = resp['scope'] self._expiration = int(resp['expire_in'] + time.time()) + # clear credentials cache + self._username = self._password = None @property def access_token(self): + if self._access_token is None: + self.obtain_token() + if self._access_token is None: + return None # FIXME: indicate failure if self._expiration < time.time(): params = { 'grant_type': 'refresh_token', 'refresh_token': self._refresh_token, 'client_id': self._client_id, 'client_secret': self._client_secret} - resp = CloudClient.post_request(self.AUTH_URL, params) + resp = CloudClient.post_request(CloudClient.AUTH_URL, params) self._access_token = resp['access_token'] self._refresh_token = resp['refresh_token'] self._expiration = int(resp['expire_in'] + time.time()) - - class User(object): - def __init__(self, auth_data): - params = { - 'access_token': auth_data.access_token} - resp = CloudClient.post_request(GETUSER_URL, params) - self._raw_data = resp['body'] - self._id = self._raw_data['_id'] - self._devices = self._raw_data['devices'] - self._ownermail = self._raw_data['mail'] - - class DeviceList(object): - def __init__(self, auth_data): - self._token = auth_data.access_token - params = { - 'access_token': self._token, - 'app_type': 'app_station'} - resp = CloudClient.post_request(DEVICELIST_URL, params) - self._raw_data = resp['body'] - self._statiosn = {d['_id'] : d for d in self._raw_data['devices']} - self._modules = {m['_id'] : m for m in self._raw_data['modules']} - self._default = list(self._stations.values())[0]['station_name'] - - def module_names(self, station=None): - res = [m['module_name'] for m in self._modules.values()] - res.append(self.station_by_name(station)['module_name']) - return res - - def station_by_name(self, station=None): - if not station: - station = self._default_station - for i, s in self._stations.items(): - if s['station_name'] == station: - return self._stations[i] - return None - - def station_by_id(self, sid): - return None if sid not in self._stations else self._stations[sid] - - def module_by_name(self, module, station=None): - s = None - if station: - s = self.station_by_name(station) - if not s: - return None - for m in self._modules: - mod = self._modules[m] - if mod['module_name'] == module: - if not s or mod['main_device'] == s['_id']: - return mod - return None - - def module_by_id(self, mid, sid=None): - s = self.station_by_id(sid) if sid else None - if mid in self._modules: - if not s or self.modules[mid]['main_device'] == s['_id']: - return self.modules[mid] - return None - - def get_measure(self, device_id, scale, mtype, module_id=None, - date_begin=None, date_end=None, limit=None, - optimize=False, real_time=False): - params = { - 'access_token': self._token, - 'device_id': device_id, - 'scale': scale, - 'type', mtype, - 'optimize': 'true' if optimize else 'false', - 'real_time': 'true' if real_time else 'false'} - if module_id: - params['module_id'] = module_id - if date_begin: - params['date_begin'] = date_begin - if date_end: - params['date_end'] = date_end - if limit: - params['limit'] = limit - return CloudClient.post_request(GETMEASURE_URL, params) - - def last_data(self, station=None, exclude=0): - s = self.station_by_name(station) - if not s: - return None - data = dict() - limit = (time.time() - exclue) if exclude else 0 - ds = s['dashboard_data'] - if ds['time_utc'] > limit: - data[s['module_name']] = ds.copy() - data[s['module_name']]['When'] = data[s['module_name']].pop('time_utc') - data[s['module_name']]['wifi_status'] = s['wifi_status'] - for mid in s['modules']: - ds = self._modules[mid]['dashboard_data'] - if ds['time_utc'] > limit: - mod = self._modules[mid] - data[mod['module_name']] = ds.copy() - data[mod['module_name']]['When'] = data[mod['module_name']].pop('time_utc') - for i in ('battery_vp', 'rf_status'): - if i in mod: - data[mod['module_name']][i] = mod[i] - return data + return self._access_token + + class StationData(object): + def __init__(self, auth): + self._auth = auth + self._last_update = 0 + self._raw_data = dict() + + def get_data(self, device_id=None, stale=600): + if int(time.time()) - self._last_update > stale: + params = {'access_token': self._auth.access_token} + if device_id: + params['device_id'] = device_id + resp = CloudClient.post_request(CloudClient.DATA_URL, params) + self._raw_data = dict(resp['body']) + self._last_update = int(time.time()) + return self._raw_data @staticmethod def post_request(url, params): # netatmo response body size is limited to 64K + url = CloudClient.NETATMO_URL + url params = urlencode(params) headers = { "Content-Type": "application/x-www-form-urlencoded;charset=urf-8"} + logdbg("url: %s data: %s hdr: %s" % (url, params, headers)) req = urllib2.Request(url=url, data=params, headers=headers) resp = urllib2.urlopen(req).read(65535) - return json.loads(resp) - + resp_obj = json.loads(resp) + logdbg("resp_obj: %s" % resp_obj) + return resp_obj + class PacketSniffer(Collector): """listen for incoming packets then parse them. put result on queue.""" @@ -291,37 +516,37 @@ class PacketSniffer(Collector): pass -class TCPPacket(object): - _HDR = re.compile('(\d+).(\d+) IP (\S+) > (\S+):') - _DATA = re.compile('0x00\d0: (.*)') - - def lines2packets(lines): - pkts = [] - ts = None - src = None - dst = None - data = [] - for line in lines: - line = line.strip() - TCPPacket._HDR.search(line) - if m: - ts = m.group(1) - src = m.group(3) - dst = m.group(4) - data = [] - pkts.append({'dateTime': ts, 'src': src, 'dst': dst, - 'data': ''.join(data)}) - continue - TCPPacket._DATA.search(line) - if m: - data.append(m.group(1)) - continue - return pkts - - @staticmethod - def parse_data(data): - pkt = dict() - return pkt + class Packet(object): + _HDR = re.compile('(\d+).(\d+) IP (\S+) > (\S+):') + _DATA = re.compile('0x00\d0: (.*)') + + def lines2packets(lines): + pkts = [] + ts = None + src = None + dst = None + data = [] + for line in lines: + line = line.strip() + PacketSniffer.Packet._HDR.search(line) + if m: + ts = m.group(1) + src = m.group(3) + dst = m.group(4) + data = [] + pkts.append({'dateTime': ts, 'src': src, 'dst': dst, + 'data': ''.join(data)}) + continue + PacketSniffer.Packet._DATA.search(line) + if m: + data.append(m.group(1)) + continue + return pkts + + @staticmethod + def parse_data(data): + pkt = dict() + return pkt # To test this driver, do the following: @@ -333,102 +558,93 @@ if __name__ == "__main__": import optparse syslog.openlog('wee_netatmo', syslog.LOG_PID | syslog.LOG_CONS) parser = optparse.OptionParser(usage=usage) - parser.add_option('--test-sniff', dest='ts', action='store_true', - help='test the driver in packet sniff mode') - parser.add_option('--test-cloud', dest='tc', action='store_true', - help='test the driver in cloud client mode') - parser.add_option('--test-parse', dest='tp', action='store_true', - help='test the parser') + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--debug', dest='debug', action='store_true', + help='display diagnostic information while running') + parser.add_option('--run-sniff-driver', dest='ts', action='store_true', + help='run the driver in packet sniff mode') + parser.add_option('--run-cloud-driver', dest='tc', action='store_true', + help='run the driver in cloud client mode') + parser.add_option('--test-parse', dest='tp', metavar='FILENAME', + help='test the tcp packet parser') parser.add_option('--username', dest='username', metavar='USERNAME', help='username for cloud mode') parser.add_option('--password', dest='password', metavar='PASSWORD', help='password for cloud mode') - parser.add_option('--get-cloud_data', dest='data', action='store_true', - help='get all cloud data') + parser.add_option('--client-id', dest='ci', metavar='CLIENT_ID', + help='client_id for cloud mode') + parser.add_option('--client-secret', dest='cs', metavar='CLIENT_SECRET', + help='client_secret for cloud mode') + parser.add_option('--get-stn-data', dest='sdata', action='store_true', + help='get formatted station data from cloud') + parser.add_option('--get-json-data', dest='jdata', action='store_true', + help='get all cloud data as json response') (opts, args) = parser.parse_args() + if opts.debug: + syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_DEBUG)) + if opts.ts: - test_packet_driver() + run_packet_driver() if opts.tc: - test_cloud_driver(opts.username, opts.password) + run_cloud_driver(opts.username, opts.password, opts.ci, opts.cs) if opts.tp: - test_parse() + test_parse(options.tp) + if opts.sdata: + get_station_data(opts.username, opts.password, opts.ci, opts.cs) + if opts.jdata: + get_json_data(opts.username, opts.password, opts.ci, opts.cs) - def test_sniff_driver(): + def run_sniff_driver(): import weeutil.weeutil driver = NetatmoDriver({'mode': 'sniff'}) for pkt in driver.genLoopPackets(): print weeutil.weeutil.timestamp_to_string(pkt['dateTime']), pkt - def test_cloud_driver(username, password): + def run_cloud_driver(username, password, c_id, c_secret): import weeutil.weeutil - driver = NetatmoDriver({'mode': 'cloud', - 'username': username, 'password': password}) - for pkt in driver.genLoopPackets(): - print weeutil.weeutil.timestamp_to_string(pkt['dateTime']), pkt - - def get_cloud_data(username, password): - auth = CloudClient.ClientAuth(username, password) - devices = CloudClient.DeviceList(auth) - for module, module_data in devices.last_data(exclue=3600).items(): - print module - for sensor, value in module_data.items(): - if sensor == 'When': - value = time.strftime("%Y.%m.%d %H:%M:%S", - time.localtime(value)) - print "%30s: %s" % (sensor, value) - - def test_parse(): - tcp_lines = """1450840574.054884 IP 10.1.10.11.56280 > b31.netatmo.net.25050: P 52:56(4) ack 33 win 2968 - 0x0000: 4500 002c 7ba7 0000 fe06 f2f2 0a01 0a0b - 0x0010: 3ed2 fb53 dbd8 61da f828 80ee d800 3574 - 0x0020: 5018 0b98 1bbe 0000 7601 0000 0000 -1450840574.153985 IP b31.netatmo.net.25050 > 10.1.10.11.56280: P 33:37(4) ack 56 win 29200 - 0x0000: 4520 002c da5b 4000 3406 1e1f 3ed2 fb53 - 0x0010: 0a01 0a0b 61da dbd8 d800 3574 f828 80f2 - 0x0020: 5018 7210 cb42 0000 6000 0000 0000 -1450840574.262671 IP 10.1.10.11.56280 > b31.netatmo.net.25050: P 56:202(146) ack 37 win 2964 - 0x0000: 4500 00ba c99d 0000 fe06 a46e 0a01 0a0b - 0x0010: 3ed2 fb53 dbd8 61da f828 80f2 d800 3578 - 0x0020: 5018 0b94 3750 0000 6100 2500 c210 7a56 - 0x0030: 3730 3a65 653a 3530 3a30 363a 3834 3a37 - 0x0040: 3200 da00 0137 0fa5 2703 d227 0731 05b7 - 0x0050: 0761 -1450840574.363385 IP b31.netatmo.net.25050 > 10.1.10.11.56280: P 37:41(4) ack 202 win 30016 - 0x0000: 4520 002c da5c 4000 3406 1e1e 3ed2 fb53 - 0x0010: 0a01 0a0b 61da dbd8 d800 3578 f828 8184 - 0x0020: 5018 7540 217d 0000 0600 0000 0000 -1450840574.464212 IP 10.1.10.11.56280 > b31.netatmo.net.25050: P 202:443(241) ack 41 win 2960 - 0x0000: 4500 0119 25c4 0000 fe06 47e9 0a01 0a0b - 0x0010: 3ed2 fb53 dbd8 61da f828 8184 d800 357c - 0x0020: 5018 0b90 132d 0000 1000 0100 0507 00e8 - 0x0030: 002b 0100 0000 0032 3032 3a30 303a 3030 - 0x0040: 3a30 363a 3836 3a32 3836 a615 0000 2b00 - 0x0050: e811 -1450840574.568157 IP b31.netatmo.net.25050 > 10.1.10.11.56280: P 41:45(4) ack 443 win 31088 - 0x0000: 4520 002c da5d 4000 3406 1e1d 3ed2 fb53 - 0x0010: 0a01 0a0b 61da dbd8 d800 357c f828 8275 - 0x0020: 5018 7970 1a58 0000 0800 0000 0000 -1450840574.666496 IP 10.1.10.11.56280 > b31.netatmo.net.25050: P 443:447(4) ack 45 win 2956 - 0x0000: 4500 002c 4bce 0000 fe06 22cc 0a01 0a0b - 0x0010: 3ed2 fb53 dbd8 61da f828 8275 d800 3580 - 0x0020: 5018 0b8c 8738 0000 0900 0000 0000 -1450840574.770808 IP b31.netatmo.net.25050 > 10.1.10.11.56280: F 45:45(0) ack 447 win 31088 - 0x0000: 4520 0028 da5e 4000 3406 1e20 3ed2 fb53 - 0x0010: 0a01 0a0b 61da dbd8 d800 3580 f828 8279 - 0x0020: 5011 7970 225b 0000 0000 0000 0000 -1450840574.870753 IP 10.1.10.11.56280 > b31.netatmo.net.25050: . ack 46 win 2955 - 0x0000: 4500 0028 7d95 0000 fe06 f108 0a01 0a0b - 0x0010: 3ed2 fb53 dbd8 61da f828 8279 d800 3581 - 0x0020: 5010 0b8b 9040 0000 0000 0000 0000 -1450840574.871685 IP 10.1.10.11.56280 > b31.netatmo.net.25050: F 447:447(0) ack 46 win 2955 - 0x0000: 4500 0028 0a80 0000 fe06 641e 0a01 0a0b - 0x0010: 3ed2 fb53 dbd8 61da f828 8279 d800 3581 - 0x0020: 5011 0b8b 903f 0000 0000 0000 0000 -1450840574.976182 IP b31.netatmo.net.25050 > 10.1.10.11.56280: . ack 448 win 31088 - 0x0000: 4520 0028 cf38 4000 3406 2946 3ed2 fb53 - 0x0010: 0a01 0a0b 61da dbd8 d800 3581 f828 827a - 0x0020: 5010 7970 225a 0000 0000 0000 0000""" - print TCPPacket.lines2packets(tcp_lines) + driver = None + try: + driver = NetatmoDriver(mode='cloud', + username=username, password=password, + client_id=c_id, client_secret=c_secret) + for pkt in driver.genLoopPackets(): + print weeutil.weeutil.timestamp_to_string(pkt['dateTime']), pkt + except KeyboardInterrupt: + driver.closePort() + + def get_station_data(username, password, c_id, c_secret): + auth = CloudClient.ClientAuth(username, password, c_id, c_secret) + sd = CloudClient.StationData(auth) + ppv('station data', sd.get_data()) + + def get_json_data(username, password, c_id, c_secret): + auth = CloudClient.ClientAuth(username, password, c_id, c_secret) + params = {'access_token': auth.access_token, 'app_type': 'app_station'} + reply = CloudClient.post_request(CloudClient.DEVICELIST_URL, params) + print json.dumps(reply, sort_keys=True, indent=2) + + def test_parse(filename): + lines = [] + with open(filename, "r") as f: + while f: + lines.append(f.readline()) + print PacketSniffer.Packet.lines2packets(''.join(lines)) + + def ppv(label, x, level=0): + """pretty-print a variable, recursing if it is a dict""" + indent = ' ' + if type(x) is dict: + print "%s%s" % (indent * level, label) + for n in x: + ppv(n, x[n], level=level+1) + elif type(x) is list: + print "%s[" % (indent * level) + for i, y in enumerate(x): + ppv("%s %s" % (label, i), y, level=level+1) + print "%s]" % (indent * level) + else: + print "%s%s=%s" % (indent * level, label, x) main() diff --git a/readme.txt b/readme.txt index c622bd5..2fbf0e1 100644 --- a/readme.txt +++ b/readme.txt @@ -1,11 +1,22 @@ netatmo - weewx driver for netatmo weather stations Copyright 2015 Matthew Wall -This driver has two modes of operation. It can use the Netatmo API to obtain +This driver has two modes of operation. It can use the netatmo API to obtain data from the netatmo servers, or it can parse the packets sent from a netatmo -station. The latter works only with netatmo firmware 102 (circa early 2015). +station. The latter works only with netatmo firmware 101 (circa early 2015). +Firmware 102 introduced arbitrary encryption in response to a poorly chosen +decision to include the wifi password in network traffic sent to the netatmo +servers. Unfortunately, the encryption blocks end-users from accessing their +data, while the netatmo stations might still send information such as the wifi +password to the netatmo servers. -By default this driver will obtain data from the netatmo servers. +By default this driver will operate in 'cloud' mode. + +Communication with the netatmo servers requires 4 things: username, password, +client_id, and client_secret. The username and password are the credentials +used to access data at netatmo.com. The client_id and client_secret must be +obtained via the dev.netatmo.com web site. Using these 4 things, the driver +automatically obtains and updates the tokens needed to get data from the server. Installation instructions: @@ -19,6 +30,8 @@ wee_extension --install weewx-netatmo.tgz [Netatmo] username = INSERT_USERNAME_HERE password = INSERT_PASSWORD_HERE + client_id = INSERT_CLIENT_ID_HERE + client_secret = INSERT_CLIENT_SECRET_HERE 3) start weewx: