commit caa3ca80c218c6b1a59dacc8b13747af31169c40 Author: Matthew Wall Date: Wed Apr 27 07:14:00 2016 -0400 initial publish to github diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f836aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +*.pyc diff --git a/bin/user/netatmo.py b/bin/user/netatmo.py new file mode 100644 index 0000000..74f5cf2 --- /dev/null +++ b/bin/user/netatmo.py @@ -0,0 +1,434 @@ +#!/usr/bin/python +# Copyright 2015 Matthew Wall +# +# Thanks to phillippe larduinat for publishing lnetatmo.py +# https://github.com/philippelt/netatmo-api-python +# +# Shame on netatmo for making it very difficult to get data from the hardware +# without going through their servers. + +from __future__ import with_statement +import Queue +import json +import re +import socket +import syslog +import threading +import time +from urllib import urlencode +import urllib2 + +import weewx.drivers +import weewx.engine +import weewx.units + +DRIVER_NAME = 'Netatmo' +DRIVER_VERSION = "0.2" + +def logmsg(level, msg): + syslog.syslog(level, 'netatmo: %s: %s' % + (threading.currentThread().getName(), 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) + +def loader(config_dict, engine): + return NetatmoDriver(**config_dict[DRIVER_NAME]) + + +class NetatmoDriver(weewx.drivers.AbstractDevice): + # 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'} + + def __init__(self, **stn_dict): + loginf("driver version is %s" % DRIVER_VERSION) + self.sensor_map = stn_dict.get('sensor_map', NetatmoDriver.DEFAULT_SENSOR_MAP) + 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) + else: + self.collector = CloudClient() + self.collector.startup() + + def closePort(self): + self.collector.shutdown() + + @property + def hardware_name(self): + return DRIVER_NAME + + def genLoopPackets(self): + while True: + try: + data = self.collector.queue.get(True, 10) + pkt = self.data_to_packet(data) + if pkt: + yield pkt + except Queue.Empty: + logdbg('empty queue') + + def data_to_packet(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] + return packet + + +class Collector(object): + queue = Queue.Queue() + + def startup(self): + pass + + def shutdown(self): + pass + + +class CloudClient(Collector): + """Poll the netatmo servers for data. Put the result on the queue. + + The netatmo server provides the following data: + + + """ + + 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): + self._poll_interval = poll_interval + + 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) + + class ClientAuth(object): + def __init__(self, client_id, client_secret, username, password): + params = { + 'grant_type': 'password', + 'client_id': client_id, + 'client_secret': client_secret, + 'username': username, + 'password': password, + 'scope': 'read_station'} + resp = CloudClient.post_request(self.AUTH_URL, params) + self._client_id = client_id + self._client_secret = client_secret + self._access_token = resp['access_token'] + self._refresh_token = resp['refresh_token'] + self._scope = resp['scope'] + self._expiration = int(resp['expire_in'] + time.time()) + + @property + def access_token(self): + 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) + 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 + + @staticmethod + def post_request(url, params): + # netatmo response body size is limited to 64K + params = urlencode(params) + headers = { + "Content-Type": "application/x-www-form-urlencoded;charset=urf-8"} + req = urllib2.Request(url=url, data=params, headers=headers) + resp = urllib2.urlopen(req).read(65535) + return json.loads(resp) + + +class PacketSniffer(Collector): + """listen for incoming packets then parse them. put result on queue.""" + + def startup(self): + pass + + def shutdown(self): + 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 + + +# To test this driver, do the following: +# PYTHONPATH=bin python user/netatmo.py +if __name__ == "__main__": + usage = """%prog [options] [--help]""" + + def 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('--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') + (opts, args) = parser.parse_args() + + if opts.ts: + test_packet_driver() + if opts.tc: + test_cloud_driver(opts.username, opts.password) + if opts.tp: + test_parse() + + def test_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): + 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) + + main() diff --git a/changelog b/changelog new file mode 100644 index 0000000..10c700d --- /dev/null +++ b/changelog @@ -0,0 +1,5 @@ +0.2 25apr2016 +* release of combined driver + +0.1 17jun2015 +* initial public release diff --git a/install.py b/install.py new file mode 100644 index 0000000..9eacc2f --- /dev/null +++ b/install.py @@ -0,0 +1,29 @@ +# $Id: install.py 1484 2016-04-25 16:20:31Z mwall $ +# installer for netatmo driver +# Copyright 2015 Matthew Wall + +from setup import ExtensionInstaller + +def loader(): + return NetatmoInstaller() + +class NetatmoInstaller(ExtensionInstaller): + def __init__(self): + super(NetatmoInstaller, self).__init__( + version="0.2", + name='netatmo', + description='Driver for netatmo weather stations.', + author="Matthew Wall", + author_email="mwall@users.sourceforge.net", + config={ + 'Station': { + 'station_type': 'netatmo'}, + 'netatmo': { + 'mode': 'cloud', + 'username': 'INSERT_USERNAME_HERE', + 'password': 'INSERT_PASSWORD_HERE', + 'client_id': 'INSERT_CLIENT_ID_HERE', + 'client_secret': 'INSERT_CLIENT_SECRET_HERE', + 'driver': 'user.netatmo'}}, + files=[('bin/user', ['bin/user/netatmo.py'])] + ) diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..c622bd5 --- /dev/null +++ b/readme.txt @@ -0,0 +1,25 @@ +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 +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). + +By default this driver will obtain data from the netatmo servers. + + +Installation instructions: + +1) run the installer: + +wee_extension --install weewx-netatmo.tgz + +2) modify weewx.conf: + +[Netatmo] + username = INSERT_USERNAME_HERE + password = INSERT_PASSWORD_HERE + +3) start weewx: + +sudo /etc/init.d/weewx start