diff --git a/Adafruit_IO/__init__.py b/Adafruit_IO/__init__.py index 1c865ef..02dba25 100644 --- a/Adafruit_IO/__init__.py +++ b/Adafruit_IO/__init__.py @@ -1,2 +1,24 @@ -from .client import Client, AdafruitIOError, RequestError, ThrottlingError, Data -from .mqtt_client import MQTTClient \ No newline at end of file +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from .client import Client +from .mqtt_client import MQTTClient +from .errors import AdafruitIOError, RequestError, ThrottlingError +from .model import Data, Stream, Feed, Group diff --git a/Adafruit_IO/client.py b/Adafruit_IO/client.py index fb941ee..5564c38 100644 --- a/Adafruit_IO/client.py +++ b/Adafruit_IO/client.py @@ -1,216 +1,242 @@ -import collections +# Copyright (c) 2014 Adafruit Industries +# Authors: Justin Cooper & Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import json -from urllib3 import connection_from_url -from urllib import urlencode, quote +import requests + +from .errors import RequestError, ThrottlingError +from .model import Data, Feed, Group -class AdafruitIOError(Exception): - """Base class for all Adafruit IO request failures.""" - pass - - -class RequestError(Exception): - """General error for a failed Adafruit IO request.""" - def __init__(self, response): - super(RequestError, self).__init__("Adafruit IO request failed: {0} {1}".format( - response.status, response.reason)) - - -class ThrottlingError(AdafruitIOError): - """Too many requests have been made to Adafruit IO in a short period of time. - Reduce the rate of requests and try again later. - """ - def __init__(self): - super(ThrottlingError, self).__init__("Exceeded the limit of Adafruit IO " \ - "requests in a short period of time. Please reduce the rate of requests " \ - "and try again later.") - - -class Data(collections.namedtuple('Data', ['created_epoch', 'created_at', - 'updated_at', 'value', 'completed_at', 'feed_id', 'expiration', 'position', - 'id'])): - """Row of data from a feed. This is a simple class that just represents data - returned from the Adafruit IO service. The value property has the value of the - data row, and other properties like created_at or id represent the metadata - of the data row. - """ - - @classmethod - def from_response(cls, response): - """Create a new Data instance based on the response dict from an Adafruit IO - request. - """ - # Be careful to support forward compatibility by only looking at attributes - # which this Data class knows about (i.e. ignore anything else that's - # unknown). - # In this case iterate through all the fields of the named tuple and grab - # their value from the response dict. If any field doesn't exist in the - # response dict then set it to None. - return cls(*[response.get(x, None) for x in cls._fields]) - -# Magic incantation to make all parameters to the constructor optional with a -# default value of None. This is useful when creating an explicit Data instance -# to pass to create_data with only the value or other properties set. -Data.__new__.__defaults__ = tuple(None for x in Data._fields) - - -#fork of ApiClient Class: https://github.com/shazow/apiclient class Client(object): - """Client instance for interacting with the Adafruit IO service using its REST - API. Use this client class to send, receive, and enumerate feed data. - """ - BASE_URL = 'https://io.adafruit.com/' - - def __init__(self, key, rate_limit_lock=None): - """Create an instance of the Adafruit IO REST API client. Key must be - provided and set to your Adafruit IO access key value. + """Client instance for interacting with the Adafruit IO service using its + REST API. Use this client class to send, receive, and enumerate feed data. """ - self.key = key - self.rate_limit_lock = rate_limit_lock - self.connection_pool = self._make_connection_pool(self.BASE_URL) - def _make_connection_pool(self, url): - return connection_from_url(url) + def __init__(self, key, proxies=None, base_url='https://io.adafruit.com'): + """Create an instance of the Adafruit IO REST API client. Key must be + provided and set to your Adafruit IO access key value. Optionaly + provide a proxies dict in the format used by the requests library, and + base_url to point at a different Adafruit IO service (the default is + the production Adafruit IO service over SSL). + """ + self.key = key + self.proxies = proxies + # Save URL without trailing slash as it will be added later when + # constructing the path. + self.base_url = base_url.rstrip('/') - def _compose_url(self, path): - return self.BASE_URL + path + def _compose_url(self, path): + return '{0}/{1}'.format(self.base_url, path) - def _compose_get_url(self, path, params=None): - return self.BASE_URL + path + '?' + urlencode(params) + def _handle_error(sefl, response): + # Handle explicit errors. + if response.status_code == 429: + raise ThrottlingError() + # Handle all other errors (400 & 500 level HTTP responses) + elif response.status_code >= 400: + raise RequestError(response) + # Else do nothing if there was no error. - def _handle_error(sefl, response): - # Handle explicit errors. - if response.status == 429: - raise ThrottlingError() - # Handle all other errors (400 & 500 level HTTP responses) - elif response.status >= 400: - raise RequestError(response) - # Else do nothing if there was no error. + def _get(self, path): + response = requests.get(self._compose_url(path), + headers={'X-AIO-Key': self.key}, + proxies=self.proxies) + self._handle_error(response) + return response.json() - def _handle_response(self, response, expect_result): - self._handle_error(response) - if expect_result: - return json.loads(response.data) - # Else no result expected so just return. + def _post(self, path, data): + response = requests.post(self._compose_url(path), + headers={'X-AIO-Key': self.key, + 'Content-Type': 'application/json'}, + proxies=self.proxies, + data=json.dumps(data)) + self._handle_error(response) + return response.json() - def _request(self, method, path, params=None, expect_result=True): - if (method.lower() == "get"): - url = self._compose_get_url(path, params) - else: - url = self._compose_url(path) + def _delete(self, path): + response = requests.delete(self._compose_url(path), + headers={'X-AIO-Key': self.key, + 'Content-Type': 'application/json'}, + proxies=self.proxies) + self._handle_error(response) - self.rate_limit_lock and self.rate_limit_lock.acquire() - headers = {"X-AIO-Key": self.key, 'Content-Type':'application/json'} - if (method.upper() == "GET"): - r = self.connection_pool.urlopen(method.upper(), url, headers=headers) - else: - r = self.connection_pool.urlopen(method.upper(), url, headers=headers, - body=json.dumps(params)) + # Data functionality. + def send(self, feed_name, value): + """Helper function to simplify adding a value to a feed. Will find the + specified feed by name or create a new feed if it doesn't exist, then + will append the provided value to the feed. Returns a Data instance + with details about the newly appended row of data. + """ + path = "api/feeds/{0}/data/send".format(feed_name) + return Data.from_dict(self._post(path, {'value': value})) - return self._handle_response(r, expect_result) + def receive(self, feed): + """Retrieve the most recent value for the specified feed. Feed can be a + feed ID, feed key, or feed name. Returns a Data instance whose value + property holds the retrieved value. + """ + path = "api/feeds/{0}/data/last".format(feed) + return Data.from_dict(self._get(path)) - def _get(self, path, **params): - return self._request('GET', path, params=params) + def receive_next(self, feed): + """Retrieve the next unread value from the specified feed. Feed can be + a feed ID, feed key, or feed name. Returns a Data instance whose value + property holds the retrieved value. + """ + path = "api/feeds/{0}/data/next".format(feed) + return Data.from_dict(self._get(path)) - def _post(self, path, params): - return self._request('POST', path, params=params) + def receive_previous(self, feed): + """Retrieve the previous unread value from the specified feed. Feed can + be a feed ID, feed key, or feed name. Returns a Data instance whose + value property holds the retrieved value. + """ + path = "api/feeds/{0}/data/previous".format(feed) + return Data.from_dict(self._get(path)) - def _delete(self, path): - return self._request('DELETE', path, expect_result=False) + def data(self, feed, data_id=None): + """Retrieve data from a feed. Feed can be a feed ID, feed key, or feed + name. Data_id is an optional id for a single data value to retrieve. + If data_id is not specified then all the data for the feed will be + returned in an array. + """ + if data_id is None: + path = "api/feeds/{0}/data".format(feed) + return list(map(Data.from_dict, self._get(path))) + else: + path = "api/feeds/{0}/data/{1}".format(feed, data_id) + return Data.from_dict(self._get(path)) - #feed functionality - def delete_feed(self, feed): - """Delete the specified feed. Feed can be a feed ID, feed key, or feed name. - """ - feed = quote(feed) - path = "api/feeds/{}".format(feed) - self._delete(path) + def create_data(self, feed, data): + """Create a new row of data in the specified feed. Feed can be a feed + ID, feed key, or feed name. Data must be an instance of the Data class + with at least a value property set on it. Returns a Data instance with + details about the newly appended row of data. + """ + path = "api/feeds/{0}/data".format(feed) + return Data.from_dict(self._post(path, data._asdict())) - #feed data functionality - def send(self, feed_name, value): - """Helper function to simplify adding a value to a feed. Will find the - specified feed by name or create a new feed if it doesn't exist, then will - append the provided value to the feed. Returns a Data instance with details - about the newly appended row of data. - """ - feed_name = quote(feed_name) - path = "api/feeds/{}/data/send".format(feed_name) - return Data.from_response(self._post(path, {'value': value})) + def delete(self, feed, data_id): + """Delete data from a feed. Feed can be a feed ID, feed key, or feed + name. Data_id must be the ID of the piece of data to delete. + """ + path = "api/feeds/{0}/data/{1}".format(feed, data_id) + self._delete(path) - def receive(self, feed): - """Retrieve the most recent value for the specified feed. Feed can be a - feed ID, feed key, or feed name. Returns a Data instance whose value - property holds the retrieved value. - """ - feed = quote(feed) - path = "api/feeds/{}/data/last".format(feed) - return Data.from_response(self._get(path)) + # Feed functionality. + def feeds(self, feed=None): + """Retrieve a list of all feeds, or the specified feed. If feed is not + specified a list of all feeds will be returned. If feed is specified it + can be a feed name, key, or ID and the requested feed will be returned. + """ + if feed is None: + path = "api/feeds" + return list(map(Feed.from_dict, self._get(path))) + else: + path = "api/feeds/{0}".format(feed) + return Feed.from_dict(self._get(path)) - def receive_next(self, feed): - """Retrieve the next unread value from the specified feed. Feed can be a - feed ID, feed key, or feed name. Returns a Data instance whose value - property holds the retrieved value. - """ - feed = quote(feed) - path = "api/feeds/{}/data/next".format(feed) - return Data.from_response(self._get(path)) + def create_feed(self, feed): + """Create the specified feed. Feed should be an instance of the Feed + type with at least the name property set. + """ + path = "api/feeds/" + return Feed.from_dict(self._post(path, feed._asdict())) - def receive_previous(self, feed): - """Retrieve the previously read value from the specified feed. Feed can be - a feed ID, feed key, or feed name. Returns a Data instance whose value - property holds the retrieved value. - """ - feed = quote(feed) - path = "api/feeds/{}/data/last".format(feed) - return Data.from_response(self._get(path)) + def delete_feed(self, feed): + """Delete the specified feed. Feed can be a feed ID, feed key, or feed + name. + """ + path = "api/feeds/{0}".format(feed) + self._delete(path) - def data(self, feed, data_id=None): - """Retrieve data from a feed. Feed can be a feed ID, feed key, or feed name. - Data_id is an optional id for a single data value to retrieve. If data_id - is not specified then all the data for the feed will be returned in an array. - """ - if data_id is None: - path = "api/feeds/{}/data".format(feed) - return map(Data.from_response, self._get(path)) - else: - path = "api/feeds/{}/data/{}".format(feed, data_id) - return Data.from_response(self._get(path)) + # Group functionality. + def send_group(self, group_name, data): + """Update all feeds in a group with one call. Group_name should be the + name of a group to update. Data should be a dict with an item for each + feed in the group, where the key is the feed name and value is the new + data row value. For example a group 'TestGroup' with feeds 'FeedOne' + and 'FeedTwo' could be updated by calling: + + send_group('TestGroup', {'FeedOne': 'value1', 'FeedTwo': 10}) + + This would add the value 'value1' to the feed 'FeedOne' and add the + value 10 to the feed 'FeedTwo'. - def create_data(self, feed, data): - """Create a new row of data in the specified feed. Feed can be a feed ID, - feed key, or feed name. Data must be an instance of the Data class with at - least a value property set on it. Returns a Data instance with details - about the newly appended row of data. - """ - path = "api/feeds/{}/data".format(feed) - return Data.from_response(self._post(path, data._asdict())) + After a successful update an instance of Group will be returned with + metadata about the updated group. + """ + path = "api/groups/{0}/send".format(group_name) + return Group.from_dict(self._post(path, {'value': data})) - #group functionality - def send_group(self, group_name, data): - group_name = quote(group_name) - path = "api/groups/{}/send".format(group_name) - return self._post(path, {'value': data}) + def receive_group(self, group): + """Retrieve the most recent value for the specified group. Group can be + a group ID, group key, or group name. Returns a Group instance whose + feeds property holds an array of Feed instances associated with the group. + """ + path = "api/groups/{0}/last".format(group) + return Group.from_dict(self._get(path)) - def receive_group(self, group_name): - group_name = quote(group_name) - path = "api/groups/{}/last".format(group_name) - return self._get(path) + def receive_next_group(self, group): + """Retrieve the next unread value from the specified group. Group can + be a group ID, group key, or group name. Returns a Group instance whose + feeds property holds an array of Feed instances associated with the + group. + """ + path = "api/groups/{0}/next".format(group) + return Group.from_dict(self._get(path)) - def receive_next_group(self, group_name): - group_name = quote(group_name) - path = "api/groups/{}/next".format(group_name) - return self._get(path) + def receive_previous_group(self, group): + """Retrieve the previous unread value from the specified group. Group + can be a group ID, group key, or group name. Returns a Group instance + whose feeds property holds an array of Feed instances associated with + the group. + """ + path = "api/groups/{0}/previous".format(group) + return Group.from_dict(self._get(path)) - def receive_previous_group(self, group_name): - group_name = quote(group_name) - path = "api/groups/{}/last".format(group_name) - return self._get(path) + def groups(self, group=None): + """Retrieve a list of all groups, or the specified group. If group is + not specified a list of all groups will be returned. If group is + specified it can be a group name, key, or ID and the requested group + will be returned. + """ + if group is None: + path = "api/groups/" + return list(map(Group.from_dict, self._get(path))) + else: + path = "api/groups/{0}".format(group) + return Group.from_dict(self._get(path)) - def groups(self, group_id_or_key): - path = "api/groups/{}".format(group_id_or_key) - return self._get(path) + def create_group(self, group): + """Create the specified group. Group should be an instance of the Group + type with at least the name and feeds property set. + """ + path = "api/groups/" + return Group.from_dict(self._post(path, group._asdict())) - def create_group(self, group_id_or_key, data): - path = "api/groups/{}".format(group_id_or_key) - return self._post(path, data) + def delete_group(self, group): + """Delete the specified group. Group can be a group ID, group key, or + group name. + """ + path = "api/groups/{0}".format(group) + self._delete(path) diff --git a/Adafruit_IO/errors.py b/Adafruit_IO/errors.py new file mode 100644 index 0000000..f2d3e11 --- /dev/null +++ b/Adafruit_IO/errors.py @@ -0,0 +1,40 @@ +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +class AdafruitIOError(Exception): + """Base class for all Adafruit IO request failures.""" + pass + + +class RequestError(Exception): + """General error for a failed Adafruit IO request.""" + def __init__(self, response): + super(RequestError, self).__init__("Adafruit IO request failed: {0} {1}".format( + response.status_code, response.reason)) + + +class ThrottlingError(AdafruitIOError): + """Too many requests have been made to Adafruit IO in a short period of time. + Reduce the rate of requests and try again later. + """ + def __init__(self): + super(ThrottlingError, self).__init__("Exceeded the limit of Adafruit IO " \ + "requests in a short period of time. Please reduce the rate of requests " \ + "and try again later.") diff --git a/Adafruit_IO/model.py b/Adafruit_IO/model.py new file mode 100644 index 0000000..ff64cee --- /dev/null +++ b/Adafruit_IO/model.py @@ -0,0 +1,117 @@ +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from collections import namedtuple +# Handle python 2 and 3 (where map functions like itertools.imap) +try: + from itertools import imap as map +except ImportError: + # Ignore import error on python 3 since map already behaves as expected. + pass + + +# List of fields/properties that are present on a data object from IO. +DATA_FIELDS = [ 'created_epoch', + 'created_at', + 'updated_at', + 'value', + 'completed_at', + 'feed_id', + 'expiration', + 'position', + 'id' ] + +STREAM_FIELDS = [ 'completed_at', + 'created_at', + 'id', + 'value' ] + +FEED_FIELDS = [ 'last_value_at', + 'name', + 'stream', + 'created_at', + 'updated_at', + 'unit_type', + 'mode', + 'key', + 'unit_symbol', + 'fixed', + 'last_value', + 'id' ] + +GROUP_FIELDS = [ 'description', + 'source_keys', + 'id', + 'source', + 'key', + 'feeds', + 'properties', + 'name' ] + + +# These are very simple data model classes that are based on namedtuple. This is +# to keep the classes simple and prevent any confusion around updating data +# locally and forgetting to send those updates back up to the IO service (since +# tuples are immutable you can't change them!). Depending on how people use the +# client it might be prudent to revisit this decision and consider making these +# full fledged classes that are mutable. +Data = namedtuple('Data', DATA_FIELDS) +Stream = namedtuple('Stream', STREAM_FIELDS) +Feed = namedtuple('Feed', FEED_FIELDS) +Group = namedtuple('Group', GROUP_FIELDS) + + +# Magic incantation to make all parameters to the initializers optional with a +# default value of None. +Data.__new__.__defaults__ = tuple(None for x in DATA_FIELDS) +Stream.__new__.__defaults__ = tuple(None for x in STREAM_FIELDS) +Feed.__new__.__defaults__ = tuple(None for x in FEED_FIELDS) +Group.__new__.__defaults__ = tuple(None for x in GROUP_FIELDS) + + +# Define methods to convert from dicts to the data types. +def _from_dict(cls, data): + # Convert dict to call to class initializer (to work with the data types + # base on namedtuple). However be very careful to preserve forwards + # compatibility by ignoring any attributes in the dict which are unknown + # by the data type. + params = {x: data.get(x, None) for x in cls._fields} + return cls(**params) + + +def _feed_from_dict(cls, data): + params = {x: data.get(x, None) for x in cls._fields} + # Parse the stream if provided and generate a stream instance. + params['stream'] = Stream.from_dict(data.get('stream', {})) + return cls(**params) + + +def _group_from_dict(cls, data): + params = {x: data.get(x, None) for x in cls._fields} + # Parse the feeds if they're provided and generate feed instances. + params['feeds'] = tuple(map(Feed.from_dict, data.get('feeds', []))) + return cls(**params) + + +# Now add the from_dict class methods defined above to the data types. +Data.from_dict = classmethod(_from_dict) +Stream.from_dict = classmethod(_from_dict) +Feed.from_dict = classmethod(_feed_from_dict) +Group.from_dict = classmethod(_group_from_dict) diff --git a/Adafruit_IO/mqtt_client.py b/Adafruit_IO/mqtt_client.py index 82aeec3..a1c8855 100644 --- a/Adafruit_IO/mqtt_client.py +++ b/Adafruit_IO/mqtt_client.py @@ -1,18 +1,29 @@ -# MQTT-based client for Adafruit.IO -# Author: Tony DiCola (tdicola@adafruit.com) -# -# Supports publishing and subscribing to feed changes from Adafruit IO using -# the MQTT protcol. -# -# Depends on the following Python libraries: -# - paho-mqtt: Paho MQTT client for python. +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import logging import paho.mqtt.client as mqtt -SERVICE_HOST = 'io.adafruit.com' -SERVICE_PORT = 1883 +# How long to wait before sending a keep alive (paho-mqtt configuration). KEEP_ALIVE_SEC = 3600 # One minute logger = logging.getLogger(__name__) @@ -23,12 +34,14 @@ class MQTTClient(object): using the MQTT protocol. """ - def __init__(self, key): + def __init__(self, key, service_host='io.adafruit.com', service_port=1883): """Create instance of MQTT client. Required parameters: - key: The Adafruit.IO access key for your account. """ + self._service_host = service_host + self._service_port = service_port # Initialize event callbacks to be None so they don't fire. self.on_connect = None self.on_disconnect = None @@ -69,11 +82,12 @@ class MQTTClient(object): def _mqtt_message(self, client, userdata, msg): logger.debug('Client on_message called.') # Parse out the feed id and call on_message callback. - # Assumes topic looks like "api/feeds/{feed_id}/data/receive.json" + # Assumes topic looks like "api/feeds/{feed}/data/receive.json" if self.on_message is not None and msg.topic.startswith('api/feeds/') \ and len(msg.topic) >= 28: - feed_id = msg.topic[10:-18] - self.on_message(self, feed_id, msg.payload) + feed = msg.topic[10:-18] + payload = '' if msg.payload is None else msg.payload.decode('utf-8') + self.on_message(self, feed, payload) def connect(self, **kwargs): """Connect to the Adafruit.IO service. Must be called before any loop @@ -85,7 +99,7 @@ class MQTTClient(object): if self._connected: return # Connect to the Adafruit IO MQTT service. - self._client.connect(SERVICE_HOST, port=SERVICE_PORT, + self._client.connect(self._service_host, port=self._service_port, keepalive=KEEP_ALIVE_SEC, **kwargs) def is_connected(self): @@ -94,7 +108,7 @@ class MQTTClient(object): return self._connected def disconnect(self): - # Disconnect MQTT client if connected. + """Disconnect MQTT client if connected.""" if self._connected: self._client.disconnect() diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d9b12..5fbd667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +0.9.0 +---- +Author: Tony DiCola +- Added REST API support for all feed, data, group create/get/update/delete APIs. +- Added explicit data model classes. +- Added many integration tests to verify client & service. +- Added docstrings to all public functions and classes. +- Ported to work with Python 2 and 3. + 0.0.1 ---- Initial Changelog \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index 4c4f1d4..6059974 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,5 +1,5 @@ Copyright (c) 2014 Adafruit -Author: Justin Cooper +Author: Justin Cooper and Tony DiCola MIT License diff --git a/README.md b/README.md index 10c1268..cba6c67 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # adafruit-io -A [Python][1] client for use with with [io.adafruit.com][2]. +A [Python][1] client for use with with [io.adafruit.com][2]. Compatible with +both Python 2 and Python 3. ## Installation ### Easy Installation +**NOTE: MODULE IS NOT YET ON PYPA SO THIS DOES NOT WORK YET. SKIP TO MANUAL INSTALL BELOW.** + If you have [pip installed](https://pip.pypa.io/en/latest/installing.html) (typically with ````apt-get install python-pip```` on a Debian/Ubuntu-based system) then run: @@ -31,25 +34,24 @@ At a high level the Adafruit IO python client provides two interfaces to the service: * A thin wrapper around the REST-based API. This is good for simple request and - response applications. + response applications like logging data. -* An MQTT client (based on [paho-mqtt](https://pypi.python.org/pypi/paho-mqtt)) +* A MQTT client (based on [paho-mqtt](https://pypi.python.org/pypi/paho-mqtt)) which can publish and subscribe to feeds so it is immediately alerted of changes. This is good for applications which need to know when something has changed as - quickly as possible, but requires keeping a connection to the service open at - all times. + quickly as possible. To use either interface you'll first need to import the python client by adding an import such as the following at the top of your program: ```python -import Adafruit_IO +from Adafruit_IO import * ``` Then a REST API client can be created with code like: ```python -aio = Adafruit_IO.Client('xxxxxxxxxxxx') +aio = Client('xxxxxxxxxxxx') ``` Where 'xxxxxxxxxxxx' is your Adafruit IO API key. @@ -57,7 +59,7 @@ Where 'xxxxxxxxxxxx' is your Adafruit IO API key. Alternatively an MQTT client can be created with code like: ```python -mqtt = Adafruit_IO.MQTTClient('xxxxxxxxxxxx') +mqtt = MQTTClient('xxxxxxxxxxxx') ``` Again where 'xxxxxxxxxxxx' is your Adafruit IO API key. @@ -83,28 +85,44 @@ example uses the REST API. from Adafruit_IO import Client aio = Client('YOUR ADAFRUIT IO KEY') -# Send a value to a feed called 'Feed Name'. -# Data can be of any type, string, number, hash, json. -aio.send('Feed Name', data) +# Send the value 100 to a feed called 'Foo'. +aio.send('Foo', 100) -# Retrieve the most recent value from the feed 'Feed Name'. -# Notice the returned object has a property called value. -data = aio.receive("Feed Name") +# Retrieve the most recent value from the feed 'Foo'. +# Access the value by reading the `value` property on the returned Data object. +# Note that all values retrieved from IO are strings so you might need to convert +# them to an int or numeric type if you expect a number. +data = aio.receive('Foo') print('Received value: {0}'.format(data.value)) ``` If you want to be notified of feed changes immediately without polling, consider using the MQTT client. See the [examples\mqtt_client.py](https://github.com/adafruit/io-client-python/blob/master/examples/mqtt_client.py) for an example of using the MQTT client. +### More Information + +See the details below for more information about using the Adafruit IO python +client. You can also print out the documentation on the client and classes by +running: + +``` +pydoc Adafruit_IO.client +pydoc Adafruit_IO.mqtt_client +pydoc Adafruit_IO.model +pydoc Adafruit_IO.errors + ## Table of Contents * [Feeds](#feeds) - * [Creation, Retrieval, Updating](#feed-creation-retrieval-updating) + * [Create](#feed-creation) + * [Read](#feed-retrieval) + * [Update](#feed-updating) * [Delete](#feed-deletion) * [Data](#data) * [Create](#data-creation) * [Read](#data-retrieval) - * [Updating, Deletion](#data-updating-deletion) + * [Update](#data-updating) + * [Delete](#data-deletion) * [Helper Methods](#helper-methods) * [Send](#send) * [Receive](#receive) @@ -112,6 +130,10 @@ using the MQTT client. See the [examples\mqtt_client.py](https://github.com/ada * [Previous](#previous) * [Publishing and Subscribing](#publishing-and-subscribing) * [Groups](#groups) + * [Create](#group-creation) + * [Read](#group-retrieval) + * [Update](#group-updating) + * [Delete](#group-deletion) ### Feeds @@ -120,18 +142,76 @@ that gets pushed, and you will have one feed for each type of data you send to the system. You can have separate feeds for each sensor in a project, or you can use one feed to contain JSON encoded data for all of your sensors. -#### Feed Creation, Retrieval, Updating +#### Feed Creation -TODO: The python client does not currently support creating, retrieving, or -updating a feed's metadata. See the send helper function below for creating a -feed and sending it new data. +Create a feed by constructing a Feed instance with at least a name specified, and +then pass it to the `create_feed(feed)` function: + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client, Feed +aio = Client('YOUR ADAFRUIT IO KEY') + +# Create Feed object with name 'Foo'. +feed = Feed(name='Foo') + +# Send the Feed to IO to create. +# The returned object will contain all the details about the created feed. +result = aio.create_feed(feed) +``` + +Note that you can use the [send](#send) function to create a feed and send it a +new value in a single call. It's recommended that you use send instead of +manually constructing feed instances. + +#### Feed Retrieval + +You can get a list of your feeds by using the `feeds()` method which will return +a list of Feed instances: + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get list of feeds. +feeds = aio.feeds() + +# Print out the feed names: +for f in feeds: + print('Feed: {0}'.format(f.name)) +``` + +Alternatively you can retrieve the metadata for a single feed by calling +`feeds(feed)` and passing the name, ID, or key of a feed to retrieve: + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get feed 'Foo' +feed = aio.feeds('Foo') + +# Print out the feed metadata. +print(feed) +``` + +#### Feed Updating + +TODO: This is not tested in the python client yet, but calling create_feed with +a Feed instance should update the feed. #### Feed Deletion -You can delete a feed by ID, key, or name by calling `aio.delete_feed(feed)`. +You can delete a feed by ID, key, or name by calling `delete_feed(feed)`. ALL data in the feed will be deleted after calling this API! ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + # Delete the feed with name 'Test'. aio.delete_feed('Test') ``` @@ -145,47 +225,76 @@ and selecting certain pieces of data. #### Data Creation Data can be created [after you create a feed](#data-creation), by using the -`aio.create_data(feed, data)` method and passing it a new Data instance with -a value set. See the [send function](#send) for a simpler and recommended way -of adding a new value to a feed. +`create_data(feed, data)` method and passing it a new Data instance a value. +See also the [send function](#send) for a simpler way to add a value to feed and +create the feed in one call. ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client, Data +aio = Client('YOUR ADAFRUIT IO KEY') + # Create a data item with value 10 in the 'Test' feed. -data = Adafruit_IO.Data(value=10) -aio.create_stream('Test', data) +data = Data(value=10) +aio.create_data('Test', data) ``` #### Data Retrieval -You can get all of the data for a feed by using the `aio.data(feed)` method. The +You can get all of the data for a feed by using the `data(feed)` method. The result will be an array of all feed data, each returned as an instance of the -Data class. Use the value property on each Data instance to get the data value. +Data class. Use the value property on each Data instance to get the data value, +and remember values are always returned as strings (so you might need to convert +to an int or number if you expect a numeric value). ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + # Get an array of all data from feed 'Test' -data = aio.streams('Test') +data = aio.data('Test') + # Print out all the results. for d in data: print('Data value: {0}'.format(d.value)) ``` -You can also get a specific value by ID by using the `aio.feeds(feed, data_id)` +You can also get a specific value by ID by using the `feeds(feed, data_id)` method. This will return a single piece of feed data with the provided data ID if it exists in the feed. The returned object will be an instance of the Data class. ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + # Get a specific value by id. # This example assumes 1 is a valid data ID in the 'Test' feed data = aio.feeds('Test', 1) + # Print the value. print('Data value: {0}'.format(data.value)) ``` -#### Data Updating, Deletion +#### Data Updating -TODO: The python client does not currently support updating or deleting feed -data. +TODO: This is not tested in the python client, but calling create_data with a +Data instance should update it. + +#### Data Deletion + +Values can be deleted by using the `delete(feed, data_id)` method: + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Delete a data value from feed 'Test' with ID 1. +data = aio.delete('Test', 1) +``` #### Helper Methods @@ -193,46 +302,72 @@ There are a few helper methods that can make interacting with data a bit easier. ##### Send -You can use the `aio.send(feed_name, value)` method to append a new value to a +You can use the `send(feed_name, value)` method to append a new value to a feed in one call. If the specified feed does not exist it will automatically be created. This is the recommended way to send data to Adafruit IO from the Python -client. +REST client. ```python -# Add the value 98.6 to the feed 'Test Send Data'. -aio.send('Test Send Data', 98.6) +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Add the value 98.6 to the feed 'Temperature'. +aio.send('Temperature', 98.6) ``` ##### Receive -You can get the last inserted value by using the `aio.receive(feed)` method. +You can get the last inserted value by using the `receive(feed)` method. ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get the last value of the temperature feed. data = aio.receive('Test') -# Print the value. -print('Data value: {0}'.format(data)) + +# Print the value and a message if it's over 100. Notice that the value is +# converted from string to int because it always comes back as a string from IO. +temp = int(data.value) +print('Temperature: {0}'.format(temp)) +if temp > 100: + print 'Hot enough for you?' ``` ##### Next -You can get the first inserted value that has not been processed by using the -`aio.receive_next(feed)` method. +You can get the first inserted value that has not been processed (read) by using +the `receive_next(feed)` method. ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get next unread value from feed 'Test'. data = aio.receive_next('Test') + # Print the value. -print('Data value: {0}'.format(data)) +print('Data value: {0}'.format(data.value)) ``` ##### Previous -You can get the the last record that has been processed by using the -`aio.receive_previous(feed)` method. +You can get the the last record that has been processed (read) by using the +`receive_previous(feed)` method. ```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get previous read value from feed 'Test'. data = aio.receive_previous('Test') + # Print the value. -print('Data value: {0}'.format(data)) +print('Data value: {0}'.format(data.value)) ``` #### Publishing and Subscribing @@ -240,14 +375,70 @@ print('Data value: {0}'.format(data)) You can get a readable stream of live data from your feed using the included MQTT client class. -TBD: Document using the MQTT client. +TBD: Document using the MQTT client. For now see the [examples\mqtt_client.py](https://github.com/adafruit/io-client-python/blob/master/examples/mqtt_client.py) example which is fully documented with comments. ### Groups Groups allow you to update and retrieve multiple feeds with one request. You can add feeds to multiple groups. -TBD +#### Group Creation + +TBD: Currently group creation doesn't work with the APIs. Groups must be created +in the UI. + +#### Group Retrieval + +You can get a list of your groups by using the `groups()` method. This will +return a list of Group instances. Each Group instance has metadata about the +group, including a `feeds` property which is a tuple of all feeds in the group. + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get list of groups. +groups = aio.groups() + +# Print the group names and number of feeds in the group. +for g in groups: + print('Group {0} has {1} feed(s).'.format(g.name, len(g.feeds))) +``` + +You can also get a specific group by ID, key, or name by using the +`groups(group)` method: + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Get group called 'GroupTest'. +group = aio.groups('GroupTest') + +# Print the group name and number of feeds in the group. +print('Group {0} has {1} feed(s).'.format(group.name, len(group.feeds))) +``` + +#### Group Updating + +TBD This is not tested in the python client yet, but calling create_group should +update a group. + +#### Group Deletion + +You can delete a group by ID, key, or name by using the `delete_group(group)` +method: + +```python +# Import library and create instance of REST client. +from Adafruit_IO import Client +aio = Client('YOUR ADAFRUIT IO KEY') + +# Delete group called 'GroupTest'. +aio.delete_group('GroupTest') +``` ## Contributing diff --git a/examples/mqtt_client.py b/examples/mqtt_client.py index 48f7076..97246d0 100644 --- a/examples/mqtt_client.py +++ b/examples/mqtt_client.py @@ -1,13 +1,13 @@ # Example of using the MQTT client class to subscribe to and publish feed values. -# Author: Tony DiCola (tdicola@adafruit.com) +# Author: Tony DiCola # Import standard python modules. import random import sys import time -# Import Adafruit IO client. -import Adafruit_IO +# Import Adafruit IO MQTT client. +from Adafruit_IO import MQTTClient # Set to your Adafruit IO key. @@ -37,7 +37,7 @@ def message(client, feed_id, payload): # Create an MQTT client instance. -client = Adafruit_IO.MQTTClient(ADAFRUIT_IO_KEY) +client = MQTTClient(ADAFRUIT_IO_KEY) # Setup the callback functions defined above. client.on_connect = connected diff --git a/examples/simple.py b/examples/simple.py new file mode 100644 index 0000000..1e93df8 --- /dev/null +++ b/examples/simple.py @@ -0,0 +1,32 @@ +# Simple example of sending and receiving values from Adafruit IO with the REST +# API client. +# Author: Tony DiCola + +# Import Adafruit IO REST client. +from Adafruit_IO import Client + +# Set to your Adafruit IO key. +ADAFRUIT_IO_KEY = 'YOUR ADAFRUIT IO KEY' + +# Create an instance of the REST client. +aio = Client(ADAFRUIT_IO_KEY) + +# Send a value to the feed 'Test'. This will create the feed if it doesn't +# exist already. +aio.send('Test', 42) + +# Send a string value 'bar' to the feed 'Foo', again creating it if it doesn't +# exist already. +aio.send('Foo', 'bar') + +# Now read the most recent value from the feed 'Test'. Notice that it comes +# back as a string and should be converted to an int if performing calculations +# on it. +data = aio.receive('Test') +print('Retrieved value from Test has attributes: {0}'.format(data)) +print('Latest value from Test: {0}'.format(data.value)) + +# Finally read the most revent value from feed 'Foo'. +data = aio.receive('Foo') +print('Retrieved value from Test has attributes: {0}'.format(data)) +print('Latest value from Test: {0}'.format(data.value)) diff --git a/examples/test.py b/examples/test.py deleted file mode 100644 index a6f76b1..0000000 --- a/examples/test.py +++ /dev/null @@ -1,11 +0,0 @@ -from Adafruit_IO import Client -io = Client('e2b0fac48ae32f324df4aa05247c16e991494b08') - -r = io.send("Test Python", 12) -print r - -r = io.receive("Test Python") -print r - -r = io.receive_next("Test Python") -print r \ No newline at end of file diff --git a/ez_setup.py b/ez_setup.py new file mode 100644 index 0000000..1bcd3e9 --- /dev/null +++ b/ez_setup.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python +"""Bootstrap setuptools installation + +To use setuptools in your package's setup.py, include this +file in the same directory and add this to the top of your setup.py:: + + from ez_setup import use_setuptools + use_setuptools() + +To require a specific version of setuptools, set a download +mirror, or use an alternate download directory, simply supply +the appropriate options to ``use_setuptools()``. + +This file can also be run as a script to install or upgrade setuptools. +""" +import os +import shutil +import sys +import tempfile +import zipfile +import optparse +import subprocess +import platform +import textwrap +import contextlib + +from distutils import log + +try: + from urllib.request import urlopen +except ImportError: + from urllib2 import urlopen + +try: + from site import USER_SITE +except ImportError: + USER_SITE = None + +DEFAULT_VERSION = "4.0.1" +DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" + +def _python_cmd(*args): + """ + Return True if the command succeeded. + """ + args = (sys.executable,) + args + return subprocess.call(args) == 0 + + +def _install(archive_filename, install_args=()): + with archive_context(archive_filename): + # installing + log.warn('Installing Setuptools') + if not _python_cmd('setup.py', 'install', *install_args): + log.warn('Something went wrong during the installation.') + log.warn('See the error message above.') + # exitcode will be 2 + return 2 + + +def _build_egg(egg, archive_filename, to_dir): + with archive_context(archive_filename): + # building an egg + log.warn('Building a Setuptools egg in %s', to_dir) + _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) + # returning the result + log.warn(egg) + if not os.path.exists(egg): + raise IOError('Could not build the egg.') + + +class ContextualZipFile(zipfile.ZipFile): + """ + Supplement ZipFile class to support context manager for Python 2.6 + """ + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.close() + + def __new__(cls, *args, **kwargs): + """ + Construct a ZipFile or ContextualZipFile as appropriate + """ + if hasattr(zipfile.ZipFile, '__exit__'): + return zipfile.ZipFile(*args, **kwargs) + return super(ContextualZipFile, cls).__new__(cls) + + +@contextlib.contextmanager +def archive_context(filename): + # extracting the archive + tmpdir = tempfile.mkdtemp() + log.warn('Extracting in %s', tmpdir) + old_wd = os.getcwd() + try: + os.chdir(tmpdir) + with ContextualZipFile(filename) as archive: + archive.extractall() + + # going in the directory + subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) + os.chdir(subdir) + log.warn('Now working in %s', subdir) + yield + + finally: + os.chdir(old_wd) + shutil.rmtree(tmpdir) + + +def _do_download(version, download_base, to_dir, download_delay): + egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' + % (version, sys.version_info[0], sys.version_info[1])) + if not os.path.exists(egg): + archive = download_setuptools(version, download_base, + to_dir, download_delay) + _build_egg(egg, archive, to_dir) + sys.path.insert(0, egg) + + # Remove previously-imported pkg_resources if present (see + # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). + if 'pkg_resources' in sys.modules: + del sys.modules['pkg_resources'] + + import setuptools + setuptools.bootstrap_install_from = egg + + +def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=os.curdir, download_delay=15): + to_dir = os.path.abspath(to_dir) + rep_modules = 'pkg_resources', 'setuptools' + imported = set(sys.modules).intersection(rep_modules) + try: + import pkg_resources + except ImportError: + return _do_download(version, download_base, to_dir, download_delay) + try: + pkg_resources.require("setuptools>=" + version) + return + except pkg_resources.DistributionNotFound: + return _do_download(version, download_base, to_dir, download_delay) + except pkg_resources.VersionConflict as VC_err: + if imported: + msg = textwrap.dedent(""" + The required version of setuptools (>={version}) is not available, + and can't be installed while this script is running. Please + install a more recent version first, using + 'easy_install -U setuptools'. + + (Currently using {VC_err.args[0]!r}) + """).format(VC_err=VC_err, version=version) + sys.stderr.write(msg) + sys.exit(2) + + # otherwise, reload ok + del pkg_resources, sys.modules['pkg_resources'] + return _do_download(version, download_base, to_dir, download_delay) + +def _clean_check(cmd, target): + """ + Run the command to download target. If the command fails, clean up before + re-raising the error. + """ + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError: + if os.access(target, os.F_OK): + os.unlink(target) + raise + +def download_file_powershell(url, target): + """ + Download the file at url to target using Powershell (which will validate + trust). Raise an exception if the command cannot complete. + """ + target = os.path.abspath(target) + ps_cmd = ( + "[System.Net.WebRequest]::DefaultWebProxy.Credentials = " + "[System.Net.CredentialCache]::DefaultCredentials; " + "(new-object System.Net.WebClient).DownloadFile(%(url)r, %(target)r)" + % vars() + ) + cmd = [ + 'powershell', + '-Command', + ps_cmd, + ] + _clean_check(cmd, target) + +def has_powershell(): + if platform.system() != 'Windows': + return False + cmd = ['powershell', '-Command', 'echo test'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True + +download_file_powershell.viable = has_powershell + +def download_file_curl(url, target): + cmd = ['curl', url, '--silent', '--output', target] + _clean_check(cmd, target) + +def has_curl(): + cmd = ['curl', '--version'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True + +download_file_curl.viable = has_curl + +def download_file_wget(url, target): + cmd = ['wget', url, '--quiet', '--output-document', target] + _clean_check(cmd, target) + +def has_wget(): + cmd = ['wget', '--version'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True + +download_file_wget.viable = has_wget + +def download_file_insecure(url, target): + """ + Use Python to download the file, even though it cannot authenticate the + connection. + """ + src = urlopen(url) + try: + # Read all the data in one block. + data = src.read() + finally: + src.close() + + # Write all the data in one block to avoid creating a partial file. + with open(target, "wb") as dst: + dst.write(data) + +download_file_insecure.viable = lambda: True + +def get_best_downloader(): + downloaders = ( + download_file_powershell, + download_file_curl, + download_file_wget, + download_file_insecure, + ) + viable_downloaders = (dl for dl in downloaders if dl.viable()) + return next(viable_downloaders, None) + +def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=os.curdir, delay=15, downloader_factory=get_best_downloader): + """ + Download setuptools from a specified location and return its filename + + `version` should be a valid setuptools version number that is available + as an egg for download under the `download_base` URL (which should end + with a '/'). `to_dir` is the directory where the egg will be downloaded. + `delay` is the number of seconds to pause before an actual download + attempt. + + ``downloader_factory`` should be a function taking no arguments and + returning a function for downloading a URL to a target. + """ + # making sure we use the absolute path + to_dir = os.path.abspath(to_dir) + zip_name = "setuptools-%s.zip" % version + url = download_base + zip_name + saveto = os.path.join(to_dir, zip_name) + if not os.path.exists(saveto): # Avoid repeated downloads + log.warn("Downloading %s", url) + downloader = downloader_factory() + downloader(url, saveto) + return os.path.realpath(saveto) + +def _build_install_args(options): + """ + Build the arguments to 'python setup.py install' on the setuptools package + """ + return ['--user'] if options.user_install else [] + +def _parse_args(): + """ + Parse the command line for options + """ + parser = optparse.OptionParser() + parser.add_option( + '--user', dest='user_install', action='store_true', default=False, + help='install in user site package (requires Python 2.6 or later)') + parser.add_option( + '--download-base', dest='download_base', metavar="URL", + default=DEFAULT_URL, + help='alternative URL from where to download the setuptools package') + parser.add_option( + '--insecure', dest='downloader_factory', action='store_const', + const=lambda: download_file_insecure, default=get_best_downloader, + help='Use internal, non-validating downloader' + ) + parser.add_option( + '--version', help="Specify which version to download", + default=DEFAULT_VERSION, + ) + options, args = parser.parse_args() + # positional arguments are ignored + return options + +def main(): + """Install or upgrade setuptools and EasyInstall""" + options = _parse_args() + archive = download_setuptools( + version=options.version, + download_base=options.download_base, + downloader_factory=options.downloader_factory, + ) + return _install(archive, _build_install_args(options)) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/setup.py b/setup.py index c6c7d71..6500adb 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,10 @@ +from ez_setup import use_setuptools +use_setuptools() from setuptools import setup setup( name='adafruit-io', - version='0.0.1', + version='0.9.0', author='Justin Cooper', author_email='justin@adafruit.com', packages=['Adafruit_IO'], @@ -11,6 +13,7 @@ setup( description='IO Client library for io.adafruit.com', long_description=open('README.md').read(), install_requires=[ - "apiclient >= 1.0.2" + "requests", + "paho-mqtt" ], ) diff --git a/tests/base.py b/tests/base.py index 5c39cfd..d299436 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,10 +1,25 @@ -# Base testcase class with functions and state available to all tests. -# Author: Tony DiCola (tdicola@adafruit.com) -import os -import time -import unittest +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola -import Adafruit_IO +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import os +import unittest class IOTestCase(unittest.TestCase): @@ -18,13 +33,3 @@ class IOTestCase(unittest.TestCase): raise RuntimeError("ADAFRUIT_IO_KEY environment variable must be " \ "set with valid Adafruit IO key to run this test!") return key - - def ensure_feed_deleted(self, client, feed): - """Delete the provided feed if it exists. Does nothing if the feed - doesn't exist. - """ - try: - client.delete_feed(feed) - except Adafruit_IO.RequestError: - # Swallow the error if the feed doesn't exist. - pass diff --git a/tests/test_client.py b/tests/test_client.py index 3388f13..030c7c4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,13 +1,22 @@ # Test REST client. # Author: Tony DiCola (tdicola@adafruit.com) import time +import unittest + +from Adafruit_IO import Client, Data, Feed, Group, RequestError -import Adafruit_IO import base -# Turn this on to see responses of requests from urllib. -#import logging -#logging.basicConfig(level=logging.DEBUG) + +# Default config for tests to run against real Adafruit IO service with no proxy. +BASE_URL = 'https://io.adafruit.com/' +PROXIES = None + +# Config to run tests against real Adafruit IO service over non-SSL and with a +# a proxy running on localhost 8888 (good for getting traces with fiddler). +#BASE_URL = 'http://io.adafruit.com/' +#PROXIES = {'http': 'http://localhost:8888/'} + class TestClient(base.IOTestCase): @@ -16,24 +25,39 @@ class TestClient(base.IOTestCase): #def tearDown(self): # time.sleep(30.0) + def get_client(self): + # Construct an Adafruit IO REST client and return it. + return Client(self.get_test_key(), proxies=PROXIES, base_url=BASE_URL) + + def ensure_feed_deleted(self, client, feed): + # Delete the specified feed if it exists. + try: + client.delete_feed(feed) + except RequestError: + # Swallow the error if the feed doesn't exist. + pass + + def ensure_group_deleted(self, client, group): + # Delete the specified group if it exists. + try: + client.delete_group(group) + except RequestError: + # Swallow the error if the group doesn't exist. + pass + + def empty_feed(self, client, feed): + # Remove all the data from a specified feed (but don't delete the feed). + data = client.data(feed) + for d in data: + client.delete(feed, d.id) + def test_set_key(self): key = "unique_key_id" - io = Adafruit_IO.Client(key) + io = Client(key) self.assertEqual(key, io.key) - def test_delete_feed(self): - io = Adafruit_IO.Client(self.get_test_key()) - io.send('TestFeed', 'foo') # Make sure a feed called TestFeed exists. - io.delete_feed('TestFeed') - self.assertRaises(Adafruit_IO.RequestError, io.receive, 'TestFeed') - - def test_delete_nonexistant_feed_fails(self): - io = Adafruit_IO.Client(self.get_test_key()) - self.ensure_feed_deleted(io, 'TestFeed') - self.assertRaises(Adafruit_IO.RequestError, io.delete_feed, 'TestFeed') - def test_send_and_receive(self): - io = Adafruit_IO.Client(self.get_test_key()) + io = self.get_client() self.ensure_feed_deleted(io, 'TestFeed') response = io.send('TestFeed', 'foo') self.assertEqual(response.value, 'foo') @@ -41,22 +65,28 @@ class TestClient(base.IOTestCase): self.assertEqual(data.value, 'foo') def test_receive_next(self): - io = Adafruit_IO.Client(self.get_test_key()) + io = self.get_client() self.ensure_feed_deleted(io, 'TestFeed') io.send('TestFeed', 1) data = io.receive_next('TestFeed') self.assertEqual(int(data.value), 1) + # BUG: Previous jumps too far back: https://github.com/adafruit/io/issues/55 + @unittest.expectedFailure def test_receive_previous(self): - io = Adafruit_IO.Client(self.get_test_key()) + io = self.get_client() self.ensure_feed_deleted(io, 'TestFeed') io.send('TestFeed', 1) - io.receive_next('TestFeed') + io.send('TestFeed', 2) + io.receive_next('TestFeed') # Receive 1 + io.receive_next('TestFeed') # Receive 2 + data = io.receive_previous('TestFeed') + self.assertEqual(int(data.value), 2) data = io.receive_previous('TestFeed') self.assertEqual(int(data.value), 1) def test_data_on_feed_returns_all_data(self): - io = Adafruit_IO.Client(self.get_test_key()) + io = self.get_client() self.ensure_feed_deleted(io, 'TestFeed') io.send('TestFeed', 1) io.send('TestFeed', 2) @@ -66,7 +96,7 @@ class TestClient(base.IOTestCase): self.assertEqual(int(result[1].value), 2) def test_data_on_feed_and_data_id_returns_data(self): - io = Adafruit_IO.Client(self.get_test_key()) + io = self.get_client() self.ensure_feed_deleted(io, 'TestFeed') data = io.send('TestFeed', 1) result = io.data('TestFeed', data.id) @@ -74,9 +104,135 @@ class TestClient(base.IOTestCase): self.assertEqual(int(data.value), int(result.value)) def test_create_data(self): - io = Adafruit_IO.Client(self.get_test_key()) + io = self.get_client() self.ensure_feed_deleted(io, 'TestFeed') io.send('TestFeed', 1) # Make sure TestFeed exists. - data = Adafruit_IO.Data(value=42) + data = Data(value=42) result = io.create_data('TestFeed', data) self.assertEqual(int(result.value), 42) + + def test_create_feed(self): + io = self.get_client() + self.ensure_feed_deleted(io, 'TestFeed') + feed = Feed(name='TestFeed') + result = io.create_feed(feed) + self.assertEqual(result.name, 'TestFeed') + + def test_feeds_returns_all_feeds(self): + io = self.get_client() + io.send('TestFeed', 1) # Make sure TestFeed exists. + feeds = io.feeds() + self.assertGreaterEqual(len(feeds), 1) + names = set(map(lambda x: x.name, feeds)) + self.assertTrue('TestFeed' in names) + + def test_feeds_returns_requested_feed(self): + io = self.get_client() + io.send('TestFeed', 1) # Make sure TestFeed exists. + result = io.feeds('TestFeed') + self.assertEqual(result.name, 'TestFeed') + self.assertEqual(int(result.last_value), 1) + + def test_delete_feed(self): + io = self.get_client() + io.send('TestFeed', 'foo') # Make sure a feed called TestFeed exists. + io.delete_feed('TestFeed') + self.assertRaises(RequestError, io.receive, 'TestFeed') + + def test_delete_nonexistant_feed_fails(self): + io = self.get_client() + self.ensure_feed_deleted(io, 'TestFeed') + self.assertRaises(RequestError, io.delete_feed, 'TestFeed') + + # NOTE: The group tests require some manual setup once before they can run. + # Log in to io.adafruit.com and create two feeds (called "GroupFeed1" and + # "GroupFeed2" by default). Then add those feeds to a new Group called + # "GroupTest". This only has to be done once for your account. Once group + # creation is supported by the API this can be refactored to dynamically + # create the group under test. + def test_send_and_receive_group(self): + io = self.get_client() + self.empty_feed(io, 'GroupTest1') + self.empty_feed(io, 'GroupTest2') + response = io.send_group('GroupTest', {'GroupTest1': 1, 'GroupTest2': 2}) + self.assertEqual(len(response.feeds), 2) + self.assertSetEqual(set(map(lambda x: int(x.stream.value), response.feeds)), + set([1, 2])) # Compare sets because order might differ. + #data = io.receive_group('GroupTest') # Group get by name currently broken. + data = io.receive_group(response.id) + self.assertEqual(len(data.feeds), 2) + self.assertSetEqual(set(map(lambda x: int(x.stream.value), data.feeds)), + set([1, 2])) + + # BUG: Next doesn't return expected metadata: https://github.com/adafruit/io/issues/57 + @unittest.expectedFailure + def test_receive_next_group(self): + io = self.get_client() + self.empty_feed(io, 'GroupTest1') + self.empty_feed(io, 'GroupTest2') + response = io.send_group('GroupTest', {'GroupTest1': 42, 'GroupTest2': 99}) + data = io.receive_next_group(response.id) + self.assertEqual(len(data.feeds), 2) + self.assertSetEqual(set(map(lambda x: int(x.stream.value), data.feeds)), + set([42, 99])) + + # BUG: Previous jumps too far back: https://github.com/adafruit/io/issues/55 + @unittest.expectedFailure + def test_receive_previous_group(self): + io = self.get_client() + self.empty_feed(io, 'GroupTest1') + self.empty_feed(io, 'GroupTest2') + response = io.send_group('GroupTest', {'GroupTest1': 10, 'GroupTest2': 20}) + response = io.send_group('GroupTest', {'GroupTest1': 30, 'GroupTest2': 40}) + io.receive_next_group(response.id) # Receive 10, 20 + io.receive_next_group(response.id) # Receive 30, 40 + data = io.receive_previous_group(response.id) + self.assertEqual(len(data.feeds), 2) + self.assertSetEqual(set(map(lambda x: int(x.stream.value), data.feeds)), + set([30, 40])) + data = io.receive_previous_group(response.id) + self.assertEqual(len(data.feeds), 2) + self.assertSetEqual(set(map(lambda x: int(x.stream.value), data.feeds)), + set([10, 20])) + + def test_groups_returns_all_groups(self): + io = self.get_client() + groups = io.groups() + self.assertGreaterEqual(len(groups), 1) + names = set(map(lambda x: x.name, groups)) + self.assertTrue('GroupTest' in names) + + def test_groups_retrieves_requested_group(self): + io = self.get_client() + response = io.groups('GroupTest') + self.assertEqual(response.name, 'GroupTest') + self.assertEqual(len(response.feeds), 2) + + # BUG: Group create doesn't work: https://github.com/adafruit/io/issues/58 + @unittest.expectedFailure + def test_create_group(self): + io = self.get_client() + self.ensure_group_deleted(io, 'GroupTest2') + self.ensure_feed_deleted(io, 'GroupTest3') + self.ensure_feed_deleted(io, 'GroupTest4') + feed1 = io.create_feed(Feed(name='GroupTest3')) + feed2 = io.create_feed(Feed(name='GroupTest4')) + io.send('GroupTest3', 10) + io.send('GroupTest4', 20) + group = Group(name='GroupTest2', feeds=[feed1, feed2]) + response = io.create_group(group) + self.assertEqual(response.name, 'GroupTest2') + self.assertEqual(len(response.feeds), 2) + + # BUG: Group create doesn't work: https://github.com/adafruit/io/issues/58 + @unittest.expectedFailure + def test_delete_group(self): + io = self.get_client() + self.ensure_group_deleted(io, 'GroupDeleteTest') + group = io.create_group(Group(name='GroupDeleteTest')) + io.delete_group('GroupDeleteTest') + self.assertRaises(RequestError, io.groups, 'GroupDeleteTest') + + # TODO: Get by group name, key, and ID + # TODO: Get data by name, key, ID + # TODO: Tests around Adafruit IO keys (make multiple, test they work, etc.) diff --git a/tests/test_data.py b/tests/test_data.py deleted file mode 100644 index 69d6cba..0000000 --- a/tests/test_data.py +++ /dev/null @@ -1,31 +0,0 @@ -# Test Data instance from REST client. -# Author: Tony DiCola (tdicola@adafruit.com) -import Adafruit_IO -import base - - -class TestData(base.IOTestCase): - - def test_data_properties_are_optional(self): - data = Adafruit_IO.Data(value='foo', feed_id=10) - self.assertEqual(data.value, 'foo') - self.assertEqual(data.feed_id, 10) - self.assertIsNone(data.created_epoch) - self.assertIsNone(data.created_at) - self.assertIsNone(data.updated_at) - self.assertIsNone(data.completed_at) - self.assertIsNone(data.expiration) - self.assertIsNone(data.position) - self.assertIsNone(data.id) - - def test_from_response_ignores_unknown_items(self): - data = Adafruit_IO.Data.from_response({'value': 'foo', 'feed_id': 10, 'unknown_param': 42}) - self.assertEqual(data.value, 'foo') - self.assertEqual(data.feed_id, 10) - self.assertIsNone(data.created_epoch) - self.assertIsNone(data.created_at) - self.assertIsNone(data.updated_at) - self.assertIsNone(data.completed_at) - self.assertIsNone(data.expiration) - self.assertIsNone(data.position) - self.assertIsNone(data.id) diff --git a/tests/test_errors.py b/tests/test_errors.py index 0431fe7..41e5b00 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,23 +1,41 @@ -# Test error responses with REST client. -# Author: Tony DiCola (tdicola@adafruit.com) +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import time import unittest -import Adafruit_IO +from Adafruit_IO import Client, RequestError, ThrottlingError import base class TestErrors(base.IOTestCase): def test_request_error_from_bad_key(self): - io = Adafruit_IO.Client("this is a bad key from a test") - with self.assertRaises(Adafruit_IO.RequestError): + io = Client("this is a bad key from a test") + with self.assertRaises(RequestError): io.send("TestStream", 42) - @unittest.skip("Throttling test must be run in isolation to prevent other failures.") + @unittest.skip("Throttling test must be run in isolation to prevent other tests from failing.") def test_throttling_error_after_6_requests_in_short_period(self): - io = Adafruit_IO.Client(self.get_test_key()) - with self.assertRaises(Adafruit_IO.ThrottlingError): + io = Client(self.get_test_key()) + with self.assertRaises(ThrottlingError): for i in range(6): io.send("TestStream", 42) time.sleep(0.1) # Small delay to keep from hammering network. diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..f001eff --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,50 @@ +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from Adafruit_IO import Data + +import base + + +class TestData(base.IOTestCase): + + def test_data_properties_are_optional(self): + data = Data(value='foo', feed_id=10) + self.assertEqual(data.value, 'foo') + self.assertEqual(data.feed_id, 10) + self.assertIsNone(data.created_epoch) + self.assertIsNone(data.created_at) + self.assertIsNone(data.updated_at) + self.assertIsNone(data.completed_at) + self.assertIsNone(data.expiration) + self.assertIsNone(data.position) + self.assertIsNone(data.id) + + def test_from_dict_ignores_unknown_items(self): + data = Data.from_dict({'value': 'foo', 'feed_id': 10, 'unknown_param': 42}) + self.assertEqual(data.value, 'foo') + self.assertEqual(data.feed_id, 10) + self.assertIsNone(data.created_epoch) + self.assertIsNone(data.created_at) + self.assertIsNone(data.updated_at) + self.assertIsNone(data.completed_at) + self.assertIsNone(data.expiration) + self.assertIsNone(data.position) + self.assertIsNone(data.id) diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 62e8832..66f5b12 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -1,9 +1,27 @@ -# Test MQTT client class. -# Author: Tony DiCola (tdicola@adafruit.com) -import logging +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import time -import Adafruit_IO +from Adafruit_IO import MQTTClient + import base @@ -13,25 +31,27 @@ TIMEOUT_SEC = 5 # Max amount of time (in seconds) to wait for asyncronous event class TestMQTTClient(base.IOTestCase): - def wait_until_connected(self, client, connect_value=True, timeout_sec=TIMEOUT_SEC): + def wait_until_connected(self, client, connect_value=True, + timeout_sec=TIMEOUT_SEC): # Pump the specified client message loop and wait until it's connected, # or the specified timeout has ellapsed. Can specify an explicit # connection state to wait for by setting connect_value (defaults to # waiting until connected, i.e. True). start = time.time() - while client.is_connected() != connect_value and (time.time() - start) < timeout_sec: + while client.is_connected() != connect_value and \ + (time.time() - start) < timeout_sec: client.loop() time.sleep(0) def test_create_client(self): # Create MQTT test client. - client = Adafruit_IO.MQTTClient(self.get_test_key()) + client = MQTTClient(self.get_test_key()) # Verify not connected by default. self.assertFalse(client.is_connected()) def test_connect(self): # Create MQTT test client. - client = Adafruit_IO.MQTTClient(self.get_test_key()) + client = MQTTClient(self.get_test_key()) # Verify on_connect handler is called and expected client is provided. def on_connect(mqtt_client): self.assertEqual(mqtt_client, client) @@ -44,7 +64,7 @@ class TestMQTTClient(base.IOTestCase): def test_disconnect(self): # Create MQTT test client. - client = Adafruit_IO.MQTTClient(self.get_test_key()) + client = MQTTClient(self.get_test_key()) # Verify on_connect handler is called and expected client is provided. def on_disconnect(mqtt_client): self.assertEqual(mqtt_client, client) @@ -60,12 +80,12 @@ class TestMQTTClient(base.IOTestCase): def test_subscribe_and_publish(self): # Create MQTT test client. - client = Adafruit_IO.MQTTClient(self.get_test_key()) + client = MQTTClient(self.get_test_key()) # Save all on_message handler responses. messages = [] - def on_message(mqtt_client, feed_id, payload): + def on_message(mqtt_client, feed, payload): self.assertEqual(mqtt_client, client) - messages.append((feed_id, payload)) + messages.append((feed, payload)) client.on_message = on_message # Connect and wait until on_connect event is fired. client.connect()