diff --git a/HISTORY.rst b/HISTORY.rst index c890faee3..c36fb0205 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -19,6 +19,22 @@ Release History * Updated both controlplane and dataplane SDKs to now use the newer 2021-06-30-preview API version. +**IoT Central updates** + +* Added commands for Edge devices and modules: + - az iot central device edge module + - az iot central device edge module list + - az iot central device edge module restart + - az iot central device edge module show + + - az iot central device edge manifest + - az iot central device edge manifest show + + - az iot central device edge children + - az iot central device edge children list + - az iot central device edge children add + - az iot central device edge children remove + * Added `--no-wait` parameter to the following functions: - az dt create diff --git a/azext_iot/central/_help.py b/azext_iot/central/_help.py index 3ef325bf5..1a92a7256 100644 --- a/azext_iot/central/_help.py +++ b/azext_iot/central/_help.py @@ -59,6 +59,7 @@ def load_central_help(): _load_central_compute_device_key() _load_central_export_help() _load_central_c2d_message_help() + _load_central_edge_help() def _load_central_export_help(): @@ -1302,3 +1303,128 @@ def _load_central_monitors_help(): type: command short-summary: Get the device twin from IoT Hub. """ + + +def _load_central_edge_help(): + helps[ + "iot central device edge" + ] = """ + type: group + short-summary: Manage and configure IoT Central edge devices + """ + + helps[ + "iot central device edge module" + ] = """ + type: group + short-summary: Manage IoT Edge device modules. + """ + + helps[ + "iot central device edge children" + ] = """ + type: group + short-summary: Manage IoT Edge device children devices. + """ + + helps[ + "iot central device edge manifest" + ] = """ + type: group + short-summary: Manage IoT Edge device manifests. + """ + + helps[ + "iot central device edge module list" + ] = """ + type: command + short-summary: Get the list of modules in an IoT Edge device. + examples: + - name: List all modules in a device. (default) + text: > + az iot central device edge module list + --app-id {appid} + --device-id {deviceId} + """ + + helps[ + "iot central device edge module restart" + ] = """ + type: command + short-summary: Restart a module in an IoT Edge device. + examples: + - name: Restart a module in a device. + text: > + az iot central device edge module restart + --app-id {appid} + --device-id {deviceId} + --module-id {moduleId} + """ + + helps[ + "iot central device edge module show" + ] = """ + type: command + short-summary: Get a module in an IoT Edge device. + examples: + - name: Get a module in a device. + text: > + az iot central device edge module show + --app-id {appid} + --device-id {deviceId} + --module-id {moduleId} + """ + + helps[ + "iot central device edge manifest show" + ] = """ + type: command + short-summary: Get the deployment manifest associated to the specified IoT Edge device. + examples: + - name: Get a deployment manifest. + text: > + az iot central device edge manifest show + --app-id {appid} + --device-id {deviceId} + """ + + helps[ + "iot central device edge children list" + ] = """ + type: command + short-summary: Get the list of children of an IoT Edge device. + examples: + - name: List all children of a device. + text: > + az iot central device edge children list + --app-id {appid} + --device-id {deviceId} + """ + + helps[ + "iot central device edge children add" + ] = """ + type: command + short-summary: Add devices as children to a target edge device. + examples: + - name: Add space-separated list of device Ids as children to the target edge device. + text: > + az iot central device edge children add + --app-id {appid} + --device-id {deviceId} + --children-ids {child_1} {child_2} + """ + + helps[ + "iot central device edge children remove" + ] = """ + type: command + short-summary: Remove child devices from a target edge device. + examples: + - name: Remove children. + text: > + az iot central device edge children remove + --app-id {appid} + --device-id {deviceId} + --children-ids {child_1} {child_2} + """ diff --git a/azext_iot/central/command_map.py b/azext_iot/central/command_map.py index fdb6672e5..ab00baaf9 100644 --- a/azext_iot/central/command_map.py +++ b/azext_iot/central/command_map.py @@ -219,3 +219,26 @@ def load_central_commands(self, _): cmd_group.command("resume", "resume_job") cmd_group.command("get-devices", "get_job_devices") cmd_group.command("rerun", "rerun_job") + + with self.command_group( + "iot central device edge children", command_type=central_device_ops + ) as cmd_group: + cmd_group.command("list", "list_children") + cmd_group.command("add", "add_children", is_preview=True) + cmd_group.command("remove", "remove_children", is_preview=True) + + with self.command_group( + "iot central device edge module", + command_type=central_device_ops, + is_preview=True, + ) as cmd_group: + cmd_group.command("list", "list_device_modules") + cmd_group.show_command("show", "get_device_module") + cmd_group.command("restart", "restart_device_module") + + with self.command_group( + "iot central device edge manifest", + command_type=central_device_ops, + is_preview=True, + ) as cmd_group: + cmd_group.show_command("show", "get_edge_manifest") diff --git a/azext_iot/central/commands_device.py b/azext_iot/central/commands_device.py index 197dc625a..bb0e119d1 100644 --- a/azext_iot/central/commands_device.py +++ b/azext_iot/central/commands_device.py @@ -5,16 +5,30 @@ # -------------------------------------------------------------------------------------------- # Dev note - think of this as a controller +from azext_iot.central.common import EDGE_ONLY_FILTER from azext_iot.central.models.devicetwin import DeviceTwin -from azext_iot.central.providers import CentralDeviceProvider -from azure.cli.core.azclierror import InvalidArgumentValueError, RequiredArgumentMissingError -from typing import Union, List +from azext_iot.central.models.edge import EdgeModule +from azext_iot.central.providers import ( + CentralDeviceProvider, + CentralDeviceTemplateProvider, +) + +from typing import Union, List, Any +from azure.cli.core.azclierror import ( + InvalidArgumentValueError, + RequiredArgumentMissingError, + ResourceNotFoundError, + ForbiddenError, +) from azext_iot.central.models.v1 import DeviceV1 from azext_iot.central.models.preview import DevicePreview from azext_iot.central.models.v1_1_preview import DeviceV1_1_preview from azext_iot.common import utility from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central.models.enum import ApiVersion +from knack.log import get_logger + +logger = get_logger(__name__) DeviceType = Union[DevicePreview, DeviceV1, DeviceV1_1_preview] @@ -22,6 +36,7 @@ def list_devices( cmd, app_id: str, + edge_only=False, token=None, central_dns_suffix=CENTRAL_ENDPOINT, api_version=ApiVersion.v1.value, @@ -29,7 +44,31 @@ def list_devices( provider = CentralDeviceProvider( cmd=cmd, app_id=app_id, token=token, api_version=api_version ) - return provider.list_devices(central_dns_suffix=central_dns_suffix) + devices = provider.list_devices( + filter=EDGE_ONLY_FILTER if edge_only else None, + central_dns_suffix=central_dns_suffix, + ) + + if edge_only and api_version != ApiVersion.v1_1_preview.value: + template_provider = CentralDeviceTemplateProvider( + cmd=cmd, app_id=app_id, token=token, api_version=api_version + ) + templates = {} + filtered = [] + for device in devices: + template_id = get_template_id(device, api_version=api_version) + if template_id is None: + continue + if template_id not in templates: + templates[template_id] = template_provider.get_device_template( + template_id, central_dns_suffix=central_dns_suffix + ) + template = templates[template_id] + if "EdgeModel" in template.raw_template[template.get_type_key()]: + filtered.append(device) + return filtered + + return devices def get_device( @@ -197,7 +236,9 @@ def run_manual_failover( central_dns_suffix=CENTRAL_ENDPOINT, ) -> dict: if ttl_minutes and ttl_minutes < 1: - raise InvalidArgumentValueError("TTL value should be a positive integer: {}".format(ttl_minutes)) + raise InvalidArgumentValueError( + "TTL value should be a positive integer: {}".format(ttl_minutes) + ) provider = CentralDeviceProvider( cmd=cmd, app_id=app_id, token=token, api_version=api_version @@ -262,6 +303,287 @@ def get_command_history( ) +def list_children( + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> List[DeviceType]: + children = [] + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=api_version + ) + + # use new apis + if api_version == ApiVersion.v1_1_preview.value: + rel_name = get_downstream_rel_name( + cmd, + app_id=app_id, + device_id=device_id, + token=token, + central_dns_suffix=central_dns_suffix, + api_version=api_version, + ) + rels = provider.list_relationships( + device_id=device_id, + rel_name=rel_name, + central_dns_suffix=central_dns_suffix, + ) + # only show children info + for idx, rel in enumerate(rels): + if idx == 0: + filter = f"id eq '{rel.target}'" + else: + filter += f" or id eq '{rel.target}'" + return provider.list_devices(filter=filter) + + warning = ( + "This command may take a long time to complete when running with this api version." + "\nConsider using Api Version 1.1-preview when listing edge devices " + "as it supports server filtering speeding up the process." + ) + logger.warning(warning) + + # get iotedge device + edge_twin = provider.get_device_twin( + device_id=device_id, central_dns_suffix=central_dns_suffix + ) + edge_scope_id = edge_twin.device_twin.get("deviceScope") + + # list all application device twins + devices = provider.list_devices(central_dns_suffix=central_dns_suffix) + for device in devices: + if device.provisioned: + twin = provider.get_device_twin( + device.id, central_dns_suffix=central_dns_suffix + ) + device_scope_id = twin.device_twin.get("deviceScope") + if ( + device_scope_id + and device_scope_id == edge_scope_id + and device.id != device_id # skip current device + ): + children.append(device) + + return children + + +def add_children( + cmd, + app_id: str, + device_id: str, + children_ids: List[str], + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +): + from uuid import uuid4 + + if api_version != ApiVersion.v1_1_preview.value: + raise InvalidArgumentValueError( + ( + "Adding children devices to IoT Edge is still in preview " + "and only available for Api version >= 1.1-preview. " + 'Please pass the right "api_version" to the command.' + ) + ) + + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=api_version + ) + rel_name = get_downstream_rel_name( + cmd, + app_id=app_id, + device_id=device_id, + token=token, + central_dns_suffix=central_dns_suffix, + api_version=api_version, + ) + + if not rel_name: + raise ResourceNotFoundError( + f'Relationship name cannot be found in the template for device with id "{device_id}"' + ) + + return [ + provider.add_relationship( + device_id=device_id, + target_id=child_id, + rel_id=str(uuid4()), + rel_name=rel_name, + ) + for child_id in children_ids + ] + + +def remove_children( + cmd, + app_id: str, + device_id: str, + children_ids: List[str], + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +): + + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=api_version + ) + rel_name = get_downstream_rel_name( + cmd, + app_id=app_id, + device_id=device_id, + token=token, + central_dns_suffix=central_dns_suffix, + api_version=api_version, + ) + + if not rel_name: + raise ResourceNotFoundError( + f'Relationship name cannot be found in the template for device with id "{device_id}"' + ) + + rels = provider.list_relationships( + device_id=device_id, rel_name=rel_name, central_dns_suffix=central_dns_suffix + ) + deleted = [] + for rel in rels: + if rel.target in children_ids: + deleted.append( + provider.delete_relationship( + device_id=device_id, + rel_id=rel.id, + central_dns_suffix=central_dns_suffix, + ) + ) + + if not deleted: + raise ForbiddenError(f"Childs {children_ids} cannot be removed.") + + return deleted + + +def get_edge_device( + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> Any: + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=api_version + ) + + def raise_error(): + raise InvalidArgumentValueError( + "The specified device Id does not identify as an IoT Edge device." + ) + + # check if device is edge + try: + twin = provider.get_device_twin( + device_id, central_dns_suffix=central_dns_suffix + ) + capabilities = twin.device_twin.get("capabilities") + if not capabilities: + raise_error() + + iot_edge = capabilities.get("iotEdge") + if not iot_edge: + raise_error() + + return provider.get_device( + device_id=device_id, central_dns_suffix=central_dns_suffix + ) + except Exception: + raise_error() + + +def list_device_modules( + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> List[EdgeModule]: + + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=ApiVersion.v1.value + ) + + return provider.list_device_modules( + device_id, central_dns_suffix=central_dns_suffix + ) + + +def get_device_module( + cmd, + app_id: str, + device_id: str, + module_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> EdgeModule: + + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=ApiVersion.v1.value + ) + + modules = provider.list_device_modules( + device_id, central_dns_suffix=central_dns_suffix + ) + + for module in modules: + if module.module_id == module_id: + return module + + raise ResourceNotFoundError( + f"A module named '{module_id}' does not exist on device {device_id} or is not currently available" + ) + + +def get_edge_manifest( + cmd, app_id: str, device_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT +): + # force API v1.1 for this to work + template_provider = CentralDeviceTemplateProvider( + cmd=cmd, app_id=app_id, token=token, api_version=ApiVersion.v1_1_preview.value + ) + + device = get_edge_device( + cmd, + app_id=app_id, + device_id=device_id, + token=token, + central_dns_suffix=central_dns_suffix, + ) + template = template_provider.get_device_template( + device.template, central_dns_suffix=central_dns_suffix + ) + return template.deployment_manifest + + +def restart_device_module( + cmd, + app_id: str, + device_id: str, + module_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> List[EdgeModule]: + + provider = CentralDeviceProvider( + cmd=cmd, app_id=app_id, token=token, api_version=ApiVersion.v1.value + ) + + return provider.restart_device_module( + device_id, module_id, central_dns_suffix=central_dns_suffix + ) + + def registration_summary( cmd, app_id: str, @@ -294,11 +616,64 @@ def get_credentials( ) -def compute_device_key( - cmd, - primary_key, - device_id -): +def compute_device_key(cmd, primary_key, device_id): return utility.compute_device_key( primary_key=primary_key, registration_id=device_id ) + + +def get_template_id(device: DeviceType, api_version=ApiVersion.v1.value): + return getattr( + device, + "instanceOf" if api_version == ApiVersion.preview.value else "template", + ) + + +def get_downstream_rel_name( + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +): + # force API v1.1 for this to work + template_provider = CentralDeviceTemplateProvider( + cmd=cmd, app_id=app_id, token=token, api_version=ApiVersion.v1_1_preview.value + ) + device = get_device( + cmd, + app_id=app_id, + device_id=device_id, + token=token, + central_dns_suffix=central_dns_suffix, + api_version=api_version, + ) + + if not device: + raise ResourceNotFoundError(f'Device with id "{device_id}" cannot be found.') + + template = template_provider.get_device_template( + get_template_id(device, api_version=api_version) + ) + + if not template: + raise ResourceNotFoundError( + f'Template for device with id "{device_id}" cannot be found.' + ) + + # Get Gateway relationship name + for _, interface in template.interfaces.items(): + for _, content in interface.items(): + if all( + ( + cond is True + for cond in [ + a in content[template.get_type_key()] + for a in ["Relationship", "GatewayDevice"] + ] + ) + ): + rel_name = content.get("name") + + return rel_name diff --git a/azext_iot/central/common.py b/azext_iot/central/common.py index c40738197..21565dfc4 100644 --- a/azext_iot/central/common.py +++ b/azext_iot/central/common.py @@ -5,12 +5,14 @@ # -------------------------------------------------------------------------------------------- """ -shared: Define shared data types(enums) +shared: Define shared data types """ from enum import Enum +EDGE_ONLY_FILTER = "type eq 'GatewayDevice' or type eq 'EdgeDevice'" + class DestinationType(Enum): """ diff --git a/azext_iot/central/models/devicetwin.py b/azext_iot/central/models/devicetwin.py index c90db03d2..393bce5c5 100644 --- a/azext_iot/central/models/devicetwin.py +++ b/azext_iot/central/models/devicetwin.py @@ -36,6 +36,6 @@ def __init__( ): self.name = name self.props = props - self.metadata = props.get("$metadata") - self.version = props.get("$version") + self.metadata = props.get("$metadata") if props else None + self.version = props.get("$version") if props else None self.device_id = device_id diff --git a/azext_iot/central/models/edge.py b/azext_iot/central/models/edge.py new file mode 100644 index 000000000..c5c4ab6c1 --- /dev/null +++ b/azext_iot/central/models/edge.py @@ -0,0 +1,24 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +class EdgeModule: + def __init__( + self, + module: dict, + ): + self._module = module + self.module_id = module.get("moduleId") + self.is_system_module = module.get("isSystemModule") + self.status_description = module.get("statusDescription") + self.restart_policy = module.get("restartPolicy") + self.twin_status = module.get("twinStatus") + self.connection_state = module.get("connectionState") + self.settings = module.get("settings") + self.last_restart_time_utc = module.get("lastRestartTimeUtc") + self.last_start_time_utc = module.get("lastStartTimeUtc") + self.last_exit_time_utc = module.get("lastExitTimeUtc") + self.exit_code = module.get("exitCode") diff --git a/azext_iot/central/models/v1_1_preview/__init__.py b/azext_iot/central/models/v1_1_preview/__init__.py index 50411f5b9..6b7731609 100644 --- a/azext_iot/central/models/v1_1_preview/__init__.py +++ b/azext_iot/central/models/v1_1_preview/__init__.py @@ -23,6 +23,9 @@ AdxDestination as AdxDestinationV1_1_preview, ) from azext_iot.central.models.v1_1_preview.export import Export as ExportV1_1_preview +from azext_iot.central.models.v1_1_preview.relationship import ( + Relationship as RelationshipV1_1_preview, +) __all__ = [ "DeviceV1_1_preview", @@ -38,4 +41,5 @@ "WebhookDestinationV1_1_preview", "AdxDestinationV1_1_preview", "ExportV1_1_preview", + "RelationshipV1_1_preview", ] diff --git a/azext_iot/central/models/v1_1_preview/relationship.py b/azext_iot/central/models/v1_1_preview/relationship.py new file mode 100644 index 000000000..d17be5c26 --- /dev/null +++ b/azext_iot/central/models/v1_1_preview/relationship.py @@ -0,0 +1,13 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +class Relationship: + def __init__(self, relationship: dict): + self.id = relationship.get("id") + self.name = relationship.get("name") + self.source = relationship.get("source") + self.target = relationship.get("target") diff --git a/azext_iot/central/params.py b/azext_iot/central/params.py index c62d6c933..d173a6e8a 100644 --- a/azext_iot/central/params.py +++ b/azext_iot/central/params.py @@ -125,6 +125,13 @@ def load_central_arguments(self, _): help="The primary symmetric shared access key stored in base64 format. ", ) + with self.argument_context("iot central device list") as context: + context.argument( + "edge_only", + options_list=["--edge-only", "-e"], + help="Only list IoT Edge devices.", + ) + with self.argument_context("iot central device") as context: context.argument( "template", @@ -599,3 +606,18 @@ def load_central_arguments(self, _): " [Example of stringified JSON:{}]." " The request body must contain partial content of Destination.", ) + + with self.argument_context("iot central device edge module") as context: + context.argument( + "module_id", + options_list=["--module-id", "-m"], + help="The module ID of the target module.", + ) + + with self.argument_context("iot central device edge children") as context: + context.argument( + "children_ids", + nargs="+", + options_list=["--children-ids"], + help="Space-separated list of children device ids.", + ) diff --git a/azext_iot/central/providers/device_provider.py b/azext_iot/central/providers/device_provider.py index bac237b20..bdbed5aee 100644 --- a/azext_iot/central/providers/device_provider.py +++ b/azext_iot/central/providers/device_provider.py @@ -14,11 +14,15 @@ ) from knack.log import get_logger from azext_iot.central.models.devicetwin import DeviceTwin +from azext_iot.central.models.edge import EdgeModule from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central import services as central_services from azext_iot.central.models.enum import DeviceStatus, ApiVersion from azext_iot.central.models.v1 import DeviceV1 -from azext_iot.central.models.v1_1_preview import DeviceV1_1_preview +from azext_iot.central.models.v1_1_preview import ( + DeviceV1_1_preview, + RelationshipV1_1_preview, +) from azext_iot.central.models.preview import DevicePreview from azext_iot.dps.services import global_service as dps_global_service @@ -70,18 +74,22 @@ def get_device( self._devices[device_id] = device if not device: - raise ResourceNotFoundError("No device found with id: '{}'.".format(device_id)) + raise ResourceNotFoundError( + "No device found with id: '{}'.".format(device_id) + ) return self._devices[device_id] def list_devices( self, + filter=None, central_dns_suffix=CENTRAL_ENDPOINT, ) -> List[Union[DeviceV1, DeviceV1_1_preview, DevicePreview]]: devices = central_services.device.list_devices( cmd=self._cmd, app_id=self._app_id, token=self._token, + filter=filter, central_dns_suffix=central_dns_suffix, api_version=self._api_version, ) @@ -120,7 +128,9 @@ def create_device( ) if not device: - raise AzureResponseError("Failed to create device with id: '{}'.".format(device_id)) + raise AzureResponseError( + "Failed to create device with id: '{}'.".format(device_id) + ) # add to cache self._devices[device.id] = device @@ -158,7 +168,9 @@ def update_device( ) if not device: - raise ResourceNotFoundError("No device found with id: '{}'.".format(device_id)) + raise ResourceNotFoundError( + "No device found with id: '{}'.".format(device_id) + ) # add to cache self._devices[device.id] = device @@ -190,6 +202,101 @@ def delete_device( return result + def list_relationships( + self, + device_id, + rel_name=None, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> List[RelationshipV1_1_preview]: + relationships = central_services.device.list_relationships( + self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + api_version=self._api_version, + central_dns_suffix=central_dns_suffix, + ) + + if relationships is None: + return [] + + if rel_name: + relationships = [rel for rel in relationships if rel.name == rel_name] + + return relationships + + def add_relationship( + self, + device_id, + target_id, + rel_id, + rel_name, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + relationship = central_services.device.create_relationship( + self._cmd, + app_id=self._app_id, + device_id=device_id, + rel_id=rel_id, + rel_name=rel_name, + target_id=target_id, + token=self._token, + api_version=self._api_version, + central_dns_suffix=central_dns_suffix, + ) + + if not relationship: + raise ResourceNotFoundError( + "No relationship found with id: '{}'.".format(rel_id) + ) + + return relationship + + def update_relationship( + self, + device_id, + target_id, + rel_id, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + relationship = central_services.device.update_relationship( + self._cmd, + app_id=self._app_id, + device_id=device_id, + rel_id=rel_id, + target_id=target_id, + token=self._token, + api_version=self._api_version, + central_dns_suffix=central_dns_suffix, + ) + + if not relationship: + raise ResourceNotFoundError( + "No relationship found with id: '{}'.".format(rel_id) + ) + + return relationship + + def delete_relationship( + self, + device_id, + rel_id, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + + # get or add to cache + result = central_services.device.delete_relationship( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + rel_id=rel_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=self._api_version, + ) + + return result + def get_device_credentials( self, device_id, @@ -330,6 +437,50 @@ def get_command_history( api_version=self._api_version, ) + def list_device_modules( + self, + device_id, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> List[EdgeModule]: + + modules = central_services.device.list_device_modules( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + ) + + if not modules: + return [] + + return modules + + def restart_device_module( + self, + device_id, + module_id, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> List[EdgeModule]: + + status = central_services.device.restart_device_module( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + module_id=module_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + ) + + if not status or status != 200: + raise ResourceNotFoundError( + "No module found for device {} with id: '{}'.".format( + device_id, module_id + ) + ) + + return status + def get_device_twin( self, device_id, @@ -345,7 +496,9 @@ def get_device_twin( ) if not twin: - raise ResourceNotFoundError("No twin found for device with id: '{}'.".format(device_id)) + raise ResourceNotFoundError( + "No twin found for device with id: '{}'.".format(device_id) + ) return twin @@ -387,7 +540,7 @@ def purge_c2d_messages( app_id=self._app_id, device_id=device_id, token=self._token, - central_dns_suffix=central_dns_suffix + central_dns_suffix=central_dns_suffix, ) def _dps_populate_essential_info(self, dps_info, device_status: DeviceStatus): diff --git a/azext_iot/central/services/device.py b/azext_iot/central/services/device.py index 7019e717f..c1d84b405 100644 --- a/azext_iot/central/services/device.py +++ b/azext_iot/central/services/device.py @@ -7,18 +7,27 @@ from typing import List, Union import requests +from azext_iot.central.common import EDGE_ONLY_FILTER +from azext_iot.central.models.edge import EdgeModule from azext_iot.common.auth import get_aad_token from knack.log import get_logger -from azure.cli.core.azclierror import AzureResponseError +from azure.cli.core.azclierror import ( + AzureResponseError, + ResourceNotFoundError, + BadRequestError, +) from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central.services import _utility from azext_iot.central.models.devicetwin import DeviceTwin from azext_iot.central.models.preview import DevicePreview from azext_iot.central.models.v1 import DeviceV1 -from azext_iot.central.models.v1_1_preview import DeviceV1_1_preview -from azext_iot.central.models.enum import DeviceStatus +from azext_iot.central.models.v1_1_preview import ( + DeviceV1_1_preview, + RelationshipV1_1_preview, +) +from azext_iot.central.models.enum import ApiVersion, DeviceStatus from azure.cli.core.util import should_disable_connection_verify from azext_iot.common.utility import dict_clean, parse_entity @@ -26,6 +35,7 @@ BASE_PATH = "api/devices" MODEL = "Device" +REL_MODEL = "Relationship" def get_device( @@ -72,6 +82,7 @@ def get_device( def list_devices( cmd, app_id: str, + filter: str, token: str, api_version: str, max_pages=0, @@ -83,6 +94,7 @@ def list_devices( Args: cmd: command passed into az app_id: name of app (used for forming request URL) + filter: only show filtered devices token: (OPTIONAL) authorization token to fetch device details from IoTC. MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') central_dns_suffix: {centralDnsSuffixInPath} as found in docs @@ -97,12 +109,22 @@ def list_devices( headers = _utility.get_headers(token, cmd) # Construct parameters - query_parameters = {} + query_parameters = ( + {"$filter": filter} if api_version == ApiVersion.v1_1_preview.value else {} + ) query_parameters["api-version"] = api_version - logger.warning( - "This command may take a long time to complete if your app contains a lot of devices" - ) + warning = "This command may take a long time to complete if your app contains a lot of devices." + if ( + filter + and filter == EDGE_ONLY_FILTER + and api_version != ApiVersion.v1_1_preview.value + ): + warning += ( + "\nAlso consider using Api Version 1.1-preview when listing edge devices " + "as it supports server filtering speeding up the process." + ) + logger.warning(warning) pages_processed = 0 while (max_pages == 0 or pages_processed < max_pages) and url: @@ -354,6 +376,160 @@ def delete_device( return _utility.try_extract_result(response) +def list_relationships( + cmd, + app_id: str, + device_id: str, + token: str, + api_version: str, + max_pages=0, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> List[RelationshipV1_1_preview]: + + url = "https://{}.{}/{}/{}/relationships".format( + app_id, central_dns_suffix, BASE_PATH, device_id + ) + headers = _utility.get_headers(token, cmd, has_json_payload=True) + + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + relationships = [] + pages_processed = 0 + while (max_pages == 0 or pages_processed < max_pages) and url: + response = requests.get( + url, + headers=headers, + params=query_parameters if pages_processed == 0 else None, + ) + result = _utility.try_extract_result(response) + + if "value" not in result: + raise AzureResponseError("Value is not present in body: {}".format(result)) + + relationships.extend( + [ + _utility.get_object(relationship, REL_MODEL, api_version) + for relationship in result["value"] + ] + ) + + url = result.get("nextLink", None) + pages_processed = pages_processed + 1 + + return relationships + + +def create_relationship( + cmd, + app_id: str, + device_id: str, + target_id: str, + rel_id: str, + rel_name: str, + token: str, + api_version: str, + central_dns_suffix=CENTRAL_ENDPOINT, +): + + payload = {"id": rel_id, "name": rel_name, "source": device_id, "target": target_id} + response = _utility.make_api_call( + cmd, + app_id=app_id, + method="PUT", + url=f"https://{app_id}.{central_dns_suffix}/api/devices/{device_id}/relationships/{rel_id}", + payload=payload, + token=token, + api_version=api_version, + central_dnx_suffix=central_dns_suffix, + ) + + return _utility.get_object(response, REL_MODEL, api_version) + + +def update_relationship( + cmd, + app_id: str, + device_id: str, + rel_id: str, + target_id: str, + token: str, + api_version: str, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> RelationshipV1_1_preview: + + """ + Update a relationship in IoTC + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_id: unique case-sensitive device id + rel_id: unique case-sensitive relationship id + target_id: (optional) unique case-sensitive device id + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + device: dict + """ + + payload = {"target": target_id} + + response = _utility.make_api_call( + cmd, + app_id=app_id, + method="PATCH", + url=f"https://{app_id}.{central_dns_suffix}/api/devices/{device_id}/relationships/{rel_id}", + payload=payload, + token=token, + api_version=api_version, + central_dnx_suffix=central_dns_suffix, + ) + + return _utility.get_object(response, REL_MODEL, api_version) + + +def delete_relationship( + cmd, + app_id: str, + device_id: str, + rel_id: str, + token: str, + api_version: str, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> dict: + """ + Delete a relationship from IoTC + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_id: unique case-sensitive device id, + rel_id: unique case-sensitive relationship id, + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + {"result": "success"} on success + Raises error on failure + """ + + return _utility.make_api_call( + cmd, + app_id=app_id, + method="DELETE", + url=f"https://{app_id}.{central_dns_suffix}/api/devices/{device_id}/relationships/{rel_id}", + payload=None, + token=token, + api_version=api_version, + central_dnx_suffix=central_dns_suffix, + ) + + def get_device_credentials( cmd, app_id: str, @@ -599,7 +775,16 @@ def get_device_twin( headers=headers, verify=not should_disable_connection_verify(), ) - return DeviceTwin(_utility.try_extract_result(response)) + response_data = _utility.try_extract_result(response) + message = response_data.get("message") + + if ( + message == f"Twin for device {device_id} was not found" + or response_data.get("code") is not None + ): # there is an error + raise ResourceNotFoundError(f"Twin for device '{device_id}' was not found") + else: + return DeviceTwin(response_data) def run_manual_failover( @@ -686,7 +871,7 @@ def purge_c2d_messages( device_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -) : +): """ Purges cloud to device (C2D) message queue for the specified device. @@ -711,3 +896,98 @@ def purge_c2d_messages( headers = _utility.get_headers(token, cmd) response = requests.delete(url, headers=headers) return _utility.try_extract_result(response) + + +def list_device_modules( + cmd, + app_id: str, + device_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> List[EdgeModule]: + """ + Get edge device modules + + Args: + cmd: command passed into az + device_id: unique case-sensitive device id, + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + modules: list + """ + + if not token: + aad_token = get_aad_token(cmd, resource="https://apps.azureiotcentral.com")[ + "accessToken" + ] + token = "Bearer {}".format(aad_token) + + url = f"https://{app_id}.{central_dns_suffix}/system/iotedge/devices/{device_id}/modules" + headers = _utility.get_headers(token, cmd) + + # Construct parameters + + response = requests.get( + url, + headers=headers, + verify=not should_disable_connection_verify(), + ) + + response_data = _utility.try_extract_result(response).get("modules") + + if not response_data: + raise BadRequestError(f"Device '{device_id}' is not an IoT Edge device.") + return [EdgeModule(dict_clean(module)) for module in response_data] + + +def restart_device_module( + cmd, + app_id: str, + device_id: str, + module_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> EdgeModule: + """ + Restart a device module + + Args: + cmd: command passed into az + device_id: unique case-sensitive device id, + module_id: unique case-sensitive module id, + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + central_dns_suffix: {centralDnsSuffixInPath} as found in docs + + Returns: + module: dict + """ + + if not token: + aad_token = get_aad_token(cmd, resource="https://apps.azureiotcentral.com")[ + "accessToken" + ] + token = "Bearer {}".format(aad_token) + + url = f"https://{app_id}.{central_dns_suffix}/system/iotedge/devices/{device_id}/modules/$edgeAgent/directmethods" + json = { + "methodName": "RestartModule", + "payload": {"schemaVersion": "1.0", "id": module_id}, + } + headers = _utility.get_headers(token, cmd) + + # Construct parameters + + response = requests.post( + url, + json=json, + headers=headers, + verify=not should_disable_connection_verify(), + ) + + return response.json() diff --git a/azext_iot/tests/central/__init__.py b/azext_iot/tests/central/__init__.py index c06f3fc80..15dab2caf 100644 --- a/azext_iot/tests/central/__init__.py +++ b/azext_iot/tests/central/__init__.py @@ -8,7 +8,7 @@ import time from typing import Tuple -from azure.cli.core.azclierror import CLIInternalError +from azure.cli.core.azclierror import CLIInternalError, InvalidArgumentValueError from azext_iot.tests import CaptureOutputLiveScenarioTest from azext_iot.tests.conftest import get_context_path from azext_iot.tests.generators import generate_generic_id @@ -25,22 +25,31 @@ "azext_iot_central_token", "azext_iot_central_dns_suffix", "azext_iot_central_storage_cstring", - "azext_iot_central_storage_container" + "azext_iot_central_storage_container", ] settings = DynamoSettings(opt_env_set=CENTRAL_SETTINGS) APP_RG = settings.env.azext_iot_testrg # Storage Account DEFAULT_CONTAINER = "central" -STORAGE_CONTAINER = settings.env.azext_iot_central_storage_container or DEFAULT_CONTAINER +STORAGE_CONTAINER = ( + settings.env.azext_iot_central_storage_container or DEFAULT_CONTAINER +) STORAGE_CSTRING = settings.env.azext_iot_central_storage_cstring -STORAGE_NAME = None if settings.env.azext_iot_central_storage_cstring else "iotstore" + generate_generic_id()[:4] +STORAGE_NAME = ( + None + if settings.env.azext_iot_central_storage_cstring + else "iotstore" + generate_generic_id()[:4] +) # Device templates device_template_path = get_context_path(__file__, "json/device_template_int_test.json") device_template_path_preview = get_context_path( __file__, "json/device_template_int_test_preview.json" ) +edge_template_path_preview = get_context_path( + __file__, "json/device_template_edge.json" +) sync_command_params = get_context_path(__file__, "json/sync_command_args.json") DEFAULT_FILE_UPLOAD_TTL = "PT1H" @@ -61,7 +70,7 @@ def cmd( api_version=None, checks=None, expect_failure=False, - include_opt_args=True + include_opt_args=True, ): if include_opt_args: command = self._appendOptionalArgsToCommand( @@ -78,7 +87,9 @@ def _create_app(self): - token - dns_suffix """ - self.app_id = settings.env.azext_iot_central_app_id or "test-app-" + generate_generic_id() + self.app_id = ( + settings.env.azext_iot_central_app_id or "test-app-" + generate_generic_id() + ) self.app_rg = APP_RG self._scope_id = settings.env.azext_iot_central_scope_id @@ -95,12 +106,9 @@ def _create_app(self): self.cmd( "iot central app create -n {} -g {} -s {} --mi-system-assigned -l {}".format( - self.app_id, - self.app_rg, - self.app_id, - "westus" + self.app_id, self.app_rg, self.app_id, "westus" ), - include_opt_args=False + include_opt_args=False, ) self.app_primary_key = None # Will be repopulated with get_app_scope_id for tests that need it @@ -210,7 +218,9 @@ def _create_users(self, api_version): def _delete_user(self, api_version, user_id) -> None: self.cmd( - "iot central user delete --app-id {} --user-id {}".format(self.app_id, user_id), + "iot central user delete --app-id {} --user-id {}".format( + self.app_id, user_id + ), api_version=api_version, checks=[self.check("result", "success")], ) @@ -249,7 +259,9 @@ def _delete_api_token(self, api_version, token_id) -> None: ) def _wait_for_provisioned(self, api_version, device_id): - command = "iot central device show --app-id {} -d {}".format(self.app_id, device_id) + command = "iot central device show --app-id {} -d {}".format( + self.app_id, device_id + ) while True: result = self.cmd(command, api_version=api_version) @@ -272,25 +284,48 @@ def _delete_device(self, api_version, device_id) -> None: command, api_version=api_version, checks=[self.check("result", "success")] ) - def _create_device_template(self, api_version): - if api_version == ApiVersion.preview.value: - template = utility.process_json_arg( - device_template_path_preview, - argument_name="device_template_path_preview", + def _create_device_template(self, api_version, edge=False): + if edge and api_version != ApiVersion.v1_1_preview.value: + raise InvalidArgumentValueError( + "Edge template creation is only available for api version >= 1.1-preview." ) + + if edge: + template_path = edge_template_path_preview + elif api_version == ApiVersion.preview.value: + template_path = device_template_path_preview else: - template = utility.process_json_arg( - device_template_path, argument_name="device_template_path" - ) + template_path = device_template_path + + template = utility.process_json_arg( + template_path, + argument_name="template_path", + ) + template_name = template["displayName"] - template_id = template_name + "id" + template_id = template_name + "id;1" + + if ( + edge + ): # check if template already exists as a create call does not work for edge templates + # since deployment manifest cannot be changed + try: + command = "iot central device-template show --app-id {} --device-template-id {}".format( + self.app_id, template_id + ) + result = self.cmd(command, api_version=api_version).get_output_in_json() + + if ( + result + and result.get(self._get_template_id_key(api_version=api_version)) + == template_id + ): + return (template_id, template_name) + except Exception: + pass command = "iot central device-template create --app-id {} --device-template-id {} -k '{}'".format( - self.app_id, - template_id, - device_template_path_preview - if api_version == ApiVersion.preview.value - else device_template_path, + self.app_id, template_id, template_path ) result = self.cmd( @@ -302,10 +337,11 @@ def _create_device_template(self, api_version): ) json_result = result.get_output_in_json() - if api_version == ApiVersion.preview.value: - assert json_result["id"] == template_id - else: - assert json_result["@id"] == template_id + assert ( + json_result[self._get_template_id_key(api_version=api_version)] + == template_id + ) + return (template_id, template_name) def _delete_device_template(self, api_version, template_id): @@ -329,7 +365,7 @@ def _delete_device_template(self, api_version, template_id): # delete associated devices if any. devices = self.cmd( command="iot central device list --app-id {}".format(self.app_id), - api_version=api_version + api_version=api_version, ).get_output_in_json() if devices: @@ -359,7 +395,8 @@ def _list_device_groups(self, api_version): def _list_roles(self, api_version): return self.cmd( - "iot central role list --app-id {}".format(self.app_id), api_version=api_version + "iot central role list --app-id {}".format(self.app_id), + api_version=api_version, ).get_output_in_json() def _get_credentials(self, api_version, device_id): @@ -439,7 +476,9 @@ def _create_fileupload(self, api_version, account_name=None, sasttl=None): ).get_output_in_json() def _delete_fileupload(self, api_version): - command = "iot central file-upload-config delete --app-id {}".format(self.app_id) + command = "iot central file-upload-config delete --app-id {}".format( + self.app_id + ) self.cmd( command, api_version=api_version, @@ -542,14 +581,14 @@ def _create_storage_account(self): "storage account create -n {} -g {}".format( self.storage_account_name, self.app_rg ), - include_opt_args=False + include_opt_args=False, ) self._populate_storage_cstring() self.cmd( "storage container create -n {} --connection-string '{}'".format( self.storage_container, self.storage_cstring ), - include_opt_args=False + include_opt_args=False, ) def _populate_storage_cstring(self): @@ -561,7 +600,7 @@ def _populate_storage_cstring(self): "storage account show-connection-string -n {} -g {}".format( self.storage_account_name, self.app_rg ), - include_opt_args=False + include_opt_args=False, ).get_output_in_json()["connectionString"] def _delete_storage_account(self): @@ -572,9 +611,9 @@ def _delete_storage_account(self): if self.storage_account_name: self.cmd( "storage account delete -n {} -g {} -y".format( - self.storage_account_name, APP_RG + self.storage_account_name, self.app_rg ), - include_opt_args=False + include_opt_args=False, ) def _wait_for_storage_configured(self, api_version): @@ -599,9 +638,7 @@ def _wait_for_storage_configured(self, api_version): # wait 10 seconds for provisioning to complete time.sleep(10) - def _appendOptionalArgsToCommand( - self, command: str, api_version: str - ): + def _appendOptionalArgsToCommand(self, command: str, api_version: str): if self.token: command += ' --token "{}"'.format(self.token) if self.dns_suffix: @@ -611,10 +648,18 @@ def _appendOptionalArgsToCommand( return command + def _get_template_id(self, api_version, template): + if api_version == ApiVersion.preview.value: + return template["id"] + return template["@id"] + + def _get_template_id_key(self, api_version): + if api_version == ApiVersion.preview.value: + return "id" + return "@id" + def tearDown(self): if not settings.env.azext_iot_central_app_id: self.cmd( - "iot central app delete -n {} -g {} -y".format( - self.app_id, self.app_rg - ) + "iot central app delete -n {} -g {} -y".format(self.app_id, self.app_rg) ) diff --git a/azext_iot/tests/central/json/device_template_edge.json b/azext_iot/tests/central/json/device_template_edge.json new file mode 100644 index 000000000..64efa4cce --- /dev/null +++ b/azext_iot/tests/central/json/device_template_edge.json @@ -0,0 +1,273 @@ +{ + "@type": [ + "ModelDefinition", + "DeviceModel", + "EdgeModel", + "GatewayModel" + ], + "displayName": "dtmi:TestEdgeDeviceTemplate", + "capabilityModel": { + "@id": "dtmi:contoso:testCapabilityModel;1", + "@type": "Interface", + "displayName": "Test Capability Model", + "extends": [ + { + "@id": "dtmi:contoso:testInterface;1", + "@type": "Interface", + "displayName": "Test Interface", + "contents": [ + { + "@type": "Telemetry", + "displayName": "Test Telemetry", + "name": "testTelemetry", + "schema": "double" + }, + { + "@type": [ + "Telemetry", + "Event", + "EventValue" + ], + "displayName": "Test Event", + "name": "testEvent", + "schema": "integer", + "severity": "warning" + }, + { + "@type": [ + "Property", + "Initialized" + ], + "displayName": "Test Property", + "name": "testProperty", + "schema": "string", + "writable": true, + "initialValue": "initialValue1" + }, + { + "@type": "Property", + "displayName": "Test Read-Only Property", + "name": "testReadOnly", + "schema": "string" + }, + { + "@type": "Property", + "displayName": "Test Complex Property", + "name": "testComplex", + "schema": { + "@id": "dtmi:contoso:testComplex;1", + "@type": "Object", + "displayName": "Object", + "fields": [ + { + "displayName": "First", + "name": "first", + "schema": "string" + }, + { + "displayName": "Second", + "name": "second", + "schema": "string" + } + ] + }, + "writable": true + }, + { + "@type": "Command", + "commandType": "synchronous", + "displayName": "Test Command", + "name": "testCommand", + "request": { + "displayName": "Test Request", + "name": "testRequest", + "schema": "double" + }, + "response": { + "displayName": "Test Response", + "name": "testResponse", + "schema": "geopoint" + } + }, + { + "@type": "Property", + "displayName": "Test Enum", + "name": "testEnum", + "schema": { + "@id": "dtmi:contoso:testEnum;1", + "@type": "Enum", + "displayName": "Enum", + "enumValues": [ + { + "displayName": "First", + "enumValue": 1, + "name": "first" + }, + { + "displayName": "Second", + "enumValue": 2, + "name": "second" + } + ], + "valueSchema": "integer" + }, + "writable": true + } + ] + } + ], + "contents": [ + { + "@type": [ + "Relationship", + "EdgeModule" + ], + "displayName": "Test Module", + "maxMultiplicity": 1, + "name": "testModule", + "target": [ + { + "@id": "dtmi:contoso:testModuleCapabilityModel;1", + "@type": "Interface", + "displayName": "Test Module Capability Model", + "extends": [ + { + "@id": "dtmi:contoso:testModuleInterface;1", + "@type": "Interface", + "contents": [ + { + "@type": "Telemetry", + "displayName": "Test Module Telemetry", + "name": "testModuleTelemetry", + "schema": "double" + }, + { + "@type": "Property", + "displayName": "Test Module Property", + "name": "testModuleProperty", + "schema": "string", + "writable": true + } + ], + "displayName": "Test Module Interface" + } + ] + } + ] + }, + { + "@type": [ + "Cloud", + "Property" + ], + "displayName": "Test Cloud Property", + "name": "testCloudProperty", + "schema": "dateTime" + }, + { + "@type": [ + "Relationship", + "GatewayDevice" + ], + "displayName": { + "en": "Device" + }, + "name": "device", + "target": [ + "dtmi:sampleApp:modelOnebz;3" + ] + } + ] + }, + "deploymentManifest": { + "modulesContent": { + "$edgeAgent": { + "properties.desired": { + "schemaVersion": "1.1", + "runtime": { + "type": "docker", + "settings": { + "minDockerVersion": "v1.25", + "loggingOptions": "", + "registryCredentials": { + "ContosoRegistry": { + "username": "myacr", + "password": "", + "address": "myacr.azurecr.io" + } + } + } + }, + "systemModules": { + "edgeAgent": { + "type": "docker", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-agent:1.1", + "createOptions": "{}" + } + }, + "edgeHub": { + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 0, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-hub:1.1", + "createOptions": "{\"HostConfig\":{\"PortBindings\":{\"443/tcp\":[{\"HostPort\":\"443\"}],\"5671/tcp\":[{\"HostPort\":\"5671\"}],\"8883/tcp\":[{\"HostPort\":\"8883\"}]}}}" + } + } + }, + "modules": { + "SimulatedTemperatureSensor": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 2, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-simulated-temperature-sensor:1.0", + "createOptions": "{}" + } + }, + "testModule": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 1, + "env": { + "tempLimit": { + "value": "100" + } + }, + "settings": { + "image": "myacr.azurecr.io/testModule:latest", + "createOptions": "{}" + } + } + } + } + }, + "$edgeHub": { + "properties.desired": { + "schemaVersion": "1.1", + "routes": { + "sensorToFilter": { + "route": "FROM /messages/modules/SimulatedTemperatureSensor/outputs/temperatureOutput INTO BrokeredEndpoint(\"/modules/testModule/inputs/input1\")", + "priority": 0, + "timeToLiveSecs": 1800 + }, + "filterToIoTHub": { + "route": "FROM /messages/modules/testModule/outputs/output1 INTO $upstream", + "priority": 1, + "timeToLiveSecs": 1800 + } + }, + "storeAndForwardConfiguration": { + "timeToLiveSecs": 100 + } + } + } + } + } +} \ No newline at end of file diff --git a/azext_iot/tests/central/json/device_template_int_test.json b/azext_iot/tests/central/json/device_template_int_test.json index bb0e0b5db..122d3bd19 100644 --- a/azext_iot/tests/central/json/device_template_int_test.json +++ b/azext_iot/tests/central/json/device_template_int_test.json @@ -208,7 +208,7 @@ } ] }, - "@id": "dtmi:ulu38i3jr:intTestDeviceTemplateid", + "@id": "dtmi:intTestDeviceTemplateid;1", "@type": [ "DeviceModel", "ModelDefinition" diff --git a/azext_iot/tests/central/json/edge_children.json b/azext_iot/tests/central/json/edge_children.json new file mode 100644 index 000000000..4fc97567a --- /dev/null +++ b/azext_iot/tests/central/json/edge_children.json @@ -0,0 +1,22 @@ +[ + { + "displayName": "dev01", + "enabled": true, + "etag": "eyJoZWFkZXIiOiJcImM3MDFjNWYxLTAwMDAtMGQwMC0wMDAwLTYxYWE1Y2M3MDAwMFwiIn0", + "id": "dev01", + "organizations": null, + "provisioned": true, + "simulated": false, + "template": "dtmi:modelDefinition:dkngh8n7:zxjfczjpc" + }, + { + "displayName": "dev02", + "enabled": true, + "etag": "eyJoZWFkZXIiOiJcIjA3MDBlYThhLTAwMDAtMGQwMC0wMDAwLTYyMWNhYmE4MDAwMFwiIn0", + "id": "dev02", + "organizations": null, + "provisioned": false, + "simulated": false, + "template": "dtmi:modelDefinition:dkngh8n7:zxjfczjpc" + } +] \ No newline at end of file diff --git a/azext_iot/tests/central/json/edge_devices.json b/azext_iot/tests/central/json/edge_devices.json new file mode 100644 index 000000000..98cb279e9 --- /dev/null +++ b/azext_iot/tests/central/json/edge_devices.json @@ -0,0 +1,42 @@ +[ + { + "displayName": "Edge - edge0", + "enabled": true, + "etag": "eyJoZWFkZXIiOiJcImM3MDE4MWIxLTAwMDAtMGQwMC0wMDAwLTYxYWE1OTZiMDAwMFwiIn0", + "id": "edge0", + "organizations": null, + "provisioned": true, + "simulated": false, + "template": "dtmi:modelDefinition:h8pzhzxn66:eezqod" + }, + { + "displayName": "testedge", + "enabled": true, + "etag": "eyJoZWFkZXIiOiJcImJjMDAwZjM0LTAwMDAtMGQwMC0wMDAwLTYyMGE2MjkyMDAwMFwiIn0", + "id": "testedge", + "organizations": null, + "provisioned": true, + "simulated": false, + "template": "dtmi:maapd66su:g4joykxupn" + }, + { + "displayName": "15xei5pig53", + "enabled": true, + "etag": "eyJoZWFkZXIiOiJcIjAxMDBjM2E4LTAwMDAtMGQwMC0wMDAwLTYyMTkwNzQyMDAwMFwiIn0", + "id": "15xei5pig53", + "organizations": null, + "provisioned": false, + "simulated": false, + "template": "dtmi:modelDefinition:gpr9qizj:rsmzkhys7f" + }, + { + "displayName": "Edge - edge1", + "enabled": true, + "etag": "eyJoZWFkZXIiOiJcIjA3MDBlMGFjLTAwMDAtMGQwMC0wMDAwLTYyMWNlNDcyMDAwMFwiIn0", + "id": "edge1", + "organizations": null, + "provisioned": false, + "simulated": false, + "template": "dtmi:modelDefinition:h8pzhzxn66:eezqod" + } +] \ No newline at end of file diff --git a/azext_iot/tests/central/json/edge_manifest.json b/azext_iot/tests/central/json/edge_manifest.json new file mode 100644 index 000000000..9dbcc853e --- /dev/null +++ b/azext_iot/tests/central/json/edge_manifest.json @@ -0,0 +1,91 @@ +{ + "modulesContent": { + "$edgeAgent": { + "properties.desired": { + "schemaVersion": "1.1", + "runtime": { + "type": "docker", + "settings": { + "minDockerVersion": "v1.25", + "loggingOptions": "", + "registryCredentials": { + "ContosoRegistry": { + "username": "myacr", + "password": "", + "address": "myacr.azurecr.io" + } + } + } + }, + "systemModules": { + "edgeAgent": { + "type": "docker", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-agent:1.1", + "createOptions": "{}" + } + }, + "edgeHub": { + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 0, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-hub:1.1", + "createOptions": "{\"HostConfig\":{\"PortBindings\":{\"443/tcp\":[{\"HostPort\":\"443\"}],\"5671/tcp\":[{\"HostPort\":\"5671\"}],\"8883/tcp\":[{\"HostPort\":\"8883\"}]}}}" + } + } + }, + "modules": { + "SimulatedTemperatureSensor": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 2, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-simulated-temperature-sensor:1.0", + "createOptions": "{}" + } + }, + "testModule": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 1, + "env": { + "tempLimit": { + "value": "100" + } + }, + "settings": { + "image": "myacr.azurecr.io/testModule:latest", + "createOptions": "{}" + } + } + } + } + }, + "$edgeHub": { + "properties.desired": { + "schemaVersion": "1.1", + "routes": { + "sensorToFilter": { + "route": "FROM /messages/modules/SimulatedTemperatureSensor/outputs/temperatureOutput INTO BrokeredEndpoint(\"/modules/testModule/inputs/input1\")", + "priority": 0, + "timeToLiveSecs": 1800 + }, + "filterToIoTHub": { + "route": "FROM /messages/modules/testModule/outputs/output1 INTO $upstream", + "priority": 1, + "timeToLiveSecs": 1800 + } + }, + "storeAndForwardConfiguration": { + "timeToLiveSecs": 100 + } + } + } + } +} \ No newline at end of file diff --git a/azext_iot/tests/central/json/edge_modules.json b/azext_iot/tests/central/json/edge_modules.json new file mode 100644 index 000000000..ed0c6bf26 --- /dev/null +++ b/azext_iot/tests/central/json/edge_modules.json @@ -0,0 +1,87 @@ +{ + "modules": [ + { + "deviceId": "edge0", + "moduleId": "$edgeAgent", + "isSystemModule": true, + "runtimeStatus": "running", + "statusDescription": "running", + "restartPolicy": "always", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-agent:1.0", + "imageHash": "sha256:2e4579e4cddb747faf7b454fe7b4fd6d14dfcc9106ba49b9a467ec36979fb118", + "createOptions": "{\"Labels\":{\"net.azure-devices.edge.create-options\":\"{}\",\"net.azure-devices.edge.env\":\"{}\",\"net.azure-devices.edge.original-image\":\"mcr.microsoft.com/azureiotedge-agent:1.0\",\"net.azure-devices.edge.owner\":\"Microsoft.Azure.Devices.Edge.Agent\"}}" + }, + "etag": "AAAAAAAAAAI=", + "deviceEtag": "ODc3ODUwMzk4", + "twinStatus": "enabled", + "statusUpdateTime": "0001-01-01T00:00:00Z", + "connectionState": "Disconnected", + "lastActivityTime": "0001-01-01T00:00:00Z", + "cloudToDeviceMessageCount": 0, + "type": "docker", + "startupOrder": 0, + "exitCode": 0, + "lastStartTimeUtc": "2022-01-18T09:45:22.7359646", + "lastExitTimeUtc": "0001-01-01T00:00:00", + "imagePullPolicy": "on-create" + }, + { + "deviceId": "edge0", + "moduleId": "$edgeHub", + "isSystemModule": true, + "runtimeStatus": "running", + "statusDescription": "running", + "restartPolicy": "always", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-hub:1.0", + "imageHash": "sha256:8361ca7a02fa3211950718280f70d5055e01c98f129e35227387460e67faa0ea", + "createOptions": "{}" + }, + "etag": "AAAAAAAAAAI=", + "deviceEtag": "ODc3ODUwMzk5", + "twinStatus": "enabled", + "statusUpdateTime": "0001-01-01T00:00:00Z", + "connectionState": "Connected", + "lastActivityTime": "0001-01-01T00:00:00Z", + "cloudToDeviceMessageCount": 0, + "type": "docker", + "status": "running", + "imagePullPolicy": "on-create", + "exitCode": 0, + "env": {}, + "lastStartTimeUtc": "2022-01-18T09:45:30.1468476", + "lastExitTimeUtc": "0001-01-01T00:00:00", + "restartCount": 0 + }, + { + "deviceId": "edge0", + "moduleId": "SimulatedTemperatureSensor", + "isSystemModule": false, + "runtimeStatus": "running", + "statusDescription": "running", + "restartPolicy": "always", + "version": "1.0", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-simulated-temperature-sensor:1.0", + "imageHash": "sha256:85fdb1e9675c837c18b75f103be6f156587d1058eced1fc508cdb84a722e4f82", + "createOptions": "{}" + }, + "etag": "AAAAAAAAAAE=", + "deviceEtag": "MTQ4NjM2MzAz", + "twinStatus": "enabled", + "statusUpdateTime": "0001-01-01T00:00:00Z", + "connectionState": "Connected", + "lastActivityTime": "2022-01-18T10:27:25.3827508Z", + "cloudToDeviceMessageCount": 0, + "exitCode": 0, + "lastStartTimeUtc": "2022-01-18T09:45:29.2322424", + "lastExitTimeUtc": "0001-01-01T00:00:00", + "restartCount": 0, + "status": "running", + "imagePullPolicy": "on-create", + "type": "docker", + "env": {} + } + ] +} \ No newline at end of file diff --git a/azext_iot/tests/central/test_iot_central_devices_int.py b/azext_iot/tests/central/test_iot_central_devices_int.py index a897340da..7f8912acd 100644 --- a/azext_iot/tests/central/test_iot_central_devices_int.py +++ b/azext_iot/tests/central/test_iot_central_devices_int.py @@ -5,6 +5,7 @@ # -------------------------------------------------------------------------------------------- +from sys import api_version import time import pytest import json @@ -115,7 +116,9 @@ def test_device_connect(self): command = "iot central device compute-device-key --pk {} -d {}".format( self.app_primary_key, device_id ) - device_primary_key = self.cmd(command, include_opt_args=False).get_output_in_json() + device_primary_key = self.cmd( + command, include_opt_args=False + ).get_output_in_json() credentials = { "idScope": app_scope_id, @@ -123,7 +126,9 @@ def test_device_connect(self): } device_client = helpers.dps_connect_device(device_id, credentials) - command = "iot central device show --app-id {} -d {}".format(self.app_id, device_id) + command = "iot central device show --app-id {} -d {}".format( + self.app_id, device_id + ) self.cmd( command, @@ -145,7 +150,9 @@ def test_central_device_methods_CRUD(self): start_dev_count = len(start_device_list) (device_id, device_name) = self._create_device(api_version=self._api_version) - command = "iot central device show --app-id {} -d {}".format(self.app_id, device_id) + command = "iot central device show --app-id {} -d {}".format( + self.app_id, device_id + ) checks = [ self.check("displayName", device_name), self.check("id", device_id), @@ -207,6 +214,127 @@ def test_central_device_methods_CRUD(self): is None ) + def test_central_edge_device_methods(self): + # force API Version 1.1-preview as we want deployment manifest to be included + + # create edge template + (template_id, _) = self._create_device_template( + api_version=ApiVersion.v1_1_preview.value, edge=True + ) + + (device_id, _) = self._create_device( + template=template_id, + api_version=ApiVersion.v1_1_preview.value, + simulated=True, + ) + + # wait about a few seconds for simulator to kick in so that provisioning completes + time.sleep(60) + + command = "iot central device list --app-id {}".format(self.app_id) + total_devs_list = self.cmd(command).get_output_in_json() + + # check if device appears as edge + command = "iot central device list --app-id {} --edge-only".format(self.app_id) + edge_list = self.cmd(command).get_output_in_json() + assert device_id in [dev["id"] for dev in edge_list] + # check edge device appears in general list + assert all(x in total_devs_list for x in edge_list) + + # MODULES + command = "iot central device edge module list --app-id {} -d {}".format( + self.app_id, device_id + ) + modules = self.cmd(command).get_output_in_json() + + assert len(modules) == 4 # edge runtime + custom modules + assert all( + ( + cond is True + for cond in [ + a in [module["moduleId"] for module in modules] + for a in [ + "$edgeHub", + "$edgeAgent", + "testModule", + "SimulatedTemperatureSensor", + ] + ] + ) + ) + self._delete_device(device_id=device_id, api_version=self._api_version) + self._delete_device_template( + template_id=template_id, api_version=self._api_version + ) + + def test_central_edge_children_methods(self): + # force API Version 1.1-preview as we want deployment manifest to be included + + # create child template + (child_template_id, _) = self._create_device_template( + api_version=ApiVersion.v1_1_preview.value + ) + + # create edge template + (template_id, _) = self._create_device_template( + api_version=ApiVersion.v1_1_preview.value, edge=True + ) + + (device_id, _) = self._create_device( + template=template_id, + api_version=ApiVersion.v1_1_preview.value, + simulated=True, + ) + + (child_id, _) = self._create_device( + template=child_template_id, + api_version=ApiVersion.v1_1_preview.value, + simulated=True, + ) + + # wait about a few seconds for simulator to kick in so that provisioning completes + time.sleep(60) + + if api_version == ApiVersion.v1_1_preview.value: + # check if device appears as edge + command = "iot central device list --app-id {} --edge-only" + devs_list = self.cmd(command).get_output_in_json() + assert device_id in [dev.id for dev in devs_list] + else: + # check twin to evaluate the iotedge flag + command = "iot central device twin show --app-id {} -d {}".format( + self.app_id, device_id + ) + twin = self.cmd(command).get_output_in_json() + + assert twin["capabilities"]["iotEdge"] is True + + # CHILDREN + command = "iot central device edge children add --app-id {} -d {} --children-ids {}".format( + self.app_id, device_id, child_id + ) + self.cmd(command, api_version=ApiVersion.v1_1_preview.value) + + command = "iot central device edge children list --app-id {} -d {}".format( + self.app_id, device_id + ) + # Use api-version 1.0 to also test interoperability + children = self.cmd( + command, api_version=ApiVersion.v1.value + ).get_output_in_json() + assert len(children) == 1 + assert children[0]["id"] == child_id + + # cleanup + self._delete_device(device_id=child_id, api_version=self._api_version) + self._delete_device(device_id=device_id, api_version=self._api_version) + self._delete_device_template( + template_id=template_id, api_version=self._api_version + ) + self._delete_device_template( + template_id=child_template_id, api_version=self._api_version + ) + def test_central_device_template_methods_CRUD(self): # currently: create, show, list, delete @@ -234,7 +362,10 @@ def test_central_device_template_methods_CRUD(self): json_result = result.get_output_in_json() - assert self._get_template_id(json_result) == template_id + assert ( + self._get_template_id(api_version=self._api_version, template=json_result) + == template_id + ) created_device_template_list = self.cmd( "iot central device-template list --app-id {}".format(self.app_id), @@ -252,7 +383,10 @@ def test_central_device_template_methods_CRUD(self): ( template for template in created_device_template_list - if self._get_template_id(template) == template_id + if self._get_template_id( + template=template, api_version=self._api_version + ) + == template_id ), None, ) @@ -261,7 +395,9 @@ def test_central_device_template_methods_CRUD(self): # UPDATE new_template_name = f"{template_name}_new" - del json_result[self._get_template_id_key()] # remove id + del json_result[ + self._get_template_id_key(api_version=self._api_version) + ] # remove id del json_result["@context"] del json_result["etag"] @@ -317,7 +453,10 @@ def test_central_device_template_methods_CRUD(self): ( template for template in start_device_template_list - if self._get_template_id(template) == template_id + if self._get_template_id( + template=template, api_version=self._api_version + ) + == template_id ), None, ) @@ -330,7 +469,10 @@ def test_central_device_template_methods_CRUD(self): ( template for template in deleted_device_template_list - if self._get_template_id(template) == template_id + if self._get_template_id( + template=template, api_version=self._api_version + ) + == template_id ), None, ) @@ -533,13 +675,3 @@ def _connect_gettwin_disconnect_wait_tobeprovisioned(self, device_id, credential device_client.disconnect() device_client.shutdown() self._wait_for_provisioned(device_id=device_id, api_version=self._api_version) - - def _get_template_id(self, template): - if self._api_version == ApiVersion.preview.value: - return template["id"] - return template["@id"] - - def _get_template_id_key(self): - if self._api_version == ApiVersion.preview.value: - return "id" - return "@id" diff --git a/azext_iot/tests/central/test_iot_central_int.py b/azext_iot/tests/central/test_iot_central_int.py index a50b99d99..9287247ba 100644 --- a/azext_iot/tests/central/test_iot_central_int.py +++ b/azext_iot/tests/central/test_iot_central_int.py @@ -34,11 +34,11 @@ def fixture_api_version(self, request): print("Testing 1.1-preview") yield - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setUpSuite(self): self._create_storage_account() - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def tearDownSuite(self): yield if self.storage_account_name: @@ -473,7 +473,7 @@ def test_central_query_methods_run(self): command = 'iot central query -n {} --query-string "{}"'.format( self.app_id, - "SELECT TOP 1 testDefaultCapability FROM dtmi:intTestDeviceTemplateid WHERE WITHIN_WINDOW(PT1H)", + f"SELECT TOP 1 testDefaultCapability FROM {template_id} WHERE WITHIN_WINDOW(PT1H)", ) response = self.cmd(command, api_version=self._api_version).get_output_in_json() diff --git a/azext_iot/tests/central/test_iot_central_unit.py b/azext_iot/tests/central/test_iot_central_unit.py index b8849d4bd..ac0941d75 100644 --- a/azext_iot/tests/central/test_iot_central_unit.py +++ b/azext_iot/tests/central/test_iot_central_unit.py @@ -4,6 +4,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from azext_iot.central.models.edge import EdgeModule from azext_iot.central.providers import ( CentralFileUploadProvider, CentralOrganizationProvider, @@ -148,8 +149,12 @@ def test_monitor_events_invalid_args(self, timeout, exception, fixture_cmd): class TestCentralDeviceProvider: _device = load_json(FileNames.central_device_file) + _edge_devices = list(load_json(FileNames.central_edge_devices_file)) + _edge_children = list(load_json(FileNames.central_edge_children_file)) _device_template = load_json(FileNames.central_device_template_file) + _edge_template = load_json(FileNames.central_edge_template_file) _device_twin = load_json(FileNames.central_device_twin_file) + _edge_modules = load_json(FileNames.central_edge_modules_file) @mock.patch("azext_iot.central.services.device_template") @mock.patch("azext_iot.central.services.device") @@ -177,6 +182,77 @@ def test_should_return_device(self, mock_device_svc, mock_device_template_svc): assert todict(device) == todict(self._device) + @mock.patch("azext_iot.central.services.device") + def test_should_return_list_edge(self, mock_device_svc): + # setup + provider = CentralDeviceProvider( + cmd=None, app_id=app_id, api_version=ApiVersion.v1_1_preview.value + ) + + devices = [ + get_object(device, "Device", api_version=ApiVersion.v1_1_preview.value) + for device in self._edge_devices + ] + mock_device_svc.list_devices.return_value = devices + + # act + edge_devices = [ + todict(dev) + for dev in provider.list_devices( + filter="type eq 'GatewayDevice' or type eq 'EdgeDevice'" + ) + ] + # verify + assert mock_device_svc.list_devices.call_count == 1 + assert edge_devices == self._edge_devices + + @mock.patch("azext_iot.central.services.device") + def test_should_return_list_children(self, mock_device_svc): + # setup + provider = CentralDeviceProvider( + cmd=None, app_id=app_id, api_version=ApiVersion.v1_1_preview.value + ) + + children = [ + get_object(device, "Device", api_version=ApiVersion.v1_1_preview.value) + for device in self._edge_children + ] + mock_device_svc.list_devices.return_value = children + + joined = "' or id eq '".join([child.id for child in children]) + filter = f"id eq '{joined}'" + + # act + children_devices = [todict(dev) for dev in provider.list_devices(filter=filter)] + # verify + assert mock_device_svc.list_devices.call_count == 1 + assert children_devices == self._edge_children + + @mock.patch("azext_iot.central.services.device.requests") + @mock.patch("azext_iot.central.services.device.get_aad_token") + def test_should_list_device_modules(self, get_aad_token_svc, req_svc): + # setup + provider = CentralDeviceProvider( + cmd=None, app_id=app_id, api_version=ApiVersion.v1.value + ) + response = mock.MagicMock() + response.status_code = 200 + response.json.return_value = self._edge_modules + req_svc.get.return_value = response + + # act + modules = [ + todict(computed) for computed in provider.list_device_modules("edge0") + ] + + # verify + # call counts should be at most 1 + assert req_svc.get.call_count == 1 + parsed_modules = [ + todict(EdgeModule(_module)) for _module in self._edge_modules.get("modules") + ] + assert parsed_modules == modules + @mock.patch("azext_iot.central.services.device_template") @mock.patch("azext_iot.central.services.device") def test_should_return_device_template( diff --git a/azext_iot/tests/test_constants.py b/azext_iot/tests/test_constants.py index 7ff2d1944..d0cc418ac 100644 --- a/azext_iot/tests/test_constants.py +++ b/azext_iot/tests/test_constants.py @@ -7,10 +7,13 @@ class FileNames: central_device_template_file = "central/json/device_template.json" + central_edge_template_file = "central/json/device_template_edge.json" central_deeply_nested_device_template_file = ( "central/json/deeply_nested_template.json" ) central_device_file = "central/json/device.json" + central_edge_devices_file = "central/json/edge_devices.json" + central_edge_children_file = "central/json/edge_children.json" central_device_group_file = "central/json/device_group.json" central_organization_file = "central/json/organization.json" central_role_file = "central/json/role.json" @@ -18,6 +21,7 @@ class FileNames: central_job_file = "central/json/job.json" central_fileupload_file = "central/json/fileupload.json" central_device_twin_file = "central/json/device_twin.json" + central_edge_modules_file = "central/json/edge_modules.json" central_property_validation_template_file = ( "central/json/property_validation_template.json" )