Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ setuptools-*
dist
build
eggs
.eggs
parts
bin
var
Expand Down
2 changes: 1 addition & 1 deletion Adafruit_IO/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.8.2"
__version__ = "3.0.0"
76 changes: 73 additions & 3 deletions Adafruit_IO/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@
from time import struct_time
import json
import platform
import pkg_resources
try:
from importlib.metadata import version as package_version # Python 3.8+
except ImportError:
try:
from importlib_metadata import version as package_version # Backport for <3.8
except ImportError: # pragma: no cover - fallback for older Python
package_version = None
import pkg_resources
import re
from urllib.parse import urlparse
from urllib.parse import parse_qs
Expand All @@ -35,8 +42,11 @@

DEFAULT_PAGE_LIMIT = 100

# set outgoing version, pulled from setup.py
version = pkg_resources.require("Adafruit_IO")[0].version
# set outgoing version, pulled from package metadata
if package_version is not None:
version = package_version("Adafruit_IO")
else:
version = pkg_resources.require("Adafruit_IO")[0].version
default_headers = {
'User-Agent': 'AdafruitIO-Python/{0} ({1}, {2} {3})'.format(version,
platform.platform(),
Expand Down Expand Up @@ -232,6 +242,66 @@ def receive_weather(self, weather_id=None):
weather_path = "integrations/weather"
return self._get(weather_path)

def create_weather(self, weather_record):
"""Create a new weather record.

:param dict weather_record: Weather record to create. e.g.
```
weather_record = {
'location': "40.726190,-74.005360",
'name': 'New York City, NY' # must be unique (per user)
}
```
"""
path = "integrations/weather"
return self._post(path, weather_record)

def delete_weather(self, weather_id):
"""Delete a weather record.

:param int weather_id: ID of the weather record to delete.
"""
path = "integrations/weather/{0}".format(weather_id)
self._delete(path)

def receive_air_quality(self, airq_location_id=None, forecast=None):
"""Adafruit IO Air Quality Service

:param int airq_location_id: optional ID for retrieving a specified air quality record.
:param string forecast: Can be "current", "forecast_today", or "forecast_tomorrow".
"""
if airq_location_id:
if forecast:
path = "integrations/air_quality/{0}/{1}".format(airq_location_id, forecast)
else:
path = "integrations/air_quality/{0}".format(airq_location_id)
else:
path = "integrations/air_quality"
return self._get(path)

def create_air_quality(self, air_quality_record):
"""Create a new air quality record.

:param dict air_quality_record: Air quality record to create. e.g.
```
air_quality_record = {
"location": "50.2423591, -5.4001148",
"name": "Godrevy Lighthouse, Cornwall", # must be unique (per user)
"provider": "open_meteo" # 'airnow' [US] or 'open_meteo' [Global]
}
```
"""
path = "integrations/air_quality"
return self._post(path, air_quality_record)

def delete_air_quality(self, air_quality_id):
"""Delete an air quality record.

:param int air_quality_id: ID of the air quality record to delete.
"""
path = "integrations/air_quality/{0}".format(air_quality_id)
self._delete(path)

def receive_random(self, randomizer_id=None):
"""Access to Adafruit IO's Random Data
service.
Expand Down
120 changes: 102 additions & 18 deletions Adafruit_IO/mqtt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import logging
import re

import paho.mqtt.client as mqtt
import sys
Expand All @@ -35,6 +36,25 @@
"forecast_hours_24", "forecast_days_1",
"forecast_days_2", "forecast_days_5",]


def validate_feed_key(feed_key):
"""Validates a provided feed key against Adafruit IO's system rules.
https://learn.adafruit.com/naming-things-in-adafruit-io/the-two-feed-identifiers

:param str feed_key: The feed key to validate.
:raises ValueError: If the feed key is too long.
:raises TypeError: If the feed key contains invalid characters or is empty.
"""
if len(feed_key) > 128: # validate feed key length
raise ValueError("Feed key must be 128 characters or less.")
if not bool(
re.match(r"^[a-zA-Z0-9-]+((\/|\.)[a-zA-Z0-9-]+)?$", feed_key)
): # validate key naming scheme
raise TypeError(
"Feed key must contain English letters, numbers, dash, and a period or a forward slash."
)


class MQTTClient(object):
"""Interface for publishing and subscribing to feed changes on Adafruit IO
using the MQTT protocol.
Expand Down Expand Up @@ -121,6 +141,9 @@ def _mqtt_message(self, client, userdata, msg):
elif parsed_topic[2] == 'weather':
topic = parsed_topic[4]
payload = '' if msg.payload is None else msg.payload.decode('utf-8')
elif parsed_topic[2] == 'air_quality':
topic = parsed_topic[3]
payload = '' if msg.payload is None else msg.payload.decode('utf-8')
else:
topic = parsed_topic[2]
payload = '' if msg.payload is None else msg.payload.decode('utf-8')
Expand Down Expand Up @@ -198,21 +221,22 @@ def loop(self, timeout_sec=1.0):
"""
self._client.loop(timeout=timeout_sec)

def subscribe(self, feed_id, feed_user=None, qos=0):
def subscribe(self, feed_key, feed_user=None, qos=0):
"""Subscribe to changes on the specified feed. When the feed is updated
the on_message function will be called with the feed_id and new value.
the on_message function will be called with the feed_key and new value.

:param str feed_id: The key of the feed to subscribe to.
:param str feed_key: The key of the feed to subscribe to.
:param str feed_user: Optional, identifies feed owner. Used for feed sharing.
:param int qos: The QoS to use when subscribing. Defaults to 0.

"""
validate_feed_key(feed_key)
if qos > 1:
raise MQTTError("Adafruit IO only supports a QoS level of 0 or 1.")
if feed_user is not None:
(res, mid) = self._client.subscribe('{0}/feeds/{1}'.format(feed_user, feed_id, qos=qos))
(res, mid) = self._client.subscribe('{0}/feeds/{1}'.format(feed_user, feed_key), qos=qos)
else:
(res, mid) = self._client.subscribe('{0}/feeds/{1}'.format(self._username, feed_id), qos=qos)
(res, mid) = self._client.subscribe('{0}/feeds/{1}'.format(self._username, feed_key), qos=qos)
return res, mid

def subscribe_group(self, group_id, qos=0):
Expand All @@ -239,15 +263,36 @@ def subscribe_randomizer(self, randomizer_id):

def subscribe_weather(self, weather_id, forecast_type):
"""Subscribe to Adafruit IO Weather

:param int weather_id: weather record you want data for
:param string type: type of forecast data requested
:param string type: type of forecast data requested. Valid types are:
- current
- forecast_minutes_5
- forecast_minutes_30
- forecast_hours_1
- forecast_hours_2
- forecast_hours_6
- forecast_hours_24
- forecast_days_1
- forecast_days_2
- forecast_days_5
"""
if forecast_type in forecast_types:
self._client.subscribe('{0}/integration/weather/{1}/{2}'.format(self._username, weather_id, forecast_type))
else:
raise TypeError("Invalid Forecast Type Specified.")
return

def subscribe_air_quality(self, airq_location_id, forecast='current'):
"""Subscribe to Adafruit IO Air Quality Service
:param int airq_location_id: air quality record you want data for
:param string forecast: Can be "current", "forecast_today", or "forecast_tomorrow".
"""
if forecast in forecast_types:
self._client.subscribe('{0}/integration/air_quality/{1}/{2}'.format(self._username, airq_location_id, forecast))
else:
raise TypeError("Invalid Forecast Type Specified.")

def subscribe_time(self, time):
"""Subscribe to changes on the Adafruit IO time feeds. When the feed is
updated, the on_message function will be called and publish a new value:
Expand All @@ -264,43 +309,82 @@ def subscribe_time(self, time):
raise TypeError('Invalid Time Feed Specified.')
return

def unsubscribe(self, feed_id=None, group_id=None):
def unsubscribe(self, feed_key=None, group_id=None):
"""Unsubscribes from a specified MQTT topic.
Note: this does not prevent publishing to a topic, it will unsubscribe
from receiving messages via on_message.
"""
if feed_id is not None:
self._client.unsubscribe('{0}/feeds/{1}'.format(self._username, feed_id))
if feed_key is not None:
validate_feed_key(feed_key)
self._client.unsubscribe('{0}/feeds/{1}'.format(self._username, feed_key))
elif group_id is not None:
self._client.unsubscribe('{0}/groups/{1}'.format(self._username, group_id))
else:
raise TypeError('Invalid topic type specified.')
return

def receive(self, feed_id):
def unsubscribe_randomizer(self, randomizer_id):
"""Unsubscribe from a specified random data stream.
:param int randomizer_id: ID of the random word record to unsubscribe from.
"""
self._client.unsubscribe('{0}/integration/words/{1}'.format(self._username, randomizer_id))

def unsubscribe_weather(self, weather_id, forecast_type):
"""Unsubscribe from Adafruit IO Weather
:param int weather_id: weather record to unsubscribe from
:param string type: type of forecast data
"""
if forecast_type in forecast_types:
self._client.unsubscribe('{0}/integration/weather/{1}/{2}'.format(self._username, weather_id, forecast_type))
else:
raise TypeError("Invalid Forecast Type Specified.")

def unsubscribe_time(self, time):
"""Unsubscribe from Adafruit IO time feeds.
"""
if time == 'millis' or time == 'seconds':
self._client.unsubscribe('time/{0}'.format(time))
elif time == 'iso':
self._client.unsubscribe('time/ISO-8601')
else:
raise TypeError('Invalid Time Feed Specified.')
return

def unsubscribe_air_quality(self, airq_location_id, forecast='current'):
"""Unsubscribe from Adafruit IO Air Quality Service
:param int airq_location_id: air quality record to unsubscribe from
:param string forecast: Can be "current", "forecast_today", or "forecast_tomorrow".
"""
if forecast in forecast_types:
self._client.unsubscribe('{0}/integration/air_quality/{1}/{2}'.format(self._username, airq_location_id, forecast))
else:
raise TypeError("Invalid Forecast Type Specified.")

def receive(self, feed_key):
"""Receive the last published value from a specified feed.

:param string feed_id: The ID of the feed to update.
:parm string value: The new value to publish to the feed
:param string feed_key: The key of the feed to retrieve the value from.
"""
(res, self._pub_mid) = self._client.publish('{0}/feeds/{1}/get'.format(self._username, feed_id),
validate_feed_key(feed_key)
(res, self._pub_mid) = self._client.publish('{0}/feeds/{1}/get'.format(self._username, feed_key),
payload='')

def publish(self, feed_id, value=None, group_id=None, feed_user=None):
def publish(self, feed_key, value=None, group_id=None, feed_user=None):
"""Publish a value to a specified feed.

Params:
- feed_id: The id of the feed to update.
- feed_key: The key of the feed to update.
- value: The new value to publish to the feed.
- (optional) group_id: The id of the group to update.
- (optional) feed_user: The feed owner's username. Used for Sharing Feeds.
"""
validate_feed_key(feed_key)
if feed_user is not None: # shared feed
(res, self._pub_mid) = self._client.publish('{0}/feeds/{1}'.format(feed_user, feed_id),
(res, self._pub_mid) = self._client.publish('{0}/feeds/{1}'.format(feed_user, feed_key),
payload=value)
elif group_id is not None: # group-specified feed
self._client.publish('{0}/feeds/{1}.{2}'.format(self._username, group_id, feed_id),
self._client.publish('{0}/feeds/{1}.{2}'.format(self._username, group_id, feed_key),
payload=value)
else: # regular feed
(res, self._pub_mid) = self._client.publish('{0}/feeds/{1}'.format(self._username, feed_id),
(res, self._pub_mid) = self._client.publish('{0}/feeds/{1}'.format(self._username, feed_key),
payload=value)
21 changes: 21 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ Usage

Documentation for this project is `available on the ReadTheDocs <https://adafruit-io-python-client.readthedocs.io/en/latest/>`_.

Service Integrations
====================

The client includes REST and MQTT helpers for Adafruit IO integrations:

- Time
- Random Data
- Weather (requires IO+ subscription)
- Air Quality (requires IO+ subscription)

Integration API details are documented in ``docs/integrations.rst``.

Service integration examples are included in:

- ``examples/api/weather.py``
- ``examples/api/weather_create_delete.py``
- ``examples/api/air_quality.py``
- ``examples/api/air_quality_create_delete.py``
- ``examples/mqtt/mqtt_weather.py``
- ``examples/mqtt/mqtt_air_quality.py``


Contributing
============
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Table of Contents
feed-sharing
data
groups
integrations



Expand Down
Loading