gurumitts / pylutron-caseta

Apache License 2.0
153 stars 98 forks source link

Does smartbridge add_button_subscriber API work? #91

Closed jeremyprz closed 2 years ago

jeremyprz commented 2 years ago

I have the following prototype code (below), which is able to communicate with my bridge, but the add_subscriber API appears to do nothing. Here is the tail end of the output (below). As can be seen - I'm able to communicate with the bridge correctly. but it seems the add_subscriber does nothing, or I have a bug in the way I'm using it.

I am pushing the various buttons on the pico remote attempting to get the callback to fire, but nothing seems to happen.

I should mention - I'm not a python developer in my day-job, so there could be silly bugs due to my lack of understanding of some python basics.

Does the add_subscriber API work?

15-Feb-22 08:15:46 - dumping device: {'device_id': '1', 'current_state': -1, 'fan_speed': None, 'zone': None, 'name': 'Smart Bridge 2', 'button_groups': None, 'type': 'SmartBridge', 'model': 'L-BDG2-WH', 'serial': 75327547}
15-Feb-22 08:15:46 - dumping device: {'device_id': '2', 'current_state': 50, 'fan_speed': None, 'zone': '1', 'name': 'Kitchen_Pendants', 'button_groups': None, 'type': 'WallDimmer', 'model': 'PD-6WCL-XX', 'serial': 65992296}
15-Feb-22 08:15:46 - dumping device: {'device_id': '3', 'current_state': -1, 'fan_speed': None, 'zone': None, 'name': 'Kitchen_OliotPico', 'button_groups': ['2'], 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 08:15:46 - FOUND Pico: {'device_id': '3', 'current_state': -1, 'fan_speed': None, 'zone': None, 'name': 'Kitchen_OliotPico', 'button_groups': ['2'], 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 08:15:46 - Added subscriber to button with device_id: 3
15-Feb-22 08:15:46 - sleeping...
15-Feb-22 08:15:51 - sleeping...
15-Feb-22 08:15:56 - sleeping...
#!/usr/bin/env python3

import sys
import logging
import asyncio
import time

from oliot.config import Config

logging.basicConfig(
    format='%(asctime)s - %(message)s',
    datefmt='%d-%b-%y %H:%M:%S',
    level=logging.DEBUG,
    stream=sys.stdout
)
log = logging.getLogger(__name__)

config = Config()

# Because pylutron_caseta uses asyncio,
# it must be run within the context of an asyncio event loop.
loop = asyncio.get_event_loop()

global finalized
global bridge

async def example_async_callback(arg1: [str]) -> None:
    log.info("Received async callback: {}".format(arg1))
    await bridge.close()
    global finalized
    finalized = True

def example_callback(arg1: [str]) -> None:
    log.info("Received callback: {}".format(arg1))
    loop.run_until_complete(example_async_callback(arg1))

async def subscribe() -> None:
    global finalized
    finalized = False
    # `Smartbridge` provides an API for interacting with the Caséta bridge.
    bridge = config.lutron.bridge(bridgeId="dh-lutron")
    await bridge.connect()

    devices = bridge.get_devices()
    for key in devices:
        device = devices[key]
        log.info("dumping device: {}".format(device))
        if 'name' in device:
            name = device['name']
            fqn = name.split("_")
            if len(fqn) > 1 and fqn[0] == 'Kitchen' and fqn[1] == 'OliotPico':
                log.info("FOUND Pico: {}".format(device))
                bridge.add_button_subscriber(key, example_callback)
                log.info("Added subscriber to button with device_id: {}".format(key))
                break
        else:
            log.debug("device {} does not have key '{}'".format(key, 'FullyQualifiedName'))

loop.run_until_complete(subscribe())

while not finalized:
    log.info("sleeping...")
    time.sleep(5)

log.info("FINALIZED!")
jeremyprz commented 2 years ago

I believe I've figured out what I was doing wrong (subscribing to device instead of button), but I don't have a solution quite yet. When I discover a solution, I'll post here and resolve.

mdonoughe commented 2 years ago

Because of the way asyncio works, your code immediately stops listening for events after registering for them. You need to use asyncio.sleep instead.

jeremyprz commented 2 years ago

Thanks! That feedback got me a long way.

I'm seeing button-presses reliably, but the callbacks aren't working quite yet.

Now seeing this, with the code below. I am seeing the button-presses, but the callbacks still aren't routing back. Trying to whittle it down to the bare essentials.

15-Feb-22 18:31:53 - FOUND Pico Button: key='101', name='Kitchen_OliotPico' id='0'
15-Feb-22 18:31:53 - Added subscriber to button with device_id: 101 button_id: 0
15-Feb-22 18:31:53 - dumping button: {'device_id': '102', 'current_state': 'Release', 'button_number': 1, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:31:53 - FOUND Pico Button: key='102', name='Kitchen_OliotPico' id='1'
15-Feb-22 18:31:53 - Added subscriber to button with device_id: 102 button_id: 1
15-Feb-22 18:31:53 - dumping button: {'device_id': '103', 'current_state': 'Release', 'button_number': 2, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:31:53 - FOUND Pico Button: key='103', name='Kitchen_OliotPico' id='2'
15-Feb-22 18:31:53 - Added subscriber to button with device_id: 103 button_id: 2
15-Feb-22 18:31:53 - dumping button: {'device_id': '104', 'current_state': 'Release', 'button_number': 3, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:31:53 - FOUND Pico Button: key='104', name='Kitchen_OliotPico' id='3'
15-Feb-22 18:31:53 - Added subscriber to button with device_id: 104 button_id: 3
15-Feb-22 18:31:53 - dumping button: {'device_id': '105', 'current_state': 'Release', 'button_number': 4, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:31:53 - FOUND Pico Button: key='105', name='Kitchen_OliotPico' id='4'
15-Feb-22 18:31:53 - Added subscriber to button with device_id: 105 button_id: 4
15-Feb-22 18:31:53 - sleeping...
15-Feb-22 18:31:57 - received for subscription d9ed7cbf-9a38-42d6-b642-ab930256160b: {'CommuniqueType': 'UpdateResponse', 'Header': {'MessageBodyType': 'OneButtonStatusEvent', 'StatusCode': '200 OK', 'Url': '/button/101/status/event'}, 'Body': {'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Press'}}}}
15-Feb-22 18:31:57 - Handling button status: Response(Header=ResponseHeader(StatusCode=ResponseStatus(200, 'OK'), Url='/button/101/status/event', MessageBodyType='OneButtonStatusEvent'), CommuniqueType='UpdateResponse', Body={'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Press'}}})
15-Feb-22 18:31:57 - received for subscription d9ed7cbf-9a38-42d6-b642-ab930256160b: {'CommuniqueType': 'UpdateResponse', 'Header': {'MessageBodyType': 'OneButtonStatusEvent', 'StatusCode': '200 OK', 'Url': '/button/101/status/event'}, 'Body': {'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Release'}}}}
15-Feb-22 18:31:57 - Handling button status: Response(Header=ResponseHeader(StatusCode=ResponseStatus(200, 'OK'), Url='/button/101/status/event', MessageBodyType='OneButtonStatusEvent'), CommuniqueType='UpdateResponse', Body={'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Release'}}})
15-Feb-22 18:31:58 - sleeping...
#!/usr/bin/env python3

import sys
import logging
import asyncio

from oliot.config import Config

logging.basicConfig(
    format='%(asctime)s - %(message)s',
    datefmt='%d-%b-%y %H:%M:%S',
    level=logging.DEBUG,
    stream=sys.stdout
)
log = logging.getLogger(__name__)

config = Config()

def example_callback(arg1: [str]) -> None:
    log.info("Received callback: {}".format(arg1))

async def mainloop():
    # `Smartbridge` provides an API for interacting with the Caséta bridge.
    bridge = config.lutron.bridge(bridgeId="dh-lutron")

    await bridge.connect()

    buttons = bridge.get_buttons()
    if len(buttons) <= 0:
        log.info("no buttons!")
    else:
        for key in buttons:
            button = buttons[key]
            log.info("dumping button: {}".format(button))
            if 'name' in button:
                name = button['name']
            fqn = name.split("_")
            if len(fqn) > 1 and fqn[0] == 'Kitchen' and fqn[1] == 'OliotPico':
                b_num = button['button_number']
                log.info("FOUND Pico Button: key='{}', name='{}' id='{}'".format(key, name, b_num))
                bridge.add_button_subscriber(b_num, example_callback)
                log.info("Added subscriber to button with device_id: {} button_id: {}".format(key, b_num))

    while True:
        log.info("sleeping...")
        await asyncio.sleep(5)

# Because pylutron_caseta uses asyncio,
# it must be run within the context of an asyncio event loop.
loop = asyncio.get_event_loop()
loop.run_until_complete(mainloop())
jeremyprz commented 2 years ago

Update the code below works. Output below. Thanks @mdonoughe !

Probably would be good to add similar code to examples/readme for those of us stumbling through python as a non-primary language.

15-Feb-22 18:49:34 - button: {'device_id': '101', 'current_state': 'Release', 'button_number': 0, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:49:34 - FOUND Pico Button: key='101' b_num='0'
15-Feb-22 18:49:34 - Added subscriber to button with device_id: 101 button_id: 0
15-Feb-22 18:49:34 - button: {'device_id': '102', 'current_state': 'Release', 'button_number': 1, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:49:34 - FOUND Pico Button: key='102' b_num='1'
15-Feb-22 18:49:34 - Added subscriber to button with device_id: 102 button_id: 1
15-Feb-22 18:49:34 - button: {'device_id': '103', 'current_state': 'Release', 'button_number': 2, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:49:34 - FOUND Pico Button: key='103' b_num='2'
15-Feb-22 18:49:34 - Added subscriber to button with device_id: 103 button_id: 2
15-Feb-22 18:49:34 - button: {'device_id': '104', 'current_state': 'Release', 'button_number': 3, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:49:34 - FOUND Pico Button: key='104' b_num='3'
15-Feb-22 18:49:34 - Added subscriber to button with device_id: 104 button_id: 3
15-Feb-22 18:49:34 - button: {'device_id': '105', 'current_state': 'Release', 'button_number': 4, 'name': 'Kitchen_OliotPico', 'type': 'Pico3ButtonRaiseLower', 'model': 'PJ2-3BRL-GXX-X01', 'serial': 66761298}
15-Feb-22 18:49:34 - FOUND Pico Button: key='105' b_num='4'
15-Feb-22 18:49:34 - Added subscriber to button with device_id: 105 button_id: 4
15-Feb-22 18:49:34 - sleeping...
15-Feb-22 18:49:36 - received for subscription e9d2786d-2409-4b61-99d0-7cd6e4c94601: {'CommuniqueType': 'UpdateResponse', 'Header': {'MessageBodyType': 'OneButtonStatusEvent', 'StatusCode': '200 OK', 'Url': '/button/101/status/event'}, 'Body': {'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Press'}}}}
15-Feb-22 18:49:36 - Handling button status: Response(Header=ResponseHeader(StatusCode=ResponseStatus(200, 'OK'), Url='/button/101/status/event', MessageBodyType='OneButtonStatusEvent'), CommuniqueType='UpdateResponse', Body={'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Press'}}})
15-Feb-22 18:49:36 - Received callback: Press
15-Feb-22 18:49:36 - received for subscription e9d2786d-2409-4b61-99d0-7cd6e4c94601: {'CommuniqueType': 'UpdateResponse', 'Header': {'MessageBodyType': 'OneButtonStatusEvent', 'StatusCode': '200 OK', 'Url': '/button/101/status/event'}, 'Body': {'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Release'}}}}
15-Feb-22 18:49:36 - Handling button status: Response(Header=ResponseHeader(StatusCode=ResponseStatus(200, 'OK'), Url='/button/101/status/event', MessageBodyType='OneButtonStatusEvent'), CommuniqueType='UpdateResponse', Body={'ButtonStatus': {'Button': {'href': '/button/101'}, 'ButtonEvent': {'EventType': 'Release'}}})
15-Feb-22 18:49:36 - Received callback: Release
15-Feb-22 18:49:39 - sleeping...
#!/usr/bin/env python3

import sys
import logging
import asyncio

from oliot.config import Config

logging.basicConfig(
    format='%(asctime)s - %(message)s',
    datefmt='%d-%b-%y %H:%M:%S',
    level=logging.DEBUG,
    stream=sys.stdout
)
log = logging.getLogger(__name__)

config = Config()

def example_callback(arg1: [str]) -> None:
    log.info("Received callback: {}".format(arg1))

async def mainloop():
    bridge = config.lutron.bridge(bridgeId="dh-lutron")
    await bridge.connect()

    buttons = bridge.get_buttons()
    assert(len(buttons) > 0)
    for key in buttons:
        button = buttons[key]
        log.info("button: {}".format(button))
        if 'name' in button and button['name'] == 'Kitchen_OliotPico':
            b_num = button['button_number']
            log.info("FOUND Pico Button: key='{}' b_num='{}'".format(key, b_num))
            bridge.add_button_subscriber(key, example_callback)
            log.info("Added subscriber to button with device_id: {} button_id: {}".format(key, b_num))

    while True:
        log.info("sleeping...")
        await asyncio.sleep(5)

# Because pylutron_caseta uses asyncio,
# it must be run within the context of an asyncio event loop.
loop = asyncio.get_event_loop()
loop.run_until_complete(mainloop())
mdonoughe commented 2 years ago

I noticed this part def example_callback(arg1: [str]) -> None: should be def example_callback(arg1: str) -> None:. It doesn't make a difference when you run the code, but if you run type checking tools with your current code you should get an error about the type of the callback not matching the type of the parameter to add_button_subscriber. In some languages, a function signature type will look like Callable<param0, param1, param2, ret>, but in Python it looks like Callable[[param0, param1, param2], ret].

jeremyprz commented 2 years ago

Probably my failure to read/understand the line of code in smartbridge.py

def add_button_subscriber(self, button_id: str, callback_: Callable[[str], None]):
jeremyprz commented 2 years ago

Great python library @gurumitts , @mdonoughe ! It literally has taken me ~6 hours to go from an amazon delivery of a Lutron starter pack to full integration into my existing homebrew IoT solution. In one button press of a pico I can trigger a sequence that sets a hue scene, turns on a bunch of WeMo switches, and sets a Lutron Caseta scene. I still have some work to do, but the button-callbacks from this library are going to make my automation child/guest friendly. Thanks!

jeremyprz commented 2 years ago

here's my final fully functional prototype for anyone interested. Ignore the wonky JSON wrapping/unwrapping ... its just temporary.

#!/usr/bin/env python3

import sys
import logging
import asyncio
import requests
import json

from oliot.config import Config

logging.basicConfig(
    format='%(asctime)s - %(message)s',
    datefmt='%d-%b-%y %H:%M:%S',
    level=logging.DEBUG,
    stream=sys.stdout
)
log = logging.getLogger(__name__)

config = Config()
actions = {"0": "bright", "1": "night", "2": "off", "3": "none", "4": "none"}

class Handler:

    def __init__(self, device_id, device_name, button_num):
        self.device_id = device_id
        self.device_name = device_name
        self.button_num = button_num

    def callback(self, action_ids: str) -> None:
        log.info("Received callback: {}[{}] => {}"
                 .format(self.device_name, self.button_num, action_ids))
        try:
            action = actions[str(self.button_num)]
            body_d = {'type': 'actions', 'version': '1.0', 'payload': [{'vendor': 'OrganL', 'action': 'sequence', 'args': {'sequence': ['dh--living-room--{0}'.format(action)]}}]}
            body_s = json.dumps(body_d)
            body_j = json.loads(body_s)
            payload = body_j['payload']

            log.info("payload: {}".format(payload))
            r = requests.post(
                config.hub.actionsUrl(),
                json=payload)
            log.info("Response: {}".format(r.status_code))
        except:
            log.exception("fail")

async def mainloop():
    bridge = config.lutron.bridge(bridgeId="dh-lutron")
    await bridge.connect()

    buttons = bridge.get_buttons()
    assert(len(buttons) > 0)
    for key in buttons:
        button = buttons[key]
        log.info("button: {}".format(button))
        if 'name' in button and button['name'] == 'Kitchen_OliotPico':
            b_num = button['button_number']
            handler = Handler(key, button['name'], b_num)
            log.info("FOUND Pico Button: key='{}' b_num='{}'".format(key, b_num))
            bridge.add_button_subscriber(key, handler.callback)
            log.info("Added subscriber to button with device_id: {} button_id: {}".format(key, b_num))

    while True:
        log.info("sleeping...")
        await asyncio.sleep(5)

# Because pylutron_caseta uses asyncio,
# it must be run within the context of an asyncio event loop.
loop = asyncio.get_event_loop()
loop.run_until_complete(mainloop())
jeremyprz commented 2 years ago

Closing out this issue. No code changes require within the library.