Skip to content

Commit

Permalink
Allow listening for Hue events (#48)
Browse files Browse the repository at this point in the history
  • Loading branch information
balloob authored May 13, 2021
1 parent dec7916 commit 935c6af
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 53 deletions.
2 changes: 1 addition & 1 deletion aiohue/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .errors import * # noqa
from .bridge import Bridge # noqa
from .errors import * # noqa
29 changes: 28 additions & 1 deletion aiohue/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
class APIItems:
"""Base class for a map of API Items."""

def __init__(self, raw, request, path, item_cls):
def __init__(self, logger, raw, request, path, item_cls):
self._logger = logger
self._request = request
self._path = path
self._item_cls = item_cls
Expand Down Expand Up @@ -33,6 +34,32 @@ def _process_raw(self, raw):
def values(self):
return self._items.values()

def process_event(self, event_type: str, data: dict):
id_v1 = data["id_v1"]
obj_id = id_v1.rsplit("/", 1)[1]
obj = self._items.get(obj_id)

if obj is None:
self._logger.debug(
"Received %s event for unknown item %s: %s", event_type, id_v1, data
)
return None

meth = getattr(obj, f"process_{event_type}_event", None)

if meth is None:
self._logger.debug(
"Don't know how to handle %s event for %s (%s): %s",
event_type,
id_v1,
type(obj).__name__,
data,
)
return None

meth(data)
return obj

def __getitem__(self, obj_id):
return self._items[obj_id]

Expand Down
136 changes: 127 additions & 9 deletions aiohue/bridge.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
from aiohttp.client_exceptions import ClientConnectionError
from __future__ import annotations

import asyncio
import logging

import aiohttp
from aiohttp import client_exceptions

from .clip import Clip
from .config import Config
from .errors import raise_error
from .groups import Groups
from .lights import Lights
from .scenes import Scenes
from .sensors import Sensors
from .errors import raise_error

_DEFAULT = object()


class Bridge:
"""Control a Hue bridge."""

def __init__(self, host, websession, *, username=None, bridge_id=None):
def __init__(
self,
host: str,
websession: aiohttp.ClientSession,
*,
username: str | None = None,
bridge_id: str | None = None,
):
self.host = host
self.username = username
self.websession = websession
Expand All @@ -23,6 +39,9 @@ def __init__(self, host, websession, *, username=None, bridge_id=None):
self.lights = None
self.scenes = None
self.sensors = None
self.clip = Clip(self.request_2)

self.logger = logging.getLogger(f"{__name__}.{host}")

# self.capabilities = None
# self.rules = None
Expand All @@ -48,13 +67,15 @@ async def create_user(self, device_type):
async def initialize(self):
result = await self.request("get", "")

self.config = Config(result["config"], self.request)
self.groups = Groups(result["groups"], self.request)
self.lights = Lights(result["lights"], self.request)
self.config = Config(result.pop("config"), self.request)
self.groups = Groups(self.logger, result.pop("groups"), self.request)
self.lights = Lights(self.logger, result.pop("lights"), self.request)
if "scenes" in result:
self.scenes = Scenes(result["scenes"], self.request)
self.scenes = Scenes(self.logger, result.pop("scenes"), self.request)
if "sensors" in result:
self.sensors = Sensors(result["sensors"], self.request)
self.sensors = Sensors(self.logger, result.pop("sensors"), self.request)

self.logger.debug("Unused result: %s", result)

async def request(self, method, path, json=None, auth=True):
"""Make a request to the API."""
Expand Down Expand Up @@ -85,13 +106,110 @@ async def request(self, method, path, json=None, auth=True):
_raise_on_error(data)
return data

except ClientConnectionError:
except client_exceptions.ClientConnectionError:
if self.proto is not None:
raise

self.proto = "http"
return await self.request(method, path, json, auth)

async def request_2(self, method, path, timeout=_DEFAULT):
"""Make a request to any path with Hue's new request method.
This method has the auth in a header.
"""
url = f"{self.proto or 'https'}://{self.host}/{path}"

kwargs = {
"ssl": False,
"headers": {"hue-application-key": self.username},
}

if timeout is not _DEFAULT:
kwargs["timeout"] = timeout

async with self.websession.request(method, url, **kwargs) as res:
res.raise_for_status()
return await res.json()

async def listen_events(self):
"""Listen to events and apply changes to objects."""
pending_events = asyncio.Queue()

async def receive_events():
while True:
self.logger.debug("Subscribing to events")
try:
for event in await self.clip.next_events():
self.logger.debug("Received event: %s", event)
pending_events.put_nowait(event)
except client_exceptions.ServerDisconnectedError:
self.logger.debug("Event endpoint disconnected")
except client_exceptions.ClientError as err:
if isinstance(err, client_exceptions.ClientResponseError):
# We get 503 when it's too busy, but any other error
# is probably also because too busy.
self.logger.debug(
"Got status %s from endpoint. Sleeping while waiting to resolve",
err.status,
)
else:
self.logger.debug("Unable to reach event endpoint: %s", err)

await asyncio.sleep(5)
except asyncio.TimeoutError:
pass
except Exception:
self.logger.exception("Unexpected error")
pending_events.put(None)
break

event_task = asyncio.create_task(receive_events())

while True:
try:
event = await pending_events.get()
except asyncio.CancelledError:
event_task.cancel()
await event_task
raise

# If unexpected error occurred
if event is None:
return

if event["type"] not in ("update", "motion"):
self.logger.debug("Unknown event type: %s", event)
continue

for event_data in event["data"]:
# We don't track object that groups all items (bridge_home)
if event_data["id_v1"] == "/groups/0":
continue

item_type = event_data["id_v1"].split("/", 2)[1]

if item_type not in (
# These all inherit from APIItems and so can handle events
"lights",
"sensors",
"scenes",
"groups",
):
self.logger.debug(
"Received %s event for unknown item type %s: %s",
event["type"],
item_type,
event_data,
)
continue

obj = getattr(self, item_type).process_event(event["type"], event_data)
# if obj is None, we didn't know the object
# We could consider triggering a full refresh
if obj is not None:
yield obj


def _raise_on_error(data):
"""Check response for error message."""
Expand Down
27 changes: 27 additions & 0 deletions aiohue/clip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class Clip:
"""Represent Hue clip."""

def __init__(self, request_2):
self._request_2 = request_2

async def next_events(self):
"""Note, this method will be pending until next event."""
return await self._request_2("get", "eventstream/clip/v2", timeout=None)

async def resources(self):
"""Fetch resources from Hue.
Available types:
homekit
device
bridge
zigbee_connectivity
entertainment
light
bridge_home
grouped_light
room
scene
"""
return await self._request_2("get", "clip/v2/resource")
14 changes: 12 additions & 2 deletions aiohue/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ class Groups(APIItems):
https://developers.meethue.com/documentation/groups-api
"""

def __init__(self, raw, request):
super().__init__(raw, request, "groups", Group)
def __init__(self, logger, raw, request):
super().__init__(logger, raw, request, "groups", Group)

async def get_all_lights_group(self):
"""Special all lights group."""
Expand All @@ -18,6 +18,8 @@ async def get_all_lights_group(self):
class Group:
"""Represents a Hue Group."""

ITEM_TYPE = "groups"

def __init__(self, id, raw, request):
self.id = id
self.raw = raw
Expand Down Expand Up @@ -52,6 +54,14 @@ def type(self):
def lights(self):
return self.raw["lights"]

def process_update_event(self, update):
action = dict(self.action)

if "on" in update:
action["on"] = update["on"]["on"]

self.raw = {**self.raw, "action": action}

async def set_action(
self,
on=None,
Expand Down
27 changes: 24 additions & 3 deletions aiohue/lights.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .api import APIItems
from collections import namedtuple

from .api import APIItems

# Represents a CIE 1931 XY coordinate pair.
XYPoint = namedtuple("XYPoint", ["x", "y"])
Expand All @@ -16,13 +16,15 @@ class Lights(APIItems):
https://developers.meethue.com/documentation/lights-api
"""

def __init__(self, raw, request):
super().__init__(raw, request, "lights", Light)
def __init__(self, logger, raw, request):
super().__init__(logger, raw, request, "lights", Light)


class Light:
"""Represents a Hue light."""

ITEM_TYPE = "lights"

def __init__(self, id, raw, request):
self.id = id
self.raw = raw
Expand Down Expand Up @@ -90,6 +92,25 @@ def colorgamut(self):

return color_gamut

def process_update_event(self, update):
state = dict(self.state)

if color := update.get("color"):
state["xy"] = [color["xy"]["x"], color["xy"]["y"]]

if ct := update.get("color_temperature"):
state["ct"] = ct["mirek"]

if "on" in update:
state["on"] = update["on"]["on"]

if dimming := update.get("dimming"):
state["bri"] = int(dimming["brightness"] / 100 * 254)

state["reachable"] = True

self.raw = {**self.raw, "state": state}

async def set_state(
self,
on=None,
Expand Down
6 changes: 4 additions & 2 deletions aiohue/scenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ class Scenes(APIItems):
https://developers.meethue.com/documentation/scenes-api
"""

def __init__(self, raw, request):
super().__init__(raw, request, "scenes", Scene)
def __init__(self, logger, raw, request):
super().__init__(logger, raw, request, "scenes", Scene)


class Scene:
"""Represents a Hue Scene."""

ITEM_TYPE = "scenes"

def __init__(self, id, raw, request):
self.id = id
self.raw = raw
Expand Down
6 changes: 4 additions & 2 deletions aiohue/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ class Sensors(APIItems):
https://developers.meethue.com/documentation/sensors-api
"""

def __init__(self, raw, request):
super().__init__(raw, request, "sensors", create_sensor)
def __init__(self, logger, raw, request):
super().__init__(logger, raw, request, "sensors", create_sensor)


class GenericSensor:
"""Represents the base Hue sensor."""

ITEM_TYPE = "sensors"

def __init__(self, id, raw, request):
self.id = id
self.raw = raw
Expand Down
Loading

0 comments on commit 935c6af

Please sign in to comment.