From 835e2a767fa2767d492244b10f41fa284a324216 Mon Sep 17 00:00:00 2001 From: Mike Maas Date: Fri, 26 Jan 2018 06:46:53 -0800 Subject: [PATCH] Updates to improve discovery and cleanup This commit includes changes to the discovery process by having discovery information stored and retrieved from the SampleEndpointDetails with a reference to AWS IoT via thingName. Additionally, there is cleanup on the Alexa response handling and initial considerations for the cooking capabilities. --- .../cloud-formation/backend.template.us | 26 +- .../python/alexa/skills/smarthome/__init__.py | 17 +- .../smarthome/alexa_acceptgrant_response.py | 45 -- .../skills/smarthome/alexa_change_report.py | 66 --- .../smarthome/alexa_discover_response.py | 41 +- .../alexa/skills/smarthome/alexa_error.py | 63 --- .../smarthome/alexa_power_controller.py | 76 ---- .../alexa/skills/smarthome/alexa_response.py | 100 +++-- .../python/endpoint_cloud/api_handler.py | 397 +----------------- .../endpoint_cloud/api_handler_directive.py | 240 +++++++++++ .../endpoint_cloud/api_handler_endpoint.py | 241 +++++++++++ .../endpoint_cloud/api_handler_event.py | 183 ++++++++ .../lambda/lambda_api/python/index.py | 10 +- 13 files changed, 800 insertions(+), 705 deletions(-) delete mode 100644 sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_acceptgrant_response.py delete mode 100644 sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_change_report.py delete mode 100644 sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_error.py delete mode 100644 sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_power_controller.py create mode 100644 sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_directive.py create mode 100644 sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_endpoint.py create mode 100644 sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_event.py diff --git a/sample_backend/cloud-formation/backend.template.us b/sample_backend/cloud-formation/backend.template.us index 430f118..8f85e84 100644 --- a/sample_backend/cloud-formation/backend.template.us +++ b/sample_backend/cloud-formation/backend.template.us @@ -372,6 +372,8 @@ "Effect": "Allow", "Action": [ "iot:CreateThing", + "iot:CreateThingType", + "iot:DeleteThing", "iot:DescribeThing", "iot:ListThings", "iot:UpdateThing" @@ -495,6 +497,26 @@ } } }, + "EndpointsMethodDelete": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "RestApiId": { + "Ref": "EndpointRestApi" + }, + "ResourceId": { + "Ref": "EndpointsResource" + }, + "HttpMethod": "DELETE", + "AuthorizationType": "NONE", + "Integration": { + "Type": "AWS_PROXY", + "IntegrationHttpMethod": "POST", + "Uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${EndpointLambda.Arn}/invocations" + } + } + } + }, "DirectivesMethodPost": { "Type": "AWS::ApiGateway::Method", "Properties": { @@ -525,13 +547,13 @@ "Properties": { "AttributeDefinitions": [ { - "AttributeName": "DetailsId", + "AttributeName": "EndpointId", "AttributeType": "S" } ], "KeySchema": [ { - "AttributeName": "DetailsId", + "AttributeName": "EndpointId", "KeyType": "HASH" } ], diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/__init__.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/__init__.py index 55b2e12..b552ff9 100644 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/__init__.py +++ b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/__init__.py @@ -1,7 +1,16 @@ -from .alexa_acceptgrant_response import AlexaAcceptGrantResponse -from .alexa_change_report import AlexaChangeReport +# -*- coding: utf-8 -*- + +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Amazon Software License (the "License"). You may not use this file except in +# compliance with the License. A copy of the License is located at +# +# http://aws.amazon.com/asl/ +# +# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific +# language governing permissions and limitations under the License. + from .alexa_discover_response import AlexaDiscoverResponse -from .alexa_error import AlexaError -from .alexa_power_controller import AlexaPowerController from .alexa_response import AlexaResponse from .alexa_utils import get_utc_timestamp diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_acceptgrant_response.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_acceptgrant_response.py deleted file mode 100644 index 0052271..0000000 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_acceptgrant_response.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Amazon Software License (the "License"). You may not use this file except in -# compliance with the License. A copy of the License is located at -# -# http://aws.amazon.com/asl/ -# -# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -import uuid - - -class AlexaAcceptGrantResponse: - - def __init__(self, **kwargs): - - self.namespace = kwargs.get('namespace', 'Alexa.Authorization') - self.name = kwargs.get('name', 'AcceptGrant.Response') - self.payload_version = kwargs.get('payload_version', "3") - self.message_id = kwargs.get('message_id', str(uuid.uuid4())) - self.type = kwargs.get('type', None) - self.message = kwargs.get('message', None) - - def get_response(self): - response = {} - - header = {} - header['namespace'] = self.namespace - header['name'] = self.name - header['payloadVersion'] = self.payload_version - header['messageId'] = self.message_id - - response['event'] = {} - response['event']['header'] = header - response['event']['payload'] = {} - - if self.type and self.message: - response['event']['payload']['type'] = self.type - response['event']['payload']['message'] = self.message - - return response diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_change_report.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_change_report.py deleted file mode 100644 index d034969..0000000 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_change_report.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Amazon Software License (the "License"). You may not use this file except in -# compliance with the License. A copy of the License is located at -# -# http://aws.amazon.com/asl/ -# -# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -import uuid -from .alexa_utils import get_utc_timestamp - - -class AlexaChangeReport: - - def __init__(self, **kwargs): - self.namespace = kwargs.get('namespace', 'Alexa') - self.name = kwargs.get('name', 'ChangeReport') - self.payload_version = kwargs.get('payload_version', '3') - self.message_id = kwargs.get('message_id', str(uuid.uuid4())) - - self.type = "BearerToken" - self.token = kwargs.get('token', 'INVALID') - - self.endpoint_id = kwargs.get('endpoint_id', 'INVALID') - - self.cause_type = kwargs.get('cause_type', 'PHYSICAL_INTERACTION') - - def create_property(self, **kwargs): - p = {} - p['namespace'] = kwargs.get('namespace', 'Alexa') - p['name'] = kwargs.get('name', 'powerState') - p['value'] = kwargs.get('value', 'ON') - p['timeOfSample'] = get_utc_timestamp() - p['uncertaintyInMilliseconds'] = kwargs.get('uncertainty_in_milliseconds', 0) - return p - - def get_response(self): - response = {} - response['context'] = {} - response['context']['properties'] = [] - response['context']['properties'].append(self.create_property(namespace='Alexa.PowerController', name='powerState', value='ON')) - response['context']['properties'].append(self.create_property(namespace='Alexa.EndpointHealth', name='connectivity', value={'value': 'OK'})) - response['event'] = {} - response['event']['header'] = {} - response['event']['header']['namespace'] = self.namespace - response['event']['header']['name'] = self.name - response['event']['header']['payloadVersion'] = self.payload_version - response['event']['header']['messageId'] = self.message_id - response['event']['endpoint'] = {} - response['event']['endpoint']['scope'] = {} - response['event']['endpoint']['scope']['type'] = self.type - response['event']['endpoint']['scope']['token'] = self.token - response['event']['endpoint']['endpointId'] = self.endpoint_id - response['event']['payload'] = {} - response['event']['payload']['change'] = {} - response['event']['payload']['change']['cause'] = {} - response['event']['payload']['change']['cause']['type'] = self.cause_type - response['event']['payload']['change']['properties'] = [] - response['event']['payload']['change']['properties'].append(self.create_property(namespace='Alexa.BrightnessController', name='brightness', value=65)) - - return response diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_discover_response.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_discover_response.py index eb6a05f..5806ebb 100644 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_discover_response.py +++ b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_discover_response.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Amazon Software License (the "License"). You may not use this file except in # compliance with the License. A copy of the License is located at @@ -25,20 +25,25 @@ def __init__(self, request): self.payload_version = request["directive"]["header"]["payloadVersion"] self.endpoints = [] - def add_endpoint(self, thing): - # Translate the AWS IoT Thing attributes - endpoint_id_value = thing['thingName'] - # HACK Using the thing name as the friendly name! - friendly_name_value = thing['thingName'].replace('_', ' ') - # NOTE SWITCH is currently hardcoded into the endpoint - self.endpoints.append(self.create_endpoint(endpoint_id=endpoint_id_value, friendly_name=friendly_name_value, display_categories=['SWITCH'])) + def add_endpoint(self, endpoint_details): + self.endpoints.append( + self.create_endpoint( + capabilities=endpoint_details.capabilities, + description=endpoint_details.description, + display_categories=endpoint_details.display_categories, + endpoint_id=endpoint_details.id, + friendly_name=endpoint_details.friendly_name, + manufacturer_name=endpoint_details.manufacturer_name, + sku=endpoint_details.sku, + user_id=endpoint_details.user_id + )) def create_capability(self, **kwargs): capability = {} capability['type'] = kwargs.get('type', 'AlexaInterface') capability['interface'] = kwargs.get('interface', 'Alexa') capability['version'] = kwargs.get('version', '3') - supported = kwargs.get('supported', None) # [{"name": "powerState"}] + supported = kwargs.get('supported', None) if supported: capability['properties'] = {} capability['properties']['supported'] = supported @@ -48,27 +53,23 @@ def create_capability(self, **kwargs): return capability def create_endpoint(self, **kwargs): + # Return the proper structure expected for the endpoint endpoint = {} + endpoint['capabilities'] = kwargs.get('capabilities', []) + endpoint['description'] = kwargs.get('description', 'Endpoint Description') + endpoint['displayCategories'] = kwargs.get('display_categories', ['OTHER']) endpoint['endpointId'] = kwargs.get('endpoint_id', 'endpoint_' + "%0.6d" % random.randint(0, 999999)) endpoint['friendlyName'] = kwargs.get('friendly_name', 'Endpoint') - endpoint['description'] = kwargs.get('description', 'Endpoint Description') endpoint['manufacturerName'] = kwargs.get('manufacturer_name', 'Unknown Manufacturer') - endpoint['displayCategories'] = kwargs.get('display_categories', ['OTHER']) - - # NOTE: These capabilities are hardcoded, how might we expose them differently? - endpoint['capabilities'] = [] - endpoint['capabilities'].append(self.create_capability()) - endpoint['capabilities'].append(self.create_capability(interface='Alexa.PowerController', supported=[{"name": "powerState"}])) - endpoint['capabilities'].append(self.create_capability(interface='Alexa.EndpointHealth', supported=[{"name": "connectivity"}])) return endpoint def create_property(self, **kwargs): p = {} - p['namespace'] = kwargs.get('namespace', 'Alexa') p['name'] = 'powerState' - p['value'] = 'ON' + p['namespace'] = kwargs.get('namespace', 'Alexa') p['timeOfSample'] = get_utc_timestamp() p['uncertaintyInMilliseconds'] = kwargs.get('uncertainty_in_milliseconds', 0) + p['value'] = 'ON' return p def get_response(self): @@ -86,8 +87,6 @@ def get_response(self): header['payloadVersion'] = self.payload_version header['messageId'] = self.message_id - # self.endpoints.append(self.create_endpoint()) - payload = {} payload['endpoints'] = self.endpoints diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_error.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_error.py deleted file mode 100644 index 1cc0e0e..0000000 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_error.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Amazon Software License (the "License"). You may not use this file except in -# compliance with the License. A copy of the License is located at -# -# http://aws.amazon.com/asl/ -# -# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -import uuid - - -class AlexaError: - - def __init__(self, **kwargs): - self.message_id = str(uuid.uuid4()) - self.name = 'ErrorResponse' - self.payload_version = kwargs.get('payload_version', '3') - self.correlation_token = kwargs.get('correlation_token', None) - self.endpoint_id = kwargs.get('endpoint_id', None) - self.token = kwargs.get('token', None) - self.type = kwargs.get('type', 'INTERNAL_ERROR') - self.message = kwargs.get('message', 'An Internal Error has occurred') - - def get_response(self): - response = {} - event = {} - - header = {} - header['namespace'] = 'Alexa' - header['name'] = self.name - header['payloadVersion'] = self.payload_version - header['messageId'] = self.message_id - if self.correlation_token: - header["correlationToken"] = self.correlation_token - - endpoint = {} - if not self.endpoint_id: - self.endpoint_id = "INVALID" - endpoint['endpointId'] = self.endpoint_id - - # If this is an asynchronous response, include the token - if self.token: - scope = {} - scope['type'] = 'BearerToken' - scope['type'] = self.token - endpoint['scope'] = scope - - payload = {} - payload['type'] = self.type - payload['message'] = self.message - - event['header'] = header - event['endpoint'] = endpoint - event['payload'] = payload - - response['event'] = event - - return response diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_power_controller.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_power_controller.py deleted file mode 100644 index 703afc8..0000000 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_power_controller.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Amazon Software License (the "License"). You may not use this file except in -# compliance with the License. A copy of the License is located at -# -# http://aws.amazon.com/asl/ -# -# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific -# language governing permissions and limitations under the License. - -import uuid -from .alexa_utils import get_utc_timestamp - - -class AlexaPowerController: - - def __init__(self, **kwargs): - self.namespace = kwargs.get('namespace', 'Alexa') - self.name = kwargs.get('name', 'Response') - self.payload_version = kwargs.get('payload_version', '3') - self.message_id = kwargs.get('message_id', str(uuid.uuid4())) - self.correlation_token = kwargs.get('correlation_token', None) - - self.type = "BearerToken" - self.token = kwargs.get('token', None) - - self.endpoint_id = kwargs.get('endpoint_id', 'INVALID') - - self.value = kwargs.get('value', 'TurnOff') - - def create_property(self, **kwargs): - p = {} - p['namespace'] = kwargs.get('namespace', 'Alexa.EndpointHealth') - p['name'] = kwargs.get('name', 'connectivity') - p['value'] = kwargs.get('value', {'value': 'OK'}) - p['timeOfSample'] = get_utc_timestamp() - p['uncertaintyInMilliseconds'] = kwargs.get('uncertainty_in_milliseconds', 0) - return p - - def get_response(self): - - powerStateValue = 'OFF' if self.value == "TurnOff" else 'ON' - - properties = [] - properties.append(self.create_property(namespace='Alexa.PowerController', name='powerState', value=powerStateValue)) - properties.append(self.create_property(namespace='Alexa.EndpointHealth', name='connectivity', value={'value': 'OK'})) - - header = {} - header['namespace'] = 'Alexa' - header['name'] = 'Response' - header['payloadVersion'] = self.payload_version - header['messageId'] = self.message_id - header['correlationToken'] = self.correlation_token - - endpoint = {} - endpoint['scope'] = {} - endpoint['scope']['type'] = 'BearerToken' - endpoint['scope']['token'] = self.token - endpoint['endpointId'] = self.endpoint_id - - payload = {} - - response = {} - - response['context'] = {} - response['context']['properties'] = properties - - response['event'] = {} - response['event']['header'] = header - response['event']['endpoint'] = endpoint - response['event']['payload'] = payload - - return response \ No newline at end of file diff --git a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_response.py b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_response.py index ee342e6..de7bceb 100644 --- a/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_response.py +++ b/sample_backend/lambda/lambda_api/python/alexa/skills/smarthome/alexa_response.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Amazon Software License (the "License"). You may not use this file except in # compliance with the License. A copy of the License is located at @@ -13,45 +13,73 @@ import uuid +from .alexa_utils import get_utc_timestamp + class AlexaResponse: def __init__(self, **kwargs): - self.message_id = str(uuid.uuid4()) - self.name = 'Response' - self.payload_version = kwargs.get('payload_version', '3') + self.correlation_token = kwargs.get('correlation_token', None) - self.endpoint_id = kwargs.get('endpoint_id', None) - self.token = kwargs.get('token', None) - - def get_response(self): - response = {} - event = {} - - header = {} - header['namespace'] = 'Alexa' - header['name'] = self.name - header['payloadVersion'] = self.payload_version - header['messageId'] = self.message_id - if self.correlation_token: - header["correlationToken"] = self.correlation_token - - endpoint = {} - if not self.endpoint_id: - self.endpoint_id = "INVALID" - endpoint['endpointId'] = self.endpoint_id - - # If this is an asynchronous response, include the token - if self.token: - scope = {} - scope['type'] = 'BearerToken' - scope['type'] = self.token - endpoint['scope'] = scope - - event['header'] = header - event['endpoint'] = endpoint - event['payload'] = {} - - response['event'] = event + self.cookies = {} + self.include_endpoint = kwargs.get('include_endpoint', True) + self.payload = {} + self.properties = [] + + # Set up the event structure + self.event = { + 'header': { + 'namespace': kwargs.get('namespace', 'Alexa'), + 'name': kwargs.get('name', 'Response'), + 'messageId': str(uuid.uuid4()), + 'payloadVersion': kwargs.get('payload_version', '3') + }, + 'endpoint': { + "scope": { + "type": "BearerToken", + "token": kwargs.get('token', 'INVALID') + }, + "endpointId": kwargs.get('endpoint_id', 'INVALID') + }, + 'payload': {} + } + + def add_cookie(self, key, value): + self.cookies[key] = value + + def add_property(self, **kwargs): + self.properties.append(self.create_property(**kwargs)) + + def create_property(self, **kwargs): + p = {} + p['namespace'] = kwargs.get('namespace', 'Alexa.EndpointHealth') + p['name'] = kwargs.get('name', 'connectivity') + p['value'] = kwargs.get('value', {'value': 'OK'}) + p['timeOfSample'] = get_utc_timestamp() + p['uncertaintyInMilliseconds'] = kwargs.get('uncertainty_in_milliseconds', 0) + return p + + def get(self): + + response = { + 'event': self.event + } + response['event']['payload'] = self.payload + + if self.correlation_token is not None: + response['event']['header']['correlationToken'] = self.correlation_token + + if self.include_endpoint: + if len(self.cookies) > 0: + response['event']['endpoint']['cookie'] = self.cookies + else: + response['event'].pop('endpoint') + + if len(self.properties) > 0: + response['context'] = {} + response['context']['properties'] = self.properties return response + + def set_payload(self, payload): + self.payload = payload diff --git a/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler.py b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler.py index b20d540..03a022c 100644 --- a/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler.py +++ b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Amazon Software License (the "License"). You may not use this file except in # compliance with the License. A copy of the License is located at @@ -11,396 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific # language governing permissions and limitations under the License. -import http.client -import json -import boto3 -from botocore.exceptions import ClientError -from datetime import datetime, timedelta -from jsonschema import validate, SchemaError, ValidationError - -from alexa.skills.smarthome import AlexaAcceptGrantResponse, AlexaChangeReport, AlexaDiscoverResponse, AlexaError, AlexaPowerController, AlexaResponse -from .api_auth import ApiAuth - -dynamodb_aws = boto3.client('dynamodb') -iot_aws = boto3.client('iot') +from .api_handler_directive import ApiHandlerDirective +from .api_handler_endpoint import ApiHandlerEndpoint +from .api_handler_event import ApiHandlerEvent class ApiHandler: def __init__(self): - self.directive = _Directive() - self.event = _Event() - self.endpoint = _Endpoint() - - -class _Directive: - def process(self, request, client_id, client_secret, redirect_uri): - print('LOG api.ApiHandler.directive.process.request:', request) - - response = None - # Only process if there is an actual body to process otherwise return an ErrorResponse - json_body = request['body'] - if json_body: - json_object = json.loads(json_body) - namespace = json_object['directive']['header']['namespace'] - - if namespace == "Alexa.Authorization": - grant_code = json_object['directive']['payload']['grant']['code'] - grantee_token = json_object['directive']['payload']['grantee']['token'] - - # Spot the default from the Alexa.Discovery sample. Use as a default for development. - if grantee_token == 'access-token-from-skill': - user_id = "0" # <- Useful for development - response_object = { - 'access_token': 'INVALID', - 'refresh_token': 'INVALID', - 'token_type': 'Bearer', - 'expires_in': 9000 - } - else: - # Get the User ID - response_user_id = json.loads(ApiAuth.get_user_id(grantee_token).read().decode('utf-8')) - if 'error' in response_user_id: - print('ERROR api.ApiHandler.directive.process.discovery.user_id:', response_user_id['error_description']) - user_id = response_user_id['user_id'] - print('LOG api.ApiHandler.directive.process.discovery.user_id:', user_id) - - # Get the Access and Refresh Tokens - api_auth = ApiAuth() - response_token = api_auth.get_access_token(grant_code, client_id, client_secret, redirect_uri) - response_token_string = response_token.read().decode('utf-8') - print('LOG api.ApiHandler.directive.process.discovery.response_token_string:', response_token_string) - response_object = json.loads(response_token_string) - - # Store the retrieved from the Authorization Server - access_token = response_object['access_token'] - refresh_token = response_object['refresh_token'] - token_type = response_object['token_type'] - expires_in = response_object['expires_in'] - - # Calculate expiration - expiration_utc = datetime.utcnow() + timedelta(seconds=(int(expires_in) - 5)) - - # Store the User Information - This is useful for inspection during development - # TODO Hash User Information - table = boto3.resource('dynamodb').Table('SampleUsers') - result = table.put_item( - Item={ - 'UserId': user_id, - 'GrantCode': grant_code, - 'GranteeToken': grantee_token, - 'AccessToken': access_token, - 'ClientId': client_id, - 'ClientSecret': client_secret, - 'ExpirationUTC': expiration_utc.strftime("%Y-%m-%dT%H:%M:%S.00Z"), - 'RedirectUri': redirect_uri, - 'RefreshToken': refresh_token, - 'TokenType': token_type - } - ) - - if result['ResponseMetadata']['HTTPStatusCode'] == 200: - print('LOG SampleUsers.put_item:', result) - response = AlexaAcceptGrantResponse().get_response() - else: - error_message = 'Error creating User' - print('LOG', error_message) - response = AlexaError(message=error_message).get_response() - - if namespace == "Alexa.Discovery": - # Given the Access Token, get the User ID - access_token = json_object['directive']['payload']['scope']['token'] - - # Spot the default from the Alexa.Discovery sample. Use as a default for development. - if access_token == 'access-token-from-skill': - print('WARN api.ApiHandler.directive.process.discovery.user_id: Using development user_id of 0') - user_id = "0" # <- Useful for development - else: - response_user_id = json.loads(ApiAuth.get_user_id(access_token).read().decode('utf-8')) - if 'error' in response_user_id: - print('ERROR api.ApiHandler.directive.process.discovery.user_id: ' + response_user_id['error_description']) - user_id = response_user_id['user_id'] - print('LOG api.ApiHandler.directive.process.discovery.user_id:', user_id) - - alexa_discover_response = AlexaDiscoverResponse(json_object) - - # Get the list of endpoints to return for a User ID and add them to the response - list_response = iot_aws.list_things(attributeName='user_id', attributeValue=user_id) - for thing in list_response['things']: - alexa_discover_response.add_endpoint(thing) - - response = alexa_discover_response.get_response() - - if namespace == "Alexa.PowerController": - value = json_object['directive']['header']['name'] - correlation_token = None - if 'correlationToken' in json_object['directive']['header']: - correlation_token = json_object['directive']['header']['correlationToken'] - token = json_object['directive']['endpoint']['scope']['token'] - endpoint_id = json_object['directive']['endpoint']['endpointId'] - - response_user_id = json.loads(ApiAuth.get_user_id(token).read().decode('utf-8')) - if 'error' in response_user_id: - print('ERROR api.ApiHandler.directive.process.power_controller.user_id: ' + response_user_id['error_description']) - user_id = response_user_id['user_id'] - print('LOG api.ApiHandler.directive.process.power_controller.user_id', user_id) - - power_state_value = 'OFF' if value == "TurnOff" else 'ON' - try: - # Send the state to the Thing - response_update = iot_aws.update_thing( - thingName=endpoint_id, - # thingTypeName='SearchableEndpointSwitch', - attributePayload={ - 'attributes': { - 'state': power_state_value, - 'proactively_reported': 'True', - 'user_id': user_id - } - } - ) - print('LOG api.ApiHandler.directive.process.power_controller.response_update:', response_update) - alexa_power_controller = AlexaPowerController(value=value, token=token, correlation_token=correlation_token, endpoint_id=endpoint_id) - response = alexa_power_controller.get_response() - except ClientError as e: - response = AlexaError(message=e).get_response() - else: - response = AlexaError().get_response() - - if response is None: - response = AlexaError(message='No response processed').get_response() - else: - # Validate the Response - if not self.validate_response(response): - response = AlexaError(message='Failed to validate message against the schema').get_response() - - print('LOG api.ApiHandler.directive.response', response) - return json.dumps(response) - - def validate_response(self, response): - valid = False - try: - with open('alexa_smart_home_message_schema.json', 'r') as schema_file: - json_schema = json.load(schema_file) - validate(response, json_schema) - valid = True - except SchemaError as se: - print('LOG validate_response: Invalid Schema') - except ValidationError as ve: - print('LOG validate_response: Invalid Content for ', response) - - return valid - - -class _Endpoint: - def create(self, request): - try: - json_object = json.loads(request['body']) - endpoint_user_id = json_object['event']['endpoint']['userId'] # Expect a Profile - endpoint_name = json_object['event']['endpoint']['id'] # Expect a valid AWS IoT Thing Name - endpoint_state = json_object['event']['endpoint']['state'] # Expect a state value, ex: ON or OFF - endpoint_type = json_object['event']['endpoint']['type'] # Expect a meaningful type, ex: SWITCH - - # TODO Add endpoint_type into the Thing by assigning a ThingType for more attribute slots - - print("LOG api.ApiHandler.endpoint.create.endpoint_name:", endpoint_name) - print("LOG api.ApiHandler.endpoint.create.endpoint_state:", endpoint_state) - - # Create the thing - # NOTE Hard coding the endpoints to be proactivelyReported - try: - response = iot_aws.create_thing( - thingName=endpoint_name, - # thingTypeName='SearchableEndpointSwitch', - attributePayload={ - 'attributes': { - 'state': endpoint_state, - 'proactively_reported': 'True', - 'user_id': endpoint_user_id - } - } - ) - print('LOG api.ApiHandler.endpoint.create.create_thing ' + str(response)) - return response - - except ClientError as e: - if e.response['Error']['Code'] == 'ResourceAlreadyExistsException': - print('WARN iot resource already exists, trying update') - response = iot_aws.update_thing( - thingName=endpoint_name, - # thingTypeName='SearchableEndpointSwitch', - attributePayload={ - 'attributes': { - 'state': endpoint_state, - 'proactively_reported': 'True', - 'user_id': endpoint_user_id - } - } - ) - return response - - except KeyError as key_error: - return "KeyError: " + str(key_error) - - def read(self, request): - try: - response = {} - resource = request['resource'] - if resource == '/endpoints': - list_response = iot_aws.list_things() - status = list_response['ResponseMetadata']['HTTPStatusCode'] - if 200 <= int(status) < 300: - things = list_response['things'] - response = [] - for thing in things: - response.append(thing) - else: - path_parameters = request['pathParameters'] - endpoint_name = path_parameters['endpoint_name'] - response = iot_aws.describe_thing(thingName=endpoint_name) - - print('LOG api.ApiHandler.endpoint.read ' + str(response)) - return response - - except KeyError as key_error: - return "KeyError: " + str(key_error) - - -class _Event: - def create(self, request): - print("LOG api.ApiHandler.event.create.request:", request) - - try: - json_object = json.loads(request['body']) - endpoint_user_id = json_object['event']['endpoint']['userId'] # Expect a Profile - endpoint_name = json_object['event']['endpoint']['id'] # Expect a valid AWS IoT Thing Name - endpoint_state = json_object['event']['endpoint']['state'] # Expect a state value, ex: ON or OFF - endpoint_type = json_object['event']['endpoint']['type'] # Expect a meaningful type, ex: SWITCH - - try: - # Update the IoT Thing - response = iot_aws.update_thing( - thingName=endpoint_name, - attributePayload={ - 'attributes': { - 'state': endpoint_state, - 'proactively_reported': 'True', - 'user_id': endpoint_user_id - } - } - ) - print('LOG api.ApiHandler.event.create.iot_aws.update_thing.response:', str(response)) - - # Update Alexa with a Proactive State Update - if endpoint_user_id == 0: - print('LOG PSU: Not sent for user_id of 0') - else: - response_psu = self.send_psu(endpoint_user_id, endpoint_name, endpoint_state) - print('LOG PSU response:', response_psu) - - except ClientError as e: - response = AlexaError(message=e).get_response() - - return response - - except KeyError as key_error: - return "KeyError: " + str(key_error) - - def is_token_expired(self, expiration_utc): - now = datetime.utcnow().replace(tzinfo=None) - then = datetime.strptime(expiration_utc, "%Y-%m-%dT%H:%M:%S.00Z") - is_expired = now > then - if is_expired: - return is_expired - seconds = (now - then).seconds - is_soon = seconds < 30 # Give a 30 second buffer for expiration - return is_soon - - def send_psu(self, endpoint_user_id, endpoint_id, endpoint_state): - - # Get the User Information - table = boto3.resource('dynamodb').Table('SampleUsers') - result = table.get_item( - Key={ - 'UserId': endpoint_user_id - }, - AttributesToGet=[ - 'UserId', - 'AccessToken', - 'ClientId', - 'ClientSecret', - 'ExpirationUTC', - 'RedirectUri', - 'RefreshToken', - 'TokenType' - ] - ) - - # Get the Token - if result['ResponseMetadata']['HTTPStatusCode'] == 200: - print('LOG api.ApiHandler.event.create.send_psu.SampleUsers.get_item:', str(result)) - - if 'Item' in result: - expiration_utc = result['Item']['ExpirationUTC'] - token_is_expired = self.is_token_expired(expiration_utc) - print('LOG api.ApiHandler.event.create.send_psu.token_is_expired:', token_is_expired) - if token_is_expired: - # The token has expired so get a new access token using the refresh token - refresh_token = result['Item']['RefreshToken'] - client_id = result['Item']['ClientId'] - client_secret = result['Item']['ClientSecret'] - redirect_uri = result['Item']['RedirectUri'] - - api_auth = ApiAuth() - response_refresh_token = api_auth.refresh_access_token(refresh_token, client_id, client_secret, redirect_uri) - response_refresh_token_string = response_refresh_token.read().decode('utf-8') - response_refresh_token_object = json.loads(response_refresh_token_string) - - # Store the new values from the refresh - access_token = response_refresh_token_object['access_token'] - refresh_token = response_refresh_token_object['refresh_token'] - token_type = response_refresh_token_object['token_type'] - expires_in = response_refresh_token_object['expires_in'] - - # Calculate expiration - expiration_utc = datetime.utcnow() + timedelta(seconds=(int(expires_in) - 5)) - - print('access_token', access_token) - print('expiration_utc', expiration_utc) - - result = table.update_item( - Key={ - 'UserId': endpoint_user_id - }, - UpdateExpression="set AccessToken=:a, RefreshToken=:r, TokenType=:t, ExpirationUTC=:e", - ExpressionAttributeValues={ - ':a': access_token, - ':r': refresh_token, - ':t': token_type, - ':e': expiration_utc.strftime("%Y-%m-%dT%H:%M:%S.00Z") - }, - ReturnValues="UPDATED_NEW" - ) - print('LOG api.ApiHandler.event.create.send_psu.SampleUsers.update_item:', str(result)) - - # TODO Return an error here if the token could not be refreshed - - else: - # Use the stored access token - access_token = result['Item']['AccessToken'] - print('LOG Using stored access_token:', access_token) - - alexa_change_report = AlexaChangeReport(endpoint_id=endpoint_id, token=access_token) - payload = json.dumps(alexa_change_report.get_response()) - print('LOG AlexaChangeReport.get_response:', payload) - - # TODO Map to correct endpoint for Europe: https://api.eu.amazonalexa.com/v3/events - # TODO Map to correct endpoint for Far East: https://api.fe.amazonalexa.com/v3/events - alexa_event_gateway_uri = 'api.amazonalexa.com' - connection = http.client.HTTPSConnection(alexa_event_gateway_uri) - headers = { - 'content-type': "application/json;charset=UTF-8", - 'cache-control': "no-cache" - } - connection.request('POST', '/v3/events', payload, headers) # preload_content=False - code = connection.getresponse().getcode() - return 'LOG PSU HTTP Status code: ' + str(code) + self.directive = ApiHandlerDirective() + self.event = ApiHandlerEvent() + self.endpoint = ApiHandlerEndpoint() diff --git a/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_directive.py b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_directive.py new file mode 100644 index 0000000..7b57098 --- /dev/null +++ b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_directive.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- + +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Amazon Software License (the "License"). You may not use this file except in +# compliance with the License. A copy of the License is located at +# +# http://aws.amazon.com/asl/ +# +# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +import boto3 +import json + +from .api_auth import ApiAuth +from .api_handler_endpoint import ApiHandlerEndpoint +from alexa.skills.smarthome import AlexaDiscoverResponse, AlexaResponse +from botocore.exceptions import ClientError +from datetime import datetime, timedelta +from jsonschema import validate, SchemaError, ValidationError + +dynamodb_aws = boto3.client('dynamodb') +iot_aws = boto3.client('iot') + + +class ApiHandlerDirective: + + @staticmethod + def get_db_value(value): + if 'S' in value: + value = value['S'] + return value + + def process(self, request, client_id, client_secret, redirect_uri): + print('LOG directive.process.request:', request) + + response = None + # Process an Alexa directive and route to the right namespace + # Only process if there is an actual body to process otherwise return an ErrorResponse + json_body = request['body'] + if json_body: + json_object = json.loads(json_body) + namespace = json_object['directive']['header']['namespace'] + + if namespace == "Alexa": + name = json_object['directive']['header']['name'] + correlation_token = json_object['directive']['header']['correlationToken'] + endpoint_id = json_object['directive']['endpoint']['endpointId'] + if name == 'ReportState': + # TODO Get the User ID from the access_token + # TODO Lookup the endpoint and get state + print('Sending StateReport for endpoint', endpoint_id) + alexa_reportstate_response = AlexaResponse(name='StateReport', endpoint_id=endpoint_id, correlation_token=correlation_token) + response = alexa_reportstate_response.get() + + if namespace == "Alexa.Authorization": + grant_code = json_object['directive']['payload']['grant']['code'] + grantee_token = json_object['directive']['payload']['grantee']['token'] + + # Spot the default from the Alexa.Discovery sample. Use as a default for development. + if grantee_token == 'access-token-from-skill': + user_id = "0" # <- Useful for development + response_object = { + 'access_token': 'INVALID', + 'refresh_token': 'INVALID', + 'token_type': 'Bearer', + 'expires_in': 9000 + } + else: + # Get the User ID + response_user_id = json.loads(ApiAuth.get_user_id(grantee_token).read().decode('utf-8')) + if 'error' in response_user_id: + print('ERROR directive.process.authorization.user_id:', response_user_id['error_description']) + user_id = response_user_id['user_id'] + print('LOG directive.process.authorization.user_id:', user_id) + + # Get the Access and Refresh Tokens + api_auth = ApiAuth() + response_token = api_auth.get_access_token(grant_code, client_id, client_secret, redirect_uri) + response_token_string = response_token.read().decode('utf-8') + print('LOG directive.process.authorization.response_token_string:', response_token_string) + response_object = json.loads(response_token_string) + + # Store the retrieved from the Authorization Server + access_token = response_object['access_token'] + refresh_token = response_object['refresh_token'] + token_type = response_object['token_type'] + expires_in = response_object['expires_in'] + + # Calculate expiration + expiration_utc = datetime.utcnow() + timedelta(seconds=(int(expires_in) - 5)) + + # Store the User Information - This is useful for inspection during development + table = boto3.resource('dynamodb').Table('SampleUsers') + result = table.put_item( + Item={ + 'UserId': user_id, + 'GrantCode': grant_code, + 'GranteeToken': grantee_token, + 'AccessToken': access_token, + 'ClientId': client_id, + 'ClientSecret': client_secret, + 'ExpirationUTC': expiration_utc.strftime("%Y-%m-%dT%H:%M:%S.00Z"), + 'RedirectUri': redirect_uri, + 'RefreshToken': refresh_token, + 'TokenType': token_type + } + ) + + if result['ResponseMetadata']['HTTPStatusCode'] == 200: + print('LOG directive.process.authorization.SampleUsers.put_item:', result) + alexa_accept_grant_response = AlexaResponse(namespace='Alexa.Authorization', name='AcceptGrant.Response') + response = alexa_accept_grant_response.get() + + else: + error_message = 'Error creating User' + print('ERR directive.process.authorization', error_message) + alexa_error_response = AlexaResponse(name='ErrorResponse') + alexa_error_response.set_payload({'type': 'INTERNAL_ERROR', 'message': error_message}) + response = alexa_error_response.get() + + if namespace == "Alexa.Cooking": + name = json_object['directive']['header']['name'] + correlation_token = json_object['directive']['header']['correlationToken'] + token = json_object['directive']['endpoint']['scope']['token'] + endpoint_id = json_object['directive']['endpoint']['endpointId'] + if name == "SetCookingMode": + alexa_error_response = AlexaResponse(endpoint_id=endpoint_id, correlation_token=correlation_token, token=token) + response = alexa_error_response.get() + + if namespace == "Alexa.Discovery": + # Given the Access Token, get the User ID + access_token = json_object['directive']['payload']['scope']['token'] + + # Spot the default from the Alexa.Discovery sample. Use as a default for development. + if access_token == 'access-token-from-skill': + print('WARN directive.process.discovery.user_id: Using development user_id of 0') + user_id = "0" # <- Useful for development + else: + response_user_id = json.loads(ApiAuth.get_user_id(access_token).read().decode('utf-8')) + if 'error' in response_user_id: + print('ERROR directive.process.discovery.user_id: ' + response_user_id['error_description']) + user_id = response_user_id['user_id'] + print('LOG directive.process.discovery.user_id:', user_id) + + alexa_discover_response = AlexaDiscoverResponse(json_object) + + # Get the list of endpoints to return for a User ID and add them to the response + # Use the AWS IoT entries for state but get the discovery details from DynamoDB + # HACK Boto3 1.4.8 attributeName and attributeValue stopped working, raw list_things() works however + # Not Working: list_response = iot_aws.list_things(attributeName='user_id', attributeValue=user_id) + list_response = iot_aws.list_things() + for thing in list_response['things']: + if thing['attributes']['user_id'] == user_id: + endpoint_details = ApiHandlerEndpoint.EndpointDetails() + endpoint_details.id = str(thing['thingName']) # Add attribute endpoint_id to free thingName? + print('LOG directive.process.discovery: Found:', endpoint_details.id, 'for user:', user_id) + result = dynamodb_aws.get_item(TableName='SampleEndpointDetails', + Key={'EndpointId': {'S': endpoint_details.id}}) + capabilities_string = self.get_db_value(result['Item']['Capabilities']) + endpoint_details.capabilities = json.loads(capabilities_string) + endpoint_details.description = self.get_db_value(result['Item']['Description']) + endpoint_details.display_categories = json.loads(self.get_db_value(result['Item']['DisplayCategories'])) + endpoint_details.friendly_name = self.get_db_value(result['Item']['FriendlyName']) + endpoint_details.manufacturer_name = self.get_db_value(result['Item']['ManufacturerName']) + endpoint_details.sku = self.get_db_value(result['Item']['SKU']) + endpoint_details.user_id = self.get_db_value(result['Item']['UserId']) + alexa_discover_response.add_endpoint(endpoint_details) + + response = alexa_discover_response.get_response() + + if namespace == "Alexa.PowerController": + name = json_object['directive']['header']['name'] + correlation_token = None + if 'correlationToken' in json_object['directive']['header']: + correlation_token = json_object['directive']['header']['correlationToken'] + token = json_object['directive']['endpoint']['scope']['token'] + endpoint_id = json_object['directive']['endpoint']['endpointId'] + + response_user_id = json.loads(ApiAuth.get_user_id(token).read().decode('utf-8')) + if 'error' in response_user_id: + print('ERROR directive.process.power_controller.user_id: ' + response_user_id['error_description']) + user_id = response_user_id['user_id'] + print('LOG directive.process.power_controller.user_id', user_id) + + # Convert to a local stored state + power_state_value = 'OFF' if name == "TurnOff" else 'ON' + try: + # Send the state to the Thing + response_update = iot_aws.update_thing( + thingName=endpoint_id, + attributePayload={ + 'attributes': { + 'state': power_state_value, + 'user_id': user_id + } + } + ) + print('LOG directive.process.power_controller.response_update:', response_update) + alexa_power_controller_response = AlexaResponse( + namespace='Alexa.PowerController', + name=name, + token=token, + correlation_token=correlation_token, + endpoint_id=endpoint_id) + response = alexa_power_controller_response.get() + except ClientError as e: + response = AlexaResponse(name='ErrorResponse', message=e).get() + + else: + response = AlexaResponse(name='ErrorResponse').get() + + if response is None: + # response set to None indicates an unhandled directive, review the logs + response = AlexaResponse(name='ErrorResponse', message='Empty Response: No response processed').get() + # else: + # TODO Validate the Response once the schema is updated + # if not self.validate_response(response): + # response = AlexaError(message='Failed to validate message against the schema').get_response() + + # print('LOG directive.process.response', response) + return json.dumps(response) + + @staticmethod + def validate_response(response): + valid = False + try: + with open('alexa_smart_home_message_schema.json', 'r') as schema_file: + json_schema = json.load(schema_file) + validate(response, json_schema) + valid = True + except SchemaError as se: + print('LOG directive.validate_response: Invalid Schema') + except ValidationError as ve: + print('LOG directive.validate_response: Invalid Content for ', response) + + return valid + diff --git a/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_endpoint.py b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_endpoint.py new file mode 100644 index 0000000..c5f93db --- /dev/null +++ b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_endpoint.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- + +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Amazon Software License (the "License"). You may not use this file except in +# compliance with the License. A copy of the License is located at +# +# http://aws.amazon.com/asl/ +# +# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +import boto3 +import json +import uuid + +from botocore.exceptions import ClientError + +dynamodb_aws = boto3.client('dynamodb') +iot_aws = boto3.client('iot') + + +class ApiHandlerEndpoint: + class EndpointDetails: + def __init__(self): + self.capabilities = '' + self.description = '' + self.display_categories = '' + self.friendly_name = '' + self.id = str(uuid.uuid4()) # Generate an ID for the endpoint + self.manufacturer_name = '' + self.sku = 'OT00' + self.user_id = 0 + + def dump(self): + print('EndpointDetails:') + print('capabilities:', self.capabilities) + print('description:', self.description) + print('display_categories:', self.display_categories) + print('friendly_name:', self.friendly_name) + print('id:', self.id) + print('manufacturer_name:', self.manufacturer_name) + print('sku:', self.sku) + print('user_id:', self.user_id) + + def create(self, request): + try: + endpoint_details = self.EndpointDetails() + + # Map our incoming API body to a thing that will virtually represent a discoverable device for Alexa + json_object = json.loads(request['body']) + endpoint_details.user_id = json_object['event']['endpoint']['userId'] # Expect a Profile + endpoint_details.friendly_name = json_object['event']['endpoint']['friendlyName'] + endpoint_details.capabilities = json_object['event']['endpoint']['capabilities'] # Capabilities JSON + endpoint_details.sku = json_object['event']['endpoint']['sku'] # A custom endpoint type, ex: SW01 + endpoint_details.description = json_object['event']['endpoint']['description'] + endpoint_details.manufacturer_name = json_object['event']['endpoint']['manufacturerName'] + endpoint_details.display_categories = json_object['event']['endpoint']['displayCategories'] + + # print("LOG endpoint.create.endpoint_details:", endpoint_details.dump()) + + # Create the thing in both DynamoDb and AWS IoT + response = self.create_thing_details(endpoint_details) + # TODO Check response + response = self.create_thing(endpoint_details) + # TODO Check response + + return response + + except KeyError as key_error: + return "KeyError: " + str(key_error) + + def create_thing(self, endpoint_details): + + # Create the ThingType if missing + thing_type_name = self.create_thing_type(endpoint_details.sku) + + # TODO Set the Default State + attribute_payload = { + 'attributes': { + 'user_id': endpoint_details.user_id + } + } + + try: + response = iot_aws.create_thing( + thingName=endpoint_details.id, + thingTypeName=thing_type_name, + attributePayload=attribute_payload + ) + print('LOG endpoint.create.create_thing ' + str(response)) + return response + + except ClientError as e: + if e.response['Error']['Code'] == 'ResourceAlreadyExistsException': + print('WARN iot resource already exists, trying update') + response = iot_aws.update_thing( + thingName=endpoint_details.id, + thingTypeName=thing_type_name, + attributePayload=attribute_payload + ) + return response + + except Exception as e: + print(e) + + def create_thing_details(self, endpoint_details): + dynamodb_aws_resource = boto3.resource('dynamodb') + table = dynamodb_aws_resource.Table('SampleEndpointDetails') + response = table.update_item( + Key={ + 'EndpointId': endpoint_details.id + }, + UpdateExpression='SET \ + Capabilities = :capabilities, \ + Description = :description, \ + DisplayCategories = :display_categories, \ + FriendlyName = :friendly_name, \ + ManufacturerName = :manufacturer_name, \ + SKU = :sku, \ + UserId = :user_id', + ExpressionAttributeValues={ + ':capabilities': str(json.dumps(endpoint_details.capabilities)), + ':description': str(endpoint_details.description), + ':display_categories': str(json.dumps(endpoint_details.display_categories)), + ':friendly_name': str(endpoint_details.friendly_name), + ':manufacturer_name': str(endpoint_details.manufacturer_name), + ':sku': str(endpoint_details.sku), + ':user_id': str(endpoint_details.user_id) + + } + ) + print('LOG endpoint.create_thing_details ' + str(response)) + return response + + def create_thing_group(self, thing_group_name): + response = iot_aws.create_thing_group( + thingGroupName=thing_group_name + ) + print('LOG endpoint.create_thing_group ' + str(response)) + + def create_thing_type(self, sku): + # Set the default at OTHER (OT00) + thing_type_name = 'SampleOther' + thing_type_description = 'A sample Other endpoint' + + if sku.upper().startswith('LI'): + thing_type_name = 'SampleLight' + thing_type_description = 'A sample light endpoint' + + if sku.upper().startswith('MW'): + thing_type_name = 'SampleMicrowave' + thing_type_description = 'A sample microwave endpoint' + + if sku.upper().startswith('SW'): + thing_type_name = 'SampleSwitch' + thing_type_description = 'A sample switch endpoint' + + response = iot_aws.create_thing_type( + thingTypeName=thing_type_name, + thingTypeProperties={ + 'thingTypeDescription': thing_type_description + } + ) + print('LOG endpoint.create_thing_type ' + str(response)) + return thing_type_name + + def delete(self, request): + try: + response = {} + print(request) + json_object = json.loads(request['body']) + endpoint_ids = [] + delete_all_sample_endpoints = False + for endpoint_id in json_object: + # Special Case for * - If any match, delete all + if endpoint_id == '*': + delete_all_sample_endpoints = True + break + endpoint_ids.append(endpoint_id) + + if delete_all_sample_endpoints is True: + self.delete_samples() + response = {'message': 'Deleted all sample endpoints'} + + for endpoint_id in endpoint_ids: + iot_aws.delete_thing(thingName=endpoint_id) + response = dynamodb_aws.delete_item(TableName='SampleEndpointDetails', Key={'EndpointId': endpoint_id}) + + return response + + except KeyError as key_error: + return "KeyError: " + str(key_error) + + def delete_samples(self): + table = boto3.resource('dynamodb').Table('SampleEndpointDetails') + result = table.scan() + items = result['Items'] + for item in items: + endpoint_id = item['EndpointId'] + self.delete_thing(endpoint_id) + + def delete_thing(self, endpoint_id): + # TODO Improve response handling + # Delete from DynamoDB + response = dynamodb_aws.delete_item( + TableName='SampleEndpointDetails', + Key={'EndpointId': {'S': endpoint_id}} + ) + print(response) + + # Delete from AWS IoT + response = iot_aws.delete_thing( + thingName=endpoint_id + ) + print(response) + + def read(self, request): + try: + response = {} + resource = request['resource'] + if resource == '/endpoints': + list_response = iot_aws.list_things() + status = list_response['ResponseMetadata']['HTTPStatusCode'] + if 200 <= int(status) < 300: + things = list_response['things'] + response = [] + for thing in things: + response.append(thing) + else: + path_parameters = request['pathParameters'] + endpoint_name = path_parameters['endpoint_name'] + response = iot_aws.describe_thing(thingName=endpoint_name) + + print('LOG endpoint.read ' + str(response)) + return response + + except KeyError as key_error: + return "KeyError: " + str(key_error) + diff --git a/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_event.py b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_event.py new file mode 100644 index 0000000..e87780c --- /dev/null +++ b/sample_backend/lambda/lambda_api/python/endpoint_cloud/api_handler_event.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Amazon Software License (the "License"). You may not use this file except in +# compliance with the License. A copy of the License is located at +# +# http://aws.amazon.com/asl/ +# +# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific +# language governing permissions and limitations under the License. + +import boto3 +import http.client +import json + +from .api_auth import ApiAuth +from alexa.skills.smarthome import AlexaResponse +from botocore.exceptions import ClientError +from datetime import datetime, timedelta + +dynamodb_aws = boto3.client('dynamodb') +iot_aws = boto3.client('iot') + + +class ApiHandlerEvent: + def create(self, request): + print("LOG event.create.request:", request) + + try: + json_object = json.loads(request['body']) + endpoint_user_id = json_object['event']['endpoint']['userId'] # Expect a Profile + endpoint_name = json_object['event']['endpoint']['id'] # Expect a valid AWS IoT Thing Name + endpoint_state = json_object['event']['endpoint']['state'] # Expect a state value, ex: ON or OFF + sku = json_object['event']['endpoint']['sku'] # Expect a meaningful type, ex: SW00 + + try: + # Update the IoT Thing + response = iot_aws.update_thing( + thingName=endpoint_name, + attributePayload={ + 'attributes': { + 'state': endpoint_state, + 'proactively_reported': 'True', + 'user_id': endpoint_user_id + } + } + ) + print('LOG event.create.iot_aws.update_thing.response:', str(response)) + + # Update Alexa with a Proactive State Update + if endpoint_user_id == 0: + print('LOG PSU: Not sent for user_id of 0') + else: + response_psu = self.send_psu(endpoint_user_id, endpoint_name, endpoint_state) + print('LOG PSU response:', response_psu) + + except ClientError as e: + alexa_response = AlexaResponse(name='ErrorResponse', message=e) + alexa_response.set_payload( + { + 'type': 'INTERNAL_ERROR', + 'message': e + } + ) + response = alexa_response.get() + + return response + + except KeyError as key_error: + return "KeyError: " + str(key_error) + + def is_token_expired(self, expiration_utc): + now = datetime.utcnow().replace(tzinfo=None) + then = datetime.strptime(expiration_utc, "%Y-%m-%dT%H:%M:%S.00Z") + is_expired = now > then + if is_expired: + return is_expired + seconds = (now - then).seconds + is_soon = seconds < 30 # Give a 30 second buffer for expiration + return is_soon + + def send_psu(self, endpoint_user_id, endpoint_id, endpoint_state): + + # Get the User Information + table = boto3.resource('dynamodb').Table('SampleUsers') + result = table.get_item( + Key={ + 'UserId': endpoint_user_id + }, + AttributesToGet=[ + 'UserId', + 'AccessToken', + 'ClientId', + 'ClientSecret', + 'ExpirationUTC', + 'RedirectUri', + 'RefreshToken', + 'TokenType' + ] + ) + + if result['ResponseMetadata']['HTTPStatusCode'] == 200: + print('LOG event.create.send_psu.SampleUsers.get_item:', str(result)) + + if 'Item' in result: + expiration_utc = result['Item']['ExpirationUTC'] + token_is_expired = self.is_token_expired(expiration_utc) + print('LOG event.create.send_psu.token_is_expired:', token_is_expired) + if token_is_expired: + # The token has expired so get a new access token using the refresh token + refresh_token = result['Item']['RefreshToken'] + client_id = result['Item']['ClientId'] + client_secret = result['Item']['ClientSecret'] + redirect_uri = result['Item']['RedirectUri'] + + api_auth = ApiAuth() + response_refresh_token = api_auth.refresh_access_token(refresh_token, client_id, client_secret, + redirect_uri) + response_refresh_token_string = response_refresh_token.read().decode('utf-8') + response_refresh_token_object = json.loads(response_refresh_token_string) + + # Store the new values from the refresh + access_token = response_refresh_token_object['access_token'] + refresh_token = response_refresh_token_object['refresh_token'] + token_type = response_refresh_token_object['token_type'] + expires_in = response_refresh_token_object['expires_in'] + + # Calculate expiration + expiration_utc = datetime.utcnow() + timedelta(seconds=(int(expires_in) - 5)) + + print('access_token', access_token) + print('expiration_utc', expiration_utc) + + result = table.update_item( + Key={ + 'UserId': endpoint_user_id + }, + UpdateExpression="set AccessToken=:a, RefreshToken=:r, TokenType=:t, ExpirationUTC=:e", + ExpressionAttributeValues={ + ':a': access_token, + ':r': refresh_token, + ':t': token_type, + ':e': expiration_utc.strftime("%Y-%m-%dT%H:%M:%S.00Z") + }, + ReturnValues="UPDATED_NEW" + ) + print('LOG event.create.send_psu.SampleUsers.update_item:', str(result)) + + # TODO Return an error here if the token could not be refreshed + else: + # Use the stored access token + access_token = result['Item']['AccessToken'] + print('LOG Using stored access_token:', access_token) + + alexa_changereport_response = AlexaResponse(name='ChangeReport', endpoint_id=endpoint_id, token=access_token) + alexa_changereport_response.set_payload( + { + 'change': { + 'cause': { + 'type': 'PHYSICAL_INTERACTION' + }, + "properties": [ + alexa_changereport_response.create_property(namespace='Alexa.PowerController', name='powerState', value='ON') + ] + } + } + ) + payload = json.dumps(alexa_changereport_response.get()) + print('LOG AlexaChangeReport.get_response:', payload) + + # TODO Map to correct endpoint for Europe: https://api.eu.amazonalexa.com/v3/events + # TODO Map to correct endpoint for Far East: https://api.fe.amazonalexa.com/v3/events + alexa_event_gateway_uri = 'api.amazonalexa.com' + connection = http.client.HTTPSConnection(alexa_event_gateway_uri) + headers = { + 'content-type': "application/json;charset=UTF-8", + 'cache-control': "no-cache" + } + connection.request('POST', '/v3/events', payload, headers) + code = connection.getresponse().getcode() + return 'LOG PSU HTTP Status code: ' + str(code) diff --git a/sample_backend/lambda/lambda_api/python/index.py b/sample_backend/lambda/lambda_api/python/index.py index cbaa175..c257918 100644 --- a/sample_backend/lambda/lambda_api/python/index.py +++ b/sample_backend/lambda/lambda_api/python/index.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Amazon Software License (the "License"). You may not use this file except in # compliance with the License. A copy of the License is located at @@ -13,7 +13,7 @@ import json import os -from endpoint_cloud import ApiAuth, ApiHandler, ApiResponse, ApiResponseBody +from endpoint_cloud import ApiHandler, ApiResponse, ApiResponseBody def get_api_url(api_id, aws_region, resource): @@ -88,6 +88,12 @@ def handler(request, context): api_response.statusCode = 200 api_response.body = json.dumps(response) + # DELETE endpoints : Delete an Endpoint + if http_method == 'DELETE' and resource == '/endpoints': + response = api_handler.endpoint.delete(request) + api_response.statusCode = 200 + api_response.body = json.dumps(response) + # POST to event : Create an Event - This will be used to trigger a Proactive State Update if http_method == 'POST' and resource == '/events': response = api_handler.event.create(request)