diff --git a/azext_iot/__init__.py b/azext_iot/__init__.py index 4d2cc95d4..e09d53fb4 100644 --- a/azext_iot/__init__.py +++ b/azext_iot/__init__.py @@ -11,51 +11,49 @@ import azext_iot._help # noqa: F401 -iothub_ops = CliCommandType( - operations_tmpl='azext_iot.operations.hub#{}' -) +iothub_ops = CliCommandType(operations_tmpl="azext_iot.operations.hub#{}") -iothub_ops_job = CliCommandType( - operations_tmpl='azext_iot.iothub.job_commands#{}' -) +iothub_ops_job = CliCommandType(operations_tmpl="azext_iot.iothub.job_commands#{}") iothub_ops_device = CliCommandType( - operations_tmpl='azext_iot.iothub.device_commands#{}' + operations_tmpl="azext_iot.iothub.device_commands#{}" ) iotdps_ops = CliCommandType( - operations_tmpl='azext_iot.operations.dps#{}', - client_factory=iot_service_provisioning_factory + operations_tmpl="azext_iot.operations.dps#{}", + client_factory=iot_service_provisioning_factory, ) -iotcentral_ops = CliCommandType( - operations_tmpl='azext_iot.operations.central#{}' -) +iotcentral_ops = CliCommandType(operations_tmpl="azext_iot.operations.central#{}") iotdigitaltwin_ops = CliCommandType( - operations_tmpl='azext_iot.operations.digitaltwin#{}' + operations_tmpl="azext_iot.operations.digitaltwin#{}" ) -iotpnp_ops = CliCommandType( - operations_tmpl='azext_iot.operations.pnp#{}' -) +iotpnp_ops = CliCommandType(operations_tmpl="azext_iot.operations.pnp#{}") class IoTExtCommandsLoader(AzCommandsLoader): - def __init__(self, cli_ctx=None): super(IoTExtCommandsLoader, self).__init__(cli_ctx=cli_ctx) def load_command_table(self, args): from azext_iot.commands import load_command_table - load_command_table(self, args) from azext_iot.iothub.command_bindings import load_iothub_commands + from azext_iot.central.command_map import load_central_commands + + load_command_table(self, args) load_iothub_commands(self, args) + load_central_commands(self, args) + return self.command_table def load_arguments(self, command): from azext_iot._params import load_arguments + from azext_iot.central.params import load_central_arguments + load_arguments(self, command) + load_central_arguments(self, command) COMMAND_LOADER_CLS = IoTExtCommandsLoader diff --git a/azext_iot/central/__init__.py b/azext_iot/central/__init__.py index 55614acbf..73178b8ad 100644 --- a/azext_iot/central/__init__.py +++ b/azext_iot/central/__init__.py @@ -3,3 +3,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- + +from azext_iot.central._help import load_central_help + +load_central_help() diff --git a/azext_iot/central/_help.py b/azext_iot/central/_help.py new file mode 100644 index 000000000..98058f486 --- /dev/null +++ b/azext_iot/central/_help.py @@ -0,0 +1,178 @@ +# 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. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps + + +def load_central_help(): + helps[ + "iot central" + ] = """ + type: group + short-summary: Manage Azure Central (IoTC) solutions & infrastructure + """ + + _load_central_devices_help() + _load_central_device_templates_help() + + +def _load_central_devices_help(): + helps[ + "iot central app device" + ] = """ + type: group + short-summary: Manage and configure IoTC devices + """ + + helps[ + "iot central app device create" + ] = """ + type: command + short-summary: Create a device in IoTC + + examples: + - name: Create a device + text: > + az iot central app device create + --app-id {appid} + --device-id {deviceid} + + - name: Create a simulated device + text: > + az iot central app device create + --app-id {appid} + --device-id {deviceid} + --instance-of {devicetemplateid} + --simulated + """ + + helps[ + "iot central app device show" + ] = """ + type: command + short-summary: Get a device from IoTC + + examples: + - name: Get a device + text: > + az iot central app device show + --app-id {appid} + --device-id {deviceid} + """ + + helps[ + "iot central app device list" + ] = """ + type: command + short-summary: List all devices in IoTC + + examples: + - name: Get a device + text: > + az iot central app device list + --app-id {appid} + """ + + helps[ + "iot central app device delete" + ] = """ + type: command + short-summary: Delete a device from IoTC + + examples: + - name: Get a device + text: > + az iot central app device list + --app-id {appid} + --device-id {deviceid} + """ + + +def _load_central_device_templates_help(): + helps[ + "iot central app device-template" + ] = """ + type: group + short-summary: Manage and configure IoTC device templates + """ + + helps[ + "iot central app device-template create" + ] = """ + type: command + short-summary: Create a device template in IoTC + + examples: + - name: Create a device with payload read from a file + text: > + az iot central app device create + --app-id {appid} + --content {pathtofile} + --device-template-id {devicetemplateid} + + - name: Create a device with payload read from raw json + text: > + az iot central app device create + --app-id {appid} + --content {json} + --device-template-id {devicetemplateid} + """ + + helps[ + "iot central app device-template show" + ] = """ + type: command + short-summary: Get a device template from IoTC + + examples: + - name: Get a device + text: > + az iot central app device show + --app-id {appid} + --device-template-id {devicetemplateid} + """ + + helps[ + "iot central app device-template list" + ] = """ + type: command + short-summary: List all device templates in IoTC + + examples: + - name: Get a device + text: > + az iot central app device-template list + --app-id {appid} + """ + + helps[ + "iot central app device-template map" + ] = """ + type: command + short-summary: Returns a mapping of device template name to device template id + + examples: + - name: Get device template name to id mapping + text: > + az iot central app device-template map + --app-id {appid} + """ + + helps[ + "iot central app device-template delete" + ] = """ + type: command + short-summary: Delete a device template from IoTC + long-summary: + Note: this is expected to fail + if any devices are still registered to this template. + + examples: + - name: Get a device + text: > + az iot central app device-template list + --app-id {appid} + """ diff --git a/azext_iot/central/command_map.py b/azext_iot/central/command_map.py new file mode 100644 index 000000000..f4a1250ac --- /dev/null +++ b/azext_iot/central/command_map.py @@ -0,0 +1,43 @@ +# 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. +# -------------------------------------------------------------------------------------------- + +""" +Load CLI commands +""" +from azure.cli.core.commands import CliCommandType + +central_device_ops = CliCommandType( + operations_tmpl="azext_iot.central.commands_device#{}" +) + +central_device_templates_ops = CliCommandType( + operations_tmpl="azext_iot.central.commands_device_template#{}" +) + + +# Dev note - think of this as the "router" and all self.command_group as the controllers +def load_central_commands(self, _): + """ + Load CLI commands + """ + with self.command_group( + "iot central app device", command_type=central_device_ops, is_preview=True, + ) as cmd_group: + cmd_group.command("list", "list_devices") + cmd_group.command("show", "get_device") + cmd_group.command("create", "create_device") + cmd_group.command("delete", "delete_device") + + with self.command_group( + "iot central app device-template", + command_type=central_device_templates_ops, + is_preview=True, + ) as cmd_group: + cmd_group.command("list", "list_device_templates") + cmd_group.command("map", "map_device_templates") + cmd_group.command("show", "get_device_template") + cmd_group.command("create", "create_device_template") + cmd_group.command("delete", "delete_device_template") diff --git a/azext_iot/central/commands_device.py b/azext_iot/central/commands_device.py new file mode 100644 index 000000000..745d92e31 --- /dev/null +++ b/azext_iot/central/commands_device.py @@ -0,0 +1,51 @@ +# 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. +# -------------------------------------------------------------------------------------------- +# Dev note - think of this as a controller + +from knack.util import CLIError +from azext_iot.central.providers import CentralDeviceProvider + + +def list_devices(cmd, app_id: str, central_dns_suffix="azureiotcentral.com"): + provider = CentralDeviceProvider(cmd, app_id) + return provider.list_devices() + + +def get_device( + cmd, app_id: str, device_id: str, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceProvider(cmd, app_id) + return provider.get_device(device_id) + + +def create_device( + cmd, + app_id: str, + device_id: str, + device_name=None, + instance_of=None, + simulated=False, + central_dns_suffix="azureiotcentral.com", +): + if simulated and not instance_of: + raise CLIError( + "Error: if you supply --simulated you must also specify --instance-of" + ) + provider = CentralDeviceProvider(cmd, app_id) + return provider.create_device( + device_id=device_id, + device_name=device_name, + instance_of=instance_of, + simulated=simulated, + central_dns_suffix=central_dns_suffix, + ) + + +def delete_device( + cmd, app_id: str, device_id: str, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceProvider(cmd, app_id) + return provider.delete_device(device_id) diff --git a/azext_iot/central/commands_device_template.py b/azext_iot/central/commands_device_template.py new file mode 100644 index 000000000..2f2050f95 --- /dev/null +++ b/azext_iot/central/commands_device_template.py @@ -0,0 +1,59 @@ +# 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. +# -------------------------------------------------------------------------------------------- +# Dev note - think of this as a controller + +from knack.util import CLIError + +from azext_iot.common import utility +from azext_iot.central.providers import CentralDeviceTemplateProvider + + +def get_device_template( + cmd, app_id: str, device_template_id: str, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceTemplateProvider(cmd, app_id) + return provider.get_device_template( + device_template_id=device_template_id, central_dns_suffix=central_dns_suffix + ) + + +def list_device_templates(cmd, app_id: str, central_dns_suffix="azureiotcentral.com"): + provider = CentralDeviceTemplateProvider(cmd, app_id) + return provider.list_device_templates(central_dns_suffix=central_dns_suffix) + + +def map_device_templates(cmd, app_id: str, central_dns_suffix="azureiotcentral.com"): + provider = CentralDeviceTemplateProvider(cmd, app_id) + return provider.map_device_templates(central_dns_suffix=central_dns_suffix) + + +def create_device_template( + cmd, + app_id: str, + device_template_id: str, + content: str, + central_dns_suffix="azureiotcentral.com", +): + if not isinstance(content, str): + raise CLIError("content must be a string: {}".format(content)) + + payload = utility.process_json_arg(content, argument_name="content") + + provider = CentralDeviceTemplateProvider(cmd, app_id) + return provider.create_device_template( + device_template_id=device_template_id, + payload=payload, + central_dns_suffix=central_dns_suffix, + ) + + +def delete_device_template( + cmd, app_id: str, device_template_id: str, central_dns_suffix="azureiotcentral.com" +): + provider = CentralDeviceTemplateProvider(cmd, app_id) + return provider.delete_device_template( + device_template_id=device_template_id, central_dns_suffix=central_dns_suffix + ) diff --git a/azext_iot/central/params.py b/azext_iot/central/params.py new file mode 100644 index 000000000..a158a969f --- /dev/null +++ b/azext_iot/central/params.py @@ -0,0 +1,48 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Unpublished works. +# -------------------------------------------------------------------------------------------- + +""" +CLI parameter definitions. +""" + +from azure.cli.core.commands.parameters import get_three_state_flag + + +def load_central_arguments(self, _): + """ + Load CLI Args for Knack parser + """ + with self.argument_context("iot central app") as context: + context.argument( + "instance_of", + options_list=["--instance-of"], + help="Central model id. Example: urn:ojpkindbz:modelDefinition:iild3tm_uo", + ) + context.argument( + "device_name", + options_list=["--device-name"], + help="Human readable device name. Example: Fridge", + ) + context.argument( + "simulated", + options_list=["--simulated"], + arg_type=get_three_state_flag(), + help="Add this flag if you would like IoT Central to set this up as a simulated device. " + "--instance-of is required if this is true", + ) + context.argument( + "device_template_id", + options_list=["--device-template-id"], + help="Device template id. Example: somedevicetemplate", + ) + context.argument( + "content", + options_list=["--content", "-k"], + help="Configuration for request. " + "Provide path to JSON file or raw stringified JSON. " + "[File Path Example: ./path/to/file.json] " + "[Stringified JSON Example: {'a': 'b'}] ", + ) diff --git a/azext_iot/central/providers/__init__.py b/azext_iot/central/providers/__init__.py index 21fd79ac1..57e791421 100644 --- a/azext_iot/central/providers/__init__.py +++ b/azext_iot/central/providers/__init__.py @@ -4,6 +4,9 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from .device_provider import CentralDeviceProvider +from azext_iot.central.providers.device_provider import CentralDeviceProvider +from azext_iot.central.providers.device_template_provider import ( + CentralDeviceTemplateProvider, +) -__all__ = ["CentralDeviceProvider"] +__all__ = ["CentralDeviceProvider", "CentralDeviceTemplateProvider"] diff --git a/azext_iot/central/providers/device_provider.py b/azext_iot/central/providers/device_provider.py index 330caec21..c45becb92 100644 --- a/azext_iot/central/providers/device_provider.py +++ b/azext_iot/central/providers/device_provider.py @@ -9,9 +9,9 @@ class CentralDeviceProvider: - def __init__(self, cmd, app_id, token=None): + def __init__(self, cmd, app_id: str, token=None): """ - Provider for device/device_template APIs + Provider for device APIs Args: cmd: command passed into az @@ -24,57 +24,118 @@ def __init__(self, cmd, app_id, token=None): self._cmd = cmd self._app_id = app_id self._token = token - self._device_templates = {} self._devices = {} + self._device_templates = {} - def get_device_template( + def get_device( self, device_id, central_dns_suffix="azureiotcentral.com", ): - device = self.get_device(device_id, central_dns_suffix) - device_template_urn = device["instanceOf"] - - if not device_template_urn: - raise CLIError( - "No device template urn found for device '{}'".format(device_id) - ) + if not device_id: + raise CLIError("Device id must be specified.") # get or add to cache - if ( - device_template_urn not in self._device_templates - or not self._device_templates.get(device_template_urn) - ): - self._device_templates[ - device_template_urn - ] = central_services.device_template.get_device_template( - self._cmd, - device_template_urn, - self._app_id, - self._token, - central_dns_suffix, + device = self._devices.get(device_id) + if not device: + device = central_services.device.get_device( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + central_dns_suffix=central_dns_suffix, ) + self._devices[device_id] = device - device_template = self._device_templates[device_template_urn] - if not device_template: - raise CLIError( - "No device template for device with id: '{}'.".format(device_id) - ) + if not device: + raise CLIError("No device found with id: '{}'.".format(device_id)) - return device_template + return device - def get_device( + def get_device_template_by_device_id( self, device_id, central_dns_suffix="azureiotcentral.com", ): + from azext_iot.central.providers import CentralDeviceTemplateProvider + if not device_id: raise CLIError("Device id must be specified.") - # get or add to cache - if device_id not in self._devices or not self._devices.get(device_id): - self._devices[device_id] = central_services.device.get_device( - self._cmd, device_id, self._app_id, self._token, central_dns_suffix + device = self.get_device(device_id, central_dns_suffix) + instance_of = device.get("instanceOf") + if not instance_of: + raise CLIError( + "Device '{}' does not have a corresponding device template.".format( + device_id + ) ) - device = self._devices[device_id] + template = CentralDeviceTemplateProvider.get_device_template( + self=self, + device_template_id=instance_of, + central_dns_suffix=central_dns_suffix, + ) + return template + + def list_devices( + self, central_dns_suffix="azureiotcentral.com", + ): + devices = central_services.device.list_devices( + cmd=self._cmd, app_id=self._app_id, token=self._token + ) + + # add to cache + self._devices.update({device["id"]: device for device in devices}) + + return self._devices + + def create_device( + self, + device_id, + device_name=None, + instance_of=None, + simulated=False, + central_dns_suffix="azureiotcentral.com", + ): + if not device_id: + raise CLIError("Device id must be specified.") + + if device_id in self._devices: + raise CLIError("Device already exists") + + device = central_services.device.create_device( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + device_name=device_name, + instance_of=instance_of, + simulated=simulated, + token=self._token, + central_dns_suffix=central_dns_suffix, + ) + if not device: raise CLIError("No device found with id: '{}'.".format(device_id)) + # add to cache + self._devices[device["id"]] = device + return device + + def delete_device( + self, device_id, central_dns_suffix="azureiotcentral.com", + ): + if not device_id: + raise CLIError("Device id must be specified.") + + # get or add to cache + result = central_services.device.delete_device( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + ) + + # remove from cache + # pop "miss" raises a KeyError if None is not provided + self._devices.pop(device_id, None) + + return result diff --git a/azext_iot/central/providers/device_template_provider.py b/azext_iot/central/providers/device_template_provider.py new file mode 100644 index 000000000..e7bc3ff0a --- /dev/null +++ b/azext_iot/central/providers/device_template_provider.py @@ -0,0 +1,114 @@ +# 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. +# -------------------------------------------------------------------------------------------- + +from knack.util import CLIError +from azext_iot.central import services as central_services + + +class CentralDeviceTemplateProvider: + def __init__(self, cmd, app_id, token=None): + """ + Provider for device_template APIs + + Args: + cmd: command passed into az + 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 ...') + Useful in scenarios where user doesn't own the app + therefore AAD token won't work, but a SAS token generated by owner will + """ + self._cmd = cmd + self._app_id = app_id + self._token = token + self._device_templates = {} + + def get_device_template( + self, device_template_id, central_dns_suffix="azureiotcentral.com", + ): + # get or add to cache + device_template = self._device_templates.get(device_template_id) + if not device_template: + device_template = central_services.device_template.get_device_template( + cmd=self._cmd, + app_id=self._app_id, + device_template_id=device_template_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + ) + self._device_templates[device_template_id] = device_template + + if not device_template: + raise CLIError( + "No device template for device template with id: '{}'.".format( + device_template_id + ) + ) + + return device_template + + def list_device_templates( + self, central_dns_suffix="azureiotcentral.com", + ): + templates = central_services.device_template.list_device_templates( + cmd=self._cmd, app_id=self._app_id, token=self._token + ) + + self._device_templates.update( + {template["id"]: template for template in templates} + ) + + return self._device_templates + + def map_device_templates( + self, central_dns_suffix="azureiotcentral.com", + ): + """ + Maps each template name to the corresponding template id + """ + templates = central_services.device_template.list_device_templates( + cmd=self._cmd, app_id=self._app_id, token=self._token + ) + return {template["displayName"]: template["id"] for template in templates} + + def create_device_template( + self, + device_template_id: str, + payload: str, + central_dns_suffix="azureiotcentral.com", + ): + template = central_services.device_template.create_device_template( + cmd=self._cmd, + app_id=self._app_id, + device_template_id=device_template_id, + payload=payload, + token=self._token, + central_dns_suffix=central_dns_suffix, + ) + + self._device_templates[template["id"]] = template + + return template + + def delete_device_template( + self, device_template_id, central_dns_suffix="azureiotcentral.com", + ): + if not device_template_id: + raise CLIError("Device template id must be specified.") + + result = central_services.device_template.delete_device_template( + cmd=self._cmd, + token=self._token, + app_id=self._app_id, + device_template_id=device_template_id, + central_dns_suffix=central_dns_suffix, + ) + + # remove from cache + # pop "miss" raises a KeyError if None is not provided + self._device_templates.pop(device_template_id, None) + + return result diff --git a/azext_iot/central/services/__init__.py b/azext_iot/central/services/__init__.py index 6e13f4bad..190ad31e8 100644 --- a/azext_iot/central/services/__init__.py +++ b/azext_iot/central/services/__init__.py @@ -4,7 +4,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from . import device, device_template +from azext_iot.central.services import device, device_template __all__ = ["device", "device_template"] diff --git a/azext_iot/central/services/_utility.py b/azext_iot/central/services/_utility.py index 5f01196e2..f3afed651 100644 --- a/azext_iot/central/services/_utility.py +++ b/azext_iot/central/services/_utility.py @@ -5,13 +5,40 @@ # -------------------------------------------------------------------------------------------- # Nothing in this file should be used outside of service/central +from knack.util import CLIError +from requests import Response + from azext_iot import constants from azext_iot.common import auth -def get_headers(token, cmd): +def get_headers(token, cmd, has_json_payload=False): if not token: aad_token = auth.get_aad_token(cmd, resource="https://apps.azureiotcentral.com") token = "Bearer {}".format(aad_token["accessToken"]) + if has_json_payload: + return { + "Authorization": token, + "User-Agent": constants.USER_AGENT, + "Content-Type": "application/json", + } + return {"Authorization": token, "User-Agent": constants.USER_AGENT} + + +def try_extract_result(response: Response): + # 201 and 204 response codes indicate success + # with no content, hence attempting to retrieve content will fail + if response.status_code in [201, 204]: + return {"result": "success"} + + try: + body = response.json() + except: + raise CLIError("Error parsing response body") + + if "error" in body: + raise CLIError(body["error"]) + + return body diff --git a/azext_iot/central/services/device.py b/azext_iot/central/services/device.py index de96af8a0..46e221bcc 100644 --- a/azext_iot/central/services/device.py +++ b/azext_iot/central/services/device.py @@ -8,13 +8,15 @@ import requests from knack.util import CLIError -from . import _utility as utility +from azext_iot.central.services import _utility + +BASE_PATH = "api/preview/devices" def get_device( cmd, - device_id: str, app_id: str, + device_id: str, token: str, central_dns_suffix="azureiotcentral.com", ) -> dict: @@ -33,16 +35,113 @@ def get_device( device: dict """ - url = "https://{}.{}/api/preview/devices/{}".format( - app_id, central_dns_suffix, device_id - ) - headers = utility.get_headers(token, cmd) + url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, device_id) + headers = _utility.get_headers(token, cmd) + + response = requests.get(url, headers=headers) + return _utility.try_extract_result(response) + + +def list_devices( + cmd, app_id: str, token: str, central_dns_suffix="azureiotcentral.com", +) -> list: + """ + Get a list of all devices in IoTC app + + Args: + cmd: command passed into az + 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: + list of devices + """ + + url = "https://{}.{}/{}".format(app_id, central_dns_suffix, BASE_PATH) + headers = _utility.get_headers(token, cmd) response = requests.get(url, headers=headers) - body = response.json() + result = _utility.try_extract_result(response) + + if "value" not in result: + raise CLIError("Value is not present in body: {}".format(result)) + + return result["value"] + + +def create_device( + cmd, + app_id: str, + device_id: str, + device_name: str, + instance_of: str, + simulated: bool, + token: str, + central_dns_suffix="azureiotcentral.com", +) -> dict: + """ + Create a device in IoTC - if "error" in body: - raise CLIError(body["error"]) + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_id: unique case-sensitive device id + device_name: (non-unique) human readable name for the device + instance_of: (optional) string that maps to the device_template_id + of the device template that this device is to be an instance of + simulated: if IoTC is to simulate data for this device + 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 + """ + + if not device_name: + device_name = device_id + + url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, device_id) + headers = _utility.get_headers(token, cmd, has_json_payload=True) + payload = { + "displayName": device_name, + "simulated": simulated, + "approved": True, + } + if instance_of: + payload["instanceOf"] = instance_of + + response = requests.put(url, headers=headers, json=payload) + return _utility.try_extract_result(response) + + +def delete_device( + cmd, + app_id: str, + device_id: str, + token: str, + central_dns_suffix="azureiotcentral.com", +) -> dict: + """ + Delete a device 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, + 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 + """ + url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, device_id) + headers = _utility.get_headers(token, cmd) - return body + response = requests.delete(url, headers=headers) + return _utility.try_extract_result(response) diff --git a/azext_iot/central/services/device_template.py b/azext_iot/central/services/device_template.py index de239f90a..46d39889b 100644 --- a/azext_iot/central/services/device_template.py +++ b/azext_iot/central/services/device_template.py @@ -8,22 +8,27 @@ import requests from knack.util import CLIError -from . import _utility as utility +from knack.log import get_logger +from azext_iot.central.services import _utility + +logger = get_logger(__name__) + +BASE_PATH = "api/preview/deviceTemplates" def get_device_template( cmd, - device_template_urn: str, app_id: str, + device_template_id: str, token: str, central_dns_suffix="azureiotcentral.com", ) -> dict: """ - Get device template given a device id + Get a specific device template from IoTC Args: cmd: command passed into az - device_template_urn: case sensitive device template urn, + device_template_id: case sensitive device template 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 ...') @@ -32,16 +37,106 @@ def get_device_template( Returns: device: dict """ - url = "https://{}.{}/api/preview/deviceTemplates/{}".format( - app_id, central_dns_suffix, device_template_urn + url = "https://{}.{}/{}/{}".format( + app_id, central_dns_suffix, BASE_PATH, device_template_id ) - headers = utility.get_headers(token, cmd) + headers = _utility.get_headers(token, cmd) response = requests.get(url, headers=headers) + return _utility.try_extract_result(response) + + +def list_device_templates( + cmd, app_id: str, token: str, central_dns_suffix="azureiotcentral.com", +) -> list: + """ + Get a list of all device templates in IoTC + + Args: + cmd: command passed into az + 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: + device: dict + """ - body = response.json() + url = "https://{}.{}/{}".format(app_id, central_dns_suffix, BASE_PATH) + headers = _utility.get_headers(token, cmd) - if "error" in body: - raise CLIError(body["error"]) + response = requests.get(url, headers=headers) + + result = _utility.try_extract_result(response) + + if "value" not in result: + raise CLIError("Value is not present in body: {}".format(result)) + + return result["value"] + + +def create_device_template( + cmd, + app_id: str, + device_template_id: str, + payload: dict, + token: str, + central_dns_suffix="azureiotcentral.com", +) -> list: + """ + Create a device template in IoTC + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_template_id: case sensitive device template id, + payload: see example payload available in + /azext_iot/tests/central/json/device_template_int_test.json + or check here for more information + https://docs.microsoft.com/en-us/rest/api/iotcentral/devicetemplates + 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 + """ + + url = "https://{}.{}/{}/{}".format( + app_id, central_dns_suffix, BASE_PATH, device_template_id + ) + headers = _utility.get_headers(token, cmd, has_json_payload=True) + + response = requests.put(url, headers=headers, json=payload) + return _utility.try_extract_result(response) + + +def delete_device_template( + cmd, + app_id: str, + device_template_id: str, + token: str, + central_dns_suffix="azureiotcentral.com", +) -> dict: + """ + Delete a device template from IoTC + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_template_id: case sensitive device template 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 + """ + url = "https://{}.{}/{}/{}".format( + app_id, central_dns_suffix, BASE_PATH, device_template_id + ) + headers = _utility.get_headers(token, cmd) - return body + response = requests.delete(url, headers=headers) + return _utility.try_extract_result(response) diff --git a/azext_iot/operations/central.py b/azext_iot/operations/central.py index a11b903b9..5870b2f2f 100644 --- a/azext_iot/operations/central.py +++ b/azext_iot/operations/central.py @@ -32,10 +32,10 @@ def iot_central_device_show( def iot_central_device_capability_model_show( - cmd, device_id, app_id, central_api_uri="api.azureiotcentral.com" + cmd, device_id, app_id, central_dns_suffix="azureiotcentral.com" ): - provider = CentralDeviceProvider(cmd, app_id) - return provider.get_device_template(device_id) + device_provider = CentralDeviceProvider(cmd, app_id) + return device_provider.get_device_template_by_device_id(device_id) def iot_central_validate_messages( diff --git a/azext_iot/operations/events3/_parser.py b/azext_iot/operations/events3/_parser.py index 72b37c4b3..c0a8d3d8f 100644 --- a/azext_iot/operations/events3/_parser.py +++ b/azext_iot/operations/events3/_parser.py @@ -20,12 +20,10 @@ class Event3Parser(object): - _info = [] - _warnings = [] - _errors = [] _logger = get_logger(__name__) def __init__(self, logger=None): + self._reset_issues() if logger: self._logger = logger @@ -286,7 +284,9 @@ def _validate_payload_against_dcm( return try: - template = central_device_provider.get_device_template(origin_device_id) + template = central_device_provider.get_device_template_by_device_id( + origin_device_id + ) except Exception as e: self._errors.append( "Unable to get DCM for device: {}." diff --git a/azext_iot/tests/central/json/device_template_int_test.json b/azext_iot/tests/central/json/device_template_int_test.json new file mode 100644 index 000000000..672320418 --- /dev/null +++ b/azext_iot/tests/central/json/device_template_int_test.json @@ -0,0 +1,285 @@ +{ + "types": [ + "DeviceModel" + ], + "displayName": "int-test-device-template", + "capabilityModel": { + "@id": "urn:sampleApp:modelOne_bz:2", + "@type": [ + "CapabilityModel" + ], + "implements": [ + { + "@id": "urn:sampleApp:modelOne_bz:_rpgcmdpo:1", + "@type": [ + "InterfaceInstance" + ], + "displayName": "Interface", + "name": "modelOne_g4", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:1", + "@type": [ + "Interface" + ], + "displayName": "Interface", + "contents": [ + { + "@id": "urn:sampleApp:modelOne_g4:Bool:1", + "@type": [ + "Telemetry" + ], + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Date:1", + "@type": [ + "Telemetry" + ], + "displayName": "Date", + "name": "Date", + "schema": "date" + }, + { + "@id": "urn:sampleApp:modelOne_g4:DateTime:1", + "@type": [ + "Telemetry" + ], + "displayName": "DateTime", + "name": "DateTime", + "schema": "dateTime" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Double:1", + "@type": [ + "Telemetry" + ], + "displayName": "Double", + "name": "Double", + "schema": "double" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Duration:1", + "@type": [ + "Telemetry" + ], + "displayName": "Duration", + "name": "Duration", + "schema": "duration" + }, + { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:1", + "@type": [ + "Telemetry" + ], + "displayName": "IntEnum", + "name": "IntEnum", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:1", + "@type": [ + "Enum" + ], + "displayName": "Enum", + "valueSchema": "integer", + "enumValues": [ + { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum1:1", + "@type": [ + "EnumValue" + ], + "displayName": "Enum1", + "enumValue": 1, + "name": "Enum1" + }, + { + "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum2:1", + "@type": [ + "EnumValue" + ], + "displayName": "Enum2", + "enumValue": 2, + "name": "Enum2" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:1", + "@type": [ + "Telemetry" + ], + "displayName": "StringEnum", + "name": "StringEnum", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:1", + "@type": [ + "Enum" + ], + "displayName": "Enum", + "valueSchema": "string", + "enumValues": [ + { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumA:1", + "@type": [ + "EnumValue" + ], + "displayName": "EnumA", + "enumValue": "A", + "name": "EnumA" + }, + { + "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumB:1", + "@type": [ + "EnumValue" + ], + "displayName": "EnumB", + "enumValue": "B", + "name": "EnumB" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_g4:Float:1", + "@type": [ + "Telemetry" + ], + "displayName": "Float", + "name": "Float", + "schema": "float" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Geopoint:1", + "@type": [ + "Telemetry" + ], + "displayName": "Geopoint", + "name": "Geopoint", + "schema": "geopoint" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Int:1", + "@type": [ + "Telemetry" + ], + "displayName": "Int", + "name": "Int", + "schema": "integer" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Long:1", + "@type": [ + "Telemetry" + ], + "displayName": "Long", + "name": "Long", + "schema": "long" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Object:1", + "@type": [ + "Telemetry" + ], + "displayName": "Object", + "name": "Object", + "schema": { + "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:1", + "@type": [ + "Object" + ], + "displayName": "Object", + "fields": [ + { + "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:Double:1", + "@type": [ + "SchemaField" + ], + "displayName": "Double", + "name": "Double", + "schema": "double" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_g4:String:1", + "@type": [ + "Telemetry" + ], + "displayName": "String", + "name": "String", + "schema": "string" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Time:1", + "@type": [ + "Telemetry" + ], + "displayName": "Time", + "name": "Time", + "schema": "time" + }, + { + "@id": "urn:sampleApp:modelOne_g4:Vector:1", + "@type": [ + "Telemetry" + ], + "displayName": "Vector", + "name": "Vector", + "schema": "vector" + } + ] + } + }, + { + "@id": "urn:sampleApp:modelOne_bz:myxqftpsr:2", + "@type": [ + "InterfaceInstance" + ], + "displayName": "Interface", + "name": "modelTwo_ed", + "schema": { + "@id": "urn:sampleApp:modelTwo_ed:1", + "@type": [ + "Interface" + ], + "displayName": "Interface", + "contents": [ + { + "@id": "urn:sampleApp:modelTwo_ed:Bool:1", + "@type": [ + "Telemetry" + ], + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "urn:sampleApp:modelTwo_ed:bool:1", + "@type": [ + "Telemetry" + ], + "displayName": "bool", + "name": "bool", + "schema": "boolean" + } + ] + } + } + ], + "displayName": "larger-telemetry-device", + "@context": [ + "http://azureiot.com/v1/contexts/IoTModel.json" + ] + }, + "solutionModel": { + "@id": "urn:d9cltbeus:lz1tl4a_jz", + "@type": [ + "SolutionModel" + ], + "cloudProperties": [], + "initialValues": [], + "overrides": [] + } +} \ No newline at end of file diff --git a/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py b/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py index e48e30125..1223cb87e 100644 --- a/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py +++ b/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py @@ -168,7 +168,7 @@ def test_jobs(self): # Cancel Job test # Create job to be cancelled - scheduled +7 days from now. - scheduled_time_iso = (datetime.utcnow() + timedelta(days=7)).isoformat() + scheduled_time_iso = (datetime.utcnow() + timedelta(days=6)).isoformat() self.cmd( "iot hub job create --job-id {} --job-type {} -q \"{}\" --twin-patch '{}' --start '{}' -n {} -g {}".format( diff --git a/azext_iot/tests/test_iot_central_int.py b/azext_iot/tests/test_iot_central_int.py index 841faf8f8..524d1b8f0 100644 --- a/azext_iot/tests/test_iot_central_int.py +++ b/azext_iot/tests/test_iot_central_int.py @@ -7,19 +7,22 @@ import os from azure.cli.testsdk import LiveScenarioTest +from azext_iot.common import utility + APP_ID = os.environ.get("azext_iot_central_app_id") DEVICE_ID = os.environ.get("azext_iot_central_device_id") +DEVICE_TEMPLATE_PATH = os.environ.get("azext_iot_central_device_template_path") -if not all([APP_ID, DEVICE_ID]): +if not all([APP_ID, DEVICE_ID, DEVICE_TEMPLATE_PATH]): raise ValueError( - "Set azext_iot_central_app_id " - "and azext_iot_central_device_id to run central integration tests. " + "Set azext_iot_central_app_id, azext_iot_central_device_id " + "and azext_iot_central_device_template_path to run central integration tests. " ) class TestIotCentral(LiveScenarioTest): - def __init__(self, test_method): - super(TestIotCentral, self).__init__("test_central_device_show") + def __init__(self, test_case): + super(TestIotCentral, self).__init__(test_case) def test_central_device_show(self): # Verify incorrect app-id throws error @@ -63,3 +66,85 @@ def test_central_validate_messages(self): # Ensure no failure # We cannot verify that the result is correct, as the Azure CLI for IoT Central does not support adding devices self.cmd("iot central app validate-messages --app-id {} --to 1".format(APP_ID)) + + def test_central_device_methods_CRLD(self): + device_id = self.create_random_name(prefix="aztest", length=24) + device_name = self.create_random_name(prefix="aztest", length=24) + # currently: create, show, list, delete + self.cmd( + "iot central app device create --app-id {} -d {} --device-name {}".format( + APP_ID, device_id, device_name + ), + checks=[ + self.check("approved", True), + self.check("displayName", device_name), + self.check("id", device_id), + self.check("simulated", False), + ], + ) + + self.cmd( + "iot central app device show --app-id {} -d {}".format(APP_ID, device_id), + checks=[ + self.check("approved", True), + self.check("displayName", device_name), + self.check("id", device_id), + self.check("simulated", False), + ], + ) + + list_output = self.cmd("iot central app device list --app-id {}".format(APP_ID)) + + self.cmd( + "iot central app device delete --app-id {} -d {}".format(APP_ID, device_id), + checks=[self.check("result", "success")], + ) + + assert device_id in list_output.get_output_in_json() + + def test_central_device_template_methods_CRLD(self): + # currently: create, show, list, delete + template = utility.process_json_arg( + DEVICE_TEMPLATE_PATH, argument_name="DEVICE_TEMPLATE_PATH" + ) + template_name = template["displayName"] + template_id = template_name + "id" + + self.cmd( + "iot central app device-template create --app-id {} --device-template-id {} -k {}".format( + APP_ID, template_id, DEVICE_TEMPLATE_PATH + ), + checks=[ + self.check("displayName", template_name), + self.check("id", template_id), + ], + ) + + self.cmd( + "iot central app device-template show --app-id {} --device-template-id {}".format( + APP_ID, template_id + ), + checks=[ + self.check("displayName", template_name), + self.check("id", template_id), + ], + ) + + list_output = self.cmd( + "iot central app device-template list --app-id {}".format(APP_ID) + ) + map_output = self.cmd( + "iot central app device-template map --app-id {}".format(APP_ID) + ) + + self.cmd( + "iot central app device-template delete --app-id {} --device-template-id {}".format( + APP_ID, template_id + ), + checks=[self.check("result", "success")], + ) + + assert template_id in list_output.get_output_in_json() + + map_json = map_output.get_output_in_json() + assert map_json[template_name] == template_id diff --git a/azext_iot/tests/test_iot_central_unit.py b/azext_iot/tests/test_iot_central_unit.py index 2365bcef3..613d87e70 100644 --- a/azext_iot/tests/test_iot_central_unit.py +++ b/azext_iot/tests/test_iot_central_unit.py @@ -11,7 +11,10 @@ from azure.cli.core.mock import DummyCli from azext_iot.operations import central as subject from azext_iot.common.shared import SdkType -from azext_iot.central.providers import CentralDeviceProvider +from azext_iot.central.providers import ( + CentralDeviceProvider, + CentralDeviceTemplateProvider, +) from .helpers import load_json from .test_constants import FileNames @@ -201,19 +204,18 @@ def test_should_return_device_template( self, mock_device_svc, mock_device_template_svc ): # setup - provider = CentralDeviceProvider(cmd=None, app_id=app_id) + provider = CentralDeviceTemplateProvider(cmd=None, app_id=app_id) mock_device_svc.get_device.return_value = self._device mock_device_template_svc.get_device_template.return_value = ( self._device_template ) # act - template = provider.get_device_template("someDeviceId") + template = provider.get_device_template("someDeviceTemplate") # check that caching is working - template = provider.get_device_template("someDeviceId") + template = provider.get_device_template("someDeviceTemplate") # verify # call counts should be at most 1 since the provider has a cache - assert mock_device_svc.get_device.call_count == 1 assert mock_device_template_svc.get_device_template.call_count == 1 assert template == self._device_template diff --git a/azext_iot/tests/test_iot_utility_unit.py b/azext_iot/tests/test_iot_utility_unit.py index c4143098d..9fc75ee09 100644 --- a/azext_iot/tests/test_iot_utility_unit.py +++ b/azext_iot/tests/test_iot_utility_unit.py @@ -313,7 +313,9 @@ def test_parse_message_should_succeed(self): device_template = load_json(FileNames.central_device_template_file) provider = CentralDeviceProvider(cmd=None, app_id=None) - provider.get_device_template = mock.MagicMock(return_value=device_template) + provider.get_device_template_by_device_id = mock.MagicMock( + return_value=device_template + ) # act parsed_msg = parser.parse_message( @@ -543,7 +545,9 @@ def test_validate_against_template_should_fail(self): device_template = load_json(FileNames.central_device_template_file) provider = CentralDeviceProvider(cmd=None, app_id=None) - provider.get_device_template = mock.MagicMock(return_value=device_template) + provider.get_device_template_by_device_id = mock.MagicMock( + return_value=device_template + ) # act parsed_msg = parser.parse_message( @@ -593,7 +597,7 @@ def test_validate_against_bad_template_should_not_throw(self): parser = _parser.Event3Parser() provider = CentralDeviceProvider(cmd=None, app_id=None) - provider.get_device_template = mock.MagicMock( + provider.get_device_template_by_device_id = mock.MagicMock( return_value="an_unparseable_template" ) diff --git a/pytest.ini.example b/pytest.ini.example index 8708ac4aa..ad6dca94e 100644 --- a/pytest.ini.example +++ b/pytest.ini.example @@ -23,3 +23,4 @@ env = azext_pnp_cs= azext_iot_central_app_id= azext_iot_central_device_id= + azext_iot_central_device_template_path=./azext_iot/tests/central/json/device_template_int_test.json