diff --git a/cherry/hue/__init__.py b/cherry/hue/__init__.py index 36ac0e0..7766036 100644 --- a/cherry/hue/__init__.py +++ b/cherry/hue/__init__.py @@ -18,13 +18,72 @@ import sys import asyncio from contextlib import AsyncExitStack -import json +import logging import yaml from dns.asyncresolver import resolve from asyncio_mqtt import Client import umsgpack -import phue +import aiohttp + + +class Hue(object): + """ + A simple Hue client. + + :param config: Hue configuration dictionary + """ + + def __init__(self, config): + self.bridge = config.get('bridge') + self.username = config.get('username') + self.url = f'https://{self.bridge}/api/{self.username}' + self.session = aiohttp.ClientSession() + + async def close(self): + """ + Performs clean up actions. + """ + await self.session.close() + + async def get_state(self): + """ + Retrieves device state from the bridge. + + :returns: a dictionary of device state + """ + state = {} + + async with self.session.get(f'{self.url}/lights', ssl=False) as resp: + state['light'] = await resp.json() + + return state + + async def set_state(self, type_, id_, state): + """ + Sets the state of a given light or group. + + :param type_: one of light or group + :param id_: light or group ID + :param state: target state dictionary + """ + if type_ == 'light': + url = f'{self.url}/lights/{id_}/state' + elif type_ == 'group': + url = f'{self.url}/groups/{id_}/action' + else: + raise Exception(f'{type_} is not a Hue object type') + + if 'ct_k' in state: + state['ct'] = int(round(1e6 / state['ct_k'])) + del state['ct_k'] + + if 'transition' in state: + state['transitiontime'] = int(round(state['transition'])) * 10 + del state['transition'] + + resp = await self.session.put(url, ssl=False, json=state) + logging.debug(await resp.text()) async def light_state_handler(bridge, messages): @@ -39,18 +98,15 @@ async def light_state_handler(bridge, messages): platform, device_class, id_, event = m.topic.split('/') data = umsgpack.unpackb(m.payload) - state = {k: v for k, v in data.items() if k in ['on', 'bri', 'ct']} - transition = data.get('transition', 0) * 10 or None + state = {k: v + for k, v in data.items() + if k in ['on', 'bri', 'ct', 'ct_k', 'transition']} - if 'ct_k' in data: - state['ct'] = int(round(1e6 / data['ct_k'])) - - if device_class == 'light': - bridge.set_light(int(id_), state, transitiontime=transition) - elif device_class == 'group': - bridge.set_group(int(id_), state, transitiontime=transition) + logging.debug(f'Setting {device_class} {id_} state: {state}') + if device_class in ['light', 'group']: + await bridge.set_state(device_class, id_, state) except Exception as ex: - print('Error:', str(ex)) + logging.error(str(ex)) async def light_attr_handler(bridge, messages): @@ -63,50 +119,38 @@ async def light_attr_handler(bridge, messages): async for m in messages: try: platform, device_class, id_, event = m.topic.split('/') - light = bridge.get_light_objects('id').get(int(id_)) + logging.debug(f'Setting {device_class} {event} state: {m.payload}') if event == 'on': - light.on = bool(int(m.payload)) - elif event == 'bri': - light.brightness = int(m.payload) - elif event == 'ct': - light.colortemp = int(m.payload) - elif event == 'ct_k': - light.colortemp_k = int(m.payload) + state = {'on': bool(int(m.payload))} + await bridge.set_state('light', id_, state) + elif event in ['bri', 'ct', 'ct_k']: + state = {event: int(m.payload)} + await bridge.set_state('light', id_, state) except Exception as ex: - print('Error:', str(ex)) + logging.error(str(ex)) -async def publish_light_changes(client, old_state, new_state): +async def publish_device_state(client, dc, old_state, new_state): """ - Compares the state of each light and publishes changes to mqtt. + Compares the state of each devices and publishes changes to mqtt. :param client: mqtt client - :param old_state: the previous state of the lights - :param new_state: the current state of the lights + :param dc: device class name + :param old_state: the previous state of the devices + :param new_state: the current state of the devices """ - old_state = old_state.get('light', {}) - new_state = new_state.get('light', {}) - for id_, s in new_state.items(): - if id_ not in old_state or s != old_state[id_]: - payload = umsgpack.packb(s['state']) - await client.publish(f'hue/light/{id_}/state', payload, retain=True) + if id_ in old_state and s == old_state[id_]: + continue - for id_, s in old_state.items(): - if id_ not in new_state: - await client.publish(f'hue/light/{id_}/state', '', retain=True) + payload = umsgpack.packb(s['state']) + logging.debug(f'Announcing {dc} {id_} state: {s["state"]}') + await client.publish(f'hue/{dc}/{id_}/state', payload, retain=True) - -async def refresh_hue_state(bridge): - """ - Fetch device state from the Hue bridge. - - :param bridge: Hue bridge - :returns: a dictionary of device state - """ - return { - 'light': bridge.get_light()} + for id_ in (x for x in old_state if x not in new_state): + logging.debug(f'Deleting stale topic for {dc} {id_}') + await client.publish(f'hue/{dc}/{id_}/state', '', retain=True) async def publish_all_changes(client, bridge): @@ -115,57 +159,21 @@ async def publish_all_changes(client, bridge): :param client: mqtt client :param bridge: Hue bridge - :param state: initial Hue state """ state = {} while True: - new_state = await refresh_hue_state(bridge) + new = await bridge.get_state() - if new_state != state: - await publish_light_changes(client, state, new_state) + for dc, ns in new.items(): + os = state.get(dc, {}) + if os != ns: + await publish_device_state(client, dc, os, ns) - state = new_state + state = new await asyncio.sleep(1) -async def purge_stale_topics(config, bridge, client): - """ - Reads the state file from disk and purges topics for missing devices. - - :param config: configuration dictionary - :param bridge: Hue bridge - :param client: mqtt client - """ - try: - with open(config.get('state'), 'r') as f: - old_state = json.loads(f.read()) - except Exception as ex: - print('Error:', str(ex)) - return - - new_state = await refresh_hue_state(bridge) - - for device_class, ids in old_state.items(): - missing = ids - new_state[device_class].keys() - for x in missing: - topic = f'hue/{device_class}/{x}/state' - await client.publish(topic, '', retain=True) - print(f'Deleted stale topic {topic}') - - -async def dump_hue_state(config, bridge): - """ - Writes a dictionary of device IDs to disk. - - :param config: configuration dictionary - :param bridge: Hue bridge - """ - with open(config.get('state'), 'w') as f: - state = await refresh_hue_state(bridge) - f.write(json.dumps({k: list(v.keys()) for k, v in state.items()})) - - async def set_up_filters(bridge, client, stack): """ Sets up message filters and handlers. @@ -193,6 +201,22 @@ async def set_up_filters(bridge, client, stack): return tasks +async def cancel_tasks(tasks): + """ + Cancels all running async tasks: + + :param tasks: set of tasks to cancel + """ + for t in tasks: + if not t.done(): + t.cancel() + + try: + await t + except asyncio.CancelledError: + pass + + async def get_broker(config): """ Gets the mqtt broker address from an SRV record. @@ -214,26 +238,25 @@ async def init(config): :param config: configuration dictionary """ - bridge = phue.Bridge(config.get('hue', {}).get('bridge')) + bridge = Hue(config.get('hue', {})) async with AsyncExitStack() as stack: + stack.push_async_callback(bridge.close) + tasks = set() + stack.push_async_callback(cancel_tasks, tasks) client = Client(await get_broker(config), client_id='cherry-hue') await stack.enter_async_context(client) - print('Connected to mqtt broker') + logging.info('Connected to mqtt broker') - await purge_stale_topics(config, bridge, client) tasks.update(await set_up_filters(bridge, client, stack)) await client.subscribe('hue/#') task = asyncio.create_task(publish_all_changes(client, bridge)) tasks.add(task) - try: - await asyncio.gather(*tasks) - finally: - await dump_hue_state(config, bridge) + await asyncio.gather(*tasks) def main(): @@ -247,6 +270,10 @@ def main(): with open(sys.argv[1], 'r') as f: config = yaml.safe_load(f) + log = config.get('log', {}) + logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', + level=log.get('level', logging.ERROR)) + try: asyncio.run(init(config)) except KeyboardInterrupt: diff --git a/hue.yaml b/hue.yaml index bc7da15..1580f23 100644 --- a/hue.yaml +++ b/hue.yaml @@ -1,8 +1,12 @@ --- mqtt: + # Comment to look up an SRV record broker: localhost +log: + # CRITICAL, ERROR, WARNING, INFO, DEBUG + level: INFO + hue: bridge: hub.example.com - -state: /var/lib/hue/state.json + username: use the Hue register endpoint to get this value diff --git a/requirements.txt b/requirements.txt index aca097a..bb5ad9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,18 @@ ## The following requirements were added by pip freeze: +aiodns==2.0.0 +aiohttp==3.7.3 +async-timeout==3.0.1 asyncio-mqtt==0.8.0 +attrs==20.3.0 +cffi==1.14.4 +chardet==3.0.4 dnspython==2.1.0 +idna==3.1 +multidict==5.1.0 paho-mqtt==1.5.1 -phue==1.1 +pycares==3.1.1 +pycparser==2.20 PyYAML==5.3.1 +typing-extensions==3.7.4.3 u-msgpack-python==2.7.1 +yarl==1.6.3 diff --git a/setup.py b/setup.py index 99aebab..289663e 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,8 @@ setup(name='cherry-hue', 'dnspython', 'asyncio_mqtt', 'u-msgpack-python', - 'phue'], + 'aiohttp', + 'aiodns'], classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Environment :: Console',