# # Dispatch incoming Alexa commands to the respective lighting. # # EXAMPLE: # $ curl --json '{"Message": "280e:1"}' --header 'x-amz-sns-message-type: Notification' http://192.168.0.$ADDR/alexa # # $ upnpc -d $EXTERNAL_PORT TCP # $ upnpc -e 'temp Alexa' -a 192.168.0.$ADDR 80 $EXTERNAL_PORT TCP # import asyncio import logging import aiohttp # mpremote mip install aiohttp LOGGER = logging.getLogger(__name__) HDR_SNS_TYPE = 'x-amz-sns-message-type' HDR_SNS_TOPIC = 'x-amz-sns-topic-arn' SNS_TYPE_SUBSCRIBE = 'SubscriptionConfirmation' SNS_TYPE_NOTIFY = 'Notification' def prepare(app): # Create an endpoint on the APP. app.post('/alexa')(app.gc_cleaning(endpoint)) async def endpoint(request): # SNS does not set the "proper" content type. Override it. #LOGGER.debug(f'CONTENT-TYPE: {request.content_type}') request.content_type = 'application/json' reqtype = request.headers.get(HDR_SNS_TYPE) #LOGGER.debug(f'REQTYPE: {reqtype}') LOGGER.debug(f'BODY: {request.json}') if reqtype == SNS_TYPE_SUBSCRIBE: return await _confirm(request.json['SubscribeURL']) if reqtype == SNS_TYPE_NOTIFY: return await _notify(request.json['Message']) # Log an error, but don't tell (attackers?) what went wrong. LOGGER(f'error. unknown reqtype "{reqtype}"') return 'Bad request.', 400 async def _confirm(url): LOGGER.debug(f'Confirming: {url}') return async with aiohttp.ClientSession() as session: _ = await session.head(url) LOGGER.info(f'Subscribed to: {request.headers.get(HDR_SNS_TOPIC)}') return '', 204 async def _notify(msg): # format: xxxx:d (x is a hex digit address; d is on/off) if len(msg) != 6 or msg[4] != ':' or not ('0'<=msg[5]<='1'): LOGGER.error(f'Message has improper format: {msg}') return 'Bad request.', 400 try: device = int(msg[:4], 16) except ValueError: LOGGER.error(f'Device code is improper: {msg[:4]}') return 'Bad request.', 400 ### bug in Lambda if device == 0x281e: device = 0x280e light = _find_light(device) LOGGER.debug(f'LIGHT: {light}') turn_on = msg[5] == '1' msg = f'Turning {"on" if turn_on else "off"}: {light["label"]}' LOGGER.info(msg) if 'shelly' in light: # Controlled by a Shelly. Defer to that controller. return await _call_shelly(light, turn_on) # Classic I2C device. ### 2025-04-04: the 110V relay boards. if turn_on: device |= 0x20 # the 'E' flag in pic/relay-board/main.asm ### get URL base from config url = f'http://192.168.0.3/i/{device:04x}' return await _ping_url(url) async def _call_shelly(light, turn_on): ### hack. fix somehow? import app # Get the Shelly device's IP address. ip = app.APP.cfg['network'].get(f'SHELLY_{light["shelly"]}') # http://192.168.0.24/script/1/on?id=2 url = f'http://{ip}/script/1/{"on" if turn_on else "off"}?id={light["light"]}' return await _ping_url(url) async def _ping_url(url): LOGGER.info(f'LIGHT URL: {url}') async with aiohttp.ClientSession() as session: async with session.get(url) as response: # We don't care about the body. The response has read the headers, # and is now pending consumption of the body. There is no close() # function, so we're just simpy done. pass return '', 204 def _find_light(device): ### hack. fix somehow? import app #print(f'FIND: {device}') for light in app.APP.cfg['lights'].values(): #print('CHECK:', light) if light.get('i2c') == device: return light return None