Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow listening for Hue events #48

Merged
merged 16 commits into from
May 13, 2021
9 changes: 9 additions & 0 deletions aiohue/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ def _process_raw(self, raw):
def values(self):
return self._items.values()

def process_update_event(self, event):
id_v1 = event["id_v1"]
obj_id = id_v1.rsplit("/", 1)[1]
obj = self._items.get(obj_id)
if obj is None:
return None
obj.process_update_event(event)
return obj

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

Expand Down
95 changes: 87 additions & 8 deletions aiohue/bridge.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from aiohttp.client_exceptions import ClientConnectionError
from __future__ import annotations
import asyncio

import logging

import aiohttp
from aiohttp import client_exceptions

from .config import Config
from .clip import Clip
from .groups import Groups
from .lights import Lights
from .scenes import Scenes
Expand All @@ -11,7 +18,14 @@
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 +37,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 +65,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(result.pop("groups"), self.request)
self.lights = Lights(result.pop("lights"), self.request)
if "scenes" in result:
self.scenes = Scenes(result["scenes"], self.request)
self.scenes = Scenes(result.pop("scenes"), self.request)
if "sensors" in result:
self.sensors = Sensors(result["sensors"], self.request)
self.sensors = Sensors(result.pop("sensors"), self.request)

logging.getLogger(__name__).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 +104,73 @@ 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):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am available for all your naming inquiries.

"""Make a request to any path with Hue's new request method.

This method has the auth in a header.
"""
url = f"{self.proto}://{self.host}/{path}"

async with self.websession.request(
method,
url,
ssl=False,
headers={"hue-application-key": self.username},
) as res:
res.raise_for_status()
return await res.json()

async def listen_events(self):
"""Listen to events and apply changes to objects."""
loop = asyncio.get_running_loop()
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.ClientResponseError as err:
self.logger.debug("Event endpoint %s", err.status)
if err.status == 503:
self.logger.debug("Sleeping while waiting for 503 to resolve")
await asyncio.sleep(1)
except asyncio.TimeoutError:
pass

event_task = loop.create_task(receive_events())
try:
while True:
event = await pending_events.get()

if event["type"] != "update":
self.logger.debug("Unknown event type: %s", event)
continue

for update in event["data"]:
item_type = update["id_v1"].split("/", 2)[1]

if item_type == "lights":
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For first version we're only going to support update events for lights.

obj = self.lights.process_update_event(update)
# if obj is None, we didn't know the object
# We could consider triggering a full refresh
if obj is not None:
yield obj

except asyncio.CancelledError:
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
event_task.cancel()
await event_task
raise


def _raise_on_error(data):
"""Check response for error message."""
Expand Down
32 changes: 32 additions & 0 deletions aiohue/clip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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")

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")

@staticmethod
balloob marked this conversation as resolved.
Show resolved Hide resolved
async def initialize(request_2):
balloob marked this conversation as resolved.
Show resolved Hide resolved
raw = await request_2("get", "clip/v2/resource")
return Clip(raw, request_2)
balloob marked this conversation as resolved.
Show resolved Hide resolved
19 changes: 19 additions & 0 deletions aiohue/lights.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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"] = dimming["brightness"]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update events have their own format.

These are the ones that I found so far for lights.


state["reachable"] = True

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

async def set_state(
self,
on=None,
Expand Down
98 changes: 67 additions & 31 deletions example.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import asyncio
import logging
import sys

import aiohttp

from aiohue.sensors import (TYPE_CLIP_GENERICFLAG, TYPE_CLIP_GENERICSTATUS,
TYPE_CLIP_HUMIDITY, TYPE_CLIP_LIGHTLEVEL, TYPE_CLIP_OPENCLOSE,
TYPE_CLIP_PRESENCE, TYPE_CLIP_SWITCH, TYPE_CLIP_TEMPERATURE,
TYPE_DAYLIGHT, TYPE_ZGP_SWITCH, TYPE_ZLL_LIGHTLEVEL,
TYPE_ZLL_PRESENCE, TYPE_ZLL_ROTARY, TYPE_ZLL_SWITCH,
TYPE_ZLL_TEMPERATURE)
from aiohue.sensors import (
TYPE_CLIP_GENERICFLAG,
TYPE_CLIP_GENERICSTATUS,
TYPE_CLIP_HUMIDITY,
TYPE_CLIP_LIGHTLEVEL,
TYPE_CLIP_OPENCLOSE,
TYPE_CLIP_PRESENCE,
TYPE_CLIP_SWITCH,
TYPE_CLIP_TEMPERATURE,
TYPE_DAYLIGHT,
TYPE_ZGP_SWITCH,
TYPE_ZLL_LIGHTLEVEL,
TYPE_ZLL_PRESENCE,
TYPE_ZLL_ROTARY,
TYPE_ZLL_SWITCH,
TYPE_ZLL_TEMPERATURE,
)

from aiohue.discovery import discover_nupnp

Expand All @@ -22,65 +34,89 @@ async def run(websession):
bridges = await discover_nupnp(websession)
bridge = bridges[0]

print('Found bridge at', bridge.host)
print("Found bridge at", bridge.host)

if len(sys.argv) == 1:
await bridge.create_user('aiophue-example')
print('Your username is', bridge.username)
print('Pass this to the example to control the bridge')
await bridge.create_user("aiophue-example")
print("Your username is", bridge.username)
print("Pass this to the example to control the bridge")
return

bridge.username = sys.argv[1]

await bridge.initialize()

print('Name', bridge.config.name)
print('Mac', bridge.config.mac)
print('API version', bridge.config.apiversion)
print("Name", bridge.config.name)
print("Mac", bridge.config.mac)
print("API version", bridge.config.apiversion)

print()
print('Lights:')
print("Lights:")
for id in bridge.lights:
light = bridge.lights[id]
print('{}: {}'.format(light.name, 'on' if light.state['on'] else 'off'))
print("{}: {}".format(light.name, "on" if light.state["on"] else "off"))

print()
print('Groups:')
print("Groups:")
for id in bridge.groups:
group = bridge.groups[id]
print('{}: {}'.format(group.name, 'on' if group.action['on'] else 'off'))
print("{}: {}".format(group.name, "on" if group.action["on"] else "off"))

print()
print('Scenes:')
print("Scenes:")
for id in bridge.scenes:
scene = bridge.scenes[id]
print(scene.name)

print()
print('Sensors:')
print("Sensors:")
for id in bridge.sensors:
sensor = bridge.sensors[id]
if sensor.type in [TYPE_CLIP_SWITCH, TYPE_ZGP_SWITCH, TYPE_ZLL_SWITCH]:
print('{}: [Button Event]: {}'.format(sensor.name, sensor.buttonevent))
print("{}: [Button Event]: {}".format(sensor.name, sensor.buttonevent))
elif sensor.type in [TYPE_ZLL_ROTARY]:
print('{}: [Rotation Event]: {}'.format(sensor.name, sensor.rotaryevent))
print("{}: [Rotation Event]: {}".format(sensor.name, sensor.rotaryevent))
elif sensor.type in [TYPE_CLIP_TEMPERATURE, TYPE_ZLL_TEMPERATURE]:
print('{}: [Temperature]: {}'.format(sensor.name, sensor.temperature))
print("{}: [Temperature]: {}".format(sensor.name, sensor.temperature))
elif sensor.type in [TYPE_CLIP_PRESENCE, TYPE_ZLL_PRESENCE]:
print('{}: [Presence]: {}'.format(sensor.name, sensor.presence))
print("{}: [Presence]: {}".format(sensor.name, sensor.presence))
elif sensor.type == TYPE_CLIP_OPENCLOSE:
print('{}: [Open]: {}'.format(sensor.name, sensor.open))
print("{}: [Open]: {}".format(sensor.name, sensor.open))
elif sensor.type in [TYPE_CLIP_LIGHTLEVEL, TYPE_ZLL_LIGHTLEVEL]:
print('{}: [Light Level]: {}'.format(sensor.name, sensor.lightlevel))
print("{}: [Light Level]: {}".format(sensor.name, sensor.lightlevel))
elif sensor.type == TYPE_CLIP_HUMIDITY:
print('{}: [Humidity]: {}'.format(sensor.name, sensor.humidity))
print("{}: [Humidity]: {}".format(sensor.name, sensor.humidity))
elif sensor.type == TYPE_CLIP_GENERICSTATUS:
print('{}: [Status]: {}'.format(sensor.name, sensor.status))
print("{}: [Status]: {}".format(sensor.name, sensor.status))
elif sensor.type == TYPE_CLIP_GENERICFLAG:
print('{}: [Flag]: {}'.format(sensor.name, sensor.flag))
print("{}: [Flag]: {}".format(sensor.name, sensor.flag))
elif sensor.type == TYPE_DAYLIGHT:
print('{}: [Daylight]: {}'.format(sensor.name, sensor.daylight))
print("{}: [Daylight]: {}".format(sensor.name, sensor.daylight))
else:
print('{}: [State]: {} [Config]: {}'.format(sensor.name, sensor.state, sensor.config))
print(
"{}: [State]: {} [Config]: {}".format(
sensor.name, sensor.state, sensor.config
)
)

asyncio.get_event_loop().run_until_complete(main())
print()
print("Listening for events")
print()
logging.basicConfig(level="DEBUG")

async for updated_object in bridge.listen_events():
print()
print(
"{}: on={}, bri={}".format(
updated_object.name,
updated_object.state.get("on"),
updated_object.state.get("bri"),
)
)
print()


try:
asyncio.get_event_loop().run_until_complete(main())
except KeyboardInterrupt:
pass