#!/usr/bin/python # # asfquart app with minimal interface, and background tasks to manage # the UPnP IGD device and listen to incoming requests from Alexa. # # This app has some specific concerns around managing its port, since # it gets mapped to a public IP/port. # import logging import quart import aiohttp import asfquart _LOGGER = logging.getLogger(__name__) # Various header values. SNS_TYPE_HEADER = 'x-amz-sns-message-type' HDR_SNS_TYPE = 'x-amz-sns-message-type' HDR_SNS_TOPIC = 'x-amz-sns-topic-arn' SNS_TYPE_SUBSCRIBE = 'SubscriptionConfirmation' SNS_TYPE_NOTIFY = 'Notification' SNS_TYPE_UNSUBSCRIBE = 'UnsubscribeConfirmation' ### not needed? class AlexaListener: def __init__(self, cfg): self.cfg = cfg self.session = None ### load mapping from config async def prepare(self): self.session = aiohttp.ClientSession() # /alexa async def endpoint(self): ### TODO: defensive logic # Essentially, two types of request. It's in the headers. hdrs = quart.request.headers reqtype = hdrs.get(HDR_SNS_TYPE) _LOGGER.debug(f'REQTYPE: {reqtype}') # NOTE: don't parse the body as JSON unless the request looks # somewhat reasonable. if reqtype == SNS_TYPE_SUBSCRIBE: # SNS does not set the "proper" content-type, so force parsing # the body as JSON j = await quart.request.get_json(force=True) #print('BODY:', j) return await _confirm(j['SubscribeURL'], hdrs.get(HDR_SNS_TOPIC)) if reqtype == SNS_TYPE_NOTIFY: j = await quart.request.get_json(force=True) #print('BODY:', j) return await _notify(j['Message']) # Don't disclose what we're looking for. Just fail. _LOGGER.error(f'error. unknown reqtype "{reqtype}"') return 'Bad headers', 400 async def _confirm(self, url, topic): _LOGGER.debug(f'Confirming: {url}') _ = await self.session.head(url) _LOGGER.info(f'Subscribed to: {topic}') return '', 204 async def _notify(self, msg): pass # /test/ async def test_endpoint(self, msg): # Message format is: "04x:1d" representing device ID and on/off. if len(msg) != 6 or msg[4] != ':' or not ('0'<=msg[5]<='1'): _LOGGER.error(f'Message has improper format: {msg}') # Note: don't tell caller how the message failed. return 'Bad request.', 400 _LOGGER.info('TEST MESSAGE:', msg) turn_on = (msg[5] == '1') try: device = int(msg[:4], 16) except ValueError: _LOGGER.error(f'Device code is improper: {msg[:4]}') # Note: don't tell caller how the message failed. return 'Bad request.', 400 ### wrong ID in the AWS/Alexa Lambda if device == 0x281e: device = 0x280e # Back Hall for light in self.cfg.lights.values(): if light.get('i2c') == device: break else: _LOGGER.error(f'Unknown device code: {device:04x}') # Note: don't tell caller how the message failed. return 'Bad request.', 400 # The entity ID in HAsst hass = light.hass _LOGGER.info(f'Turning {"on" if turn_on else "off"} the "{light.label}"') response = await self.session.get('http://192.168.0.9/s') print('RESPONSE:', await response.text()) return 'hey there' def main(): logging.basicConfig(level=logging.DEBUG, style='{', format='[{asctime}|{levelname}|{module}] {message}', datefmt='%m/%d %H:%M', ) app = asfquart.construct('alexa') # Build the singleton to manage incoming Alexa requests. global ALEXA ### maybe not needed? ALEXA = AlexaListener(app.cfg) # Now that we have an APP and ALEXA, add the route. app.post('/alexa')(ALEXA.endpoint) ### add a testing endpoint app.get('/test/')(ALEXA.test_endpoint) # The Alexa listener needs some preparation, once a loop exists. app.before_serving(ALEXA.prepare) ### fetch port from config app.runx(port=3555) if __name__ == '__main__': main()