From 54775cc80a24895ae9894e1f153c09a5f8aff1ca Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Wed, 30 Apr 2025 13:08:33 -0700 Subject: [PATCH 01/26] Checkpoint pre-codegen --- awsiot/__init__.py | 68 ++++++++++++- awsiot/iotidentity.py | 44 ++++++++ awsiot/iotjobs.py | 56 +++++++++++ awsiot/iotshadow.py | 227 ++++++++++++++++++++++++++++++++++++++++++ samples/shadowv2.py | 150 ++++++++++++++++++++++++++++ 5 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 samples/shadowv2.py diff --git a/awsiot/__init__.py b/awsiot/__init__.py index 8cd0f3f7..db6d0895 100644 --- a/awsiot/__init__.py +++ b/awsiot/__init__.py @@ -11,10 +11,12 @@ 'mqtt5_client_builder', ] -from awscrt import mqtt, mqtt5 +from awscrt import mqtt, mqtt5, mqtt_request_response from concurrent.futures import Future +from dataclasses import dataclass import json -from typing import Any, Callable, Dict, Optional, Tuple, TypeVar +from typing import Any, Callable, Dict, Generic, Optional, Tuple, TypeVar + __version__ = '1.0.0-dev' @@ -192,3 +194,65 @@ def __repr__(self): self.__class__.__module__, self.__class__.__name__, ', '.join(properties)) + + +class V2ServiceException(Exception): + + def __init__(self, message: str, inner_error: 'Optional[Exception]', modeled_error: 'Optional[Any]'): + self.message = message + self.inner_error = inner_error + self.modeled_error = modeled_error + +def create_v2_service_modeled_future(internal_unmodeled_future : Future, operation_name : str, accepted_topic : str, response_class, modeled_error_class): + modeled_future = Future() + + # force a strong ref to the hidden/internal unmodeled future so that it can't be GCed prior to completion + modeled_future.unmodeled_future = internal_unmodeled_future + + def complete_modeled_future(unmodeled_future): + if unmodeled_future.exception(): + service_error = V2ServiceException(f"{operation_name} failure", unmodeled_future.exception(), None) + modeled_future.set_exception(service_error) + else: + unmodeled_result = unmodeled_future.result() + try: + payload_as_json = json.loads(unmodeled_result.payload.decode()) + if unmodeled_result.topic == accepted_topic: + modeled_future.set_result(response_class.from_payload(payload_as_json)) + else: + modeled_error = modeled_error_class.from_payload(payload_as_json) + modeled_future.set_exception(V2ServiceException(f"{operation_name} failure", None, modeled_error)) + except Exception as e: + modeled_future.set_exception(V2ServiceException(f"{operation_name} failure", e, None)) + + internal_unmodeled_future.add_done_callback(lambda f: complete_modeled_future(f)) + + return modeled_future + +class V2DeserializationFailure(Exception): + def __init__(self, message: str, inner_error: 'Optional[Exception]', payload: Optional[bytes]): + self.message = message + self.inner_error = inner_error + self.payload = payload + +@dataclass +class ServiceStreamOptions(Generic[T]): + """ + Configuration options for an MQTT-based service streaming operation. + + Args: + incoming_event_listener (Callable[[T], None]): function object to invoke when a stream message is successfully deserialized + subscription_status_listener (Optional[mqtt_request_response.SubscriptionStatusListener]): function object to invoke when the operation's subscription status changes + deserialization_failure_listener (Optional[Callable[[V2DeserializationFailure], None]]): function object to invoke when a publish is received on the streaming subscription that cannot be deserialized into the stream's output type. Should never happen. + """ + incoming_event_listener: 'Callable[[T], None]' + subscription_status_listener: 'Optional[mqtt_request_response.SubscriptionStatusListener]' = None + deserialization_failure_listener: 'Optional[Callable[[V2DeserializationFailure], None]]' = None + + def validate(self): + """ + Stringently type-checks an instance's field values. + """ + assert callable(self.incoming_event_listener) + assert callable(self.subscription_status_listener) or self.subscription_status_listener is None + assert callable(self.deserialization_failure_listener) or self.deserialization_failure_listener is None diff --git a/awsiot/iotidentity.py b/awsiot/iotidentity.py index 3b79b7a3..d36f3f84 100644 --- a/awsiot/iotidentity.py +++ b/awsiot/iotidentity.py @@ -598,3 +598,47 @@ def __init__(self, *args, **kwargs): for key, val in zip(['template_name'], args): setattr(self, key, val) +class V2ErrorResponse(awsiot.ModeledClass): + """ + + Response document containing details about a failed request. + + All attributes are None by default, and may be set by keyword in the constructor. + + Keyword Args: + error_code (str): Response error code + error_message (str): Response error message + status_code (int): Response status code + + Attributes: + error_code (str): Response error code + error_message (str): Response error message + status_code (int): Response status code + """ + + __slots__ = ['error_code', 'error_message', 'status_code'] + + def __init__(self, *args, **kwargs): + self.error_code = kwargs.get('error_code') + self.error_message = kwargs.get('error_message') + self.status_code = kwargs.get('status_code') + + # for backwards compatibility, read any arguments that used to be accepted by position + for key, val in zip([], args): + setattr(self, key, val) + + @classmethod + def from_payload(cls, payload): + # type: (typing.Dict[str, typing.Any]) -> V2ErrorResponse + new = cls() + val = payload.get('errorCode') + if val is not None: + new.error_code = val + val = payload.get('errorMessage') + if val is not None: + new.error_message = val + val = payload.get('statusCode') + if val is not None: + new.status_code = val + return new + diff --git a/awsiot/iotjobs.py b/awsiot/iotjobs.py index 2c16eb91..1554c205 100644 --- a/awsiot/iotjobs.py +++ b/awsiot/iotjobs.py @@ -1316,6 +1316,62 @@ def __init__(self, *args, **kwargs): for key, val in zip(['job_id', 'thing_name'], args): setattr(self, key, val) +class V2ErrorResponse(awsiot.ModeledClass): + """ + + Response document containing details about a failed request. + + All attributes are None by default, and may be set by keyword in the constructor. + + Keyword Args: + client_token (str): Opaque token that can correlate this response to the original request. + code (str): Indicates the type of error. + execution_state (JobExecutionState): A JobExecutionState object. This field is included only when the code field has the value InvalidStateTransition or VersionMismatch. + message (str): A text message that provides additional information. + timestamp (datetime.datetime): The date and time the response was generated by AWS IoT. + + Attributes: + client_token (str): Opaque token that can correlate this response to the original request. + code (str): Indicates the type of error. + execution_state (JobExecutionState): A JobExecutionState object. This field is included only when the code field has the value InvalidStateTransition or VersionMismatch. + message (str): A text message that provides additional information. + timestamp (datetime.datetime): The date and time the response was generated by AWS IoT. + """ + + __slots__ = ['client_token', 'code', 'execution_state', 'message', 'timestamp'] + + def __init__(self, *args, **kwargs): + self.client_token = kwargs.get('client_token') + self.code = kwargs.get('code') + self.execution_state = kwargs.get('execution_state') + self.message = kwargs.get('message') + self.timestamp = kwargs.get('timestamp') + + # for backwards compatibility, read any arguments that used to be accepted by position + for key, val in zip([], args): + setattr(self, key, val) + + @classmethod + def from_payload(cls, payload): + # type: (typing.Dict[str, typing.Any]) -> V2ErrorResponse + new = cls() + val = payload.get('clientToken') + if val is not None: + new.client_token = val + val = payload.get('code') + if val is not None: + new.code = val + val = payload.get('executionState') + if val is not None: + new.execution_state = JobExecutionState.from_payload(val) + val = payload.get('message') + if val is not None: + new.message = val + val = payload.get('timestamp') + if val is not None: + new.timestamp = datetime.datetime.fromtimestamp(val) + return new + class JobStatus: """ diff --git a/awsiot/iotshadow.py b/awsiot/iotshadow.py index d33888eb..587ea140 100644 --- a/awsiot/iotshadow.py +++ b/awsiot/iotshadow.py @@ -3,10 +3,16 @@ # This file is generated +from awscrt import mqtt, mqtt5, mqtt_request_response, exceptions import awsiot import concurrent.futures import datetime +import json import typing +from uuid import uuid4 + +import pdb + class IotShadowClient(awsiot.MqttServiceClient): """ @@ -828,6 +834,10 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload + def validate(self): + assert isinstance(self.thing_name, str) + + class DeleteShadowResponse(awsiot.ModeledClass): """ @@ -1040,6 +1050,10 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload + def validate(self): + assert isinstance(self.thing_name, str) + + class GetShadowResponse(awsiot.ModeledClass): """ @@ -1250,6 +1264,9 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def validate(self): + assert isinstance(self.thing_name, str) + class ShadowMetadata(awsiot.ModeledClass): """ @@ -1505,6 +1522,10 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def validate(self): + assert isinstance(self.thing_name, str) + + class UpdateNamedShadowRequest(awsiot.ModeledClass): """ @@ -1620,6 +1641,9 @@ def to_payload(self): payload['version'] = self.version return payload + def validate(self): + assert isinstance(self.thing_name, str) + class UpdateShadowResponse(awsiot.ModeledClass): """ @@ -1699,3 +1723,206 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) +class V2ErrorResponse(awsiot.ModeledClass): + """ + + Response document containing details about a failed request. + + All attributes are None by default, and may be set by keyword in the constructor. + + Keyword Args: + client_token (str): Opaque request-response correlation data. Present only if a client token was used in the request. + code (int): An HTTP response code that indicates the type of error. + message (str): A text message that provides additional information. + timestamp (datetime.datetime): The date and time the response was generated by AWS IoT. This property is not present in all error response documents. + + Attributes: + client_token (str): Opaque request-response correlation data. Present only if a client token was used in the request. + code (int): An HTTP response code that indicates the type of error. + message (str): A text message that provides additional information. + timestamp (datetime.datetime): The date and time the response was generated by AWS IoT. This property is not present in all error response documents. + """ + + __slots__ = ['client_token', 'code', 'message', 'timestamp'] + + def __init__(self, *args, **kwargs): + self.client_token = kwargs.get('client_token') + self.code = kwargs.get('code') + self.message = kwargs.get('message') + self.timestamp = kwargs.get('timestamp') + + # for backwards compatibility, read any arguments that used to be accepted by position + for key, val in zip([], args): + setattr(self, key, val) + + @classmethod + def from_payload(cls, payload): + # type: (typing.Dict[str, typing.Any]) -> V2ErrorResponse + new = cls() + val = payload.get('clientToken') + if val is not None: + new.client_token = val + val = payload.get('code') + if val is not None: + new.code = val + val = payload.get('message') + if val is not None: + new.message = val + val = payload.get('timestamp') + if val is not None: + new.timestamp = datetime.datetime.fromtimestamp(val) + return new + + +class IotShadowClientV2: + + def __init__(self, protocol_client: mqtt.Connection or mqtt5.Client, options: mqtt_request_response.ClientOptions): + self._rr_client = mqtt_request_response.Client(protocol_client, options) + + def get_shadow(self, request: GetShadowRequest): + request.validate() + + thing_name = request.thing_name + topic_prefix = f"$aws/things/{thing_name}/shadow/get" + accepted_topic = topic_prefix + "/accepted" + rejected_topic = topic_prefix + "/rejected" + subscription1 = topic_prefix + "/+" + + correlation_token = str(uuid4()) + request.client_token = correlation_token + + request_options = mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription1 + ], + response_paths = [ + mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = topic_prefix, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "get_shadow", accepted_topic, GetShadowResponse, V2ErrorResponse) + + def delete_shadow(self, request: DeleteShadowRequest): + request.validate() + + thing_name = request.thing_name + topic_prefix = f"$aws/things/{thing_name}/shadow/delete" + accepted_topic = topic_prefix + "/accepted" + rejected_topic = topic_prefix + "/rejected" + subscription1 = topic_prefix + "/+" + + correlation_token = str(uuid4()) + request.client_token = correlation_token + + request_options = mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription1 + ], + response_paths = [ + mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = topic_prefix, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "delete_shadow", accepted_topic, DeleteShadowResponse, V2ErrorResponse) + + def update_shadow(self, request: UpdateShadowRequest): + request.validate() + + thing_name = request.thing_name + topic_prefix = f"$aws/things/{thing_name}/shadow/update" + accepted_topic = topic_prefix + "/accepted" + rejected_topic = topic_prefix + "/rejected" + subscription1 = accepted_topic + subscription2 = rejected_topic + + correlation_token = str(uuid4()) + request.client_token = correlation_token + + request_options = mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription1, + subscription2 + ], + response_paths = [ + mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = topic_prefix, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "update_shadow", accepted_topic, UpdateShadowResponse, V2ErrorResponse) + + def create_shadow_delta_updated_stream(self, config : ShadowDeltaUpdatedSubscriptionRequest, stream_options: awsiot.ServiceStreamOptions[ShadowDeltaUpdatedEvent]): + config.validate() + stream_options.validate() + + subscription_topic = f"$aws/things/{config.thing_name}/shadow/update/delta" + + def modeled_event_callback(unmodeled_event : mqtt_request_response.IncomingPublishEvent): + try: + payload_as_json = json.loads(unmodeled_event.payload.decode()) + modeled_event = ShadowDeltaUpdatedEvent.from_payload(payload_as_json) + stream_options.incoming_event_listener(modeled_event) + except Exception as e: + if stream_options.deserialization_failure_listener is not None: + failure_event = awsiot.V2DeserializationFailure(f"shadow_delta_updated stream deserialization failure", e, unmodeled_event.payload) + stream_options.deserialization_failure_listener(failure_event) + + unmodeled_options = mqtt_request_response.StreamingOperationOptions(subscription_topic, stream_options.subscription_status_listener, modeled_event_callback) + + return self._rr_client.create_stream(unmodeled_options) + + def create_shadow_updated_stream(self, config : ShadowUpdatedSubscriptionRequest, stream_options: awsiot.ServiceStreamOptions[ShadowUpdatedEvent]): + config.validate() + stream_options.validate() + + subscription_topic = f"$aws/things/{config.thing_name}/shadow/update/documents" + + def modeled_event_callback(unmodeled_event : mqtt_request_response.IncomingPublishEvent): + try: + payload_as_json = json.loads(unmodeled_event.payload.decode()) + modeled_event = ShadowUpdatedEvent.from_payload(payload_as_json) + stream_options.incoming_event_listener(modeled_event) + except Exception as e: + if stream_options.deserialization_failure_listener is not None: + failure_event = awsiot.V2DeserializationFailure(f"shadow_updated stream deserialization failure", e, unmodeled_event.payload) + stream_options.deserialization_failure_listener(failure_event) + + unmodeled_options = mqtt_request_response.StreamingOperationOptions(subscription_topic, stream_options.subscription_status_listener, modeled_event_callback) + + return self._rr_client.create_stream(unmodeled_options) + diff --git a/samples/shadowv2.py b/samples/shadowv2.py new file mode 100644 index 00000000..dfeb89ac --- /dev/null +++ b/samples/shadowv2.py @@ -0,0 +1,150 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +from awscrt import mqtt5, mqtt_request_response, io +from awsiot import iotshadow, mqtt5_client_builder +from concurrent.futures import Future +from dataclasses import dataclass +from typing import Optional +import awsiot +import argparse +import json +import sys + + +@dataclass +class SampleContext: + shadow_client: 'iotshadow.IotShadowClientV2' + thing: 'str' + updated_stream: 'Optional[mqtt_request_response.StreamingOperation]' = None + delta_updated_stream: 'Optional[mqtt_request_response.StreamingOperation]' = None + +def print_help(): + print("Shadow Sandbox\n") + print("Commands:") + print(" get - gets the current value of the IoT thing's shadow") + print(" delete - deletes the IoT thing's shadow") + print(" update-desired - updates the desired state of the IoT thing's shadow. If the shadow does not exist, it will be created.") + print(" update-reported - updates the reported state of the IoT thing's shadow. If the shadow does not exist, it will be created.") + print(" quit - quits the sample application\n") + pass + +def handle_get(context : SampleContext): + request = iotshadow.GetShadowRequest(thing_name = context.thing) + response = context.shadow_client.get_shadow(request).result() + print(f"get response:\n {response}\n") + pass + +def handle_delete(context : SampleContext): + request = iotshadow.DeleteShadowRequest(thing_name = context.thing) + response = context.shadow_client.delete_shadow(request).result() + print(f"delete response:\n {response}\n") + +def handle_update_desired(context : SampleContext, line: str): + request = iotshadow.UpdateShadowRequest(thing_name=context.thing) + request.state = iotshadow.ShadowState(desired=json.loads(line.strip())) + + response = context.shadow_client.update_shadow(request).result() + print(f"update-desired response:\n {response}\n") + +def handle_update_reported(context : SampleContext, line: str): + request = iotshadow.UpdateShadowRequest(thing_name=context.thing) + request.state = iotshadow.ShadowState(reported=json.loads(line.strip())) + + response = context.shadow_client.update_shadow(request).result() + print(f"update-reported response:\n {response}\n") + +def handle_input(context : SampleContext, line: str): + words = line.strip().split(" ", 1) + command = words[0] + + if command == "quit": + return True + elif command == "get": + handle_get(context) + elif command == "delete": + handle_delete(context) + elif command == "update-desired": + handle_update_desired(context, words[1]) + elif command == "update-reported": + handle_update_reported(context, words[1]) + else: + print_help() + + return False + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description="AWS IoT Shadow sandbox application") + parser.add_argument('--endpoint', required=True, help="AWS IoT endpoint to connect to") + parser.add_argument('--cert', required=True, + help="Path to the certificate file to use during mTLS connection establishment") + parser.add_argument('--key', required=True, + help="Path to the private key file to use during mTLS connection establishment") + parser.add_argument('--thing', required=True, + help="Name of the IoT thing to interact with") + + args = parser.parse_args() + + initial_connection_success = Future() + def on_lifecycle_connection_success(event: mqtt5.LifecycleConnectSuccessData): + initial_connection_success.set_result(True) + + def on_lifecycle_connection_failure(event: mqtt5.LifecycleConnectFailureData): + initial_connection_success.set_exception(Exception("Failed to connect")) + + stopped = Future() + def on_lifecycle_stopped(event: mqtt5.LifecycleStoppedData): + stopped.set_result(True) + + # Create a mqtt5 connection from the command line data + mqtt5_client = mqtt5_client_builder.mtls_from_path( + endpoint=args.endpoint, + port=8883, + cert_filepath=args.cert, + pri_key_filepath=args.key, + clean_session=True, + keep_alive_secs=1200, + on_lifecycle_connection_success=on_lifecycle_connection_success, + on_lifecycle_stopped=on_lifecycle_stopped) + + mqtt5_client.start() + + rr_options = mqtt_request_response.ClientOptions( + max_request_response_subscriptions = 2, + max_streaming_subscriptions = 2, + operation_timeout_in_seconds = 30, + ) + shadow_client = iotshadow.IotShadowClientV2(mqtt5_client, rr_options) + + initial_connection_success.result() + print("Connected!") + + def shadow_updated_callback(event: iotshadow.ShadowUpdatedEvent): + print(f"Received ShadowUpdatedEvent: \n {event}\n") + + updated_stream = shadow_client.create_shadow_updated_stream(iotshadow.ShadowUpdatedSubscriptionRequest(thing_name=args.thing), awsiot.ServiceStreamOptions(shadow_updated_callback)) + updated_stream.open() + + def shadow_delta_updated_callback(event: iotshadow.ShadowDeltaUpdatedEvent): + print(f"Received ShadowDeltaUpdatedEvent: \n {event}\n") + + delta_updated_stream = shadow_client.create_shadow_delta_updated_stream(iotshadow.ShadowDeltaUpdatedSubscriptionRequest(thing_name=args.thing), awsiot.ServiceStreamOptions(shadow_delta_updated_callback)) + delta_updated_stream.open() + + context = SampleContext(shadow_client, args.thing, updated_stream, delta_updated_stream) + + for line in sys.stdin: + try: + if handle_input(context, line): + break + + except Exception as e: + print(f"Exception: {e}\n") + + mqtt5_client.stop() + stopped.result() + print("Stopped!") + + + From 8f8d16a950d4677636b8710605d257b55c523423 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Fri, 2 May 2025 10:30:31 -0700 Subject: [PATCH 02/26] Checkpoint --- awsiot/__init__.py | 13 +++++++++++++ awsiot/iotshadow.py | 36 +++++++----------------------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/awsiot/__init__.py b/awsiot/__init__.py index db6d0895..5294b835 100644 --- a/awsiot/__init__.py +++ b/awsiot/__init__.py @@ -256,3 +256,16 @@ def validate(self): assert callable(self.incoming_event_listener) assert callable(self.subscription_status_listener) or self.subscription_status_listener is None assert callable(self.deserialization_failure_listener) or self.deserialization_failure_listener is None + +def create_streaming_unmodeled_options(stream_options: ServiceStreamOptions[T], subscription_topic: str, event_name: str, event_class): + def modeled_event_callback(unmodeled_event : mqtt_request_response.IncomingPublishEvent): + try: + payload_as_json = json.loads(unmodeled_event.payload.decode()) + modeled_event = event_class.from_payload(payload_as_json) + stream_options.incoming_event_listener(modeled_event) + except Exception as e: + if stream_options.deserialization_failure_listener is not None: + failure_event = V2DeserializationFailure(f"{event_name} stream deserialization failure", e, unmodeled_event.payload) + stream_options.deserialization_failure_listener(failure_event) + + return mqtt_request_response.StreamingOperationOptions(subscription_topic, stream_options.subscription_status_listener, modeled_event_callback) \ No newline at end of file diff --git a/awsiot/iotshadow.py b/awsiot/iotshadow.py index 587ea140..07173a94 100644 --- a/awsiot/iotshadow.py +++ b/awsiot/iotshadow.py @@ -3,15 +3,13 @@ # This file is generated -from awscrt import mqtt, mqtt5, mqtt_request_response, exceptions +from awscrt import mqtt, mqtt5, mqtt_request_response import awsiot import concurrent.futures import datetime import json import typing -from uuid import uuid4 - -import pdb +import uuid class IotShadowClient(awsiot.MqttServiceClient): @@ -1788,7 +1786,7 @@ def get_shadow(self, request: GetShadowRequest): rejected_topic = topic_prefix + "/rejected" subscription1 = topic_prefix + "/+" - correlation_token = str(uuid4()) + correlation_token = str(uuid.uuid4()) request.client_token = correlation_token request_options = mqtt_request_response.RequestOptions( @@ -1823,7 +1821,7 @@ def delete_shadow(self, request: DeleteShadowRequest): rejected_topic = topic_prefix + "/rejected" subscription1 = topic_prefix + "/+" - correlation_token = str(uuid4()) + correlation_token = str(uuid.uuid4()) request.client_token = correlation_token request_options = mqtt_request_response.RequestOptions( @@ -1859,7 +1857,7 @@ def update_shadow(self, request: UpdateShadowRequest): subscription1 = accepted_topic subscription2 = rejected_topic - correlation_token = str(uuid4()) + correlation_token = str(uuid.uuid4()) request.client_token = correlation_token request_options = mqtt_request_response.RequestOptions( @@ -1892,17 +1890,7 @@ def create_shadow_delta_updated_stream(self, config : ShadowDeltaUpdatedSubscrip subscription_topic = f"$aws/things/{config.thing_name}/shadow/update/delta" - def modeled_event_callback(unmodeled_event : mqtt_request_response.IncomingPublishEvent): - try: - payload_as_json = json.loads(unmodeled_event.payload.decode()) - modeled_event = ShadowDeltaUpdatedEvent.from_payload(payload_as_json) - stream_options.incoming_event_listener(modeled_event) - except Exception as e: - if stream_options.deserialization_failure_listener is not None: - failure_event = awsiot.V2DeserializationFailure(f"shadow_delta_updated stream deserialization failure", e, unmodeled_event.payload) - stream_options.deserialization_failure_listener(failure_event) - - unmodeled_options = mqtt_request_response.StreamingOperationOptions(subscription_topic, stream_options.subscription_status_listener, modeled_event_callback) + unmodeled_options = awsiot.create_streaming_unmodeled_options(stream_options, subscription_topic, "ShadowDeltaUpdatedEvent", ShadowDeltaUpdatedEvent) return self._rr_client.create_stream(unmodeled_options) @@ -1912,17 +1900,7 @@ def create_shadow_updated_stream(self, config : ShadowUpdatedSubscriptionRequest subscription_topic = f"$aws/things/{config.thing_name}/shadow/update/documents" - def modeled_event_callback(unmodeled_event : mqtt_request_response.IncomingPublishEvent): - try: - payload_as_json = json.loads(unmodeled_event.payload.decode()) - modeled_event = ShadowUpdatedEvent.from_payload(payload_as_json) - stream_options.incoming_event_listener(modeled_event) - except Exception as e: - if stream_options.deserialization_failure_listener is not None: - failure_event = awsiot.V2DeserializationFailure(f"shadow_updated stream deserialization failure", e, unmodeled_event.payload) - stream_options.deserialization_failure_listener(failure_event) - - unmodeled_options = mqtt_request_response.StreamingOperationOptions(subscription_topic, stream_options.subscription_status_listener, modeled_event_callback) + unmodeled_options = awsiot.create_streaming_unmodeled_options(stream_options, subscription_topic, "ShadowUpdatedEvent", ShadowUpdatedEvent) return self._rr_client.create_stream(unmodeled_options) From d0ca844f598f8dd8f7d5516880c2f5c5428805e7 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Tue, 6 May 2025 07:37:13 -0700 Subject: [PATCH 03/26] Identity testing part1 --- awsiot/__init__.py | 2 +- awsiot/iotidentity.py | 202 ++++++++++++++- awsiot/iotshadow.py | 591 ++++++++++++++++++++++++++++++++---------- test/test_identity.py | 272 +++++++++++++++++++ test/test_shadow.py | 366 ++++++++++++++++++++++++++ 5 files changed, 1291 insertions(+), 142 deletions(-) create mode 100644 test/test_identity.py create mode 100644 test/test_shadow.py diff --git a/awsiot/__init__.py b/awsiot/__init__.py index 5294b835..0a7217e4 100644 --- a/awsiot/__init__.py +++ b/awsiot/__init__.py @@ -249,7 +249,7 @@ class ServiceStreamOptions(Generic[T]): subscription_status_listener: 'Optional[mqtt_request_response.SubscriptionStatusListener]' = None deserialization_failure_listener: 'Optional[Callable[[V2DeserializationFailure], None]]' = None - def validate(self): + def _validate(self): """ Stringently type-checks an instance's field values. """ diff --git a/awsiot/iotidentity.py b/awsiot/iotidentity.py index d36f3f84..c60b69f9 100644 --- a/awsiot/iotidentity.py +++ b/awsiot/iotidentity.py @@ -3,8 +3,10 @@ # This file is generated +import awscrt import awsiot import concurrent.futures +import json import typing class IotIdentityClient(awsiot.MqttServiceClient): @@ -33,6 +35,7 @@ def publish_create_certificate_from_csr(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ + request._validate() return self._publish_operation( topic='$aws/certificates/create-from-csr/json', @@ -56,11 +59,12 @@ def publish_create_keys_and_certificate(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ + request._validate() return self._publish_operation( topic='$aws/certificates/create/json', qos=qos, - payload=None) + payload=request.to_payload()) def publish_register_thing(self, request, qos): # type: (RegisterThingRequest, int) -> concurrent.futures.Future @@ -79,8 +83,7 @@ def publish_register_thing(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.template_name: - raise ValueError("request.template_name is required") + request._validate() return self._publish_operation( topic='$aws/provisioning-templates/{0.template_name}/provision/json'.format(request), @@ -109,6 +112,7 @@ def subscribe_to_create_certificate_from_csr_accepted(self, request, qos, callba to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -141,6 +145,7 @@ def subscribe_to_create_certificate_from_csr_rejected(self, request, qos, callba to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -173,6 +178,7 @@ def subscribe_to_create_keys_and_certificate_accepted(self, request, qos, callba to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -205,6 +211,7 @@ def subscribe_to_create_keys_and_certificate_rejected(self, request, qos, callba to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -237,8 +244,7 @@ def subscribe_to_register_thing_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.template_name: - raise ValueError("request.template_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -271,8 +277,7 @@ def subscribe_to_register_thing_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.template_name: - raise ValueError("request.template_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -313,6 +318,9 @@ def to_payload(self): payload['certificateSigningRequest'] = self.certificate_signing_request return payload + def _validate(self): + return + class CreateCertificateFromCsrResponse(awsiot.ModeledClass): """ @@ -357,6 +365,9 @@ def from_payload(cls, payload): new.certificate_pem = val return new + def _validate(self): + return + class CreateCertificateFromCsrSubscriptionRequest(awsiot.ModeledClass): """ @@ -373,6 +384,9 @@ def __init__(self, *args, **kwargs): for key, val in zip([], args): setattr(self, key, val) + def _validate(self): + return + class CreateKeysAndCertificateRequest(awsiot.ModeledClass): """ @@ -389,6 +403,14 @@ def __init__(self, *args, **kwargs): for key, val in zip([], args): setattr(self, key, val) + def to_payload(self): + # type: () -> typing.Dict[str, typing.Any] + payload = {} # type: typing.Dict[str, typing.Any] + return payload + + def _validate(self): + return + class CreateKeysAndCertificateResponse(awsiot.ModeledClass): """ @@ -439,6 +461,9 @@ def from_payload(cls, payload): new.private_key = val return new + def _validate(self): + return + class CreateKeysAndCertificateSubscriptionRequest(awsiot.ModeledClass): """ @@ -455,6 +480,9 @@ def __init__(self, *args, **kwargs): for key, val in zip([], args): setattr(self, key, val) + def _validate(self): + return + class ErrorResponse(awsiot.ModeledClass): """ @@ -499,6 +527,9 @@ def from_payload(cls, payload): new.status_code = val return new + def _validate(self): + return + class RegisterThingRequest(awsiot.ModeledClass): """ @@ -537,6 +568,11 @@ def to_payload(self): payload['parameters'] = self.parameters return payload + def _validate(self): + if not self.template_name: + raise ValueError("template_name is required") + return + class RegisterThingResponse(awsiot.ModeledClass): """ @@ -575,6 +611,9 @@ def from_payload(cls, payload): new.thing_name = val return new + def _validate(self): + return + class RegisterThingSubscriptionRequest(awsiot.ModeledClass): """ @@ -598,6 +637,11 @@ def __init__(self, *args, **kwargs): for key, val in zip(['template_name'], args): setattr(self, key, val) + def _validate(self): + if not self.template_name: + raise ValueError("template_name is required") + return + class V2ErrorResponse(awsiot.ModeledClass): """ @@ -642,3 +686,147 @@ def from_payload(cls, payload): new.status_code = val return new + def _validate(self): + return + +class IotIdentityClientV2: + """ + + An AWS IoT service that assists with provisioning a device and installing unique client certificates on it + + AWS Docs: https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html + + """ + + def __init__(self, protocol_client: awscrt.mqtt.Connection or awscrt.mqtt5.Client, options: awscrt.mqtt_request_response.ClientOptions): + self._rr_client = awscrt.mqtt_request_response.Client(protocol_client, options) + + def create_certificate_from_csr(self, request : CreateCertificateFromCsrRequest) -> concurrent.futures.Future : + """ + + Creates a certificate from a certificate signing request (CSR). AWS IoT provides client certificates that are signed by the Amazon Root certificate authority (CA). The new certificate has a PENDING_ACTIVATION status. When you call RegisterThing to provision a thing with this certificate, the certificate status changes to ACTIVE or INACTIVE as described in the template. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html#fleet-provision-api + + Args: + request: `CreateCertificateFromCsrRequest` instance. + + Returns: + A Future whose result will be an instance of `CreateCertificateFromCsrResponse`. + """ + request._validate() + + publish_topic = '$aws/certificates/create-from-csr/json' + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/certificates/create-from-csr/json/accepted'; + subscription1 = '$aws/certificates/create-from-csr/json/rejected'; + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + subscription1, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "create_certificate_from_csr", accepted_topic, CreateCertificateFromCsrResponse, V2ErrorResponse) + + def create_keys_and_certificate(self, request : CreateKeysAndCertificateRequest) -> concurrent.futures.Future : + """ + + Creates new keys and a certificate. AWS IoT provides client certificates that are signed by the Amazon Root certificate authority (CA). The new certificate has a PENDING_ACTIVATION status. When you call RegisterThing to provision a thing with this certificate, the certificate status changes to ACTIVE or INACTIVE as described in the template. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html#fleet-provision-api + + Args: + request: `CreateKeysAndCertificateRequest` instance. + + Returns: + A Future whose result will be an instance of `CreateKeysAndCertificateResponse`. + """ + request._validate() + + publish_topic = '$aws/certificates/create/json' + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/certificates/create/json/accepted'; + subscription1 = '$aws/certificates/create/json/rejected'; + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + subscription1, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "create_keys_and_certificate", accepted_topic, CreateKeysAndCertificateResponse, V2ErrorResponse) + + def register_thing(self, request : RegisterThingRequest) -> concurrent.futures.Future : + """ + + Provisions an AWS IoT thing using a pre-defined template. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html#fleet-provision-api + + Args: + request: `RegisterThingRequest` instance. + + Returns: + A Future whose result will be an instance of `RegisterThingResponse`. + """ + request._validate() + + publish_topic = '$aws/provisioning-templates/{0.template_name}/provision/json'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/provisioning-templates/{0.template_name}/provision/json/accepted'.format(request); + subscription1 = '$aws/provisioning-templates/{0.template_name}/provision/json/rejected'.format(request); + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + subscription1, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "register_thing", accepted_topic, RegisterThingResponse, V2ErrorResponse) + diff --git a/awsiot/iotshadow.py b/awsiot/iotshadow.py index 07173a94..7d7a850f 100644 --- a/awsiot/iotshadow.py +++ b/awsiot/iotshadow.py @@ -3,7 +3,7 @@ # This file is generated -from awscrt import mqtt, mqtt5, mqtt_request_response +import awscrt import awsiot import concurrent.futures import datetime @@ -11,7 +11,6 @@ import typing import uuid - class IotShadowClient(awsiot.MqttServiceClient): """ @@ -38,10 +37,7 @@ def publish_delete_named_shadow(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/delete'.format(request), @@ -65,8 +61,7 @@ def publish_delete_shadow(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/shadow/delete'.format(request), @@ -90,10 +85,7 @@ def publish_get_named_shadow(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/get'.format(request), @@ -117,8 +109,7 @@ def publish_get_shadow(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/shadow/get'.format(request), @@ -142,10 +133,7 @@ def publish_update_named_shadow(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/update'.format(request), @@ -169,8 +157,7 @@ def publish_update_shadow(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/shadow/update'.format(request), @@ -199,10 +186,7 @@ def subscribe_to_delete_named_shadow_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -235,10 +219,7 @@ def subscribe_to_delete_named_shadow_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -271,8 +252,7 @@ def subscribe_to_delete_shadow_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -305,8 +285,7 @@ def subscribe_to_delete_shadow_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -339,10 +318,7 @@ def subscribe_to_get_named_shadow_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -375,10 +351,7 @@ def subscribe_to_get_named_shadow_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -411,8 +384,7 @@ def subscribe_to_get_shadow_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -445,8 +417,7 @@ def subscribe_to_get_shadow_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -479,10 +450,7 @@ def subscribe_to_named_shadow_delta_updated_events(self, request, qos, callback) to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -515,10 +483,7 @@ def subscribe_to_named_shadow_updated_events(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -551,8 +516,7 @@ def subscribe_to_shadow_delta_updated_events(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -585,8 +549,7 @@ def subscribe_to_shadow_updated_events(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -619,10 +582,7 @@ def subscribe_to_update_named_shadow_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -655,10 +615,7 @@ def subscribe_to_update_named_shadow_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.shadow_name: - raise ValueError("request.shadow_name is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -691,8 +648,7 @@ def subscribe_to_update_shadow_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -725,8 +681,7 @@ def subscribe_to_update_shadow_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -773,6 +728,13 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class DeleteNamedShadowSubscriptionRequest(awsiot.ModeledClass): """ @@ -799,6 +761,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['shadow_name', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class DeleteShadowRequest(awsiot.ModeledClass): """ @@ -832,9 +801,10 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload - def validate(self): - assert isinstance(self.thing_name, str) - + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return class DeleteShadowResponse(awsiot.ModeledClass): """ @@ -880,6 +850,9 @@ def from_payload(cls, payload): new.version = val return new + def _validate(self): + return + class DeleteShadowSubscriptionRequest(awsiot.ModeledClass): """ @@ -903,6 +876,11 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class ErrorResponse(awsiot.ModeledClass): """ @@ -953,6 +931,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class GetNamedShadowRequest(awsiot.ModeledClass): """ @@ -989,6 +970,13 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class GetNamedShadowSubscriptionRequest(awsiot.ModeledClass): """ @@ -1015,6 +1003,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['shadow_name', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class GetShadowRequest(awsiot.ModeledClass): """ @@ -1048,9 +1043,10 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload - def validate(self): - assert isinstance(self.thing_name, str) - + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return class GetShadowResponse(awsiot.ModeledClass): """ @@ -1108,6 +1104,9 @@ def from_payload(cls, payload): new.version = val return new + def _validate(self): + return + class GetShadowSubscriptionRequest(awsiot.ModeledClass): """ @@ -1131,6 +1130,11 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class NamedShadowDeltaUpdatedSubscriptionRequest(awsiot.ModeledClass): """ @@ -1157,6 +1161,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['shadow_name', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class NamedShadowUpdatedSubscriptionRequest(awsiot.ModeledClass): """ @@ -1183,6 +1194,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['shadow_name', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class ShadowDeltaUpdatedEvent(awsiot.ModeledClass): """ @@ -1239,6 +1257,9 @@ def from_payload(cls, payload): new.version = val return new + def _validate(self): + return + class ShadowDeltaUpdatedSubscriptionRequest(awsiot.ModeledClass): """ @@ -1262,8 +1283,10 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) - def validate(self): - assert isinstance(self.thing_name, str) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return class ShadowMetadata(awsiot.ModeledClass): """ @@ -1303,6 +1326,9 @@ def from_payload(cls, payload): new.reported = val return new + def _validate(self): + return + class ShadowState(awsiot.ModeledClass): """ @@ -1365,6 +1391,9 @@ def to_payload(self): payload['reported'] = self.reported return payload + def _validate(self): + return + class ShadowStateWithDelta(awsiot.ModeledClass): """ @@ -1409,6 +1438,9 @@ def from_payload(cls, payload): new.reported = val return new + def _validate(self): + return + class ShadowUpdatedEvent(awsiot.ModeledClass): """ @@ -1453,6 +1485,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class ShadowUpdatedSnapshot(awsiot.ModeledClass): """ @@ -1497,6 +1532,9 @@ def from_payload(cls, payload): new.version = val return new + def _validate(self): + return + class ShadowUpdatedSubscriptionRequest(awsiot.ModeledClass): """ @@ -1520,9 +1558,10 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) - def validate(self): - assert isinstance(self.thing_name, str) - + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return class UpdateNamedShadowRequest(awsiot.ModeledClass): """ @@ -1570,6 +1609,13 @@ def to_payload(self): payload['version'] = self.version return payload + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class UpdateNamedShadowSubscriptionRequest(awsiot.ModeledClass): """ @@ -1596,6 +1642,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['shadow_name', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.shadow_name: + raise ValueError("shadow_name is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class UpdateShadowRequest(awsiot.ModeledClass): """ @@ -1639,8 +1692,10 @@ def to_payload(self): payload['version'] = self.version return payload - def validate(self): - assert isinstance(self.thing_name, str) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return class UpdateShadowResponse(awsiot.ModeledClass): """ @@ -1698,6 +1753,9 @@ def from_payload(cls, payload): new.version = val return new + def _validate(self): + return + class UpdateShadowSubscriptionRequest(awsiot.ModeledClass): """ @@ -1721,6 +1779,11 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class V2ErrorResponse(awsiot.ModeledClass): """ @@ -1771,136 +1834,396 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return class IotShadowClientV2: + """ - def __init__(self, protocol_client: mqtt.Connection or mqtt5.Client, options: mqtt_request_response.ClientOptions): - self._rr_client = mqtt_request_response.Client(protocol_client, options) + The AWS IoT Device Shadow service adds shadows to AWS IoT thing objects. Shadows are a simple data store for device properties and state. Shadows can make a device’s state available to apps and other services whether the device is connected to AWS IoT or not. - def get_shadow(self, request: GetShadowRequest): - request.validate() + AWS Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html - thing_name = request.thing_name - topic_prefix = f"$aws/things/{thing_name}/shadow/get" - accepted_topic = topic_prefix + "/accepted" - rejected_topic = topic_prefix + "/rejected" - subscription1 = topic_prefix + "/+" + """ + + def __init__(self, protocol_client: awscrt.mqtt.Connection or awscrt.mqtt5.Client, options: awscrt.mqtt_request_response.ClientOptions): + self._rr_client = awscrt.mqtt_request_response.Client(protocol_client, options) + + def delete_named_shadow(self, request : DeleteNamedShadowRequest) -> concurrent.futures.Future : + """ + + Deletes a named shadow for an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#delete-pub-sub-topic + + Args: + request: `DeleteNamedShadowRequest` instance. + + Returns: + A Future whose result will be an instance of `DeleteShadowResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/delete'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/delete/+'.format(request); correlation_token = str(uuid.uuid4()) request.client_token = correlation_token - request_options = mqtt_request_response.RequestOptions( + request_options = awscrt.mqtt_request_response.RequestOptions( subscription_topic_filters = [ - subscription1 + subscription0, ], response_paths = [ - mqtt_request_response.ResponsePath( + awscrt.mqtt_request_response.ResponsePath( accepted_topic, "clientToken" ), - mqtt_request_response.ResponsePath( + awscrt.mqtt_request_response.ResponsePath( rejected_topic, "clientToken" ) ], - publish_topic = topic_prefix, + publish_topic = publish_topic, payload = json.dumps(request.to_payload()).encode(), - correlation_token = correlation_token + correlation_token = correlation_token, ) internal_unmodeled_future = self._rr_client.make_request(request_options) - return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "get_shadow", accepted_topic, GetShadowResponse, V2ErrorResponse) + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "delete_named_shadow", accepted_topic, DeleteShadowResponse, V2ErrorResponse) + + def delete_shadow(self, request : DeleteShadowRequest) -> concurrent.futures.Future : + """ + + Deletes the (classic) shadow for an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#delete-pub-sub-topic + + Args: + request: `DeleteShadowRequest` instance. + + Returns: + A Future whose result will be an instance of `DeleteShadowResponse`. + """ + request._validate() - def delete_shadow(self, request: DeleteShadowRequest): - request.validate() + publish_topic = '$aws/things/{0.thing_name}/shadow/delete'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; - thing_name = request.thing_name - topic_prefix = f"$aws/things/{thing_name}/shadow/delete" - accepted_topic = topic_prefix + "/accepted" - rejected_topic = topic_prefix + "/rejected" - subscription1 = topic_prefix + "/+" + subscription0 = '$aws/things/{0.thing_name}/shadow/delete/+'.format(request); correlation_token = str(uuid.uuid4()) request.client_token = correlation_token - request_options = mqtt_request_response.RequestOptions( + request_options = awscrt.mqtt_request_response.RequestOptions( subscription_topic_filters = [ - subscription1 + subscription0, ], response_paths = [ - mqtt_request_response.ResponsePath( + awscrt.mqtt_request_response.ResponsePath( accepted_topic, "clientToken" ), - mqtt_request_response.ResponsePath( + awscrt.mqtt_request_response.ResponsePath( rejected_topic, "clientToken" ) ], - publish_topic = topic_prefix, + publish_topic = publish_topic, payload = json.dumps(request.to_payload()).encode(), - correlation_token = correlation_token + correlation_token = correlation_token, ) internal_unmodeled_future = self._rr_client.make_request(request_options) return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "delete_shadow", accepted_topic, DeleteShadowResponse, V2ErrorResponse) - def update_shadow(self, request: UpdateShadowRequest): - request.validate() + def get_named_shadow(self, request : GetNamedShadowRequest) -> concurrent.futures.Future : + """ + + Gets a named shadow for an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#get-pub-sub-topic + + Args: + request: `GetNamedShadowRequest` instance. + + Returns: + A Future whose result will be an instance of `GetShadowResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/get'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/get/+'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "get_named_shadow", accepted_topic, GetShadowResponse, V2ErrorResponse) + + def get_shadow(self, request : GetShadowRequest) -> concurrent.futures.Future : + """ + + Gets the (classic) shadow for an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#get-pub-sub-topic + + Args: + request: `GetShadowRequest` instance. + + Returns: + A Future whose result will be an instance of `GetShadowResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/shadow/get'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/shadow/get/+'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "get_shadow", accepted_topic, GetShadowResponse, V2ErrorResponse) + + def update_named_shadow(self, request : UpdateNamedShadowRequest) -> concurrent.futures.Future : + """ + + Update a named shadow for a device. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#update-pub-sub-topic + + Args: + request: `UpdateNamedShadowRequest` instance. + + Returns: + A Future whose result will be an instance of `UpdateShadowResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/update'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/update/accepted'.format(request); + subscription1 = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/update/rejected'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + subscription1, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "update_named_shadow", accepted_topic, UpdateShadowResponse, V2ErrorResponse) + + def update_shadow(self, request : UpdateShadowRequest) -> concurrent.futures.Future : + """ + + Update a device's (classic) shadow. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#update-pub-sub-topic + + Args: + request: `UpdateShadowRequest` instance. + + Returns: + A Future whose result will be an instance of `UpdateShadowResponse`. + """ + request._validate() - thing_name = request.thing_name - topic_prefix = f"$aws/things/{thing_name}/shadow/update" - accepted_topic = topic_prefix + "/accepted" - rejected_topic = topic_prefix + "/rejected" - subscription1 = accepted_topic - subscription2 = rejected_topic + publish_topic = '$aws/things/{0.thing_name}/shadow/update'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/shadow/update/accepted'.format(request); + subscription1 = '$aws/things/{0.thing_name}/shadow/update/rejected'.format(request); correlation_token = str(uuid.uuid4()) request.client_token = correlation_token - request_options = mqtt_request_response.RequestOptions( + request_options = awscrt.mqtt_request_response.RequestOptions( subscription_topic_filters = [ + subscription0, subscription1, - subscription2 ], response_paths = [ - mqtt_request_response.ResponsePath( + awscrt.mqtt_request_response.ResponsePath( accepted_topic, "clientToken" ), - mqtt_request_response.ResponsePath( + awscrt.mqtt_request_response.ResponsePath( rejected_topic, "clientToken" ) ], - publish_topic = topic_prefix, + publish_topic = publish_topic, payload = json.dumps(request.to_payload()).encode(), - correlation_token = correlation_token + correlation_token = correlation_token, ) internal_unmodeled_future = self._rr_client.make_request(request_options) return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "update_shadow", accepted_topic, UpdateShadowResponse, V2ErrorResponse) - def create_shadow_delta_updated_stream(self, config : ShadowDeltaUpdatedSubscriptionRequest, stream_options: awsiot.ServiceStreamOptions[ShadowDeltaUpdatedEvent]): - config.validate() - stream_options.validate() + def create_named_shadow_delta_updated_stream(self, request : NamedShadowDeltaUpdatedSubscriptionRequest, options: awsiot.ServiceStreamOptions[ShadowDeltaUpdatedEvent]): + """ + + Create a stream for NamedShadowDelta events for a named shadow of an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#update-delta-pub-sub-topic + + Args: + request: `NamedShadowDeltaUpdatedSubscriptionRequest` instance. + options: callbacks to invoke for streaming operation events + + Returns: + An instance of `awscrt.mqtt_request_response.StreamingOperation` + """ + request._validate() + options._validate() - subscription_topic = f"$aws/things/{config.thing_name}/shadow/update/delta" + subscription_topic_filter = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/update/delta'.format(request) - unmodeled_options = awsiot.create_streaming_unmodeled_options(stream_options, subscription_topic, "ShadowDeltaUpdatedEvent", ShadowDeltaUpdatedEvent) + unmodeled_options = awsiot.create_streaming_unmodeled_options(options, subscription_topic_filter, "ShadowDeltaUpdatedEvent", ShadowDeltaUpdatedEvent) return self._rr_client.create_stream(unmodeled_options) - def create_shadow_updated_stream(self, config : ShadowUpdatedSubscriptionRequest, stream_options: awsiot.ServiceStreamOptions[ShadowUpdatedEvent]): - config.validate() - stream_options.validate() + def create_named_shadow_updated_stream(self, request : NamedShadowUpdatedSubscriptionRequest, options: awsiot.ServiceStreamOptions[ShadowUpdatedEvent]): + """ + + Create a stream for ShadowUpdated events for a named shadow of an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#update-documents-pub-sub-topic + + Args: + request: `NamedShadowUpdatedSubscriptionRequest` instance. + options: callbacks to invoke for streaming operation events + + Returns: + An instance of `awscrt.mqtt_request_response.StreamingOperation` + """ + request._validate() + options._validate() + + subscription_topic_filter = '$aws/things/{0.thing_name}/shadow/name/{0.shadow_name}/update/documents'.format(request) + + unmodeled_options = awsiot.create_streaming_unmodeled_options(options, subscription_topic_filter, "ShadowUpdatedEvent", ShadowUpdatedEvent) + + return self._rr_client.create_stream(unmodeled_options) + + def create_shadow_delta_updated_stream(self, request : ShadowDeltaUpdatedSubscriptionRequest, options: awsiot.ServiceStreamOptions[ShadowDeltaUpdatedEvent]): + """ + + Create a stream for ShadowDelta events for the (classic) shadow of an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#update-delta-pub-sub-topic + + Args: + request: `ShadowDeltaUpdatedSubscriptionRequest` instance. + options: callbacks to invoke for streaming operation events + + Returns: + An instance of `awscrt.mqtt_request_response.StreamingOperation` + """ + request._validate() + options._validate() + + subscription_topic_filter = '$aws/things/{0.thing_name}/shadow/update/delta'.format(request) + + unmodeled_options = awsiot.create_streaming_unmodeled_options(options, subscription_topic_filter, "ShadowDeltaUpdatedEvent", ShadowDeltaUpdatedEvent) + + return self._rr_client.create_stream(unmodeled_options) + + def create_shadow_updated_stream(self, request : ShadowUpdatedSubscriptionRequest, options: awsiot.ServiceStreamOptions[ShadowUpdatedEvent]): + """ + + Create a stream for ShadowUpdated events for the (classic) shadow of an AWS IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-mqtt.html#update-documents-pub-sub-topic + + Args: + request: `ShadowUpdatedSubscriptionRequest` instance. + options: callbacks to invoke for streaming operation events + + Returns: + An instance of `awscrt.mqtt_request_response.StreamingOperation` + """ + request._validate() + options._validate() - subscription_topic = f"$aws/things/{config.thing_name}/shadow/update/documents" + subscription_topic_filter = '$aws/things/{0.thing_name}/shadow/update/documents'.format(request) - unmodeled_options = awsiot.create_streaming_unmodeled_options(stream_options, subscription_topic, "ShadowUpdatedEvent", ShadowUpdatedEvent) + unmodeled_options = awsiot.create_streaming_unmodeled_options(options, subscription_topic_filter, "ShadowUpdatedEvent", ShadowUpdatedEvent) return self._rr_client.create_stream(unmodeled_options) diff --git a/test/test_identity.py b/test/test_identity.py new file mode 100644 index 00000000..6d0402f4 --- /dev/null +++ b/test/test_identity.py @@ -0,0 +1,272 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. +import pdb + +from awscrt import io, mqtt, mqtt5, mqtt_request_response +import awsiot +from awsiot import iotidentity + +import boto3 +from concurrent.futures import Future +import os +import time +import unittest +import uuid + +TIMEOUT = 30.0 + + +def create_client_id(): + return f"test-{uuid.uuid4().hex}" + + +def _get_env_variable(env_name): + env_data = os.environ.get(env_name) + if not env_data: + raise unittest.SkipTest(f"test requires env var: {env_name}") + return env_data + + +class IdentityTestCallbacks(): + def __init__(self): + self.future_connection_success = Future() + self.future_stopped = Future() + + def ws_handshake_transform(self, transform_args): + transform_args.set_done() + + def on_publish_received(self, publish_received_data: mqtt5.PublishReceivedData): + pass + + def on_lifecycle_stopped(self, lifecycle_stopped: mqtt5.LifecycleStoppedData): + if self.future_stopped: + self.future_stopped.set_result(None) + + def on_lifecycle_attempting_connect(self, lifecycle_attempting_connect: mqtt5.LifecycleAttemptingConnectData): + pass + + def on_lifecycle_connection_success(self, lifecycle_connection_success: mqtt5.LifecycleConnectSuccessData): + if self.future_connection_success: + self.future_connection_success.set_result(lifecycle_connection_success) + + def on_lifecycle_connection_failure(self, lifecycle_connection_failure: mqtt5.LifecycleConnectFailureData): + if self.future_connection_success: + if self.future_connection_success.done(): + pass + else: + self.future_connection_success.set_exception(lifecycle_connection_failure.exception) + + def on_lifecycle_disconnection(self, lifecycle_disconnect_data: mqtt5.LifecycleDisconnectData): + pass + +class TestContext(): + def __init__(self): + self.region = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_REGION") + self.csr_path = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_CSR_PATH") + self.provisioning_template_name = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_TEMPLATE_NAME") + self.thing_name = None + self.certificate_id = None + +class IdentityServiceTest(unittest.TestCase): + + def _create_protocol_client5(self): + + input_host_name = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_HOST") + input_cert = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_CERTIFICATE_PATH") + input_key = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_KEY_PATH") + + client_options = mqtt5.ClientOptions( + host_name=input_host_name, + port=8883 + ) + + tls_ctx_options = io.TlsContextOptions.create_client_with_mtls_from_path( + input_cert, + input_key + ) + client_options.tls_ctx = io.ClientTlsContext(tls_ctx_options) + + client_options.connect_options = mqtt5.ConnectPacket() + client_options.connect_options.client_id = create_client_id() + + callbacks = IdentityTestCallbacks() + client_options.on_lifecycle_event_stopped_fn = callbacks.on_lifecycle_stopped + client_options.on_lifecycle_event_connection_success_fn = callbacks.on_lifecycle_connection_success + client_options.on_lifecycle_event_connection_failure_fn = callbacks.on_lifecycle_connection_failure + client_options.on_lifecycle_event_stopped_fn = callbacks.on_lifecycle_stopped + + protocol_client = mqtt5.Client(client_options) + protocol_client.start() + + callbacks.future_connection_success.result(TIMEOUT) + + return protocol_client, callbacks + + def _shutdown_protocol_client5(self, protocol_client, callbacks): + + protocol_client.stop() + callbacks.future_stopped.result(TIMEOUT) + + def _create_protocol_client311(self): + + input_host_name = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_HOST") + input_cert = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_CERTIFICATE_PATH") + input_key = _get_env_variable("AWS_TEST_IOT_CORE_PROVISIONING_KEY_PATH") + + tls_ctx_options = io.TlsContextOptions.create_client_with_mtls_from_path( + input_cert, + input_key + ) + tls_ctx = io.ClientTlsContext(tls_ctx_options) + + client = mqtt.Client(None, tls_ctx) + + protocol_client = mqtt.Connection( + client=client, + client_id=create_client_id(), + host_name=input_host_name, + port=8883, + ping_timeout_ms=10000, + keep_alive_secs=30 + ) + protocol_client.connect().result(TIMEOUT) + + return protocol_client + + def _shutdown_protocol_client311(self, protocol_client): + protocol_client.disconnect().result(TIMEOUT) + + def _create_identity_client( + self, + protocol_client, + max_request_response_subscriptions, + max_streaming_subscriptions, + operation_timeout_seconds): + rr_client_options = mqtt_request_response.ClientOptions( + max_request_response_subscriptions, max_streaming_subscriptions) + rr_client_options.operation_timeout_in_seconds = operation_timeout_seconds + + identity_client = iotidentity.IotIdentityClientV2(protocol_client, rr_client_options) + + return identity_client + + def _do_identity_creation_test5(self, test_callable): + (protocol_client, callbacks) = self._create_protocol_client5() + + test_callable(protocol_client) + + self._shutdown_protocol_client5(protocol_client, callbacks) + + def _do_identity_creation_test311(self, test_callable): + protocol_client = self._create_protocol_client311() + + test_callable(protocol_client) + + self._shutdown_protocol_client311(protocol_client) + + def _do_identity_operation_test5(self, test_callable): + (protocol_client, callbacks) = self._create_protocol_client5() + identity_client = self._create_identity_client(protocol_client, 2, 2, 30) + + test_callable(identity_client) + + self._shutdown_protocol_client5(protocol_client, callbacks) + + def _do_identity_operation_test311(self, test_callable): + protocol_client = self._create_protocol_client311() + identity_client = self._create_identity_client(protocol_client, 2, 2, 30) + + test_callable(identity_client) + + self._shutdown_protocol_client311(protocol_client) + + def _tear_down(self, test_context): + iot_client = boto3.client('iot',region_name=test_context.region) + certificate_arn = None + if test_context.certificate_id is not None: + describe_response = iot_client.describe_certificate(certificateId=test_context.certificate_id) + certificate_arn = describe_response['certificateDescription']['certificateArn'] + + if test_context.thing_name is not None: + if certificate_arn is not None: + iot_client.detach_thing_principal(thingName=test_context.thing_name, principal=certificate_arn) + time.sleep(1) + + iot_client.delete_thing(thingName=test_context.thing_name) + time.sleep(1) + + if test_context.certificate_id is not None: + iot_client.update_certificate(certificateId=test_context.certificate_id, newStatus='INACTIVE') + + list_policies_response = iot_client.list_attached_policies(target=certificate_arn) + for policy in list_policies_response['policies']: + iot_client.detach_policy(target=certificate_arn, policyName=policy['policyName']) + + time.sleep(1) + iot_client.delete_certificate(certificateId=test_context.certificate_id) + + def _do_basic_provisioning_test(self, identity_client): + test_context = TestContext() + try: + create_response = identity_client.create_keys_and_certificate(iotidentity.CreateKeysAndCertificateRequest()).result(TIMEOUT) + test_context.certificate_id = create_response.certificate_id + + self.assertIsNotNone(create_response.certificate_id) + self.assertIsNotNone(create_response.certificate_pem) + self.assertIsNotNone(create_response.private_key) + self.assertIsNotNone(create_response.certificate_ownership_token) + + register_thing_request = iotidentity.RegisterThingRequest( + template_name=test_context.provisioning_template_name, + certificate_ownership_token=create_response.certificate_ownership_token, + parameters= { + "SerialNumber": uuid.uuid4().hex, + } + ) + + register_thing_response = identity_client.register_thing(register_thing_request).result(TIMEOUT) + test_context.thing_name = register_thing_response.thing_name; + + self.assertIsNotNone(register_thing_response.thing_name) + finally: + self._tear_down(test_context) + + def _do_csr_provisioning_test(self, identity_client): + test_context = TestContext() + try: + pass + finally: + self._tear_down(test_context) + + # ============================================================== + # CREATION SUCCESS TEST CASES + # ============================================================== + def test_client_creation_success5(self): + self._do_identity_creation_test5(lambda protocol_client: self._create_identity_client(protocol_client, 2, 2, 30)) + + def test_client_creation_success311(self): + self._do_identity_creation_test311(lambda protocol_client: self._create_identity_client(protocol_client, 2, 2, 30)) + + # ============================================================== + # CREATION FAILURE TEST CASES + # ============================================================== + def test_client_creation_failure_no_protocol_client(self): + self.assertRaises(Exception, self._create_identity_client, None, 2, 2, 30) + + # ============================================================== + # REQUEST RESPONSE OPERATION TEST CASES + # ============================================================== + def test_basic_provisioning5(self): + self._do_identity_operation_test5(lambda identity_client: self._do_basic_provisioning_test(identity_client)) + + def test_basic_provisioning311(self): + self._do_identity_operation_test311(lambda identity_client: self._do_basic_provisioning_test(identity_client)) + + def test_csr_provisioning5(self): + self._do_identity_operation_test5(lambda identity_client: self._do_csr_provisioning_test(identity_client)) + + def test_csr_provisioning311(self): + self._do_identity_operation_test311(lambda identity_client: self._do_csr_provisioning_test(identity_client)) + +if __name__ == 'main': + unittest.main() diff --git a/test/test_shadow.py b/test/test_shadow.py new file mode 100644 index 00000000..2618321d --- /dev/null +++ b/test/test_shadow.py @@ -0,0 +1,366 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +from awscrt import io, mqtt, mqtt5, mqtt_request_response +import awsiot +from awsiot import iotshadow + +from concurrent.futures import Future +import os +import unittest +import uuid + +TIMEOUT = 30.0 + + +def create_client_id(): + return f"aws-iot-device-sdk-python-v2-shadow-test-{uuid.uuid4()}" + + +def _get_env_variable(env_name): + env_data = os.environ.get(env_name) + if not env_data: + raise unittest.SkipTest(f"test requires env var: {env_name}") + return env_data + + +class ShadowTestCallbacks(): + def __init__(self): + self.future_connection_success = Future() + self.future_stopped = Future() + + def ws_handshake_transform(self, transform_args): + transform_args.set_done() + + def on_publish_received(self, publish_received_data: mqtt5.PublishReceivedData): + pass + + def on_lifecycle_stopped(self, lifecycle_stopped: mqtt5.LifecycleStoppedData): + if self.future_stopped: + self.future_stopped.set_result(None) + + def on_lifecycle_attempting_connect(self, lifecycle_attempting_connect: mqtt5.LifecycleAttemptingConnectData): + pass + + def on_lifecycle_connection_success(self, lifecycle_connection_success: mqtt5.LifecycleConnectSuccessData): + if self.future_connection_success: + self.future_connection_success.set_result(lifecycle_connection_success) + + def on_lifecycle_connection_failure(self, lifecycle_connection_failure: mqtt5.LifecycleConnectFailureData): + if self.future_connection_success: + if self.future_connection_success.done(): + pass + else: + self.future_connection_success.set_exception(lifecycle_connection_failure.exception) + + def on_lifecycle_disconnection(self, lifecycle_disconnect_data: mqtt5.LifecycleDisconnectData): + pass + + +class ShadowServiceTest(unittest.TestCase): + + def _create_protocol_client5(self): + + input_host_name = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_HOST") + input_cert = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_RSA_CERT") + input_key = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_RSA_KEY") + + client_options = mqtt5.ClientOptions( + host_name=input_host_name, + port=8883 + ) + + tls_ctx_options = io.TlsContextOptions.create_client_with_mtls_from_path( + input_cert, + input_key + ) + client_options.tls_ctx = io.ClientTlsContext(tls_ctx_options) + + client_options.connect_options = mqtt5.ConnectPacket() + client_options.connect_options.client_id = create_client_id() + + callbacks = ShadowTestCallbacks() + client_options.on_lifecycle_event_stopped_fn = callbacks.on_lifecycle_stopped + client_options.on_lifecycle_event_connection_success_fn = callbacks.on_lifecycle_connection_success + client_options.on_lifecycle_event_connection_failure_fn = callbacks.on_lifecycle_connection_failure + client_options.on_lifecycle_event_stopped_fn = callbacks.on_lifecycle_stopped + + protocol_client = mqtt5.Client(client_options) + protocol_client.start() + + callbacks.future_connection_success.result(TIMEOUT) + + return protocol_client, callbacks + + def _shutdown_protocol_client5(self, protocol_client, callbacks): + + protocol_client.stop() + callbacks.future_stopped.result(TIMEOUT) + + def _create_protocol_client311(self): + + input_host_name = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_HOST") + input_cert = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_RSA_CERT") + input_key = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_RSA_KEY") + + tls_ctx_options = io.TlsContextOptions.create_client_with_mtls_from_path( + input_cert, + input_key + ) + tls_ctx = io.ClientTlsContext(tls_ctx_options) + + client = mqtt.Client(None, tls_ctx) + + protocol_client = mqtt.Connection( + client=client, + client_id=create_client_id(), + host_name=input_host_name, + port=8883, + ping_timeout_ms=10000, + keep_alive_secs=30 + ) + protocol_client.connect().result(TIMEOUT) + + return protocol_client + + def _shutdown_protocol_client311(self, protocol_client): + protocol_client.disconnect().result(TIMEOUT) + + def _create_shadow_client( + self, + protocol_client, + max_request_response_subscriptions, + max_streaming_subscriptions, + operation_timeout_seconds): + rr_client_options = mqtt_request_response.ClientOptions( + max_request_response_subscriptions, max_streaming_subscriptions) + rr_client_options.operation_timeout_in_seconds = operation_timeout_seconds + + shadow_client = iotshadow.IotShadowClientV2(protocol_client, rr_client_options) + + return shadow_client + + def _do_shadow_test5(self, test_callable): + (protocol_client, callbacks) = self._create_protocol_client5() + + test_callable(protocol_client) + + self._shutdown_protocol_client5(protocol_client, callbacks) + + def _do_shadow_test311(self, test_callable): + protocol_client = self._create_protocol_client311() + + test_callable(protocol_client) + + self._shutdown_protocol_client311(protocol_client) + + def _do_shadow_operation_test5(self, test_callable): + (protocol_client, callbacks) = self._create_protocol_client5() + shadow_client = self._create_shadow_client(protocol_client, 2, 2, 30) + + test_callable(shadow_client) + + self._shutdown_protocol_client5(protocol_client, callbacks) + + def _do_shadow_operation_test311(self, test_callable): + protocol_client = self._create_protocol_client311() + shadow_client = self._create_shadow_client(protocol_client, 2, 2, 30) + + test_callable(shadow_client) + + self._shutdown_protocol_client311(protocol_client) + + def _get_non_existent_named_shadow(self, shadow_client, thing_name, shadow_name): + try: + shadow_client.get_named_shadow(iotshadow.GetNamedShadowRequest( + thing_name=thing_name, + shadow_name=shadow_name, + )).result(TIMEOUT) + self.assertTrue(False) + except Exception as e: + assert isinstance(e, awsiot.V2ServiceException) + assert isinstance(e.modeled_error, iotshadow.V2ErrorResponse) + self.assertEqual(404, e.modeled_error.code) + self.assertIn("No shadow exists with name", e.modeled_error.message) + + def _do_get_non_existent_named_shadow_test(self, shadow_client): + self._get_non_existent_named_shadow(shadow_client, uuid.uuid4(), uuid.uuid4()) + + def _create_named_shadow(self, shadow_client, thing_name, shadow_name, state_document): + request = iotshadow.UpdateNamedShadowRequest( + thing_name=thing_name, + shadow_name=shadow_name, + state=iotshadow.ShadowState( + desired=state_document, + reported=state_document, + ) + ) + + response = shadow_client.update_named_shadow(request).result(TIMEOUT) + self.assertIsNotNone(response) + self.assertIsNotNone(response.state) + self.assertEqual(response.state.desired, state_document) + self.assertEqual(response.state.reported, state_document) + + def _get_named_shadow(self, shadow_client, thing_name, shadow_name, expected_state_document): + request = iotshadow.GetNamedShadowRequest( + thing_name=thing_name, + shadow_name=shadow_name, + ) + + response = shadow_client.get_named_shadow(request).result(TIMEOUT) + self.assertIsNotNone(response) + self.assertIsNotNone(response.state) + self.assertEqual(response.state.desired, expected_state_document) + self.assertEqual(response.state.reported, expected_state_document) + + def _delete_named_shadow(self, shadow_client, thing_name, shadow_name): + request = iotshadow.DeleteNamedShadowRequest( + thing_name=thing_name, + shadow_name=shadow_name, + ) + + response = shadow_client.delete_named_shadow(request).result(TIMEOUT) + self.assertIsNotNone(response) + + def _do_create_get_delete_shadow_test(self, shadow_client): + thing_name = uuid.uuid4() + shadow_name = uuid.uuid4() + document = { + "Color": "Green", + "On": True + } + + self._get_non_existent_named_shadow(shadow_client, thing_name, shadow_name) + + try: + self._create_named_shadow(shadow_client, thing_name, shadow_name, document) + self._get_named_shadow(shadow_client, thing_name, shadow_name, document) + finally: + self._delete_named_shadow(shadow_client, thing_name, shadow_name) + + self._get_non_existent_named_shadow(shadow_client, thing_name, shadow_name) + + def _update_named_shadow_desired(self, shadow_client, thing_name, shadow_name, state_document): + request = iotshadow.UpdateNamedShadowRequest( + thing_name=thing_name, + shadow_name=shadow_name, + state=iotshadow.ShadowState( + desired=state_document, + ) + ) + + response = shadow_client.update_named_shadow(request).result(TIMEOUT) + self.assertIsNotNone(response) + self.assertIsNotNone(response.state) + self.assertEqual(response.state.desired, state_document) + + def _do_update_shadow_test(self, shadow_client): + thing_name = uuid.uuid4() + shadow_name = uuid.uuid4() + document = { + "Color": "Green", + "On": True + } + + self._get_non_existent_named_shadow(shadow_client, thing_name, shadow_name) + + try: + self._create_named_shadow(shadow_client, thing_name, shadow_name, document) + + delta_subscription_future = Future() + delta_future = Future() + delta_event_stream = shadow_client.create_named_shadow_delta_updated_stream( + iotshadow.NamedShadowDeltaUpdatedSubscriptionRequest( + thing_name=thing_name, + shadow_name=shadow_name, + ), + awsiot.ServiceStreamOptions( + subscription_status_listener=lambda event: delta_subscription_future.set_result(event), + incoming_event_listener=lambda event: delta_future.set_result(event), + ) + ) + + delta_event_stream.open() + delta_subscription_event = delta_subscription_future.result(TIMEOUT) + + self.assertEqual(mqtt_request_response.SubscriptionStatusEventType.SUBSCRIPTION_ESTABLISHED, + delta_subscription_event.type) + + update_subscription_future = Future() + update_future = Future() + update_event_stream = shadow_client.create_named_shadow_updated_stream( + iotshadow.NamedShadowUpdatedSubscriptionRequest( + thing_name=thing_name, + shadow_name=shadow_name, + ), + awsiot.ServiceStreamOptions( + subscription_status_listener=lambda event: update_subscription_future.set_result(event), + incoming_event_listener=lambda event: update_future.set_result(event), + ) + ) + + update_event_stream.open() + update_subscription_event = update_subscription_future.result(TIMEOUT) + + self.assertEqual(mqtt_request_response.SubscriptionStatusEventType.SUBSCRIPTION_ESTABLISHED, + update_subscription_event.type) + + new_document = { + "Color": "Red", + "On": False + } + self._update_named_shadow_desired(shadow_client, thing_name, shadow_name, new_document) + + delta_event = delta_future.result(TIMEOUT) + self.assertEqual(new_document, delta_event.state) + + update_event = update_future.result(TIMEOUT) + self.assertEqual(new_document, update_event.current.state.desired) + + finally: + self._delete_named_shadow(shadow_client, thing_name, shadow_name) + + self._get_non_existent_named_shadow(shadow_client, thing_name, shadow_name) + + # ============================================================== + # CREATION SUCCESS TEST CASES + # ============================================================== + + def test_client_creation_success5(self): + self._do_shadow_test5(lambda protocol_client: self._create_shadow_client(protocol_client, 2, 2, 30)) + + def test_client_creation_success311(self): + self._do_shadow_test311(lambda protocol_client: self._create_shadow_client(protocol_client, 2, 2, 30)) + + # ============================================================== + # CREATION FAILURE TEST CASES + # ============================================================== + + def test_client_creation_failure_no_protocol_client(self): + self.assertRaises(Exception, self._create_shadow_client, None, 2, 2, 30) + + # ============================================================== + # REQUEST RESPONSE OPERATION TEST CASES + # ============================================================== + def test_get_non_existent_named_shadow5(self): + self._do_shadow_operation_test5(lambda shadow_client: self._do_get_non_existent_named_shadow_test(shadow_client)) + + def test_get_non_existent_named_shadow311(self): + self._do_shadow_operation_test311(lambda shadow_client: self._do_get_non_existent_named_shadow_test(shadow_client)) + + def test_create_get_delete_shadow5(self): + self._do_shadow_operation_test5(lambda shadow_client: self._do_create_get_delete_shadow_test(shadow_client)) + + def test_create_get_delete_shadow311(self): + self._do_shadow_operation_test311(lambda shadow_client: self._do_create_get_delete_shadow_test(shadow_client)) + + def test_update_shadow5(self): + self._do_shadow_operation_test5(lambda shadow_client: self._do_update_shadow_test(shadow_client)) + + def test_update_shadow311(self): + self._do_shadow_operation_test311(lambda shadow_client: self._do_update_shadow_test(shadow_client)) + + +if __name__ == 'main': + unittest.main() From b9b6f98a07a384df1ac979cf4d9188781587c9b1 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Tue, 6 May 2025 08:15:48 -0700 Subject: [PATCH 04/26] CSR signing --- test/test_identity.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test/test_identity.py b/test/test_identity.py index 6d0402f4..3e7c686e 100644 --- a/test/test_identity.py +++ b/test/test_identity.py @@ -234,7 +234,32 @@ def _do_basic_provisioning_test(self, identity_client): def _do_csr_provisioning_test(self, identity_client): test_context = TestContext() try: - pass + with open(test_context.csr_path, "r") as csr_file: + csr_data = csr_file.read() + + create_response = identity_client.create_certificate_from_csr( + iotidentity.CreateCertificateFromCsrRequest( + certificate_signing_request=csr_data, + )).result(TIMEOUT) + + test_context.certificate_id = create_response.certificate_id + + self.assertIsNotNone(create_response.certificate_id) + self.assertIsNotNone(create_response.certificate_pem) + self.assertIsNotNone(create_response.certificate_ownership_token) + + register_thing_request = iotidentity.RegisterThingRequest( + template_name=test_context.provisioning_template_name, + certificate_ownership_token=create_response.certificate_ownership_token, + parameters= { + "SerialNumber": uuid.uuid4().hex, + } + ) + + register_thing_response = identity_client.register_thing(register_thing_request).result(TIMEOUT) + test_context.thing_name = register_thing_response.thing_name; + + self.assertIsNotNone(register_thing_response.thing_name) finally: self._tear_down(test_context) From 217ff49ad871cb3fc7a8bcd3fd07a55e920ab7c5 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Tue, 6 May 2025 08:24:11 -0700 Subject: [PATCH 05/26] Updated jobs module --- awsiot/iotjobs.py | 402 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 362 insertions(+), 40 deletions(-) diff --git a/awsiot/iotjobs.py b/awsiot/iotjobs.py index 1554c205..011bfb4e 100644 --- a/awsiot/iotjobs.py +++ b/awsiot/iotjobs.py @@ -3,10 +3,13 @@ # This file is generated +import awscrt import awsiot import concurrent.futures import datetime +import json import typing +import uuid class IotJobsClient(awsiot.MqttServiceClient): """ @@ -34,10 +37,7 @@ def publish_describe_job_execution(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.job_id: - raise ValueError("request.job_id is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/jobs/{0.job_id}/get'.format(request), @@ -61,8 +61,7 @@ def publish_get_pending_job_executions(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/jobs/get'.format(request), @@ -86,8 +85,7 @@ def publish_start_next_pending_job_execution(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/jobs/start-next'.format(request), @@ -111,10 +109,7 @@ def publish_update_job_execution(self, request, qos): request is successfully published. The Future's result will be an exception if the request cannot be published. """ - if not request.job_id: - raise ValueError("request.job_id is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() return self._publish_operation( topic='$aws/things/{0.thing_name}/jobs/{0.job_id}/update'.format(request), @@ -143,10 +138,7 @@ def subscribe_to_describe_job_execution_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.job_id: - raise ValueError("request.job_id is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -179,10 +171,7 @@ def subscribe_to_describe_job_execution_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.job_id: - raise ValueError("request.job_id is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -215,8 +204,7 @@ def subscribe_to_get_pending_job_executions_accepted(self, request, qos, callbac to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -249,8 +237,7 @@ def subscribe_to_get_pending_job_executions_rejected(self, request, qos, callbac to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -283,8 +270,7 @@ def subscribe_to_job_executions_changed_events(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -315,8 +301,7 @@ def subscribe_to_next_job_execution_changed_events(self, request, qos, callback) to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -349,8 +334,7 @@ def subscribe_to_start_next_pending_job_execution_accepted(self, request, qos, c to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -383,8 +367,7 @@ def subscribe_to_start_next_pending_job_execution_rejected(self, request, qos, c to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -417,10 +400,7 @@ def subscribe_to_update_job_execution_accepted(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.job_id: - raise ValueError("request.job_id is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -453,10 +433,7 @@ def subscribe_to_update_job_execution_rejected(self, request, qos, callback): to `unsubscribe()` to stop receiving messages. Note that messages may arrive before the subscription is acknowledged. """ - if not request.job_id: - raise ValueError("request.job_id is required") - if not request.thing_name: - raise ValueError("request.thing_name is required") + request._validate() if not callable(callback): raise ValueError("callback is required") @@ -513,6 +490,13 @@ def to_payload(self): payload['includeJobDocument'] = self.include_job_document return payload + def _validate(self): + if not self.job_id: + raise ValueError("job_id is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class DescribeJobExecutionResponse(awsiot.ModeledClass): """ @@ -557,6 +541,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class DescribeJobExecutionSubscriptionRequest(awsiot.ModeledClass): """ @@ -583,6 +570,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['job_id', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.job_id: + raise ValueError("job_id is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class GetPendingJobExecutionsRequest(awsiot.ModeledClass): """ @@ -616,6 +610,11 @@ def to_payload(self): payload['clientToken'] = self.client_token return payload + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class GetPendingJobExecutionsResponse(awsiot.ModeledClass): """ @@ -666,6 +665,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class GetPendingJobExecutionsSubscriptionRequest(awsiot.ModeledClass): """ @@ -689,6 +691,11 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class JobExecutionData(awsiot.ModeledClass): """ @@ -775,6 +782,9 @@ def from_payload(cls, payload): new.version_number = val return new + def _validate(self): + return + class JobExecutionState(awsiot.ModeledClass): """ @@ -819,6 +829,9 @@ def from_payload(cls, payload): new.version_number = val return new + def _validate(self): + return + class JobExecutionSummary(awsiot.ModeledClass): """ @@ -881,6 +894,9 @@ def from_payload(cls, payload): new.version_number = val return new + def _validate(self): + return + class JobExecutionsChangedEvent(awsiot.ModeledClass): """ @@ -919,6 +935,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class JobExecutionsChangedSubscriptionRequest(awsiot.ModeledClass): """ @@ -942,6 +961,16 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def to_payload(self): + # type: () -> typing.Dict[str, typing.Any] + payload = {} # type: typing.Dict[str, typing.Any] + return payload + + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class NextJobExecutionChangedEvent(awsiot.ModeledClass): """ @@ -980,6 +1009,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class NextJobExecutionChangedSubscriptionRequest(awsiot.ModeledClass): """ @@ -1003,6 +1035,16 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def to_payload(self): + # type: () -> typing.Dict[str, typing.Any] + payload = {} # type: typing.Dict[str, typing.Any] + return payload + + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class RejectedError(awsiot.ModeledClass): """ @@ -1059,6 +1101,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class StartNextJobExecutionResponse(awsiot.ModeledClass): """ @@ -1103,6 +1148,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class StartNextPendingJobExecutionRequest(awsiot.ModeledClass): """ @@ -1146,6 +1194,11 @@ def to_payload(self): payload['stepTimeoutInMinutes'] = self.step_timeout_in_minutes return payload + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class StartNextPendingJobExecutionSubscriptionRequest(awsiot.ModeledClass): """ @@ -1169,6 +1222,11 @@ def __init__(self, *args, **kwargs): for key, val in zip(['thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.thing_name: + raise ValueError("thing_name is required") + return + class UpdateJobExecutionRequest(awsiot.ModeledClass): """ @@ -1240,6 +1298,13 @@ def to_payload(self): payload['stepTimeoutInMinutes'] = self.step_timeout_in_minutes return payload + def _validate(self): + if not self.job_id: + raise ValueError("job_id is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class UpdateJobExecutionResponse(awsiot.ModeledClass): """ @@ -1290,6 +1355,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class UpdateJobExecutionSubscriptionRequest(awsiot.ModeledClass): """ @@ -1316,6 +1384,13 @@ def __init__(self, *args, **kwargs): for key, val in zip(['job_id', 'thing_name'], args): setattr(self, key, val) + def _validate(self): + if not self.job_id: + raise ValueError("job_id is required") + if not self.thing_name: + raise ValueError("thing_name is required") + return + class V2ErrorResponse(awsiot.ModeledClass): """ @@ -1372,6 +1447,9 @@ def from_payload(cls, payload): new.timestamp = datetime.datetime.fromtimestamp(val) return new + def _validate(self): + return + class JobStatus: """ @@ -1463,3 +1541,247 @@ class RejectedErrorCode: Occurs when a command to describe a job is performed on a job that is in a terminal state. """ +class IotJobsClientV2: + """ + + The AWS IoT jobs service can be used to define a set of remote operations that are sent to and executed on one or more devices connected to AWS IoT. + + AWS Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#jobs-mqtt-api + + """ + + def __init__(self, protocol_client: awscrt.mqtt.Connection or awscrt.mqtt5.Client, options: awscrt.mqtt_request_response.ClientOptions): + self._rr_client = awscrt.mqtt_request_response.Client(protocol_client, options) + + def describe_job_execution(self, request : DescribeJobExecutionRequest) -> concurrent.futures.Future : + """ + + Gets detailed information about a job execution. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#mqtt-describejobexecution + + Args: + request: `DescribeJobExecutionRequest` instance. + + Returns: + A Future whose result will be an instance of `DescribeJobExecutionResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/jobs/{0.job_id}/get'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/jobs/{0.job_id}/get/+'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "describe_job_execution", accepted_topic, DescribeJobExecutionResponse, V2ErrorResponse) + + def get_pending_job_executions(self, request : GetPendingJobExecutionsRequest) -> concurrent.futures.Future : + """ + + Gets the list of all jobs for a thing that are not in a terminal state. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#mqtt-getpendingjobexecutions + + Args: + request: `GetPendingJobExecutionsRequest` instance. + + Returns: + A Future whose result will be an instance of `GetPendingJobExecutionsResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/jobs/get'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/jobs/get/+'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "get_pending_job_executions", accepted_topic, GetPendingJobExecutionsResponse, V2ErrorResponse) + + def start_next_pending_job_execution(self, request : StartNextPendingJobExecutionRequest) -> concurrent.futures.Future : + """ + + Gets and starts the next pending job execution for a thing (status IN_PROGRESS or QUEUED). + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#mqtt-startnextpendingjobexecution + + Args: + request: `StartNextPendingJobExecutionRequest` instance. + + Returns: + A Future whose result will be an instance of `StartNextJobExecutionResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/jobs/start-next'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/jobs/start-next/+'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "start_next_pending_job_execution", accepted_topic, StartNextJobExecutionResponse, V2ErrorResponse) + + def update_job_execution(self, request : UpdateJobExecutionRequest) -> concurrent.futures.Future : + """ + + Updates the status of a job execution. You can optionally create a step timer by setting a value for the stepTimeoutInMinutes property. If you don't update the value of this property by running UpdateJobExecution again, the job execution times out when the step timer expires. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#mqtt-updatejobexecution + + Args: + request: `UpdateJobExecutionRequest` instance. + + Returns: + A Future whose result will be an instance of `UpdateJobExecutionResponse`. + """ + request._validate() + + publish_topic = '$aws/things/{0.thing_name}/jobs/{0.job_id}/update'.format(request) + accepted_topic = publish_topic + "/accepted"; + rejected_topic = publish_topic + "/rejected"; + + subscription0 = '$aws/things/{0.thing_name}/jobs/{0.job_id}/update/+'.format(request); + + correlation_token = str(uuid.uuid4()) + request.client_token = correlation_token + + request_options = awscrt.mqtt_request_response.RequestOptions( + subscription_topic_filters = [ + subscription0, + ], + response_paths = [ + awscrt.mqtt_request_response.ResponsePath( + accepted_topic, + "clientToken" + ), + awscrt.mqtt_request_response.ResponsePath( + rejected_topic, + "clientToken" + ) + ], + publish_topic = publish_topic, + payload = json.dumps(request.to_payload()).encode(), + correlation_token = correlation_token, + ) + + internal_unmodeled_future = self._rr_client.make_request(request_options) + + return awsiot.create_v2_service_modeled_future(internal_unmodeled_future, "update_job_execution", accepted_topic, UpdateJobExecutionResponse, V2ErrorResponse) + + def create_job_executions_changed_stream(self, request : JobExecutionsChangedSubscriptionRequest, options: awsiot.ServiceStreamOptions[JobExecutionsChangedEvent]): + """ + + Creates a stream of JobExecutionsChanged notifications for a given IoT thing. + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#mqtt-jobexecutionschanged + + Args: + request: `JobExecutionsChangedSubscriptionRequest` instance. + options: callbacks to invoke for streaming operation events + + Returns: + An instance of `awscrt.mqtt_request_response.StreamingOperation` + """ + request._validate() + options._validate() + + subscription_topic_filter = '$aws/things/{0.thing_name}/jobs/notify'.format(request) + + unmodeled_options = awsiot.create_streaming_unmodeled_options(options, subscription_topic_filter, "JobExecutionsChangedEvent", JobExecutionsChangedEvent) + + return self._rr_client.create_stream(unmodeled_options) + + def create_next_job_execution_changed_stream(self, request : NextJobExecutionChangedSubscriptionRequest, options: awsiot.ServiceStreamOptions[NextJobExecutionChangedEvent]): + """ + + API Docs: https://docs.aws.amazon.com/iot/latest/developerguide/jobs-api.html#mqtt-nextjobexecutionchanged + + Args: + request: `NextJobExecutionChangedSubscriptionRequest` instance. + options: callbacks to invoke for streaming operation events + + Returns: + An instance of `awscrt.mqtt_request_response.StreamingOperation` + """ + request._validate() + options._validate() + + subscription_topic_filter = '$aws/things/{0.thing_name}/jobs/notify-next'.format(request) + + unmodeled_options = awsiot.create_streaming_unmodeled_options(options, subscription_topic_filter, "NextJobExecutionChangedEvent", NextJobExecutionChangedEvent) + + return self._rr_client.create_stream(unmodeled_options) + From 9f18d595425efe4ea7cf5e5c3f8740c68f48b3d1 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Tue, 6 May 2025 08:33:38 -0700 Subject: [PATCH 06/26] Jobs framework --- test/test_jobs.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 test/test_jobs.py diff --git a/test/test_jobs.py b/test/test_jobs.py new file mode 100644 index 00000000..955758ca --- /dev/null +++ b/test/test_jobs.py @@ -0,0 +1,208 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. +import pdb + +from awscrt import io, mqtt, mqtt5, mqtt_request_response +import awsiot +from awsiot import iotjobs + +import boto3 +from concurrent.futures import Future +import os +import time +import unittest +import uuid + +TIMEOUT = 30.0 + + +def create_client_id(): + return f"test-{uuid.uuid4().hex}" + + +def _get_env_variable(env_name): + env_data = os.environ.get(env_name) + if not env_data: + raise unittest.SkipTest(f"test requires env var: {env_name}") + return env_data + + +class JobsTestCallbacks(): + def __init__(self): + self.future_connection_success = Future() + self.future_stopped = Future() + + def ws_handshake_transform(self, transform_args): + transform_args.set_done() + + def on_publish_received(self, publish_received_data: mqtt5.PublishReceivedData): + pass + + def on_lifecycle_stopped(self, lifecycle_stopped: mqtt5.LifecycleStoppedData): + if self.future_stopped: + self.future_stopped.set_result(None) + + def on_lifecycle_attempting_connect(self, lifecycle_attempting_connect: mqtt5.LifecycleAttemptingConnectData): + pass + + def on_lifecycle_connection_success(self, lifecycle_connection_success: mqtt5.LifecycleConnectSuccessData): + if self.future_connection_success: + self.future_connection_success.set_result(lifecycle_connection_success) + + def on_lifecycle_connection_failure(self, lifecycle_connection_failure: mqtt5.LifecycleConnectFailureData): + if self.future_connection_success: + if self.future_connection_success.done(): + pass + else: + self.future_connection_success.set_exception(lifecycle_connection_failure.exception) + + def on_lifecycle_disconnection(self, lifecycle_disconnect_data: mqtt5.LifecycleDisconnectData): + pass + +class TestContext(): + def __init__(self): + self.region = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_REGION") + self.thing_name = None + self.thing_group_name = None + self.thing_group_arn = None + self.job_id = None + +class JobsServiceTest(unittest.TestCase): + + def _create_protocol_client5(self): + + input_host_name = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_HOST") + input_cert = _get_env_variable("AWS_TEST_MQTT5_IOT_CERTIFICATE_PATH") + input_key = _get_env_variable("AWS_TEST_MQTT5_IOT_KEY_PATH") + + client_options = mqtt5.ClientOptions( + host_name=input_host_name, + port=8883 + ) + + tls_ctx_options = io.TlsContextOptions.create_client_with_mtls_from_path( + input_cert, + input_key + ) + client_options.tls_ctx = io.ClientTlsContext(tls_ctx_options) + + client_options.connect_options = mqtt5.ConnectPacket() + client_options.connect_options.client_id = create_client_id() + + callbacks = JobsTestCallbacks() + client_options.on_lifecycle_event_stopped_fn = callbacks.on_lifecycle_stopped + client_options.on_lifecycle_event_connection_success_fn = callbacks.on_lifecycle_connection_success + client_options.on_lifecycle_event_connection_failure_fn = callbacks.on_lifecycle_connection_failure + client_options.on_lifecycle_event_stopped_fn = callbacks.on_lifecycle_stopped + + protocol_client = mqtt5.Client(client_options) + protocol_client.start() + + callbacks.future_connection_success.result(TIMEOUT) + + return protocol_client, callbacks + + def _shutdown_protocol_client5(self, protocol_client, callbacks): + + protocol_client.stop() + callbacks.future_stopped.result(TIMEOUT) + + def _create_protocol_client311(self): + + input_host_name = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_HOST") + input_cert = _get_env_variable("AWS_TEST_MQTT5_IOT_CERTIFICATE_PATH") + input_key = _get_env_variable("AWS_TEST_MQTT5_IOT_KEY_PATH") + + tls_ctx_options = io.TlsContextOptions.create_client_with_mtls_from_path( + input_cert, + input_key + ) + tls_ctx = io.ClientTlsContext(tls_ctx_options) + + client = mqtt.Client(None, tls_ctx) + + protocol_client = mqtt.Connection( + client=client, + client_id=create_client_id(), + host_name=input_host_name, + port=8883, + ping_timeout_ms=10000, + keep_alive_secs=30 + ) + protocol_client.connect().result(TIMEOUT) + + return protocol_client + + def _shutdown_protocol_client311(self, protocol_client): + protocol_client.disconnect().result(TIMEOUT) + + def _create_jobs_client( + self, + protocol_client, + max_request_response_subscriptions, + max_streaming_subscriptions, + operation_timeout_seconds): + rr_client_options = mqtt_request_response.ClientOptions( + max_request_response_subscriptions, max_streaming_subscriptions) + rr_client_options.operation_timeout_in_seconds = operation_timeout_seconds + + jobs_client = iotjobs.IotJobsClientV2(protocol_client, rr_client_options) + + return jobs_client + + def _do_jobs_creation_test5(self, test_callable): + (protocol_client, callbacks) = self._create_protocol_client5() + + test_callable(protocol_client) + + self._shutdown_protocol_client5(protocol_client, callbacks) + + def _do_jobs_creation_test311(self, test_callable): + protocol_client = self._create_protocol_client311() + + test_callable(protocol_client) + + self._shutdown_protocol_client311(protocol_client) + + def _do_jobs_operation_test5(self, test_callable): + (protocol_client, callbacks) = self._create_protocol_client5() + identity_client = self._create_jobs_client(protocol_client, 2, 2, 30) + + test_callable(identity_client) + + self._shutdown_protocol_client5(protocol_client, callbacks) + + def _do_jobs_operation_test311(self, test_callable): + protocol_client = self._create_protocol_client311() + identity_client = self._create_jobs_client(protocol_client, 2, 2, 30) + + test_callable(identity_client) + + self._shutdown_protocol_client311(protocol_client) + + def _tear_down(self, test_context): + pass + + + # ============================================================== + # CREATION SUCCESS TEST CASES + # ============================================================== + def test_client_creation_success5(self): + self._do_jobs_creation_test5(lambda protocol_client: self._create_jobs_client(protocol_client, 2, 2, 30)) + + def test_client_creation_success311(self): + self._do_jobs_creation_test311(lambda protocol_client: self._create_jobs_client(protocol_client, 2, 2, 30)) + + # ============================================================== + # CREATION FAILURE TEST CASES + # ============================================================== + def test_client_creation_failure_no_protocol_client(self): + self.assertRaises(Exception, self._create_jobs_client, None, 2, 2, 30) + + # ============================================================== + # REQUEST RESPONSE OPERATION TEST CASES + # ============================================================== + + +if __name__ == 'main': + unittest.main() From defefac39305f6edc941ad05f5962931b0c62a44 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Tue, 6 May 2025 15:30:19 -0700 Subject: [PATCH 07/26] Jobs processing tests --- test/test_jobs.py | 207 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 1 deletion(-) diff --git a/test/test_jobs.py b/test/test_jobs.py index 955758ca..e79af31a 100644 --- a/test/test_jobs.py +++ b/test/test_jobs.py @@ -1,18 +1,21 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0. import pdb +import threading from awscrt import io, mqtt, mqtt5, mqtt_request_response import awsiot from awsiot import iotjobs import boto3 +from botocore.exceptions import ClientError from concurrent.futures import Future import os import time import unittest import uuid + TIMEOUT = 30.0 @@ -62,10 +65,15 @@ def on_lifecycle_disconnection(self, lifecycle_disconnect_data: mqtt5.LifecycleD class TestContext(): def __init__(self): self.region = _get_env_variable("AWS_TEST_MQTT5_IOT_CORE_REGION") + self.iot_client = boto3.client('iot', self.region) self.thing_name = None self.thing_group_name = None self.thing_group_arn = None self.job_id = None + self.job_executions_changed_events = [] + self.next_job_execution_changed_events = [] + self.lock = threading.Lock() + self.signal = threading.Condition(lock=self.lock) class JobsServiceTest(unittest.TestCase): @@ -181,8 +189,201 @@ def _do_jobs_operation_test311(self, test_callable): self._shutdown_protocol_client311(protocol_client) def _tear_down(self, test_context): - pass + if test_context.iot_client is None: + return + + if test_context.job_id is not None: + done = False + while not done: + try: + test_context.iot_client.delete_job(jobId=test_context.job_id, force=True) + done=True + except ClientError as ce: + exception_type = ce.response['Error']['Code'] + if exception_type == 'ThrottlingException' or exception_type == 'LimitExceededException': + time.sleep(10) + elif exception_type == 'ResourceNotFoundException': + done = True + + + if test_context.thing_name is not None: + test_context.iot_client.delete_thing(thingName=test_context.thing_name) + + if test_context.thing_group_name is not None: + test_context.iot_client.delete_thing_group(thingGroupName=test_context.thing_group_name) + + def _setup(self, test_context): + tgn = "tgn-" + uuid.uuid4().hex + + create_tg_response = test_context.iot_client.create_thing_group(thingGroupName=tgn) + + test_context.thing_group_name = tgn + test_context.thing_group_arn = create_tg_response['thingGroupArn'] + + thing_name = "thing-" + uuid.uuid4().hex + + test_context.iot_client.create_thing(thingName=thing_name) + test_context.thing_name = thing_name + + time.sleep(1) + + job_id = "job-" + uuid.uuid4().hex + job_document = '{"test":"do-something"}' + + test_context.iot_client.create_job( + jobId=job_id, + document=job_document, + targets=[test_context.thing_group_arn], + targetSelection='CONTINUOUS') + + test_context.job_id = job_id + + def _create_job_executions_changed_stream(self, test_context, jobs_client): + subscribed = Future() + + def on_incoming_publish_event(event): + with test_context.signal: + test_context.job_executions_changed_events.append(event) + test_context.signal.notify_all() + + def on_subscription_event(event): + subscribed.set_result(event) + + stream_options = awsiot.ServiceStreamOptions( + incoming_event_listener=on_incoming_publish_event, + subscription_status_listener=on_subscription_event + ) + + stream = jobs_client.create_job_executions_changed_stream( + iotjobs.JobExecutionsChangedSubscriptionRequest(thing_name=test_context.thing_name), + stream_options) + stream.open() + + subscription_event = subscribed.result(TIMEOUT) + self.assertEqual(mqtt_request_response.SubscriptionStatusEventType.SUBSCRIPTION_ESTABLISHED, + subscription_event.type) + + return stream + + def _create_next_job_execution_changed_stream(self, test_context, jobs_client): + subscribed = Future() + + def on_incoming_publish_event(event): + with test_context.signal: + test_context.next_job_execution_changed_events.append(event) + test_context.signal.notify_all() + + def on_subscription_event(event): + subscribed.set_result(event) + + stream_options = awsiot.ServiceStreamOptions( + incoming_event_listener=on_incoming_publish_event, + subscription_status_listener=on_subscription_event + ) + + stream = jobs_client.create_next_job_execution_changed_stream( + iotjobs.NextJobExecutionChangedSubscriptionRequest(thing_name=test_context.thing_name), + stream_options) + stream.open() + + subscription_event = subscribed.result(TIMEOUT) + self.assertEqual(mqtt_request_response.SubscriptionStatusEventType.SUBSCRIPTION_ESTABLISHED, + subscription_event.type) + + return stream + + def _wait_for_initial_stream_events(self, test_context): + with test_context.signal: + while len(test_context.next_job_execution_changed_events) == 0: + test_context.signal.wait() + + next_job_execution_changed_event = test_context.next_job_execution_changed_events[0] + self.assertEqual(test_context.job_id, next_job_execution_changed_event.execution.job_id) + self.assertEqual(iotjobs.JobStatus.QUEUED, next_job_execution_changed_event.execution.status) + + while len(test_context.job_executions_changed_events) == 0: + test_context.signal.wait() + + job_executions_changed_event = test_context.job_executions_changed_events[0] + queued_jobs = job_executions_changed_event.jobs[iotjobs.JobStatus.QUEUED] + self.assertTrue(len(queued_jobs) > 0) + self.assertEqual(test_context.job_id, queued_jobs[0].job_id) + + def _wait_for_final_stream_events(self, test_context): + with test_context.signal: + while len(test_context.next_job_execution_changed_events) < 2: + test_context.signal.wait() + + final_next_job_execution_changed_event = test_context.next_job_execution_changed_events[1] + self.assertIsNotNone(final_next_job_execution_changed_event.timestamp) + self.assertIsNone(final_next_job_execution_changed_event.execution) + + while len(test_context.job_executions_changed_events) < 2: + test_context.signal.wait() + + final_job_executions_changed_event = test_context.job_executions_changed_events[1] + self.assertTrue(final_job_executions_changed_event.jobs == None or + len(final_job_executions_changed_event.jobs) == 0) + + def _verify_nothing_in_progress(self, test_context, jobs_client): + get_pending_response = jobs_client.get_pending_job_executions( + iotjobs.GetPendingJobExecutionsRequest( + thing_name=test_context.thing_name + ) + ).result(TIMEOUT) + + self.assertEqual(0, len(get_pending_response.queued_jobs)) + self.assertEqual(0, len(get_pending_response.in_progress_jobs)) + + def _do_job_processing_test(self, jobs_client): + test_context = TestContext() + try: + self._setup(test_context) + job_executions_changed_stream = self._create_job_executions_changed_stream(test_context, jobs_client) + next_job_execution_changed_stream = self._create_next_job_execution_changed_stream(test_context, jobs_client) + + self._verify_nothing_in_progress(test_context, jobs_client) + + test_context.iot_client.add_thing_to_thing_group( + thingName=test_context.thing_name, + thingGroupName=test_context.thing_group_name + ) + + self._wait_for_initial_stream_events(test_context) + + # start the job + start_next_response = jobs_client.start_next_pending_job_execution( + iotjobs.StartNextPendingJobExecutionRequest(thing_name=test_context.thing_name) + ).result(TIMEOUT) + + self.assertEqual(test_context.job_id, start_next_response.execution.job_id) + + # pretend to do the job + time.sleep(1) + + # verify in progress + describe_job_response = jobs_client.describe_job_execution( + iotjobs.DescribeJobExecutionRequest( + job_id=test_context.job_id, + thing_name=test_context.thing_name + ) + ).result(TIMEOUT) + + self.assertEqual(test_context.job_id, describe_job_response.execution.job_id) + self.assertEqual(iotjobs.JobStatus.IN_PROGRESS, describe_job_response.execution.status) + + # complete the job + jobs_client.update_job_execution(iotjobs.UpdateJobExecutionRequest( + job_id=test_context.job_id, + thing_name=test_context.thing_name, + status=iotjobs.JobStatus.SUCCEEDED + )).result(TIMEOUT) + self._wait_for_final_stream_events(test_context) + self._verify_nothing_in_progress(test_context, jobs_client) + + finally: + self._tear_down(test_context) # ============================================================== # CREATION SUCCESS TEST CASES @@ -202,7 +403,11 @@ def test_client_creation_failure_no_protocol_client(self): # ============================================================== # REQUEST RESPONSE OPERATION TEST CASES # ============================================================== + def test_job_processing5(self): + self._do_jobs_operation_test5(lambda jobs_client: self._do_job_processing_test(jobs_client)) + def test_job_processing311(self): + self._do_jobs_operation_test311(lambda jobs_client: self._do_job_processing_test(jobs_client)) if __name__ == 'main': unittest.main() From cd10937b5170f281586f7e15cfd5aafc2eae8a65 Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Thu, 15 May 2025 10:34:27 -0700 Subject: [PATCH 08/26] V2 shadow sample and usage guide --- codebuild/samples/shadow-linux.sh | 4 +- samples/README.md | 3 +- samples/deprecated/shadow.md | 87 ++++ samples/deprecated/shadow.py | 426 ++++++++++++++++++ samples/{ => deprecated}/shadow_mqtt5.md | 0 samples/{ => deprecated}/shadow_mqtt5.py | 0 samples/shadow.md | 252 ++++++++++- samples/shadow.py | 548 ++++++----------------- samples/shadowv2.py | 150 ------- 9 files changed, 882 insertions(+), 588 deletions(-) create mode 100644 samples/deprecated/shadow.md create mode 100644 samples/deprecated/shadow.py rename samples/{ => deprecated}/shadow_mqtt5.md (100%) rename samples/{ => deprecated}/shadow_mqtt5.py (100%) delete mode 100644 samples/shadowv2.py diff --git a/codebuild/samples/shadow-linux.sh b/codebuild/samples/shadow-linux.sh index e9aad66c..ffb34179 100755 --- a/codebuild/samples/shadow-linux.sh +++ b/codebuild/samples/shadow-linux.sh @@ -10,7 +10,7 @@ pushd $CODEBUILD_SRC_DIR/samples/ ENDPOINT=$(aws secretsmanager get-secret-value --secret-id "ci/endpoint" --query "SecretString" | cut -f2 -d":" | sed -e 's/[\\\"\}]//g') echo "Shadow test" -python3 shadow.py --endpoint $ENDPOINT --key /tmp/privatekey.pem --cert /tmp/certificate.pem --thing_name CI_CodeBuild_Thing --is_ci true -python3 shadow_mqtt5.py --endpoint $ENDPOINT --key /tmp/privatekey.pem --cert /tmp/certificate.pem --thing_name CI_CodeBuild_Thing --is_ci true +python3 deprecated/shadow.py --endpoint $ENDPOINT --key /tmp/privatekey.pem --cert /tmp/certificate.pem --thing_name CI_CodeBuild_Thing --is_ci true +python3 deprecated/shadow_mqtt5.py --endpoint $ENDPOINT --key /tmp/privatekey.pem --cert /tmp/certificate.pem --thing_name CI_CodeBuild_Thing --is_ci true popd diff --git a/samples/README.md b/samples/README.md index c35e638f..5eb7fda3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -9,7 +9,6 @@ * [MQTT5 Shared Subscription](./mqtt5_shared_subscription.md) * [MQTT5 PKCS#11 Connect](./mqtt5_pkcs11_connect.md) * [MQTT5 Custom Authorizer Connect](./mqtt5_custom_authorizer_connect.md) -* [MQTT5 Shadow](./shadow_mqtt5.md) * [MQTT5 Jobs](./jobs_mqtt5.md) * [MQTT5 Fleet Provisioning](./fleetprovisioning_mqtt5.md) ## MQTT311 Samples @@ -22,10 +21,10 @@ * [Custom Authorizer Connect](./custom_authorizer_connect.md) * [Cognito Connect](./cognito_connect.md) * [X509 Connect](./x509_connect.md) -* [Shadow](./shadow.md) * [Jobs](./jobs.md) * [Fleet Provisioning](./fleetprovisioning.md) ## Other +* [Shadow](./shadow.md) * [Greengrass Discovery](./basic_discovery.md) * [Greengrass IPC](./ipc_greengrass.md) diff --git a/samples/deprecated/shadow.md b/samples/deprecated/shadow.md new file mode 100644 index 00000000..9afea682 --- /dev/null +++ b/samples/deprecated/shadow.md @@ -0,0 +1,87 @@ +# Shadow + +[**Return to main sample list**](./README.md) + +This sample uses the AWS IoT [Device Shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html) Service to keep a property in sync between device and server. Imagine a light whose color may be changed through an app, or set by a local user. + +Once connected, type a value in the terminal and press Enter to update the property's "reported" value. The sample also responds when the "desired" value changes on the server. To observe this, edit the Shadow document in the AWS Console and set a new "desired" value. + +On startup, the sample requests the shadow document to learn the property's initial state. The sample also subscribes to "delta" events from the server, which are sent when a property's "desired" value differs from its "reported" value. When the sample learns of a new desired value, that value is changed on the device and an update is sent to the server with the new "reported" value. + +Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerguide/iot-policies.html) must provide privileges for this sample to connect, subscribe, publish, and receive. Below is a sample policy that can be used on your IoT Core Thing that will allow this sample to run as intended. + +
+Sample Policy +
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": [
+        "iot:Publish"
+      ],
+      "Resource": [
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": [
+        "iot:Receive"
+      ],
+      "Resource": [
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get/accepted",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get/rejected",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/accepted",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/rejected",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/delta"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": [
+        "iot:Subscribe"
+      ],
+      "Resource": [
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/get/accepted",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/get/rejected",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/accepted",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/rejected",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/delta"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": "iot:Connect",
+      "Resource": "arn:aws:iot:region:account:client/test-*"
+    }
+  ]
+}
+
+ +Replace with the following with the data from your AWS account: +* ``: The AWS IoT Core region where you created your AWS IoT Core thing you wish to use with this sample. For example `us-east-1`. +* ``: Your AWS IoT Core account ID. This is the set of numbers in the top right next to your AWS account name when using the AWS IoT Core website. +* ``: The name of your AWS IoT Core thing you want the device connection to be associated with + +Note that in a real application, you may want to avoid the use of wildcards in your ClientID or use them selectively. Please follow best practices when working with AWS on production applications using the SDK. Also, for the purposes of this sample, please make sure your policy allows a client ID of `test-*` to connect or use `--client_id ` to send the client ID your policy supports. + +
+ +## How to run + +To run the Shadow sample from the `samples` folder, use the following command: + +``` sh +# For Windows: replace 'python3' with 'python' and '/' with '\' +python3 shadow.py --endpoint --cert --key --thing_name +``` + +You can also pass a Certificate Authority file (CA) if your certificate and key combination requires it: + +``` sh +# For Windows: replace 'python3' with 'python' and '/' with '\' +python3 shadow.py --endpoint --cert --key --thing_name --ca_file +``` diff --git a/samples/deprecated/shadow.py b/samples/deprecated/shadow.py new file mode 100644 index 00000000..285883c4 --- /dev/null +++ b/samples/deprecated/shadow.py @@ -0,0 +1,426 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +from time import sleep +from awscrt import mqtt, http +from awsiot import iotshadow, mqtt_connection_builder +from concurrent.futures import Future +import sys +import threading +import traceback +from uuid import uuid4 +from utils.command_line_utils import CommandLineUtils + +# - Overview - +# This sample uses the AWS IoT Device Shadow Service to keep a property in +# sync between device and server. Imagine a light whose color may be changed +# through an app, or set by a local user. +# +# - Instructions - +# Once connected, type a value in the terminal and press Enter to update +# the property's "reported" value. The sample also responds when the "desired" +# value changes on the server. To observe this, edit the Shadow document in +# the AWS Console and set a new "desired" value. +# +# - Detail - +# On startup, the sample requests the shadow document to learn the property's +# initial state. The sample also subscribes to "delta" events from the server, +# which are sent when a property's "desired" value differs from its "reported" +# value. When the sample learns of a new desired value, that value is changed +# on the device and an update is sent to the server with the new "reported" +# value. + +# cmdData is the arguments/input from the command line placed into a single struct for +# use in this sample. This handles all of the command line parsing, validating, etc. +# See the Utils/CommandLineUtils for more information. +cmdData = CommandLineUtils.parse_sample_input_shadow() + +# Using globals to simplify sample code +is_sample_done = threading.Event() +mqtt_connection = None +shadow_thing_name = cmdData.input_thing_name +shadow_property = cmdData.input_shadow_property + +SHADOW_VALUE_DEFAULT = "off" + + +class LockedData: + def __init__(self): + self.lock = threading.Lock() + self.shadow_value = None + self.disconnect_called = False + self.request_tokens = set() + + +locked_data = LockedData() + +# Function for gracefully quitting this sample + + +def exit(msg_or_exception): + if isinstance(msg_or_exception, Exception): + print("Exiting sample due to exception.") + traceback.print_exception(msg_or_exception.__class__, msg_or_exception, sys.exc_info()[2]) + else: + print("Exiting sample:", msg_or_exception) + + with locked_data.lock: + if not locked_data.disconnect_called: + print("Disconnecting...") + locked_data.disconnect_called = True + future = mqtt_connection.disconnect() + future.add_done_callback(on_disconnected) + + +def on_disconnected(disconnect_future): + # type: (Future) -> None + print("Disconnected.") + + # Signal that sample is finished + is_sample_done.set() + + +def on_get_shadow_accepted(response): + # type: (iotshadow.GetShadowResponse) -> None + try: + with locked_data.lock: + # check that this is a response to a request from this session + try: + locked_data.request_tokens.remove(response.client_token) + except KeyError: + print("Ignoring get_shadow_accepted message due to unexpected token.") + return + + print("Finished getting initial shadow state.") + if locked_data.shadow_value is not None: + print(" Ignoring initial query because a delta event has already been received.") + return + + if response.state: + if response.state.delta: + value = response.state.delta.get(shadow_property) + if value: + print(" Shadow contains delta value '{}'.".format(value)) + change_shadow_value(value) + return + + if response.state.reported: + value = response.state.reported.get(shadow_property) + if value: + print(" Shadow contains reported value '{}'.".format(value)) + set_local_value_due_to_initial_query(response.state.reported[shadow_property]) + return + + print(" Shadow document lacks '{}' property. Setting defaults...".format(shadow_property)) + change_shadow_value(SHADOW_VALUE_DEFAULT) + return + + except Exception as e: + exit(e) + + +def on_get_shadow_rejected(error): + # type: (iotshadow.ErrorResponse) -> None + try: + # check that this is a response to a request from this session + with locked_data.lock: + try: + locked_data.request_tokens.remove(error.client_token) + except KeyError: + print("Ignoring get_shadow_rejected message due to unexpected token.") + return + + if error.code == 404: + print("Thing has no shadow document. Creating with defaults...") + change_shadow_value(SHADOW_VALUE_DEFAULT) + else: + exit("Get request was rejected. code:{} message:'{}'".format( + error.code, error.message)) + + except Exception as e: + exit(e) + + +def on_shadow_delta_updated(delta): + # type: (iotshadow.ShadowDeltaUpdatedEvent) -> None + try: + print("Received shadow delta event.") + if delta.state and (shadow_property in delta.state): + value = delta.state[shadow_property] + if value is None: + print(" Delta reports that '{}' was deleted. Resetting defaults...".format(shadow_property)) + change_shadow_value(SHADOW_VALUE_DEFAULT) + return + else: + print(" Delta reports that desired value is '{}'. Changing local value...".format(value)) + if (delta.client_token is not None): + print(" ClientToken is: " + delta.client_token) + change_shadow_value(value) + else: + print(" Delta did not report a change in '{}'".format(shadow_property)) + + except Exception as e: + exit(e) + + +def on_publish_update_shadow(future): + # type: (Future) -> None + try: + future.result() + print("Update request published.") + except Exception as e: + print("Failed to publish update request.") + exit(e) + + +def on_update_shadow_accepted(response): + # type: (iotshadow.UpdateShadowResponse) -> None + try: + # check that this is a response to a request from this session + with locked_data.lock: + try: + locked_data.request_tokens.remove(response.client_token) + except KeyError: + print("Ignoring update_shadow_accepted message due to unexpected token.") + return + + try: + if response.state.reported is not None: + if shadow_property in response.state.reported: + print("Finished updating reported shadow value to '{}'.".format( + response.state.reported[shadow_property])) # type: ignore + else: + print("Could not find shadow property with name: '{}'.".format(shadow_property)) # type: ignore + else: + print("Shadow states cleared.") # when the shadow states are cleared, reported and desired are set to None + print("Enter desired value: ") # remind user they can input new values + except BaseException: + exit("Updated shadow is missing the target property") + + except Exception as e: + exit(e) + + +def on_update_shadow_rejected(error): + # type: (iotshadow.ErrorResponse) -> None + try: + # check that this is a response to a request from this session + with locked_data.lock: + try: + locked_data.request_tokens.remove(error.client_token) + except KeyError: + print("Ignoring update_shadow_rejected message due to unexpected token.") + return + + exit("Update request was rejected. code:{} message:'{}'".format( + error.code, error.message)) + + except Exception as e: + exit(e) + + +def set_local_value_due_to_initial_query(reported_value): + with locked_data.lock: + locked_data.shadow_value = reported_value + print("Enter desired value: ") # remind user they can input new values + + +def change_shadow_value(value): + with locked_data.lock: + if locked_data.shadow_value == value: + print("Local value is already '{}'.".format(value)) + print("Enter desired value: ") # remind user they can input new values + return + + print("Changed local shadow value to '{}'.".format(value)) + locked_data.shadow_value = value + + print("Updating reported shadow value to '{}'...".format(value)) + + # use a unique token so we can correlate this "request" message to + # any "response" messages received on the /accepted and /rejected topics + token = str(uuid4()) + + # if the value is "clear shadow" then send a UpdateShadowRequest with None + # for both reported and desired to clear the shadow document completely. + if value == "clear_shadow": + tmp_state = iotshadow.ShadowState( + reported=None, + desired=None, + reported_is_nullable=True, + desired_is_nullable=True) + request = iotshadow.UpdateShadowRequest( + thing_name=shadow_thing_name, + state=tmp_state, + client_token=token, + ) + # Otherwise, send a normal update request + else: + # if the value is "none" then set it to a Python none object to + # clear the individual shadow property + if value == "none": + value = None + + request = iotshadow.UpdateShadowRequest( + thing_name=shadow_thing_name, + state=iotshadow.ShadowState( + reported={shadow_property: value}, + desired={shadow_property: value}, + ), + client_token=token, + ) + + future = shadow_client.publish_update_shadow(request, mqtt.QoS.AT_LEAST_ONCE) + + locked_data.request_tokens.add(token) + + future.add_done_callback(on_publish_update_shadow) + + +def user_input_thread_fn(): + # If we are not in CI, then take terminal input + if not cmdData.input_is_ci: + while True: + try: + # Read user input + new_value = input() + + # If user wants to quit sample, then quit. + # Otherwise change the shadow value. + if new_value in ['exit', 'quit']: + exit("User has quit") + break + else: + change_shadow_value(new_value) + + except Exception as e: + print("Exception on input thread.") + exit(e) + break + # Otherwise, send shadow updates automatically + else: + try: + messages_sent = 0 + while messages_sent < 5: + cli_input = "Shadow_Value_" + str(messages_sent) + change_shadow_value(cli_input) + sleep(1) + messages_sent += 1 + exit("CI has quit") + except Exception as e: + print("Exception on input thread (CI)") + exit(e) + + +if __name__ == '__main__': + # Create the proxy options if the data is present in cmdData + proxy_options = None + if cmdData.input_proxy_host is not None and cmdData.input_proxy_port != 0: + proxy_options = http.HttpProxyOptions( + host_name=cmdData.input_proxy_host, + port=cmdData.input_proxy_port) + + # Create a MQTT connection from the command line data + mqtt_connection = mqtt_connection_builder.mtls_from_path( + endpoint=cmdData.input_endpoint, + port=cmdData.input_port, + cert_filepath=cmdData.input_cert, + pri_key_filepath=cmdData.input_key, + ca_filepath=cmdData.input_ca, + client_id=cmdData.input_clientId, + clean_session=False, + keep_alive_secs=30, + http_proxy_options=proxy_options) + + if not cmdData.input_is_ci: + print(f"Connecting to {cmdData.input_endpoint} with client ID '{cmdData.input_clientId}'...") + else: + print("Connecting to endpoint with client ID") + + connected_future = mqtt_connection.connect() + + shadow_client = iotshadow.IotShadowClient(mqtt_connection) + + # Wait for connection to be fully established. + # Note that it's not necessary to wait, commands issued to the + # mqtt_connection before its fully connected will simply be queued. + # But this sample waits here so it's obvious when a connection + # fails or succeeds. + connected_future.result() + print("Connected!") + + try: + # Subscribe to necessary topics. + # Note that is **is** important to wait for "accepted/rejected" subscriptions + # to succeed before publishing the corresponding "request". + print("Subscribing to Update responses...") + update_accepted_subscribed_future, _ = shadow_client.subscribe_to_update_shadow_accepted( + request=iotshadow.UpdateShadowSubscriptionRequest(thing_name=shadow_thing_name), + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_update_shadow_accepted) + + update_rejected_subscribed_future, _ = shadow_client.subscribe_to_update_shadow_rejected( + request=iotshadow.UpdateShadowSubscriptionRequest(thing_name=shadow_thing_name), + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_update_shadow_rejected) + + # Wait for subscriptions to succeed + update_accepted_subscribed_future.result() + update_rejected_subscribed_future.result() + + print("Subscribing to Get responses...") + get_accepted_subscribed_future, _ = shadow_client.subscribe_to_get_shadow_accepted( + request=iotshadow.GetShadowSubscriptionRequest(thing_name=shadow_thing_name), + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_get_shadow_accepted) + + get_rejected_subscribed_future, _ = shadow_client.subscribe_to_get_shadow_rejected( + request=iotshadow.GetShadowSubscriptionRequest(thing_name=shadow_thing_name), + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_get_shadow_rejected) + + # Wait for subscriptions to succeed + get_accepted_subscribed_future.result() + get_rejected_subscribed_future.result() + + print("Subscribing to Delta events...") + delta_subscribed_future, _ = shadow_client.subscribe_to_shadow_delta_updated_events( + request=iotshadow.ShadowDeltaUpdatedSubscriptionRequest(thing_name=shadow_thing_name), + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_shadow_delta_updated) + + # Wait for subscription to succeed + delta_subscribed_future.result() + + # The rest of the sample runs asynchronously. + + # Issue request for shadow's current state. + # The response will be received by the on_get_accepted() callback + print("Requesting current shadow state...") + + with locked_data.lock: + # use a unique token so we can correlate this "request" message to + # any "response" messages received on the /accepted and /rejected topics + token = str(uuid4()) + + publish_get_future = shadow_client.publish_get_shadow( + request=iotshadow.GetShadowRequest(thing_name=shadow_thing_name, client_token=token), + qos=mqtt.QoS.AT_LEAST_ONCE) + + locked_data.request_tokens.add(token) + + # Ensure that publish succeeds + publish_get_future.result() + + # Launch thread to handle user input. + # A "daemon" thread won't prevent the program from shutting down. + print("Launching thread to read user input...") + user_input_thread = threading.Thread(target=user_input_thread_fn, name='user_input_thread') + user_input_thread.daemon = True + user_input_thread.start() + + except Exception as e: + exit(e) + + # Wait for the sample to finish (user types 'quit', or an error occurs) + is_sample_done.wait() diff --git a/samples/shadow_mqtt5.md b/samples/deprecated/shadow_mqtt5.md similarity index 100% rename from samples/shadow_mqtt5.md rename to samples/deprecated/shadow_mqtt5.md diff --git a/samples/shadow_mqtt5.py b/samples/deprecated/shadow_mqtt5.py similarity index 100% rename from samples/shadow_mqtt5.py rename to samples/deprecated/shadow_mqtt5.py diff --git a/samples/shadow.md b/samples/shadow.md index 9afea682..64fc2688 100644 --- a/samples/shadow.md +++ b/samples/shadow.md @@ -1,13 +1,22 @@ -# Shadow +# Shadow Sandbox [**Return to main sample list**](./README.md) -This sample uses the AWS IoT [Device Shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html) Service to keep a property in sync between device and server. Imagine a light whose color may be changed through an app, or set by a local user. +This is an interactive sample that supports a set of commands that allow you to interact with "classic" (unnamed) shadows of the AWS IoT [Device Shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html) Service. -Once connected, type a value in the terminal and press Enter to update the property's "reported" value. The sample also responds when the "desired" value changes on the server. To observe this, edit the Shadow document in the AWS Console and set a new "desired" value. +### Commands +Once connected, the sample supports the following shadow-related commands: -On startup, the sample requests the shadow document to learn the property's initial state. The sample also subscribes to "delta" events from the server, which are sent when a property's "desired" value differs from its "reported" value. When the sample learns of a new desired value, that value is changed on the device and an update is sent to the server with the new "reported" value. +* `get` - gets the current full state of the classic (unnamed) shadow. This includes both a "desired" state component and a "reported" state component. +* `delete` - deletes the classic (unnamed) shadow completely +* `update-desired ` - applies an update to the classic shadow's desired state component. Properties in the JSON document set to non-null will be set to new values. Properties in the JSON document set to null will be removed. +* `update-reported ` - applies an update to the classic shadow's reported state component. Properties in the JSON document set to non-null will be set to new values. Properties in the JSON document set to null will be removed. +Two additional commands are supported: +* `help` - prints the set of supported commands +* `quit` - quits the sample application + +### Prerequisites Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerguide/iot-policies.html) must provide privileges for this sample to connect, subscribe, publish, and receive. Below is a sample policy that can be used on your IoT Core Thing that will allow this sample to run as intended.
@@ -23,6 +32,7 @@ Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerg ], "Resource": [ "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get", + "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/delete", "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update" ] }, @@ -32,11 +42,9 @@ Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerg "iot:Receive" ], "Resource": [ - "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get/accepted", - "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get/rejected", - "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/accepted", - "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/rejected", - "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/delta" + "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/get/*", + "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/delete/*", + "arn:aws:iot:region:account:topic/$aws/things/thingname/shadow/update/*" ] }, { @@ -45,17 +53,15 @@ Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerg "iot:Subscribe" ], "Resource": [ - "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/get/accepted", - "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/get/rejected", - "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/accepted", - "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/rejected", - "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/delta" + "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/get/*", + "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/delete/*", + "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/shadow/update/*" ] }, { "Effect": "Allow", "Action": "iot:Connect", - "Resource": "arn:aws:iot:region:account:client/test-*" + "Resource": "arn:aws:iot:region:account:client/*" } ] } @@ -70,18 +76,220 @@ Note that in a real application, you may want to avoid the use of wildcards in y
-## How to run +## Walkthrough + +First, from an empty directory, clone the SDK via git: +``` sh +git clone https://github.com/aws/aws-iot-device-sdk-python-v2 +``` +If not already active, activate the [virtual environment](https://docs.python.org/3/library/venv.html) that will be used to contain Python's execution context. -To run the Shadow sample from the `samples` folder, use the following command: +If the venv does not yet have the device SDK installed, install it: ``` sh -# For Windows: replace 'python3' with 'python' and '/' with '\' -python3 shadow.py --endpoint --cert --key --thing_name +python3 -m pip install awsiotsdk ``` -You can also pass a Certificate Authority file (CA) if your certificate and key combination requires it: +Assuming you are in the SDK root directory, you can now run the shadow sanbox sample: ``` sh -# For Windows: replace 'python3' with 'python' and '/' with '\' -python3 shadow.py --endpoint --cert --key --thing_name --ca_file +python3 samples/shadow.py --cert --key --endpoint --thing +``` + +The sample also listens to a pair of event streams related to the classic (unnamed) shadow state of your thing, so in addition to responses, you will occasionally see output from these streaming operations as they receive events from the shadow service. + +Once successfully connected, you can issue commands. + +### Initialization + +Start off by getting the shadow state: + +``` +get +``` + +If your thing does have shadow state, you will get its current value, which this sample has no control over. + +If your thing does not have any shadow state, you'll get a ResourceNotFound error: + +``` +Exception: ('get_shadow failure', None, awsiot.iotshadow.V2ErrorResponse(client_token='a8c0465f-e9d3-4a72-bf80-a8e39dd8ba00', code=404, message="No shadow exists with name: 'HelloWorld'", timestamp=None)) +``` + +To create a shadow, you can issue an update call that will initialize the shadow to a starting state: + +``` +update-reported {"Color":"green"} +``` + +which will yield output similar to: + +``` +Received ShadowUpdatedEvent: + awsiot.iotshadow.ShadowUpdatedEvent(current=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired=None, reported={'Color': {'timestamp': 1747329779}}), state=awsiot.iotshadow.ShadowState(desired=None, desired_is_nullable=False, reported={'Color': 'green'}, reported_is_nullable=False), version=1), previous=None, timestamp=datetime.datetime(2025, 5, 15, 10, 22, 59)) + +update-reported response: + awsiot.iotshadow.UpdateShadowResponse(client_token='1400d004-176a-4639-8378-d1da06aaebe4', metadata=awsiot.iotshadow.ShadowMetadata(desired=None, reported={'Color': {'timestamp': 1747329779}}), state=awsiot.iotshadow.ShadowState(desired=None, desired_is_nullable=False, reported={'Color': 'green'}, reported_is_nullable=False), timestamp=datetime.datetime(2025, 5, 15, 10, 22, 59), version=1) +``` + +Notice that in addition to receiving a response to the update request, you also receive a `ShadowUpdated` event containing what changed about +the shadow plus additional metadata (version, update timestamps, etc...). Every time a shadow is updated, this +event is triggered. If you wish to listen and react to this event, use the `create_shadow_updated_stream` API in the shadow client to create a +streaming operation that converts the raw MQTT publish messages into modeled data that the streaming operation emits via a callback. + +Issue one more update to get the shadow's reported and desired states in sync: + +``` +update-desired {"Color":"green"} +``` + +yielding output similar to: + +``` +update-desired response: + awsiot.iotshadow.UpdateShadowResponse(client_token='15a30d2b-3406-4494-8a75-93bb4314d301', metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329882}}, reported=None), state=awsiot.iotshadow.ShadowState(desired={'Color': 'green'}, desired_is_nullable=False, reported=None, reported_is_nullable=False), timestamp=datetime.datetime(2025, 5, 15, 10, 24, 42), version=2) + + +``` + +### Changing Properties +A device shadow contains two independent states: reported and desired. "Reported" represents the device's last-known local state, while +"desired" represents the state that control application(s) would like the device to change to. In general, each application (whether on the device or running +remotely as a control process) will only update one of these two state components. + +Let's walk through the multi-step process to coordinate a change-of-state on the device. First, a control application needs to update the shadow's desired +state with the change it would like applied: + +``` +update-desired {"Color":"red"} +``` + +For our sample, this yields output similar to: + +``` +Received ShadowUpdatedEvent: + awsiot.iotshadow.ShadowUpdatedEvent(current=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}}, reported={'Color': {'timestamp': 1747329779}}), state=awsiot.iotshadow.ShadowState(desired={'Color': 'red'}, desired_is_nullable=False, reported={'Color': 'green'}, reported_is_nullable=False), version=3), previous=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329882}}, reported={'Color': {'timestamp': 1747329779}}), state=awsiot.iotshadow.ShadowState(desired={'Color': 'green'}, desired_is_nullable=False, reported={'Color': 'green'}, reported_is_nullable=False), version=2), timestamp=datetime.datetime(2025, 5, 15, 10, 25, 45)) + +Received ShadowDeltaUpdatedEvent: + awsiot.iotshadow.ShadowDeltaUpdatedEvent(client_token='7ebae3ac-588c-4d73-9c59-59f38b3a0802', metadata={'Color': {'timestamp': 1747329945}}, state={'Color': 'red'}, timestamp=datetime.datetime(2025, 5, 15, 10, 25, 45), version=3) + +update-desired response: + awsiot.iotshadow.UpdateShadowResponse(client_token='7ebae3ac-588c-4d73-9c59-59f38b3a0802', metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}}, reported=None), state=awsiot.iotshadow.ShadowState(desired={'Color': 'red'}, desired_is_nullable=False, reported=None, reported_is_nullable=False), timestamp=datetime.datetime(2025, 5, 15, 10, 25, 45), version=3) +``` + +The key thing to notice here is that in addition to the update response (which only the control application would see) and the ShadowUpdated event, +there is a new event, ShadowDeltaUpdated, which indicates properties on the shadow that are out-of-sync between desired and reported. All out-of-sync +properties will be included in this event, including properties that became out-of-sync due to a previous update. + +Like the ShadowUpdated event, ShadowDeltaUpdated events can be listened to by creating and configuring a streaming operation, this time by using +the `create_shadow_delta_updated_stream` API. Using the `ShadowDeltaUpdatedEvent` events (rather than `ShadowUpdatedEvent`) lets a device focus on just what has +changed without having to do complex JSON diffs on the full shadow state itself. + +Assuming that the change expressed in the desired state is reasonable, the device should apply it internally and then let the service know it +has done so by updating the reported state of the shadow: + +``` +update-reported {"Color":"red"} +``` + +yielding + +``` +Received ShadowUpdatedEvent: + awsiot.iotshadow.ShadowUpdatedEvent(current=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}}, reported={'Color': {'timestamp': 1747330109}}), state=awsiot.iotshadow.ShadowState(desired={'Color': 'red'}, desired_is_nullable=False, reported={'Color': 'red'}, reported_is_nullable=False), version=4), previous=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}}, reported={'Color': {'timestamp': 1747329779}}), state=awsiot.iotshadow.ShadowState(desired={'Color': 'red'}, desired_is_nullable=False, reported={'Color': 'green'}, reported_is_nullable=False), version=3), timestamp=datetime.datetime(2025, 5, 15, 10, 28, 29)) + +update-reported response: + awsiot.iotshadow.UpdateShadowResponse(client_token='a5aad610-0a23-4b46-bf74-575c85caa70d', metadata=awsiot.iotshadow.ShadowMetadata(desired=None, reported={'Color': {'timestamp': 1747330109}}), state=awsiot.iotshadow.ShadowState(desired=None, desired_is_nullable=False, reported={'Color': 'red'}, reported_is_nullable=False), timestamp=datetime.datetime(2025, 5, 15, 10, 28, 29), version=4) +``` + +Notice that no ShadowDeltaUpdated event is generated because the reported and desired states are now back in sync. + +### Multiple Properties +Not all shadow properties represent device configuration. To illustrate several more aspects of the Shadow service, let's add a second property to our shadow document, +starting out in sync (output omitted): + +``` +update-reported {"Status":"Great"} +``` + +``` +update-desired {"Status":"Great"} +``` + +Notice that shadow updates work by deltas rather than by complete state changes. Updating the "Status" property to a value had no effect on the shadow's +"Color" property: + +``` +get +``` + +yields + +``` +get response: + awsiot.iotshadow.GetShadowResponse(client_token='132f013d-b6f2-482e-a841-3b40e8611791', metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}, 'Status': {'timestamp': 1747330183}}, reported={'Color': {'timestamp': 1747330109}, 'Status': {'timestamp': 1747330176}}), state=awsiot.iotshadow.ShadowStateWithDelta(delta=None, desired={'Color': 'red', 'Status': 'Great'}, reported={'Color': 'red', 'Status': 'Great'}), timestamp=datetime.datetime(2025, 5, 15, 10, 29, 51), version=6) +``` + +Suppose something goes wrong with the device and its status is no longer "Great" + +``` +update-reported {"Status":"Awful"} +``` + +which yields something similar to: + +``` +Received ShadowUpdatedEvent: + awsiot.iotshadow.ShadowUpdatedEvent(current=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}, 'Status': {'timestamp': 1747330183}}, reported={'Color': {'timestamp': 1747330109}, 'Status': {'timestamp': 1747330244}}), state=awsiot.iotshadow.ShadowState(desired={'Color': 'red', 'Status': 'Great'}, desired_is_nullable=False, reported={'Color': 'red', 'Status': 'Awful'}, reported_is_nullable=False), version=7), previous=awsiot.iotshadow.ShadowUpdatedSnapshot(metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}, 'Status': {'timestamp': 1747330183}}, reported={'Color': {'timestamp': 1747330109}, 'Status': {'timestamp': 1747330176}}), state=awsiot.iotshadow.ShadowState(desired={'Color': 'red', 'Status': 'Great'}, desired_is_nullable=False, reported={'Color': 'red', 'Status': 'Great'}, reported_is_nullable=False), version=6), timestamp=datetime.datetime(2025, 5, 15, 10, 30, 44)) + +update-reported response: + awsiot.iotshadow.UpdateShadowResponse(client_token='17c3e551-afd9-4951-bdba-fc9425e86a08', metadata=awsiot.iotshadow.ShadowMetadata(desired=None, reported={'Status': {'timestamp': 1747330244}}), state=awsiot.iotshadow.ShadowState(desired=None, desired_is_nullable=False, reported={'Status': 'Awful'}, reported_is_nullable=False), timestamp=datetime.datetime(2025, 5, 15, 10, 30, 44), version=7) + +Received ShadowDeltaUpdatedEvent: + awsiot.iotshadow.ShadowDeltaUpdatedEvent(client_token='17c3e551-afd9-4951-bdba-fc9425e86a08', metadata={'Status': {'timestamp': 1747330183}}, state={'Status': 'Great'}, timestamp=datetime.datetime(2025, 5, 15, 10, 30, 44), version=7) +``` + +Similar to how updates are delta-based, notice how the ShadowDeltaUpdated event only includes the "Status" property, leaving the "Color" property out because it +is still in sync between desired and reported. + +### Removing properties +Properties can be removed from a shadow by setting them to null. Removing a property completely would require its removal from both the +reported and desired states of the shadow (output omitted): + +``` +update-reported {"Status":null} +``` + +``` +update-desired {"Status":null} +``` + +If you now get the shadow state: + +``` +get +``` + +its output yields something like + +``` +get response: + awsiot.iotshadow.GetShadowResponse(client_token='3157f35a-f7a7-4ed2-8e2d-eff3fe7f0bff', metadata=awsiot.iotshadow.ShadowMetadata(desired={'Color': {'timestamp': 1747329945}}, reported={'Color': {'timestamp': 1747330109}}), state=awsiot.iotshadow.ShadowStateWithDelta(delta=None, desired={'Color': 'red'}, reported={'Color': 'red'}), timestamp=datetime.datetime(2025, 5, 15, 10, 31, 51), version=9) +``` + +The Status property has been fully removed from the shadow state. + +### Removing a shadow +To remove a shadow, you must invoke the DeleteShadow API (setting the reported and desired +states to null will only clear the states, but not delete the shadow resource itself). + +``` +delete +``` + +yields something like + ``` +delete response: + awsiot.iotshadow.DeleteShadowResponse(client_token='31a0b27a-a4b6-4883-afd5-ce485f309926', timestamp=datetime.datetime(2025, 5, 15, 10, 32, 24), version=9) +``` \ No newline at end of file diff --git a/samples/shadow.py b/samples/shadow.py index 285883c4..dfeb89ac 100644 --- a/samples/shadow.py +++ b/samples/shadow.py @@ -1,426 +1,150 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0. -from time import sleep -from awscrt import mqtt, http -from awsiot import iotshadow, mqtt_connection_builder +from awscrt import mqtt5, mqtt_request_response, io +from awsiot import iotshadow, mqtt5_client_builder from concurrent.futures import Future +from dataclasses import dataclass +from typing import Optional +import awsiot +import argparse +import json import sys -import threading -import traceback -from uuid import uuid4 -from utils.command_line_utils import CommandLineUtils - -# - Overview - -# This sample uses the AWS IoT Device Shadow Service to keep a property in -# sync between device and server. Imagine a light whose color may be changed -# through an app, or set by a local user. -# -# - Instructions - -# Once connected, type a value in the terminal and press Enter to update -# the property's "reported" value. The sample also responds when the "desired" -# value changes on the server. To observe this, edit the Shadow document in -# the AWS Console and set a new "desired" value. -# -# - Detail - -# On startup, the sample requests the shadow document to learn the property's -# initial state. The sample also subscribes to "delta" events from the server, -# which are sent when a property's "desired" value differs from its "reported" -# value. When the sample learns of a new desired value, that value is changed -# on the device and an update is sent to the server with the new "reported" -# value. - -# cmdData is the arguments/input from the command line placed into a single struct for -# use in this sample. This handles all of the command line parsing, validating, etc. -# See the Utils/CommandLineUtils for more information. -cmdData = CommandLineUtils.parse_sample_input_shadow() - -# Using globals to simplify sample code -is_sample_done = threading.Event() -mqtt_connection = None -shadow_thing_name = cmdData.input_thing_name -shadow_property = cmdData.input_shadow_property - -SHADOW_VALUE_DEFAULT = "off" - - -class LockedData: - def __init__(self): - self.lock = threading.Lock() - self.shadow_value = None - self.disconnect_called = False - self.request_tokens = set() - - -locked_data = LockedData() - -# Function for gracefully quitting this sample - - -def exit(msg_or_exception): - if isinstance(msg_or_exception, Exception): - print("Exiting sample due to exception.") - traceback.print_exception(msg_or_exception.__class__, msg_or_exception, sys.exc_info()[2]) - else: - print("Exiting sample:", msg_or_exception) - - with locked_data.lock: - if not locked_data.disconnect_called: - print("Disconnecting...") - locked_data.disconnect_called = True - future = mqtt_connection.disconnect() - future.add_done_callback(on_disconnected) - - -def on_disconnected(disconnect_future): - # type: (Future) -> None - print("Disconnected.") - - # Signal that sample is finished - is_sample_done.set() - - -def on_get_shadow_accepted(response): - # type: (iotshadow.GetShadowResponse) -> None - try: - with locked_data.lock: - # check that this is a response to a request from this session - try: - locked_data.request_tokens.remove(response.client_token) - except KeyError: - print("Ignoring get_shadow_accepted message due to unexpected token.") - return - - print("Finished getting initial shadow state.") - if locked_data.shadow_value is not None: - print(" Ignoring initial query because a delta event has already been received.") - return - - if response.state: - if response.state.delta: - value = response.state.delta.get(shadow_property) - if value: - print(" Shadow contains delta value '{}'.".format(value)) - change_shadow_value(value) - return - - if response.state.reported: - value = response.state.reported.get(shadow_property) - if value: - print(" Shadow contains reported value '{}'.".format(value)) - set_local_value_due_to_initial_query(response.state.reported[shadow_property]) - return - - print(" Shadow document lacks '{}' property. Setting defaults...".format(shadow_property)) - change_shadow_value(SHADOW_VALUE_DEFAULT) - return - - except Exception as e: - exit(e) - - -def on_get_shadow_rejected(error): - # type: (iotshadow.ErrorResponse) -> None - try: - # check that this is a response to a request from this session - with locked_data.lock: - try: - locked_data.request_tokens.remove(error.client_token) - except KeyError: - print("Ignoring get_shadow_rejected message due to unexpected token.") - return - - if error.code == 404: - print("Thing has no shadow document. Creating with defaults...") - change_shadow_value(SHADOW_VALUE_DEFAULT) - else: - exit("Get request was rejected. code:{} message:'{}'".format( - error.code, error.message)) - - except Exception as e: - exit(e) - - -def on_shadow_delta_updated(delta): - # type: (iotshadow.ShadowDeltaUpdatedEvent) -> None - try: - print("Received shadow delta event.") - if delta.state and (shadow_property in delta.state): - value = delta.state[shadow_property] - if value is None: - print(" Delta reports that '{}' was deleted. Resetting defaults...".format(shadow_property)) - change_shadow_value(SHADOW_VALUE_DEFAULT) - return - else: - print(" Delta reports that desired value is '{}'. Changing local value...".format(value)) - if (delta.client_token is not None): - print(" ClientToken is: " + delta.client_token) - change_shadow_value(value) - else: - print(" Delta did not report a change in '{}'".format(shadow_property)) - - except Exception as e: - exit(e) - - -def on_publish_update_shadow(future): - # type: (Future) -> None - try: - future.result() - print("Update request published.") - except Exception as e: - print("Failed to publish update request.") - exit(e) - - -def on_update_shadow_accepted(response): - # type: (iotshadow.UpdateShadowResponse) -> None - try: - # check that this is a response to a request from this session - with locked_data.lock: - try: - locked_data.request_tokens.remove(response.client_token) - except KeyError: - print("Ignoring update_shadow_accepted message due to unexpected token.") - return - try: - if response.state.reported is not None: - if shadow_property in response.state.reported: - print("Finished updating reported shadow value to '{}'.".format( - response.state.reported[shadow_property])) # type: ignore - else: - print("Could not find shadow property with name: '{}'.".format(shadow_property)) # type: ignore - else: - print("Shadow states cleared.") # when the shadow states are cleared, reported and desired are set to None - print("Enter desired value: ") # remind user they can input new values - except BaseException: - exit("Updated shadow is missing the target property") - - except Exception as e: - exit(e) - - -def on_update_shadow_rejected(error): - # type: (iotshadow.ErrorResponse) -> None - try: - # check that this is a response to a request from this session - with locked_data.lock: - try: - locked_data.request_tokens.remove(error.client_token) - except KeyError: - print("Ignoring update_shadow_rejected message due to unexpected token.") - return - - exit("Update request was rejected. code:{} message:'{}'".format( - error.code, error.message)) - - except Exception as e: - exit(e) - - -def set_local_value_due_to_initial_query(reported_value): - with locked_data.lock: - locked_data.shadow_value = reported_value - print("Enter desired value: ") # remind user they can input new values - - -def change_shadow_value(value): - with locked_data.lock: - if locked_data.shadow_value == value: - print("Local value is already '{}'.".format(value)) - print("Enter desired value: ") # remind user they can input new values - return - - print("Changed local shadow value to '{}'.".format(value)) - locked_data.shadow_value = value - - print("Updating reported shadow value to '{}'...".format(value)) - - # use a unique token so we can correlate this "request" message to - # any "response" messages received on the /accepted and /rejected topics - token = str(uuid4()) - - # if the value is "clear shadow" then send a UpdateShadowRequest with None - # for both reported and desired to clear the shadow document completely. - if value == "clear_shadow": - tmp_state = iotshadow.ShadowState( - reported=None, - desired=None, - reported_is_nullable=True, - desired_is_nullable=True) - request = iotshadow.UpdateShadowRequest( - thing_name=shadow_thing_name, - state=tmp_state, - client_token=token, - ) - # Otherwise, send a normal update request - else: - # if the value is "none" then set it to a Python none object to - # clear the individual shadow property - if value == "none": - value = None - - request = iotshadow.UpdateShadowRequest( - thing_name=shadow_thing_name, - state=iotshadow.ShadowState( - reported={shadow_property: value}, - desired={shadow_property: value}, - ), - client_token=token, - ) - - future = shadow_client.publish_update_shadow(request, mqtt.QoS.AT_LEAST_ONCE) - - locked_data.request_tokens.add(token) - - future.add_done_callback(on_publish_update_shadow) - - -def user_input_thread_fn(): - # If we are not in CI, then take terminal input - if not cmdData.input_is_ci: - while True: - try: - # Read user input - new_value = input() - - # If user wants to quit sample, then quit. - # Otherwise change the shadow value. - if new_value in ['exit', 'quit']: - exit("User has quit") - break - else: - change_shadow_value(new_value) - - except Exception as e: - print("Exception on input thread.") - exit(e) - break - # Otherwise, send shadow updates automatically + +@dataclass +class SampleContext: + shadow_client: 'iotshadow.IotShadowClientV2' + thing: 'str' + updated_stream: 'Optional[mqtt_request_response.StreamingOperation]' = None + delta_updated_stream: 'Optional[mqtt_request_response.StreamingOperation]' = None + +def print_help(): + print("Shadow Sandbox\n") + print("Commands:") + print(" get - gets the current value of the IoT thing's shadow") + print(" delete - deletes the IoT thing's shadow") + print(" update-desired - updates the desired state of the IoT thing's shadow. If the shadow does not exist, it will be created.") + print(" update-reported - updates the reported state of the IoT thing's shadow. If the shadow does not exist, it will be created.") + print(" quit - quits the sample application\n") + pass + +def handle_get(context : SampleContext): + request = iotshadow.GetShadowRequest(thing_name = context.thing) + response = context.shadow_client.get_shadow(request).result() + print(f"get response:\n {response}\n") + pass + +def handle_delete(context : SampleContext): + request = iotshadow.DeleteShadowRequest(thing_name = context.thing) + response = context.shadow_client.delete_shadow(request).result() + print(f"delete response:\n {response}\n") + +def handle_update_desired(context : SampleContext, line: str): + request = iotshadow.UpdateShadowRequest(thing_name=context.thing) + request.state = iotshadow.ShadowState(desired=json.loads(line.strip())) + + response = context.shadow_client.update_shadow(request).result() + print(f"update-desired response:\n {response}\n") + +def handle_update_reported(context : SampleContext, line: str): + request = iotshadow.UpdateShadowRequest(thing_name=context.thing) + request.state = iotshadow.ShadowState(reported=json.loads(line.strip())) + + response = context.shadow_client.update_shadow(request).result() + print(f"update-reported response:\n {response}\n") + +def handle_input(context : SampleContext, line: str): + words = line.strip().split(" ", 1) + command = words[0] + + if command == "quit": + return True + elif command == "get": + handle_get(context) + elif command == "delete": + handle_delete(context) + elif command == "update-desired": + handle_update_desired(context, words[1]) + elif command == "update-reported": + handle_update_reported(context, words[1]) else: - try: - messages_sent = 0 - while messages_sent < 5: - cli_input = "Shadow_Value_" + str(messages_sent) - change_shadow_value(cli_input) - sleep(1) - messages_sent += 1 - exit("CI has quit") - except Exception as e: - print("Exception on input thread (CI)") - exit(e) + print_help() + return False if __name__ == '__main__': - # Create the proxy options if the data is present in cmdData - proxy_options = None - if cmdData.input_proxy_host is not None and cmdData.input_proxy_port != 0: - proxy_options = http.HttpProxyOptions( - host_name=cmdData.input_proxy_host, - port=cmdData.input_proxy_port) - - # Create a MQTT connection from the command line data - mqtt_connection = mqtt_connection_builder.mtls_from_path( - endpoint=cmdData.input_endpoint, - port=cmdData.input_port, - cert_filepath=cmdData.input_cert, - pri_key_filepath=cmdData.input_key, - ca_filepath=cmdData.input_ca, - client_id=cmdData.input_clientId, - clean_session=False, - keep_alive_secs=30, - http_proxy_options=proxy_options) - - if not cmdData.input_is_ci: - print(f"Connecting to {cmdData.input_endpoint} with client ID '{cmdData.input_clientId}'...") - else: - print("Connecting to endpoint with client ID") + parser = argparse.ArgumentParser( + description="AWS IoT Shadow sandbox application") + parser.add_argument('--endpoint', required=True, help="AWS IoT endpoint to connect to") + parser.add_argument('--cert', required=True, + help="Path to the certificate file to use during mTLS connection establishment") + parser.add_argument('--key', required=True, + help="Path to the private key file to use during mTLS connection establishment") + parser.add_argument('--thing', required=True, + help="Name of the IoT thing to interact with") + + args = parser.parse_args() + + initial_connection_success = Future() + def on_lifecycle_connection_success(event: mqtt5.LifecycleConnectSuccessData): + initial_connection_success.set_result(True) + + def on_lifecycle_connection_failure(event: mqtt5.LifecycleConnectFailureData): + initial_connection_success.set_exception(Exception("Failed to connect")) + + stopped = Future() + def on_lifecycle_stopped(event: mqtt5.LifecycleStoppedData): + stopped.set_result(True) + + # Create a mqtt5 connection from the command line data + mqtt5_client = mqtt5_client_builder.mtls_from_path( + endpoint=args.endpoint, + port=8883, + cert_filepath=args.cert, + pri_key_filepath=args.key, + clean_session=True, + keep_alive_secs=1200, + on_lifecycle_connection_success=on_lifecycle_connection_success, + on_lifecycle_stopped=on_lifecycle_stopped) + + mqtt5_client.start() + + rr_options = mqtt_request_response.ClientOptions( + max_request_response_subscriptions = 2, + max_streaming_subscriptions = 2, + operation_timeout_in_seconds = 30, + ) + shadow_client = iotshadow.IotShadowClientV2(mqtt5_client, rr_options) + + initial_connection_success.result() + print("Connected!") - connected_future = mqtt_connection.connect() + def shadow_updated_callback(event: iotshadow.ShadowUpdatedEvent): + print(f"Received ShadowUpdatedEvent: \n {event}\n") + + updated_stream = shadow_client.create_shadow_updated_stream(iotshadow.ShadowUpdatedSubscriptionRequest(thing_name=args.thing), awsiot.ServiceStreamOptions(shadow_updated_callback)) + updated_stream.open() + + def shadow_delta_updated_callback(event: iotshadow.ShadowDeltaUpdatedEvent): + print(f"Received ShadowDeltaUpdatedEvent: \n {event}\n") + + delta_updated_stream = shadow_client.create_shadow_delta_updated_stream(iotshadow.ShadowDeltaUpdatedSubscriptionRequest(thing_name=args.thing), awsiot.ServiceStreamOptions(shadow_delta_updated_callback)) + delta_updated_stream.open() + + context = SampleContext(shadow_client, args.thing, updated_stream, delta_updated_stream) + + for line in sys.stdin: + try: + if handle_input(context, line): + break + + except Exception as e: + print(f"Exception: {e}\n") + + mqtt5_client.stop() + stopped.result() + print("Stopped!") - shadow_client = iotshadow.IotShadowClient(mqtt_connection) - # Wait for connection to be fully established. - # Note that it's not necessary to wait, commands issued to the - # mqtt_connection before its fully connected will simply be queued. - # But this sample waits here so it's obvious when a connection - # fails or succeeds. - connected_future.result() - print("Connected!") - try: - # Subscribe to necessary topics. - # Note that is **is** important to wait for "accepted/rejected" subscriptions - # to succeed before publishing the corresponding "request". - print("Subscribing to Update responses...") - update_accepted_subscribed_future, _ = shadow_client.subscribe_to_update_shadow_accepted( - request=iotshadow.UpdateShadowSubscriptionRequest(thing_name=shadow_thing_name), - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_update_shadow_accepted) - - update_rejected_subscribed_future, _ = shadow_client.subscribe_to_update_shadow_rejected( - request=iotshadow.UpdateShadowSubscriptionRequest(thing_name=shadow_thing_name), - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_update_shadow_rejected) - - # Wait for subscriptions to succeed - update_accepted_subscribed_future.result() - update_rejected_subscribed_future.result() - - print("Subscribing to Get responses...") - get_accepted_subscribed_future, _ = shadow_client.subscribe_to_get_shadow_accepted( - request=iotshadow.GetShadowSubscriptionRequest(thing_name=shadow_thing_name), - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_get_shadow_accepted) - - get_rejected_subscribed_future, _ = shadow_client.subscribe_to_get_shadow_rejected( - request=iotshadow.GetShadowSubscriptionRequest(thing_name=shadow_thing_name), - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_get_shadow_rejected) - - # Wait for subscriptions to succeed - get_accepted_subscribed_future.result() - get_rejected_subscribed_future.result() - - print("Subscribing to Delta events...") - delta_subscribed_future, _ = shadow_client.subscribe_to_shadow_delta_updated_events( - request=iotshadow.ShadowDeltaUpdatedSubscriptionRequest(thing_name=shadow_thing_name), - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_shadow_delta_updated) - - # Wait for subscription to succeed - delta_subscribed_future.result() - - # The rest of the sample runs asynchronously. - - # Issue request for shadow's current state. - # The response will be received by the on_get_accepted() callback - print("Requesting current shadow state...") - - with locked_data.lock: - # use a unique token so we can correlate this "request" message to - # any "response" messages received on the /accepted and /rejected topics - token = str(uuid4()) - - publish_get_future = shadow_client.publish_get_shadow( - request=iotshadow.GetShadowRequest(thing_name=shadow_thing_name, client_token=token), - qos=mqtt.QoS.AT_LEAST_ONCE) - - locked_data.request_tokens.add(token) - - # Ensure that publish succeeds - publish_get_future.result() - - # Launch thread to handle user input. - # A "daemon" thread won't prevent the program from shutting down. - print("Launching thread to read user input...") - user_input_thread = threading.Thread(target=user_input_thread_fn, name='user_input_thread') - user_input_thread.daemon = True - user_input_thread.start() - - except Exception as e: - exit(e) - - # Wait for the sample to finish (user types 'quit', or an error occurs) - is_sample_done.wait() diff --git a/samples/shadowv2.py b/samples/shadowv2.py deleted file mode 100644 index dfeb89ac..00000000 --- a/samples/shadowv2.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0. - -from awscrt import mqtt5, mqtt_request_response, io -from awsiot import iotshadow, mqtt5_client_builder -from concurrent.futures import Future -from dataclasses import dataclass -from typing import Optional -import awsiot -import argparse -import json -import sys - - -@dataclass -class SampleContext: - shadow_client: 'iotshadow.IotShadowClientV2' - thing: 'str' - updated_stream: 'Optional[mqtt_request_response.StreamingOperation]' = None - delta_updated_stream: 'Optional[mqtt_request_response.StreamingOperation]' = None - -def print_help(): - print("Shadow Sandbox\n") - print("Commands:") - print(" get - gets the current value of the IoT thing's shadow") - print(" delete - deletes the IoT thing's shadow") - print(" update-desired - updates the desired state of the IoT thing's shadow. If the shadow does not exist, it will be created.") - print(" update-reported - updates the reported state of the IoT thing's shadow. If the shadow does not exist, it will be created.") - print(" quit - quits the sample application\n") - pass - -def handle_get(context : SampleContext): - request = iotshadow.GetShadowRequest(thing_name = context.thing) - response = context.shadow_client.get_shadow(request).result() - print(f"get response:\n {response}\n") - pass - -def handle_delete(context : SampleContext): - request = iotshadow.DeleteShadowRequest(thing_name = context.thing) - response = context.shadow_client.delete_shadow(request).result() - print(f"delete response:\n {response}\n") - -def handle_update_desired(context : SampleContext, line: str): - request = iotshadow.UpdateShadowRequest(thing_name=context.thing) - request.state = iotshadow.ShadowState(desired=json.loads(line.strip())) - - response = context.shadow_client.update_shadow(request).result() - print(f"update-desired response:\n {response}\n") - -def handle_update_reported(context : SampleContext, line: str): - request = iotshadow.UpdateShadowRequest(thing_name=context.thing) - request.state = iotshadow.ShadowState(reported=json.loads(line.strip())) - - response = context.shadow_client.update_shadow(request).result() - print(f"update-reported response:\n {response}\n") - -def handle_input(context : SampleContext, line: str): - words = line.strip().split(" ", 1) - command = words[0] - - if command == "quit": - return True - elif command == "get": - handle_get(context) - elif command == "delete": - handle_delete(context) - elif command == "update-desired": - handle_update_desired(context, words[1]) - elif command == "update-reported": - handle_update_reported(context, words[1]) - else: - print_help() - - return False - -if __name__ == '__main__': - parser = argparse.ArgumentParser( - description="AWS IoT Shadow sandbox application") - parser.add_argument('--endpoint', required=True, help="AWS IoT endpoint to connect to") - parser.add_argument('--cert', required=True, - help="Path to the certificate file to use during mTLS connection establishment") - parser.add_argument('--key', required=True, - help="Path to the private key file to use during mTLS connection establishment") - parser.add_argument('--thing', required=True, - help="Name of the IoT thing to interact with") - - args = parser.parse_args() - - initial_connection_success = Future() - def on_lifecycle_connection_success(event: mqtt5.LifecycleConnectSuccessData): - initial_connection_success.set_result(True) - - def on_lifecycle_connection_failure(event: mqtt5.LifecycleConnectFailureData): - initial_connection_success.set_exception(Exception("Failed to connect")) - - stopped = Future() - def on_lifecycle_stopped(event: mqtt5.LifecycleStoppedData): - stopped.set_result(True) - - # Create a mqtt5 connection from the command line data - mqtt5_client = mqtt5_client_builder.mtls_from_path( - endpoint=args.endpoint, - port=8883, - cert_filepath=args.cert, - pri_key_filepath=args.key, - clean_session=True, - keep_alive_secs=1200, - on_lifecycle_connection_success=on_lifecycle_connection_success, - on_lifecycle_stopped=on_lifecycle_stopped) - - mqtt5_client.start() - - rr_options = mqtt_request_response.ClientOptions( - max_request_response_subscriptions = 2, - max_streaming_subscriptions = 2, - operation_timeout_in_seconds = 30, - ) - shadow_client = iotshadow.IotShadowClientV2(mqtt5_client, rr_options) - - initial_connection_success.result() - print("Connected!") - - def shadow_updated_callback(event: iotshadow.ShadowUpdatedEvent): - print(f"Received ShadowUpdatedEvent: \n {event}\n") - - updated_stream = shadow_client.create_shadow_updated_stream(iotshadow.ShadowUpdatedSubscriptionRequest(thing_name=args.thing), awsiot.ServiceStreamOptions(shadow_updated_callback)) - updated_stream.open() - - def shadow_delta_updated_callback(event: iotshadow.ShadowDeltaUpdatedEvent): - print(f"Received ShadowDeltaUpdatedEvent: \n {event}\n") - - delta_updated_stream = shadow_client.create_shadow_delta_updated_stream(iotshadow.ShadowDeltaUpdatedSubscriptionRequest(thing_name=args.thing), awsiot.ServiceStreamOptions(shadow_delta_updated_callback)) - delta_updated_stream.open() - - context = SampleContext(shadow_client, args.thing, updated_stream, delta_updated_stream) - - for line in sys.stdin: - try: - if handle_input(context, line): - break - - except Exception as e: - print(f"Exception: {e}\n") - - mqtt5_client.stop() - stopped.result() - print("Stopped!") - - - From 6d830d7c5c53b5c6415b3b27ff8d783157b5c69e Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Thu, 15 May 2025 13:58:22 -0700 Subject: [PATCH 09/26] Jobs sandbox and readme --- samples/{ => deprecated}/fleetprovisioning.md | 0 samples/{ => deprecated}/fleetprovisioning.py | 0 .../fleetprovisioning_mqtt5.md | 0 .../fleetprovisioning_mqtt5.py | 0 samples/deprecated/jobs.md | 84 +++ samples/deprecated/jobs.py | 396 +++++++++++++ samples/{ => deprecated}/jobs_mqtt5.md | 0 samples/{ => deprecated}/jobs_mqtt5.py | 0 samples/jobs.md | 266 ++++++++- samples/jobs.py | 543 ++++++------------ samples/shadow.md | 2 +- 11 files changed, 909 insertions(+), 382 deletions(-) rename samples/{ => deprecated}/fleetprovisioning.md (100%) rename samples/{ => deprecated}/fleetprovisioning.py (100%) rename samples/{ => deprecated}/fleetprovisioning_mqtt5.md (100%) rename samples/{ => deprecated}/fleetprovisioning_mqtt5.py (100%) create mode 100644 samples/deprecated/jobs.md create mode 100644 samples/deprecated/jobs.py rename samples/{ => deprecated}/jobs_mqtt5.md (100%) rename samples/{ => deprecated}/jobs_mqtt5.py (100%) diff --git a/samples/fleetprovisioning.md b/samples/deprecated/fleetprovisioning.md similarity index 100% rename from samples/fleetprovisioning.md rename to samples/deprecated/fleetprovisioning.md diff --git a/samples/fleetprovisioning.py b/samples/deprecated/fleetprovisioning.py similarity index 100% rename from samples/fleetprovisioning.py rename to samples/deprecated/fleetprovisioning.py diff --git a/samples/fleetprovisioning_mqtt5.md b/samples/deprecated/fleetprovisioning_mqtt5.md similarity index 100% rename from samples/fleetprovisioning_mqtt5.md rename to samples/deprecated/fleetprovisioning_mqtt5.md diff --git a/samples/fleetprovisioning_mqtt5.py b/samples/deprecated/fleetprovisioning_mqtt5.py similarity index 100% rename from samples/fleetprovisioning_mqtt5.py rename to samples/deprecated/fleetprovisioning_mqtt5.py diff --git a/samples/deprecated/jobs.md b/samples/deprecated/jobs.md new file mode 100644 index 00000000..d33ab597 --- /dev/null +++ b/samples/deprecated/jobs.md @@ -0,0 +1,84 @@ +# Jobs + +[**Return to main sample list**](./README.md) + +This sample uses the AWS IoT [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) Service to describe jobs to execute. [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) is a service that allows you to define and respond to remote operation requests defined through the AWS IoT Core website or via any other device (or CLI command) that can access the [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) service. + +Note: This sample requires you to create jobs for your device to execute. See +[instructions here](https://docs.aws.amazon.com/iot/latest/developerguide/create-manage-jobs.html) for how to make jobs. + +On startup, the sample describes the jobs that are pending execution and pretends to process them, marking each job as complete as it does so. + +Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerguide/iot-policies.html) must provide privileges for this sample to connect, subscribe, publish, and receive. Below is a sample policy that can be used on your IoT Core Thing that will allow this sample to run as intended. + +
+Sample Policy +
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": "iot:Publish",
+      "Resource": [
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/start-next",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/*/update",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/*/get",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/get"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": "iot:Receive",
+      "Resource": [
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/notify-next",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/start-next/*",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/*/update/*",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/get/*",
+        "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/*/get/*"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": "iot:Subscribe",
+      "Resource": [
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/notify-next",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/start-next/*",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/*/update/*",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/get/*",
+        "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/*/get/*"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": "iot:Connect",
+      "Resource": "arn:aws:iot:region:account:client/test-*"
+    }
+  ]
+}
+
+ +Replace with the following with the data from your AWS account: +* ``: The AWS IoT Core region where you created your AWS IoT Core thing you wish to use with this sample. For example `us-east-1`. +* ``: Your AWS IoT Core account ID. This is the set of numbers in the top right next to your AWS account name when using the AWS IoT Core website. +* ``: The name of your AWS IoT Core thing you want the device connection to be associated with + +Note that in a real application, you may want to avoid the use of wildcards in your ClientID or use them selectively. Please follow best practices when working with AWS on production applications using the SDK. Also, for the purposes of this sample, please make sure your policy allows a client ID of `test-*` to connect or use `--client_id ` to send the client ID your policy supports. + +
+ +## How to run + +Use the following command to run the Jobs sample from the `samples` folder: + +``` sh +# For Windows: replace 'python3' with 'python' and '/' with '\' +python3 jobs.py --endpoint --cert --key --thing_name +``` + +You can also pass a Certificate Authority file (CA) if your certificate and key combination requires it: + +``` sh +# For Windows: replace 'python3' with 'python' and '/' with '\' +python3 jobs.py --endpoint --cert --key --thing_name --ca_file +``` diff --git a/samples/deprecated/jobs.py b/samples/deprecated/jobs.py new file mode 100644 index 00000000..4ac8d40a --- /dev/null +++ b/samples/deprecated/jobs.py @@ -0,0 +1,396 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +from awscrt import mqtt, http +from awsiot import iotjobs, mqtt_connection_builder +from concurrent.futures import Future +import sys +import threading +import time +import traceback +import time +from utils.command_line_utils import CommandLineUtils + +# - Overview - +# This sample uses the AWS IoT Jobs Service to get a list of pending jobs and +# then execution operations on these pending jobs until there are no more +# remaining on the device. Imagine periodic software updates that must be sent to and +# executed on devices in the wild. +# +# - Instructions - +# This sample requires you to create jobs for your device to execute. See: +# https://docs.aws.amazon.com/iot/latest/developerguide/create-manage-jobs.html +# +# - Detail - +# On startup, the sample tries to get a list of all the in-progress and queued +# jobs and display them in a list. Then it tries to start the next pending job execution. +# If such a job exists, the sample emulates "doing work" by spawning a thread +# that sleeps for several seconds before marking the job as SUCCEEDED. When no +# pending job executions exist, the sample sits in an idle state. +# +# The sample also subscribes to receive "Next Job Execution Changed" events. +# If the sample is idle, this event wakes it to start the job. If the sample is +# already working on a job, it remembers to try for another when it's done. +# This event is sent by the service when the current job completes, so the +# sample will be continually prompted to try another job until none remain. + +# Using globals to simplify sample code +is_sample_done = threading.Event() + +# cmdData is the arguments/input from the command line placed into a single struct for +# use in this sample. This handles all of the command line parsing, validating, etc. +# See the Utils/CommandLineUtils for more information. +cmdData = CommandLineUtils.parse_sample_input_jobs() + +mqtt_connection = None +jobs_client = None +jobs_thing_name = cmdData.input_thing_name + + +class LockedData: + def __init__(self): + self.lock = threading.Lock() + self.disconnect_called = False + self.is_working_on_job = False + self.is_next_job_waiting = False + self.got_job_response = False + + +locked_data = LockedData() + +# Function for gracefully quitting this sample +def exit(msg_or_exception): + if isinstance(msg_or_exception, Exception): + print("Exiting Sample due to exception.") + traceback.print_exception(msg_or_exception.__class__, msg_or_exception, sys.exc_info()[2]) + else: + print("Exiting Sample:", msg_or_exception) + + with locked_data.lock: + if not locked_data.disconnect_called: + print("Disconnecting...") + locked_data.disconnect_called = True + future = mqtt_connection.disconnect() + future.add_done_callback(on_disconnected) + + +def try_start_next_job(): + print("Trying to start the next job...") + with locked_data.lock: + if locked_data.is_working_on_job: + print("Nevermind, already working on a job.") + return + + if locked_data.disconnect_called: + print("Nevermind, sample is disconnecting.") + return + + locked_data.is_working_on_job = True + locked_data.is_next_job_waiting = False + + print("Publishing request to start next job...") + request = iotjobs.StartNextPendingJobExecutionRequest(thing_name=jobs_thing_name) + publish_future = jobs_client.publish_start_next_pending_job_execution(request, mqtt.QoS.AT_LEAST_ONCE) + publish_future.add_done_callback(on_publish_start_next_pending_job_execution) + + +def done_working_on_job(): + with locked_data.lock: + locked_data.is_working_on_job = False + try_again = locked_data.is_next_job_waiting + + if try_again: + try_start_next_job() + + +def on_disconnected(disconnect_future): + # type: (Future) -> None + print("Disconnected.") + + # Signal that sample is finished + is_sample_done.set() + + +# A list to hold all the pending jobs +available_jobs = [] + + +def on_get_pending_job_executions_accepted(response): + # type: (iotjobs.GetPendingJobExecutionsResponse) -> None + with locked_data.lock: + if (len(response.queued_jobs) > 0 or len(response.in_progress_jobs) > 0): + print("Pending Jobs:") + for job in response.in_progress_jobs: + available_jobs.append(job) + print(f" In Progress: {job.job_id} @ {job.last_updated_at}") + for job in response.queued_jobs: + available_jobs.append(job) + print(f" {job.job_id} @ {job.last_updated_at}") + else: + print("No pending or queued jobs found!") + locked_data.got_job_response = True + + +def on_get_pending_job_executions_rejected(error): + # type: (iotjobs.RejectedError) -> None + print(f"Request rejected: {error.code}: {error.message}") + exit("Get pending jobs request rejected!") + + +def on_next_job_execution_changed(event): + # type: (iotjobs.NextJobExecutionChangedEvent) -> None + try: + execution = event.execution + if execution: + print("Received Next Job Execution Changed event. job_id:{} job_document:{}".format( + execution.job_id, execution.job_document)) + + # Start job now, or remember to start it when current job is done + start_job_now = False + with locked_data.lock: + if locked_data.is_working_on_job: + locked_data.is_next_job_waiting = True + else: + start_job_now = True + + if start_job_now: + try_start_next_job() + + else: + print("Received Next Job Execution Changed event: None. Waiting for further jobs...") + + except Exception as e: + exit(e) + + +def on_publish_start_next_pending_job_execution(future): + # type: (Future) -> None + try: + future.result() # raises exception if publish failed + + print("Published request to start the next job.") + + except Exception as e: + exit(e) + + +def on_start_next_pending_job_execution_accepted(response): + # type: (iotjobs.StartNextJobExecutionResponse) -> None + try: + if response.execution: + execution = response.execution + print("Request to start next job was accepted. job_id:{} job_document:{}".format( + execution.job_id, execution.job_document)) + + # To emulate working on a job, spawn a thread that sleeps for a few seconds + job_thread = threading.Thread( + target=lambda: job_thread_fn(execution.job_id, execution.job_document), + name='job_thread') + job_thread.start() + else: + print("Request to start next job was accepted, but there are no jobs to be done. Waiting for further jobs...") + done_working_on_job() + + except Exception as e: + exit(e) + + +def on_start_next_pending_job_execution_rejected(rejected): + # type: (iotjobs.RejectedError) -> None + exit("Request to start next pending job rejected with code:'{}' message:'{}'".format( + rejected.code, rejected.message)) + + +def job_thread_fn(job_id, job_document): + try: + print("Starting local work on job...") + time.sleep(cmdData.input_job_time) + print("Done working on job.") + + print("Publishing request to update job status to SUCCEEDED...") + request = iotjobs.UpdateJobExecutionRequest( + thing_name=jobs_thing_name, + job_id=job_id, + status=iotjobs.JobStatus.SUCCEEDED) + publish_future = jobs_client.publish_update_job_execution(request, mqtt.QoS.AT_LEAST_ONCE) + publish_future.add_done_callback(on_publish_update_job_execution) + + except Exception as e: + exit(e) + + +def on_publish_update_job_execution(future): + # type: (Future) -> None + try: + future.result() # raises exception if publish failed + print("Published request to update job.") + + except Exception as e: + exit(e) + + +def on_update_job_execution_accepted(response): + # type: (iotjobs.UpdateJobExecutionResponse) -> None + try: + print("Request to update job was accepted.") + done_working_on_job() + except Exception as e: + exit(e) + + +def on_update_job_execution_rejected(rejected): + # type: (iotjobs.RejectedError) -> None + exit("Request to update job status was rejected. code:'{}' message:'{}'.".format( + rejected.code, rejected.message)) + + +if __name__ == '__main__': + + # Create the proxy options if the data is present in cmdData + proxy_options = None + if cmdData.input_proxy_host is not None and cmdData.input_proxy_port != 0: + proxy_options = http.HttpProxyOptions( + host_name=cmdData.input_proxy_host, + port=cmdData.input_proxy_port) + + # Create a MQTT connection from the command line data + mqtt_connection = mqtt_connection_builder.mtls_from_path( + endpoint=cmdData.input_endpoint, + port=cmdData.input_port, + cert_filepath=cmdData.input_cert, + pri_key_filepath=cmdData.input_key, + ca_filepath=cmdData.input_ca, + client_id=cmdData.input_clientId, + clean_session=False, + keep_alive_secs=30, + http_proxy_options=proxy_options) + + if not cmdData.input_is_ci: + print(f"Connecting to {cmdData.input_endpoint} with client ID '{cmdData.input_clientId}'...") + else: + print("Connecting to endpoint with client ID") + + connected_future = mqtt_connection.connect() + + jobs_client = iotjobs.IotJobsClient(mqtt_connection) + + # Wait for connection to be fully established. + # Note that it's not necessary to wait, commands issued to the + # mqtt_connection before its fully connected will simply be queued. + # But this sample waits here so it's obvious when a connection + # fails or succeeds. + connected_future.result() + print("Connected!") + + try: + # List the jobs queued and pending + get_jobs_request = iotjobs.GetPendingJobExecutionsRequest(thing_name=jobs_thing_name) + jobs_request_future_accepted, _ = jobs_client.subscribe_to_get_pending_job_executions_accepted( + request=get_jobs_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_get_pending_job_executions_accepted + ) + # Wait for the subscription to succeed + jobs_request_future_accepted.result() + + jobs_request_future_rejected, _ = jobs_client.subscribe_to_get_pending_job_executions_rejected( + request=get_jobs_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_get_pending_job_executions_rejected + ) + # Wait for the subscription to succeed + jobs_request_future_rejected.result() + + # Get a list of all the jobs + get_jobs_request_future = jobs_client.publish_get_pending_job_executions( + request=get_jobs_request, + qos=mqtt.QoS.AT_LEAST_ONCE + ) + # Wait for the publish to succeed + get_jobs_request_future.result() + except Exception as e: + exit(e) + + # If we are running in CI, then we want to check how many jobs were reported and stop + if (cmdData.input_is_ci): + # Wait until we get a response. If we do not get a response after 50 tries, then abort + got_job_response_tries = 0 + while (locked_data.got_job_response == False): + got_job_response_tries += 1 + if (got_job_response_tries > 50): + exit("Got job response timeout exceeded") + sys.exit(-1) + time.sleep(0.2) + + if (len(available_jobs) > 0): + print("At least one job queued in CI! No further work to do. Exiting sample...") + sys.exit(0) + else: + print("ERROR: No jobs queued in CI! At least one job should be queued!") + sys.exit(-1) + + try: + # Subscribe to necessary topics. + # Note that is **is** important to wait for "accepted/rejected" subscriptions + # to succeed before publishing the corresponding "request". + print("Subscribing to Next Changed events...") + changed_subscription_request = iotjobs.NextJobExecutionChangedSubscriptionRequest( + thing_name=jobs_thing_name) + + subscribed_future, _ = jobs_client.subscribe_to_next_job_execution_changed_events( + request=changed_subscription_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_next_job_execution_changed) + + # Wait for subscription to succeed + subscribed_future.result() + + print("Subscribing to Start responses...") + start_subscription_request = iotjobs.StartNextPendingJobExecutionSubscriptionRequest( + thing_name=jobs_thing_name) + subscribed_accepted_future, _ = jobs_client.subscribe_to_start_next_pending_job_execution_accepted( + request=start_subscription_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_start_next_pending_job_execution_accepted) + + subscribed_rejected_future, _ = jobs_client.subscribe_to_start_next_pending_job_execution_rejected( + request=start_subscription_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_start_next_pending_job_execution_rejected) + + # Wait for subscriptions to succeed + subscribed_accepted_future.result() + subscribed_rejected_future.result() + + print("Subscribing to Update responses...") + # Note that we subscribe to "+", the MQTT wildcard, to receive + # responses about any job-ID. + update_subscription_request = iotjobs.UpdateJobExecutionSubscriptionRequest( + thing_name=jobs_thing_name, + job_id='+') + + subscribed_accepted_future, _ = jobs_client.subscribe_to_update_job_execution_accepted( + request=update_subscription_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_update_job_execution_accepted) + + subscribed_rejected_future, _ = jobs_client.subscribe_to_update_job_execution_rejected( + request=update_subscription_request, + qos=mqtt.QoS.AT_LEAST_ONCE, + callback=on_update_job_execution_rejected) + + # Wait for subscriptions to succeed + subscribed_accepted_future.result() + subscribed_rejected_future.result() + + # Make initial attempt to start next job. The service should reply with + # an "accepted" response, even if no jobs are pending. The response + # will contain data about the next job, if there is one. + # (Will do nothing if we are in CI) + try_start_next_job() + + except Exception as e: + exit(e) + + # Wait for the sample to finish + is_sample_done.wait() diff --git a/samples/jobs_mqtt5.md b/samples/deprecated/jobs_mqtt5.md similarity index 100% rename from samples/jobs_mqtt5.md rename to samples/deprecated/jobs_mqtt5.md diff --git a/samples/jobs_mqtt5.py b/samples/deprecated/jobs_mqtt5.py similarity index 100% rename from samples/jobs_mqtt5.py rename to samples/deprecated/jobs_mqtt5.py diff --git a/samples/jobs.md b/samples/jobs.md index d33ab597..1ff74252 100644 --- a/samples/jobs.md +++ b/samples/jobs.md @@ -1,13 +1,39 @@ -# Jobs +# Jobs Sandbox [**Return to main sample list**](./README.md) -This sample uses the AWS IoT [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) Service to describe jobs to execute. [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) is a service that allows you to define and respond to remote operation requests defined through the AWS IoT Core website or via any other device (or CLI command) that can access the [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) service. +This is an interactive sample that supports a set of commands that allow you to interact with the AWS IoT [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) Service. The sample includes both control plane +commands (that require the AWS SDK for Python and use HTTP as transport) and data plane commands (that use the v2 device SDK and use MQTT as transport). In a real use case, +control plane commands would be issued by applications under control of the customer, while the data plane operations would be issued by software running on the +IoT device itself. -Note: This sample requires you to create jobs for your device to execute. See -[instructions here](https://docs.aws.amazon.com/iot/latest/developerguide/create-manage-jobs.html) for how to make jobs. +Using the Jobs service and this sample requires an understanding of two closely-related but different service terms: +* **Job** - metadata describing a task that the user would like one or more devices to run +* **Job Execution** - metadata describing the state of a single device's attempt to execute a job -On startup, the sample describes the jobs that are pending execution and pretends to process them, marking each job as complete as it does so. +In particular, you could have many IoT devices (things) that belong to a thing group. You could create a **Job** that targets the thing group. Each device/thing would +manage its own individual **Job Execution** that corresponded to its attempt to fulfill the overall job request. In the section that follows, notice that all of the data-plane +commands use `job-execution` while all of the control plane commands use `job`. + +### Commands + +Once connected, the sample supports the following commands: + +Control Plane +* `create-job ` - creates a new job resource that targets the thing/device the sample has been configured with. It is up to the device application to interpret the Job document appropriately and carry out the execution it describes. +* `delete-job ` - delete a job. A job must be in a terminal state (all executions terminal) for this command to complete successfully. + +Data Plane +* `get-pending-job-executions` - gets the state of all incomplete job executions for this thing/device. +* `start-next-pending-job-execution` - if one or more pending job executions exist for this thing/device, attempts to transition the next one from QUEUED to IN_PROGRESS. Returns information about the newly-in-progress job execution, if it exists. +* `describe-job-execution ` - gets the current state of this thing's execution of a particular job. +* `update-job-execution ` - updates the status field of this thing's execution of a particular job. SUCCEEDED, FAILED, and CANCELED are all terminal states. + +Miscellaneous +* `help` - prints the set of supported commands +* `quit` - quits the sample application + +## Prerequisites Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerguide/iot-policies.html) must provide privileges for this sample to connect, subscribe, publish, and receive. Below is a sample policy that can be used on your IoT Core Thing that will allow this sample to run as intended. @@ -31,6 +57,7 @@ Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerg "Effect": "Allow", "Action": "iot:Receive", "Resource": [ + "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/notify", "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/notify-next", "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/start-next/*", "arn:aws:iot:region:account:topic/$aws/things/thingname/jobs/*/update/*", @@ -42,6 +69,7 @@ Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerg "Effect": "Allow", "Action": "iot:Subscribe", "Resource": [ + "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/notify", "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/notify-next", "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/start-next/*", "arn:aws:iot:region:account:topicfilter/$aws/things/thingname/jobs/*/update/*", @@ -52,7 +80,7 @@ Your IoT Core Thing's [Policy](https://docs.aws.amazon.com/iot/latest/developerg { "Effect": "Allow", "Action": "iot:Connect", - "Resource": "arn:aws:iot:region:account:client/test-*" + "Resource": "arn:aws:iot:region:account:client/*" } ] } @@ -67,18 +95,230 @@ Note that in a real application, you may want to avoid the use of wildcards in y -## How to run +Additionally, the sample's control plane operations require that AWS credentials with appropriate permissions be sourceable by the default credentials provider chain +of the Python SDK. At a minimum, the following permissions must be granted: +
+Sample Policy +
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": "iot:CreateJob",
+      "Resource": [
+        "arn:aws:iot:region:account:job/*",
+        "arn:aws:iot:region:account:thing/thingname"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": "iot:DeleteJob",
+      "Resource": [
+        "arn:aws:iot:region:account:job/*",
+        "arn:aws:iot:region:account:thing/thingname"
+      ]
+    }
+  ]
+}
+
+ +Replace with the following with the data from your AWS account: +* ``: The AWS IoT Core region where you created your AWS IoT Core thing you wish to use with this sample. For example `us-east-1`. +* ``: Your AWS IoT Core account ID. This is the set of numbers in the top right next to your AWS account name when using the AWS IoT Core website. +* ``: The name of your AWS IoT Core thing you want the device connection to be associated with + +Notice that you must provide `iot:CreateJob` permission to all things targeted by your jobs as well as the job itself. In this example, we use a wildcard for the +job permission so that you can name the jobs whatever you would like. + +
+ +## Walkthrough + +### Run The Sample +First, from an empty directory, clone the SDK via git: +``` sh +git clone https://github.com/aws/aws-iot-device-sdk-python-v2 +``` +If not already active, activate the [virtual environment](https://docs.python.org/3/library/venv.html) that will be used to contain Python's execution context. -Use the following command to run the Jobs sample from the `samples` folder: +If the venv does not yet have the device SDK installed, install it: ``` sh -# For Windows: replace 'python3' with 'python' and '/' with '\' -python3 jobs.py --endpoint --cert --key --thing_name +python3 -m pip install awsiotsdk ``` -You can also pass a Certificate Authority file (CA) if your certificate and key combination requires it: +If the venv does not yet have the AWS SDK for Python installed, install it: ``` sh -# For Windows: replace 'python3' with 'python' and '/' with '\' -python3 jobs.py --endpoint --cert --key --thing_name --ca_file +python3 -m pip install boto3 ``` + +Assuming you are in the SDK root directory, you can now run the jobs sandbox sample: + +``` sh +python3 samples/jobs.py --cert --key --endpoint --thing --region +``` + +The region value passed in the region parameter must match the region referred to by the endpoint parameter. + +If an AWS IoT Thing resource with the given name does not exist, the sample will first create it. Once the thing +exists, the sample connects via MQTT and you can issue commands to the Jobs service and inspect the results. This walkthrough assumes a fresh thing +that has no pre-existing jobs targeting it. + +### Job Creation +First, we check if there are any incomplete job executions for this device. Assuming the thing is freshly-created, we expect there to be nothing: + +``` +get-pending-job-executions +``` +yields output like +``` +GetPendingJobExecutionsResponse: awsiot.iotjobs.GetPendingJobExecutionsResponse(client_token='b0c2519d-e611-438c-a7f2-8dabecb52e10', in_progress_jobs=[], queued_jobs=[], timestamp=datetime.datetime(2025, 5, 15, 13, 43, 56)) +``` +from which we can see that the device has no pending job executions and no in-progress job executions. + +Next, we'll create a couple of jobs that target the device: + +``` +create-job Job1 {"ToDo":"Reboot"} +``` + +which yields output similar to + +``` +CreateJobResponse: {'ResponseMetadata': {'RequestId': '05693db6-72d2-41f3-85f9-fc0a050275c5', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Thu, 15 May 2025 20:44:30 GMT', 'content-type': 'application/json', 'content-length': '90', 'connection': 'keep-alive', 'x-amzn-requestid': '05693db6-72d2-41f3-85f9-fc0a050275c5'}, 'RetryAttempts': 0}, 'jobArn': 'arn:aws:iot:us-east-1:123124136734:job/Job1', 'jobId': 'Job1'} + +Received JobExecutionsChangedEvent: + awsiot.iotjobs.JobExecutionsChangedEvent(jobs={'QUEUED': [awsiot.iotjobs.JobExecutionSummary(execution_number=1, job_id='Job1', last_updated_at=datetime.datetime(2025, 5, 15, 13, 44, 31), queued_at=datetime.datetime(2025, 5, 15, 13, 44, 31), started_at=None, version_number=1)]}, timestamp=datetime.datetime(2025, 5, 15, 13, 44, 32)) + +Received NextJobExecutionChangedEvent: + awsiot.iotjobs.NextJobExecutionChangedEvent(execution=awsiot.iotjobs.JobExecutionData(execution_number=1, job_document={'ToDo': 'Reboot'}, job_id='Job1', last_updated_at=datetime.datetime(2025, 5, 15, 13, 44, 31), queued_at=datetime.datetime(2025, 5, 15, 13, 44, 31), started_at=None, status='QUEUED', status_details=None, thing_name=None, version_number=1), timestamp=datetime.datetime(2025, 5, 15, 13, 44, 32)) +``` + +In addition to the successful (HTTP) response to the CreateJob API call, our action triggered two (MQTT-based) events: a JobExecutionsChanged event and a +NextJobExecutionChanged event. When the sample is run, it creates and opens two streaming operations that listen for these two different events, by using the +`create_job_executions_changed_stream` and `create_next_job_execution_changed_stream` APIs. + +A JobExecutionsChanged event is emitted every time either the queued or in-progress job execution sets change for the device. A NextJobExecutionChanged event is emitted +only when the next job to be executed changes. So if you create N jobs targeting a device, you'll get N JobExecutionsChanged events, but only (up to) one +NextJobExecutionChanged event (unless the device starts completing jobs, triggering additional NextJobExecutionChanged events). + +Let's create a second job as well: + +``` +create-job Job2 {"ToDo":"Delete Root User"} +``` + +whose output might look like + +``` +CreateJobResponse: {'ResponseMetadata': {'RequestId': '47abf702-e987-463e-a258-be2de1630d53', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Thu, 15 May 2025 20:46:05 GMT', 'content-type': 'application/json', 'content-length': '90', 'connection': 'keep-alive', 'x-amzn-requestid': '47abf702-e987-463e-a258-be2de1630d53'}, 'RetryAttempts': 0}, 'jobArn': 'arn:aws:iot:us-east-1:123124136734:job/Job2', 'jobId': 'Job2'} + +Received JobExecutionsChangedEvent: + awsiot.iotjobs.JobExecutionsChangedEvent(jobs={'QUEUED': [awsiot.iotjobs.JobExecutionSummary(execution_number=1, job_id='Job1', last_updated_at=datetime.datetime(2025, 5, 15, 13, 44, 31), queued_at=datetime.datetime(2025, 5, 15, 13, 44, 31), started_at=None, version_number=1), awsiot.iotjobs.JobExecutionSummary(execution_number=1, job_id='Job2', last_updated_at=datetime.datetime(2025, 5, 15, 13, 46, 6), queued_at=datetime.datetime(2025, 5, 15, 13, 46, 6), started_at=None, version_number=1)]}, timestamp=datetime.datetime(2025, 5, 15, 13, 46, 6)) +``` + +Notice how this time, there is no NextJobExecutionChanged event because the second job is behind the first, and therefore the next job execution hasn't changed. As we will +see below, a NextJobExecutionChanged event referencing the second job will be emitted when the first job (in progress) is completed. + +### Job Execution +Our device now has two jobs queued that it needs to (pretend to) execute. Let's see how to do that, and what happens when we do. + +The easiest way to start a job execution is via the `start_next_pending_job_execution` API. This API takes the job execution at the head of the QUEUED list and moves it +into the IN_PROGRESS state, returning its job document in the process. + +``` +start-next-pending-job-execution +``` +``` +StartNextPendingJobExecutionResponse: awsiot.iotjobs.StartNextJobExecutionResponse(client_token='ed500cb5-1d8b-4301-81a7-fbb19769888d', execution=awsiot.iotjobs.JobExecutionData(execution_number=1, job_document={'ToDo': 'Reboot'}, job_id='Job1', last_updated_at=datetime.datetime(2025, 5, 15, 13, 47, 30), queued_at=datetime.datetime(2025, 5, 15, 13, 44, 31), started_at=datetime.datetime(2025, 5, 15, 13, 47, 30), status='IN_PROGRESS', status_details=None, thing_name=None, version_number=2), timestamp=datetime.datetime(2025, 5, 15, 13, 47, 30)) +``` +Note that the response includes the job's document, which is what describes what the job actually entails. The contents of the job document and its interpretation and +execution are the responsibility of the developer. Notice also that no events were emitted from the action of moving a job from the QUEUED state to the IN_PROGRESS state. + +If we run `getPendingJobExecutions` again, we see that Job1 is now in progress, while Job2 remains in the queued state: + +``` +get-pending-job-executions +``` +``` +GetPendingJobExecutionsResponse: awsiot.iotjobs.GetPendingJobExecutionsResponse(client_token='00f9a380-9707-4230-951e-554df3ba2a0a', in_progress_jobs=[awsiot.iotjobs.JobExecutionSummary(execution_number=1, job_id='Job1', last_updated_at=datetime.datetime(2025, 5, 15, 13, 47, 30), queued_at=datetime.datetime(2025, 5, 15, 13, 44, 31), started_at=datetime.datetime(2025, 5, 15, 13, 47, 30), version_number=2)], queued_jobs=[awsiot.iotjobs.JobExecutionSummary(execution_number=1, job_id='Job2', last_updated_at=datetime.datetime(2025, 5, 15, 13, 46, 6), queued_at=datetime.datetime(2025, 5, 15, 13, 46, 6), started_at=None, version_number=1)], timestamp=datetime.datetime(2025, 5, 15, 13, 51, 13)) +``` + +A real device application would perform the job execution steps as needed. Let's assume that has been done. We need to tell the service the job has +completed: + +``` +update-job-execution Job1 SUCCEEDED +``` +will trigger output similar to +``` +UpdateJobExecutionResponse: awsiot.iotjobs.UpdateJobExecutionResponse(client_token='6ac251a5-40d0-4fd5-97c7-fcd71a7469f5', execution_state=None, job_document=None, timestamp=datetime.datetime(2025, 5, 15, 13, 51, 52)) + +Received NextJobExecutionChangedEvent: + awsiot.iotjobs.NextJobExecutionChangedEvent(execution=awsiot.iotjobs.JobExecutionData(execution_number=1, job_document={'ToDo': 'Delete Root User'}, job_id='Job2', last_updated_at=datetime.datetime(2025, 5, 15, 13, 46, 6), queued_at=datetime.datetime(2025, 5, 15, 13, 46, 6), started_at=None, status='QUEUED', status_details=None, thing_name=None, version_number=1), timestamp=datetime.datetime(2025, 5, 15, 13, 51, 53)) + +Received JobExecutionsChangedEvent: + awsiot.iotjobs.JobExecutionsChangedEvent(jobs={'QUEUED': [awsiot.iotjobs.JobExecutionSummary(execution_number=1, job_id='Job2', last_updated_at=datetime.datetime(2025, 5, 15, 13, 46, 6), queued_at=datetime.datetime(2025, 5, 15, 13, 46, 6), started_at=None, version_number=1)]}, timestamp=datetime.datetime(2025, 5, 15, 13, 51, 53)) +``` +Notice we get a response as well as two events, since both +1. The set of incomplete job executions set has changed. +1. The next job to be executed has changed. + +As expected, we can move Job2's execution into IN_PROGRESS by invoking `start_next_pending_job_execution` again: + +``` +start-next-pending-job-execution +``` +``` +StartNextPendingJobExecutionResponse: awsiot.iotjobs.StartNextJobExecutionResponse(client_token='5253a01f-d415-470d-87bb-e95e22197e30', execution=awsiot.iotjobs.JobExecutionData(execution_number=1, job_document={'ToDo': 'Delete Root User'}, job_id='Job2', last_updated_at=datetime.datetime(2025, 5, 15, 13, 52, 55), queued_at=datetime.datetime(2025, 5, 15, 13, 46, 6), started_at=datetime.datetime(2025, 5, 15, 13, 52, 55), status='IN_PROGRESS', status_details=None, thing_name=None, version_number=2), timestamp=datetime.datetime(2025, 5, 15, 13, 52, 55)) +``` + +Let's pretend that the job execution failed. An update variant can notify the Jobs service of this fact: + +``` +update-job-execution Job2 FAILED +``` +triggering +``` +UpdateJobExecutionResponse: awsiot.iotjobs.UpdateJobExecutionResponse(client_token='d981c292-2362-4efb-949b-920581494027', execution_state=None, job_document=None, timestamp=datetime.datetime(2025, 5, 15, 13, 53, 27)) + +Received JobExecutionsChangedEvent: + awsiot.iotjobs.JobExecutionsChangedEvent(jobs={}, timestamp=datetime.datetime(2025, 5, 15, 13, 53, 28)) + +Received NextJobExecutionChangedEvent: + awsiot.iotjobs.NextJobExecutionChangedEvent(execution=None, timestamp=datetime.datetime(2025, 5, 15, 13, 53, 28)) +``` +At this point, no incomplete job executions remain. + +### Job Cleanup +When all executions for a given job have reached a terminal state (SUCCEEDED, FAILED, CANCELED), you can delete the job itself. This is a control plane operation +that requires the AWS SDK for Python (boto3) and should not be performed by the device executing jobs: + +``` +delete-job Job1 +``` +yielding +``` +DeleteJobResponse: {'ResponseMetadata': {'RequestId': 'cbd856d6-9d52-4603-a0bf-0a0800025903', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Thu, 15 May 2025 20:54:16 GMT', 'content-type': 'application/json', 'content-length': '0', 'connection': 'keep-alive', 'x-amzn-requestid': 'cbd856d6-9d52-4603-a0bf-0a0800025903'}, 'RetryAttempts': 0}} +``` + +### Misc. Topics +#### What happens if I call `start_next_pending_job_execution` and there are no jobs to execute? +The request will not fail, but the `execution` field of the response will be empty, indicating that there is nothing to do. + +#### What happens if I call `start_next_pending_job_execution` twice in a row (or while another job is in the IN_PROGRESS state)? +The service will return the execution information for the IN_PROGRESS job again. + +#### What if I want my device to handle multiple job executions at once? +Since `start_next_pending_job_execution` does not help here, the device application can manually update a job execution from the QUEUED state to the IN_PROGRESS +state in the same manner that it completes a job execution: use `get_pending_job_executions` to get the list of queued executions and use +`update_job_execution` to move one or more job executions into the IN_PROGRESS state. + +#### What is the proper generic architecture for a job-processing application running on a device? +A device's persistent job executor should: +1. On startup, create and open streaming operations for both the JobExecutionsChanged and NextJobExecutionChanged events +2. On startup, get and cache the set of incomplete job executions using `get_pending_job_executions` +3. Keep the cached job execution set up to date by reacting appropriately to JobExecutionsChanged and NextJobExecutionChanged events +4. While there are incomplete job executions, start and execute them one-at-a-time; otherwise wait for a new entry in the incomplete (queued) job executions set. \ No newline at end of file diff --git a/samples/jobs.py b/samples/jobs.py index 4ac8d40a..0516a5e9 100644 --- a/samples/jobs.py +++ b/samples/jobs.py @@ -1,396 +1,203 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0. -from awscrt import mqtt, http -from awsiot import iotjobs, mqtt_connection_builder +from awscrt import mqtt5, mqtt_request_response +from awsiot import iotjobs, mqtt5_client_builder +import boto3 from concurrent.futures import Future +from dataclasses import dataclass +from typing import Optional +import awsiot +import argparse import sys -import threading -import time -import traceback -import time -from utils.command_line_utils import CommandLineUtils - -# - Overview - -# This sample uses the AWS IoT Jobs Service to get a list of pending jobs and -# then execution operations on these pending jobs until there are no more -# remaining on the device. Imagine periodic software updates that must be sent to and -# executed on devices in the wild. -# -# - Instructions - -# This sample requires you to create jobs for your device to execute. See: -# https://docs.aws.amazon.com/iot/latest/developerguide/create-manage-jobs.html -# -# - Detail - -# On startup, the sample tries to get a list of all the in-progress and queued -# jobs and display them in a list. Then it tries to start the next pending job execution. -# If such a job exists, the sample emulates "doing work" by spawning a thread -# that sleeps for several seconds before marking the job as SUCCEEDED. When no -# pending job executions exist, the sample sits in an idle state. -# -# The sample also subscribes to receive "Next Job Execution Changed" events. -# If the sample is idle, this event wakes it to start the job. If the sample is -# already working on a job, it remembers to try for another when it's done. -# This event is sent by the service when the current job completes, so the -# sample will be continually prompted to try another job until none remain. - -# Using globals to simplify sample code -is_sample_done = threading.Event() - -# cmdData is the arguments/input from the command line placed into a single struct for -# use in this sample. This handles all of the command line parsing, validating, etc. -# See the Utils/CommandLineUtils for more information. -cmdData = CommandLineUtils.parse_sample_input_jobs() - -mqtt_connection = None -jobs_client = None -jobs_thing_name = cmdData.input_thing_name - - -class LockedData: - def __init__(self): - self.lock = threading.Lock() - self.disconnect_called = False - self.is_working_on_job = False - self.is_next_job_waiting = False - self.got_job_response = False - - -locked_data = LockedData() - -# Function for gracefully quitting this sample -def exit(msg_or_exception): - if isinstance(msg_or_exception, Exception): - print("Exiting Sample due to exception.") - traceback.print_exception(msg_or_exception.__class__, msg_or_exception, sys.exc_info()[2]) - else: - print("Exiting Sample:", msg_or_exception) - - with locked_data.lock: - if not locked_data.disconnect_called: - print("Disconnecting...") - locked_data.disconnect_called = True - future = mqtt_connection.disconnect() - future.add_done_callback(on_disconnected) - - -def try_start_next_job(): - print("Trying to start the next job...") - with locked_data.lock: - if locked_data.is_working_on_job: - print("Nevermind, already working on a job.") - return - - if locked_data.disconnect_called: - print("Nevermind, sample is disconnecting.") - return - - locked_data.is_working_on_job = True - locked_data.is_next_job_waiting = False - - print("Publishing request to start next job...") - request = iotjobs.StartNextPendingJobExecutionRequest(thing_name=jobs_thing_name) - publish_future = jobs_client.publish_start_next_pending_job_execution(request, mqtt.QoS.AT_LEAST_ONCE) - publish_future.add_done_callback(on_publish_start_next_pending_job_execution) - - -def done_working_on_job(): - with locked_data.lock: - locked_data.is_working_on_job = False - try_again = locked_data.is_next_job_waiting - - if try_again: - try_start_next_job() - - -def on_disconnected(disconnect_future): - # type: (Future) -> None - print("Disconnected.") - - # Signal that sample is finished - is_sample_done.set() - - -# A list to hold all the pending jobs -available_jobs = [] - - -def on_get_pending_job_executions_accepted(response): - # type: (iotjobs.GetPendingJobExecutionsResponse) -> None - with locked_data.lock: - if (len(response.queued_jobs) > 0 or len(response.in_progress_jobs) > 0): - print("Pending Jobs:") - for job in response.in_progress_jobs: - available_jobs.append(job) - print(f" In Progress: {job.job_id} @ {job.last_updated_at}") - for job in response.queued_jobs: - available_jobs.append(job) - print(f" {job.job_id} @ {job.last_updated_at}") - else: - print("No pending or queued jobs found!") - locked_data.got_job_response = True - - -def on_get_pending_job_executions_rejected(error): - # type: (iotjobs.RejectedError) -> None - print(f"Request rejected: {error.code}: {error.message}") - exit("Get pending jobs request rejected!") - - -def on_next_job_execution_changed(event): - # type: (iotjobs.NextJobExecutionChangedEvent) -> None - try: - execution = event.execution - if execution: - print("Received Next Job Execution Changed event. job_id:{} job_document:{}".format( - execution.job_id, execution.job_document)) - - # Start job now, or remember to start it when current job is done - start_job_now = False - with locked_data.lock: - if locked_data.is_working_on_job: - locked_data.is_next_job_waiting = True - else: - start_job_now = True - if start_job_now: - try_start_next_job() - else: - print("Received Next Job Execution Changed event: None. Waiting for further jobs...") - - except Exception as e: - exit(e) - - -def on_publish_start_next_pending_job_execution(future): - # type: (Future) -> None - try: - future.result() # raises exception if publish failed - - print("Published request to start the next job.") - - except Exception as e: - exit(e) +@dataclass +class SampleContext: + jobs_client: 'iotjobs.IotJobsClientV2' + thing: 'str' + thing_arn: Optional['str'] + region: 'str' + control_plane_client: any + +def print_help(): + print("Jobs Sandbox\n") + print('IoT control plane commands:'); + print(' create-job -- create a new job with the specified job id and (JSON) document'); + print(' delete-job -- deletes a job with the specified job id'); + print('MQTT Jobs service commands:'); + print(' describe-job-execution -- gets the service status of a job execution with the specified job id'); + print(' get-pending-job-executions -- gets all incomplete job executions'); + print(' start-next-pending-job-execution -- moves the next pending job execution into the IN_PROGRESS state'); + print(' update-job-execution -- updates a job execution with a new status'); + print('Miscellaneous commands:') + print(" quit - quits the sample application\n"); + pass + +def handle_create_job(context : SampleContext, parameters: str): + params = parameters.strip().split(" ", 1) + job_id = params[0] + job_document = params[1] + + create_response = context.control_plane_client.create_job( + jobId=job_id, + document=job_document, + targets=[context.thing_arn], + targetSelection='SNAPSHOT') + print(f"CreateJobResponse: {create_response}\n") + +def handle_delete_job(context : SampleContext, parameters: str): + job_id = parameters.strip() + delete_response = context.control_plane_client.delete_job(jobId=job_id, force=True) + print(f"DeleteJobResponse: {delete_response}\n") + +def handle_describe_job_execution(context : SampleContext, parameters: str): + job_id = parameters.strip() + describe_response = context.jobs_client.describe_job_execution(iotjobs.DescribeJobExecutionRequest(job_id=job_id, thing_name=context.thing)).result() + print(f"DescribeJobExecutionResponse: {describe_response}\n") + +def handle_get_pending_job_executions(context : SampleContext): + get_response = context.jobs_client.get_pending_job_executions(iotjobs.GetPendingJobExecutionsRequest(thing_name=context.thing)).result() + print(f"GetPendingJobExecutionsResponse: {get_response}\n") + +def handle_start_next_pending_job_execution(context : SampleContext): + start_response = context.jobs_client.start_next_pending_job_execution(iotjobs.StartNextPendingJobExecutionRequest(thing_name=context.thing)).result() + print(f"StartNextPendingJobExecutionResponse: {start_response}\n") + +def handle_update_job_execution(context : SampleContext, parameters: str): + params = parameters.strip().split(" ", 1) + job_id = params[0] + status = params[1] + update_response = context.jobs_client.update_job_execution(iotjobs.UpdateJobExecutionRequest(thing_name=context.thing, job_id=job_id, status=status)).result() + print(f"UpdateJobExecutionResponse: {update_response}\n") + +def handle_input(context : SampleContext, line: str): + words = line.strip().split(" ", 1) + command = words[0] + + if command == "quit": + return True + elif command == "create-job": + handle_create_job(context, words[1]) + elif command == "delete-job": + handle_delete_job(context, words[1]) + elif command == "describe-job-execution": + handle_describe_job_execution(context, words[1]) + elif command == "get-pending-job-executions": + handle_get_pending_job_executions(context) + elif command == "start-next-pending-job-execution": + handle_start_next_pending_job_execution(context) + elif command == "update-job-execution": + handle_update_job_execution(context, words[1]) + else: + print_help() + return False -def on_start_next_pending_job_execution_accepted(response): - # type: (iotjobs.StartNextJobExecutionResponse) -> None +def create_thing_if_needed(context: SampleContext): try: - if response.execution: - execution = response.execution - print("Request to start next job was accepted. job_id:{} job_document:{}".format( - execution.job_id, execution.job_document)) - - # To emulate working on a job, spawn a thread that sleeps for a few seconds - job_thread = threading.Thread( - target=lambda: job_thread_fn(execution.job_id, execution.job_document), - name='job_thread') - job_thread.start() - else: - print("Request to start next job was accepted, but there are no jobs to be done. Waiting for further jobs...") - done_working_on_job() - - except Exception as e: - exit(e) - - -def on_start_next_pending_job_execution_rejected(rejected): - # type: (iotjobs.RejectedError) -> None - exit("Request to start next pending job rejected with code:'{}' message:'{}'".format( - rejected.code, rejected.message)) + describe_response = context.control_plane_client.describe_thing(thingName=context.thing) + context.thing_arn = describe_response['thingArn'] + return + except: + pass + print(f"Thing {context.thing} not found, creating...") -def job_thread_fn(job_id, job_document): - try: - print("Starting local work on job...") - time.sleep(cmdData.input_job_time) - print("Done working on job.") + create_response = context.control_plane_client.create_thing(thingName=context.thing) + context.thing_arn = create_response['thingArn'] - print("Publishing request to update job status to SUCCEEDED...") - request = iotjobs.UpdateJobExecutionRequest( - thing_name=jobs_thing_name, - job_id=job_id, - status=iotjobs.JobStatus.SUCCEEDED) - publish_future = jobs_client.publish_update_job_execution(request, mqtt.QoS.AT_LEAST_ONCE) - publish_future.add_done_callback(on_publish_update_job_execution) + print(f"Thing {context.thing} successfully created with arn {context.thing_arn}") - except Exception as e: - exit(e) +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description="AWS IoT Jobs sandbox application") + parser.add_argument('--endpoint', required=True, help="AWS IoT endpoint to connect to") + parser.add_argument('--cert', required=True, + help="Path to the certificate file to use during mTLS connection establishment") + parser.add_argument('--key', required=True, + help="Path to the private key file to use during mTLS connection establishment") + parser.add_argument('--thing', required=True, + help="Name of the IoT thing to interact with") + parser.add_argument('--region', required=True, + help="AWS region to use. Must match the endpoint region.") + + args = parser.parse_args() + + initial_connection_success = Future() + def on_lifecycle_connection_success(event: mqtt5.LifecycleConnectSuccessData): + initial_connection_success.set_result(True) + + def on_lifecycle_connection_failure(event: mqtt5.LifecycleConnectFailureData): + initial_connection_success.set_exception(Exception("Failed to connect")) + + stopped = Future() + def on_lifecycle_stopped(event: mqtt5.LifecycleStoppedData): + stopped.set_result(True) + + # Create a mqtt5 connection from the command line data + mqtt5_client = mqtt5_client_builder.mtls_from_path( + endpoint=args.endpoint, + port=8883, + cert_filepath=args.cert, + pri_key_filepath=args.key, + clean_session=True, + keep_alive_secs=1200, + on_lifecycle_connection_success=on_lifecycle_connection_success, + on_lifecycle_stopped=on_lifecycle_stopped) + + mqtt5_client.start() + + rr_options = mqtt_request_response.ClientOptions( + max_request_response_subscriptions = 2, + max_streaming_subscriptions = 2, + operation_timeout_in_seconds = 30, + ) + jobs_client = iotjobs.IotJobsClientV2(mqtt5_client, rr_options) + + initial_connection_success.result() + print("Connected!") + def on_job_executions_changed_event(event: iotjobs.JobExecutionsChangedEvent): + print(f"Received JobExecutionsChangedEvent:\n {event}\n"); -def on_publish_update_job_execution(future): - # type: (Future) -> None - try: - future.result() # raises exception if publish failed - print("Published request to update job.") + stream_options = awsiot.ServiceStreamOptions( + incoming_event_listener=on_job_executions_changed_event, + ) - except Exception as e: - exit(e) + job_executions_changed_stream = jobs_client.create_job_executions_changed_stream( + iotjobs.JobExecutionsChangedSubscriptionRequest(thing_name=args.thing), + stream_options) + job_executions_changed_stream.open() + def on_next_job_execution_changed_event(event: iotjobs.NextJobExecutionChangedEvent): + print(f"Received NextJobExecutionChangedEvent:\n {event}\n"); -def on_update_job_execution_accepted(response): - # type: (iotjobs.UpdateJobExecutionResponse) -> None - try: - print("Request to update job was accepted.") - done_working_on_job() - except Exception as e: - exit(e) + stream_options = awsiot.ServiceStreamOptions( + incoming_event_listener=on_next_job_execution_changed_event, + ) + next_job_execution_changed_stream = jobs_client.create_next_job_execution_changed_stream( + iotjobs.NextJobExecutionChangedSubscriptionRequest(thing_name=args.thing), + stream_options) + next_job_execution_changed_stream.open() -def on_update_job_execution_rejected(rejected): - # type: (iotjobs.RejectedError) -> None - exit("Request to update job status was rejected. code:'{}' message:'{}'.".format( - rejected.code, rejected.message)) + boto3_client = boto3.client('iot', args.region) + context = SampleContext(jobs_client, args.thing, None, args.region, boto3_client) + create_thing_if_needed(context) -if __name__ == '__main__': + for line in sys.stdin: + try: + if handle_input(context, line): + break - # Create the proxy options if the data is present in cmdData - proxy_options = None - if cmdData.input_proxy_host is not None and cmdData.input_proxy_port != 0: - proxy_options = http.HttpProxyOptions( - host_name=cmdData.input_proxy_host, - port=cmdData.input_proxy_port) - - # Create a MQTT connection from the command line data - mqtt_connection = mqtt_connection_builder.mtls_from_path( - endpoint=cmdData.input_endpoint, - port=cmdData.input_port, - cert_filepath=cmdData.input_cert, - pri_key_filepath=cmdData.input_key, - ca_filepath=cmdData.input_ca, - client_id=cmdData.input_clientId, - clean_session=False, - keep_alive_secs=30, - http_proxy_options=proxy_options) - - if not cmdData.input_is_ci: - print(f"Connecting to {cmdData.input_endpoint} with client ID '{cmdData.input_clientId}'...") - else: - print("Connecting to endpoint with client ID") + except Exception as e: + print(f"Exception: {e}\n") - connected_future = mqtt_connection.connect() + mqtt5_client.stop() + stopped.result() + print("Stopped!") - jobs_client = iotjobs.IotJobsClient(mqtt_connection) - # Wait for connection to be fully established. - # Note that it's not necessary to wait, commands issued to the - # mqtt_connection before its fully connected will simply be queued. - # But this sample waits here so it's obvious when a connection - # fails or succeeds. - connected_future.result() - print("Connected!") - try: - # List the jobs queued and pending - get_jobs_request = iotjobs.GetPendingJobExecutionsRequest(thing_name=jobs_thing_name) - jobs_request_future_accepted, _ = jobs_client.subscribe_to_get_pending_job_executions_accepted( - request=get_jobs_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_get_pending_job_executions_accepted - ) - # Wait for the subscription to succeed - jobs_request_future_accepted.result() - - jobs_request_future_rejected, _ = jobs_client.subscribe_to_get_pending_job_executions_rejected( - request=get_jobs_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_get_pending_job_executions_rejected - ) - # Wait for the subscription to succeed - jobs_request_future_rejected.result() - - # Get a list of all the jobs - get_jobs_request_future = jobs_client.publish_get_pending_job_executions( - request=get_jobs_request, - qos=mqtt.QoS.AT_LEAST_ONCE - ) - # Wait for the publish to succeed - get_jobs_request_future.result() - except Exception as e: - exit(e) - - # If we are running in CI, then we want to check how many jobs were reported and stop - if (cmdData.input_is_ci): - # Wait until we get a response. If we do not get a response after 50 tries, then abort - got_job_response_tries = 0 - while (locked_data.got_job_response == False): - got_job_response_tries += 1 - if (got_job_response_tries > 50): - exit("Got job response timeout exceeded") - sys.exit(-1) - time.sleep(0.2) - - if (len(available_jobs) > 0): - print("At least one job queued in CI! No further work to do. Exiting sample...") - sys.exit(0) - else: - print("ERROR: No jobs queued in CI! At least one job should be queued!") - sys.exit(-1) - - try: - # Subscribe to necessary topics. - # Note that is **is** important to wait for "accepted/rejected" subscriptions - # to succeed before publishing the corresponding "request". - print("Subscribing to Next Changed events...") - changed_subscription_request = iotjobs.NextJobExecutionChangedSubscriptionRequest( - thing_name=jobs_thing_name) - - subscribed_future, _ = jobs_client.subscribe_to_next_job_execution_changed_events( - request=changed_subscription_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_next_job_execution_changed) - - # Wait for subscription to succeed - subscribed_future.result() - - print("Subscribing to Start responses...") - start_subscription_request = iotjobs.StartNextPendingJobExecutionSubscriptionRequest( - thing_name=jobs_thing_name) - subscribed_accepted_future, _ = jobs_client.subscribe_to_start_next_pending_job_execution_accepted( - request=start_subscription_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_start_next_pending_job_execution_accepted) - - subscribed_rejected_future, _ = jobs_client.subscribe_to_start_next_pending_job_execution_rejected( - request=start_subscription_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_start_next_pending_job_execution_rejected) - - # Wait for subscriptions to succeed - subscribed_accepted_future.result() - subscribed_rejected_future.result() - - print("Subscribing to Update responses...") - # Note that we subscribe to "+", the MQTT wildcard, to receive - # responses about any job-ID. - update_subscription_request = iotjobs.UpdateJobExecutionSubscriptionRequest( - thing_name=jobs_thing_name, - job_id='+') - - subscribed_accepted_future, _ = jobs_client.subscribe_to_update_job_execution_accepted( - request=update_subscription_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_update_job_execution_accepted) - - subscribed_rejected_future, _ = jobs_client.subscribe_to_update_job_execution_rejected( - request=update_subscription_request, - qos=mqtt.QoS.AT_LEAST_ONCE, - callback=on_update_job_execution_rejected) - - # Wait for subscriptions to succeed - subscribed_accepted_future.result() - subscribed_rejected_future.result() - - # Make initial attempt to start next job. The service should reply with - # an "accepted" response, even if no jobs are pending. The response - # will contain data about the next job, if there is one. - # (Will do nothing if we are in CI) - try_start_next_job() - - except Exception as e: - exit(e) - - # Wait for the sample to finish - is_sample_done.wait() diff --git a/samples/shadow.md b/samples/shadow.md index 64fc2688..1c3eab6b 100644 --- a/samples/shadow.md +++ b/samples/shadow.md @@ -90,7 +90,7 @@ If the venv does not yet have the device SDK installed, install it: python3 -m pip install awsiotsdk ``` -Assuming you are in the SDK root directory, you can now run the shadow sanbox sample: +Assuming you are in the SDK root directory, you can now run the shadow sandbox sample: ``` sh python3 samples/shadow.py --cert --key --endpoint --thing From 88e4ed78ad9dbddbb12f1220f89a0ce91bd609db Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Thu, 15 May 2025 13:59:59 -0700 Subject: [PATCH 10/26] Update deprecated file path --- samples/deprecated/fleetprovisioning.md | 2 +- samples/deprecated/fleetprovisioning_mqtt5.md | 2 +- samples/deprecated/jobs.md | 2 +- samples/deprecated/jobs_mqtt5.md | 2 +- samples/deprecated/shadow.md | 2 +- samples/deprecated/shadow_mqtt5.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/samples/deprecated/fleetprovisioning.md b/samples/deprecated/fleetprovisioning.md index eec65692..e035f416 100644 --- a/samples/deprecated/fleetprovisioning.md +++ b/samples/deprecated/fleetprovisioning.md @@ -1,6 +1,6 @@ # Fleet provisioning -[**Return to main sample list**](./README.md) +[**Return to main sample list**](../README.md) This sample uses the AWS IoT [Fleet provisioning](https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html) to provision devices using either a CSR or Keys-And-Certificate and subsequently calls RegisterThing. This allows you to create new AWS IoT Core things using a Fleet Provisioning Template. diff --git a/samples/deprecated/fleetprovisioning_mqtt5.md b/samples/deprecated/fleetprovisioning_mqtt5.md index a89688f5..57b00ab8 100644 --- a/samples/deprecated/fleetprovisioning_mqtt5.md +++ b/samples/deprecated/fleetprovisioning_mqtt5.md @@ -1,6 +1,6 @@ # Fleet provisioning MQTT5 -[**Return to main sample list**](./README.md) +[**Return to main sample list**](../README.md) This sample uses the AWS IoT [Fleet provisioning](https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html) to provision devices using either a CSR or Keys-And-Certificate and subsequently calls RegisterThing. This allows you to create new AWS IoT Core things using a Fleet Provisioning Template. diff --git a/samples/deprecated/jobs.md b/samples/deprecated/jobs.md index d33ab597..4648ab73 100644 --- a/samples/deprecated/jobs.md +++ b/samples/deprecated/jobs.md @@ -1,6 +1,6 @@ # Jobs -[**Return to main sample list**](./README.md) +[**Return to main sample list**](../README.md) This sample uses the AWS IoT [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) Service to describe jobs to execute. [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) is a service that allows you to define and respond to remote operation requests defined through the AWS IoT Core website or via any other device (or CLI command) that can access the [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) service. diff --git a/samples/deprecated/jobs_mqtt5.md b/samples/deprecated/jobs_mqtt5.md index e8b2e1ed..f189f76e 100644 --- a/samples/deprecated/jobs_mqtt5.md +++ b/samples/deprecated/jobs_mqtt5.md @@ -1,6 +1,6 @@ # Jobs MQTT5 -[**Return to main sample list**](./README.md) +[**Return to main sample list**](../README.md) This sample uses the AWS IoT [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) Service to describe jobs to execute. [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) is a service that allows you to define and respond to remote operation requests defined through the AWS IoT Core website or via any other device (or CLI command) that can access the [Jobs](https://docs.aws.amazon.com/iot/latest/developerguide/iot-jobs.html) service. diff --git a/samples/deprecated/shadow.md b/samples/deprecated/shadow.md index 9afea682..aa250757 100644 --- a/samples/deprecated/shadow.md +++ b/samples/deprecated/shadow.md @@ -1,6 +1,6 @@ # Shadow -[**Return to main sample list**](./README.md) +[**Return to main sample list**](../README.md) This sample uses the AWS IoT [Device Shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html) Service to keep a property in sync between device and server. Imagine a light whose color may be changed through an app, or set by a local user. diff --git a/samples/deprecated/shadow_mqtt5.md b/samples/deprecated/shadow_mqtt5.md index cc2dfb5d..14ca79cd 100644 --- a/samples/deprecated/shadow_mqtt5.md +++ b/samples/deprecated/shadow_mqtt5.md @@ -1,6 +1,6 @@ # Shadow MQTT5 -[**Return to main sample list**](./README.md) +[**Return to main sample list**](../README.md) This sample uses the AWS IoT [Device Shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html) Service to keep a property in sync between device and server. Imagine a light whose color may be changed through an app, or set by a local user. From a9d5ebb3d50e1b91e79927361493025d775808da Mon Sep 17 00:00:00 2001 From: Bret Ambrose Date: Fri, 16 May 2025 10:58:37 -0700 Subject: [PATCH 11/26] Fleet provisioning samples --- samples/README.md | 7 +- samples/fleet_provisioning_basic.md | 271 +++++++++++++++++++++++++++ samples/fleet_provisioning_basic.py | 85 +++++++++ samples/fleet_provisioning_csr.md | 281 ++++++++++++++++++++++++++++ samples/fleet_provisioning_csr.py | 92 +++++++++ 5 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 samples/fleet_provisioning_basic.md create mode 100644 samples/fleet_provisioning_basic.py create mode 100644 samples/fleet_provisioning_csr.md create mode 100644 samples/fleet_provisioning_csr.py diff --git a/samples/README.md b/samples/README.md index 5eb7fda3..4758b875 100644 --- a/samples/README.md +++ b/samples/README.md @@ -9,8 +9,6 @@ * [MQTT5 Shared Subscription](./mqtt5_shared_subscription.md) * [MQTT5 PKCS#11 Connect](./mqtt5_pkcs11_connect.md) * [MQTT5 Custom Authorizer Connect](./mqtt5_custom_authorizer_connect.md) -* [MQTT5 Jobs](./jobs_mqtt5.md) -* [MQTT5 Fleet Provisioning](./fleetprovisioning_mqtt5.md) ## MQTT311 Samples * [PubSub](./pubsub.md) * [Basic Connect](./basic_connect.md) @@ -21,10 +19,11 @@ * [Custom Authorizer Connect](./custom_authorizer_connect.md) * [Cognito Connect](./cognito_connect.md) * [X509 Connect](./x509_connect.md) -* [Jobs](./jobs.md) -* [Fleet Provisioning](./fleetprovisioning.md) ## Other +* [Basic Fleet Provisioning](./fleet_provisioning_basic.md) +* [CSR Fleet Provisioning](./fleet_provisioning_csr.md) * [Shadow](./shadow.md) +* [Jobs](./jobs.md) * [Greengrass Discovery](./basic_discovery.md) * [Greengrass IPC](./ipc_greengrass.md) diff --git a/samples/fleet_provisioning_basic.md b/samples/fleet_provisioning_basic.md new file mode 100644 index 00000000..b182ae22 --- /dev/null +++ b/samples/fleet_provisioning_basic.md @@ -0,0 +1,271 @@ +# Basic Fleet provisioning + +[**Return to main sample list**](./README.md) + +This sample uses the AWS IoT [Fleet provisioning service](https://docs.aws.amazon.com/iot/latest/developerguide/provision-wo-cert.html) to provision devices using the CreateKeysAndCertificate and RegisterThing APIs. This allows you to create new AWS IoT Core thing resources using a Fleet Provisioning Template. + +The [IAM Policy](https://docs.aws.amazon.com/iot/latest/developerguide/iot-policies.html) attached to your provisioning certificate must provide privileges for this sample to connect, subscribe, publish, and receive. Below is a sample policy that can be used that will allow this sample to run as intended. + +
+(see sample policy) +
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": "iot:Publish",
+      "Resource": [
+        "arn:aws:iot:region:account:topic/$aws/certificates/create/json",
+        "arn:aws:iot:region:account:topic/$aws/provisioning-templates/templatename/provision/json"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": [
+        "iot:Receive"
+      ],
+      "Resource": [
+        "arn:aws:iot:region:account:topic/$aws/certificates/create/json/accepted",
+        "arn:aws:iot:region:account:topic/$aws/certificates/create/json/rejected",
+        "arn:aws:iot:region:account:topic/$aws/provisioning-templates/templatename/provision/json/accepted",
+        "arn:aws:iot:region:account:topic/$aws/provisioning-templates/templatename/provision/json/rejected"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": [
+        "iot:Subscribe"
+      ],
+      "Resource": [
+        "arn:aws:iot:region:account:topicfilter/$aws/certificates/create/json/accepted",
+        "arn:aws:iot:region:account:topicfilter/$aws/certificates/create/json/rejected",
+        "arn:aws:iot:region:account:topicfilter/$aws/provisioning-templates/templatename/provision/json/accepted",
+        "arn:aws:iot:region:account:topicfilter/$aws/provisioning-templates/templatename/provision/json/rejected"
+      ]
+    },
+    {
+      "Effect": "Allow",
+      "Action": "iot:Connect",
+      "Resource": "arn:aws:iot:region:account:client/test-*"
+    }
+  ]
+}
+
+ +Replace with the following with the data from your AWS account: +* ``: The AWS IoT Core region where you created your AWS IoT Core thing you wish to use with this sample. For example `us-east-1`. +* ``: Your AWS IoT Core account ID. This is the set of numbers in the top right next to your AWS account name when using the AWS IoT Core website. +* ``: The name of your AWS Fleet Provisioning template you want to use to create new AWS IoT Core Things. + +Note that in a real application, you may want to avoid the use of wildcards in your ClientID or use them selectively. Please follow best practices when working with AWS on production applications using the SDK. Also, for the purposes of this sample, please make sure your policy allows a client ID of `test-*` to connect or use `--client_id ` to send the client ID your policy supports. + +
+ +### How to run +First, from an empty directory, clone the SDK via git: +``` sh +git clone https://github.com/aws/aws-iot-device-sdk-python-v2 +``` +If not already active, activate the [virtual environment](https://docs.python.org/3/library/venv.html) that will be used to contain Python's execution context. + +If the venv does not yet have the device SDK installed, install it: + +``` sh +python3 -m pip install awsiotsdk +``` + +Assuming you are in the SDK root directory, you can now run the shadow sandbox sample: + +``` sh +# from the samples folder +python3 samples/fleet_provisioning_basic.py --endpoint --cert --key --template_name