def handleOneWayDeviceRPCRequests(deviceId, remote_method, param_dict=None): """POST method to place an one way RPC call to a device identified by the 'deviceId' parameter provided as argument. The RPC command needs to have a valid target in the device (client) side to have any effect. This command should be specified in the 'remote_method argument' and any expected arguments required for its execution should be provided in the 'param_dict' argument This method is equivalent to a 'fire-and-forget' routine: no replies are expected nor are going to be returned either from the client side (this service doesn't wait for a response msg in the respective topic. This means that this method should be use for ACTUATE only. To get any information from the device side use the two way version of this method since all device-side communications are to be initiated from "this" side (the ThingsBoard/application side) and thus a response is expected following a request @:param deviceId (str) - The typical 32-byte, dash separated hexadecimal string that uniquely identifies the device in the intended ThingsBoard installation @:param remote_method (str) - The name of the method that is defined client-side that is to be executed with this call @:param param_dict (dict) - If the remote method requires arguments to be executed, use this dictionary argument to provide them @:return http_status_code (int) - This method only returns the HTTP status code regarding the execution of the HTTP request and nothing more Usage example: Suppose that there's a device configured in the Thingsboard installation that abstracts a lighting switch that can be turned ON or OFF. The device (Raspberry Pi) that controls that switch has a continuously running method (via a while True for instance) that listens and reacts to messages received from the ThingsBoard server instance. One of these control methods is called "setLightSwitch" and expects a boolean action "value" passed in the message strucure (True or False) regarding what to do with the light switch (False = OFF, True = ON). The switch is currently OFF. To turn it ON using this method, use the following calling structure: handleOneWayDeviceRPCRequests(deviceId="3806ac00-5411-11ea-aa0c-135d877fb643", remote_method="setLightSwitch", param_dict={"value": True}) The message that is going to be forward to the device as a RPC request is the following JSON structure: { "method": "setLightSwitch", "params": { "value": true } } The "method" - "params" JSON is somewhat of a format for RPC interactions, with "params" being a complex (multi-level) dictionary to allow for complex, multi-argument remote method executions """ one_way_log = ambi_logger.get_logger(__name__) # Validate inputs utils.validate_input_type(deviceId, str) utils.validate_id(entity_id=deviceId) utils.validate_input_type(remote_method, str) if param_dict: utils.validate_input_type(param_dict, dict) # The rest is pretty much more of the same. This one gets the deviceId built in the calling URL service_endpoint = '/api/plugins/rpc/oneway/' + deviceId # Crete the data payload in the dictionary format data = { "method": str(remote_method), "params": param_dict } service_dict = utils.build_service_calling_info(mac.get_auth_token(user_type="tenant_admin"), service_endpoint=service_endpoint) # Done. Set things in motion then try: response = requests.post(url=service_dict["url"], headers=service_dict["headers"], data=json.dumps(data)) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format(str(service_dict['url'])) one_way_log.error(error_msg) raise ce return response.status_code
def checkUpdates(): """ GET method to retrieve any available updates to the system @:type user_types allowed for this service: SYS_ADMIN @:return { "message": "string", "updateAvailable": true } """ check_updates_log = ambi_logger.get_logger(__name__) service_endpoint = "/api/admin/updates" service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='sys_admin'), service_endpoint) try: response = requests.get(url=service_dict["url"], headers=service_dict["headers"]) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format( str(service_dict['url'])) check_updates_log.error(error_msg) raise ce return response
def handleTwoWayDeviceRPCRequest(deviceId, remote_method, param_dict=None): """POST method to execute bidirectional RPC calls to a specific method and a specific device, all identified through the argument list passed to this method. If the method to execute requires arguments itself, use the param_dict argument to provide a dictionary with these arguments, in a key-value scheme (key is the name of the argument with the actual value passes as... value). This method implements a reply-response dynamic, as opposed to the previous 'fire-and-forget' approach. The server submits a RPC requests and then listens for a response with the same requestId in the response topic. The method blocks for a while until a valid response can be returned. The usage of this method is identical to the previous one. @:param deviceId (str) - The typical 32-byte, dash separated hexadecimal string that uniquely identifies the device in the intended ThingsBoard installation. @:param remote_method (str) - The name of the method that is defined client-side that is to be executed with this call. @:param param_dict (dict) - If the remote method requires arguments to be executed, use this dictionary argument to provide them. @:raise utils.InputValidationException - If any of the input fails initial validation @:raise utils.ServiceEndpointException - If the request was not properly executed @:return response (request.models.Response) - This method returns the object that comes back from the HTTP request using the 'requests' package. This object has loads of interesting information regarding the original request that created it. For this context, the most relevant fields are response.status_code (int), which has the HTTP response code of the last execution, and the response.text, which contains the response of the method that was called, if any. If the remote method doesn't return anything, this field comes back empty. """ two_way_log = ambi_logger.get_logger(__name__) # Validate inputs utils.validate_input_type(deviceId, str) utils.validate_id(entity_id=deviceId) utils.validate_input_type(remote_method, str) if param_dict: utils.validate_input_type(param_dict, dict) # Set the endpoint service_endpoint = '/api/plugins/rpc/twoway/' + deviceId # Create the data payload as a dictionary data = { "method": str(remote_method), "params": param_dict } service_dict = utils.build_service_calling_info(mac.get_auth_token(user_type="tenant_admin"), service_endpoint=service_endpoint) # Send the request to the server. The response, if obtained, contains the response data try: response = requests.post(url=service_dict['url'], headers=service_dict['headers'], data=json.dumps(data)) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format(str(service_dict['url'])) two_way_log.error(error_msg) raise ce # Watch out for HTTP 408. In this project, when that code is returned (server timeout), it normally means that communication with the remote device was not established properly, by whatever reason if response.status_code == 408: error_msg = "Received a HTTP 408 - Server timed out. Could not get a response from device with id = {0}...".format(str(deviceId)) two_way_log.error(error_msg) raise utils.ServiceEndpointException elif response.status_code == 200 and response.text == "": warn_msg = "Received a HTTP 200 - OK - But no response was returned..." two_way_log.warning(warn_msg) # Send back the result as a string, because that's what this method returns return "200" else: return response.text
def getSecuritySettings(): """ Simple GET method to retrieve the current administration security settings. These can be also consulted in the ThingsBoard Admin dashboard under 'System Settings' -> 'Security Settings' @:type user_types allowed for this service: SYS_ADMIN (NOTE: This service is NOT AVAILABLE in the ThingsBoard remote installation at 62.48.174.118) @:param auth_token - A valid admin authorization token @:return { "maxFailedLoginAttempts": 0, "passwordPolicy": { "minimumDigits": 0, "minimumLength": 0, "minimumLowercaseLetters": 0, "minimumSpecialCharacters": 0, "minimumUppercaseLetters": 0, "passwordExpirationPeriodDays": 0, "passwordReuseFrequencyDays": 0 }, "userLockoutNotificationEmail": "string" } """ security_set_log = ambi_logger.get_logger(__name__) service_endpoint = "/api/admin/securitySettings" service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='sys_admin'), service_endpoint) try: response = requests.get(url=service_dict["url"], headers=service_dict["headers"]) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format( str(service_dict['url'])) security_set_log.error(error_msg) raise ce # There's a possibility that a non-admin authorization token can be used at this point. If that's the case, the request will return the appropriate response if response.status_code == 403 and ast.literal_eval( response.text)["errorCode"] == 20: # Throw the relevant exception if that is the case error_msg = "The authorization token provided does not have admin privileges!" security_set_log.error(error_msg) raise utils.AuthenticationException(message=error_msg, error_code=20) return response
def getAssetTypes(): """This method employs the same logic as all the ThingsBoard interface methods so far: it creates the necessary endpoint, gather any arguments necessary for the service call, places a GET request to the remote API and returns the response, in a dictionary data structure, as usual @:type user_types allowed for this service: TENANT_ADMIN, CUSTOMER_USER @:raise utils.ServiceEndpointException - If errors occur when accessing the remote API @:return result (dict) - A dictionary in the following format (dictated by the remote API): result = [ { "tenantId": { "entityType": <str>, "id": <str> }, "entityType": <str>, "type": <str> } ] This dictionary contains a list of the information associated to each asset as a whole (this dictionary doesn't reveal how many assets exist in the database, just the types that were defined so far) """ # The usual log asset_types_log = ambi_logger.get_logger(__name__) # Base endpoint string service_endpoint = "/api/asset/types" # Build the full service dictionary for the executing the remote call service_dict = utils.build_service_calling_info(mac.get_auth_token(user_type='tenant_admin'), service_endpoint) try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a request from {0}...".format(str(service_dict['url'])) asset_types_log.error(error_msg) raise ce # Check the status code of the HTTP response before moving forward if response.status_code != 200: error_msg = "Request unsuccessful: Received an HTTP {0} with message {1}.".format(str(eval(response.text)['status']), str(eval(response.text)['message'])) asset_types_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # Return the response return response
def getDeviceTypes(): """ Simple GET method to retrieve the list of all device types stored in the ThingsBoard platform @:type user_types allowed for this service: TENANT_ADMIN, CUSTOMER_USER @:return standard request response """ device_types_log = ambi_logger.get_logger(__name__) # The service endpoint to call service_endpoint = "/api/device/types" # Get the standard request elements service_dict = utils.build_service_calling_info(mac.get_auth_token(user_type='tenant_admin'), service_endpoint) # Execute the service call try: response = requests.get(url=service_dict["url"], headers=service_dict["headers"]) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format(str(service_dict['url'])) device_types_log.error(error_msg) raise ce return response
def getAttributes(entityType=None, entityId=None, deviceName=None, keys=None): """ GET method to retrieve all server-type attributes configured for the device identified by the pair entityType/entityId or deviceName provided. This method requires either the entityType/entityId pair or the deviceName to be provided to execute this method. If insufficient data is provided, the relevant Exception shall be raised. :param entityType (str) - The entity type of the object whose attributes are to be retrieved. :param entityId (str) - The id string that identifies the device whose attributes are to be retrieved. :param deviceName (str) - The name of the device that can be used to retrieve the entityType/entityId. :param keys (list of str) - Each attribute returned is a key-value pair. Use this argument to provide a key based filter, i.e., if this list is set, only attributes whose keys match any of the list elements are to be returned. :raise utils.InputValidationException - If any of the inputs has the wrong data type or the method doesn't have the necessary data to execute. :raise utils.ServiceEndpointException - If problem occur when accessing the remote API :return attribute_dictionary (dict) - A dictionary with the retrieved attributes in the following format: attribute_dictionary = { 'attribute_1_key': 'attribute_1_value', 'attribute_2_key': 'attribute_2_value', ... 'attribute_N_key': 'attribute_N_value' } where the keys in the dictionary are the ontology-specific names (official names) and the respective values are the timeseries keys being measured by the device that map straight into those ontology names. If the device identified by the argument data does exist but doesn't have any attributes configured, this method returns None instead. """ log = ambi_logger.get_logger(__name__) # Validate inputs if entityId: utils.validate_id(entity_id=entityId) # The entityId seems OK but its useless unless the entityType was also provided or, at least, the deviceName, the method cannot continue if not entityType and not deviceName: error_msg = "A valid entityId was provided but no entityType nor deviceName were added. Cannot execute this method until a valid entityType/entityId or a valid deviceName is provided!" log.error(error_msg) raise utils.InputValidationException(message=error_msg) if entityType: utils.validate_entity_type(entity_type=entityType) # Again, as before, the method can only move forward if a corresponding entityId or deviceName was also provided if not entityId and not deviceName: error_msg = "A valid entityType was provided but no corresponding entityId nor deviceName. Cannot continue until a valid entityType/entityId or a valid deviceName is provided!" log.error(error_msg) raise utils.InputValidationException(message=error_msg) if deviceName: utils.validate_input_type(deviceName, str) if keys: utils.validate_input_type(keys, list) for key in keys: utils.validate_input_type(key, str) # If the code got to this point, I either have a valid entityId/entityType pair or a deviceName. Check if only the deviceName was provided and retrieve the entityId/entityType from it if deviceName and (not entityType or not entityId): # Get the entityId and entityType from the deviceName provided device_data = mysql_device_controller.get_device_credentials( device_name=deviceName) # Check if the previous statement returned a non-empty (not None) result. If that is the case, either the device is not (yet) configured in the device table or the table needs to be updated if not device_data: error_msg = "Cannot retrieve a pair of entityId/entityType from the device name provided: {0}. Either:" \ "\n1. The device is not yet configured in the database/ThingsBoard platform" \ "\n2. The MySQL device table needs to be updated." \ "\nCannot continue for now".format(str(deviceName)) log.error(error_msg) raise utils.InputValidationException(message=error_msg) # The previous method returns a 3-element tuple in the format (entityType, entityId, timeseriesKeys). Grab the relevant data straight from it entityType = device_data[0] entityId = device_data[1] # Validation complete. I have all I need to execute the remote call service_endpoint = "/api/plugins/telemetry/{0}/{1}/values/attributes".format( str(entityType), str(entityId)) # If a list of keys was provided, concatenate them to the current endpoint if keys: service_endpoint += "?keys=" # Add all the keys to the endpoint concatenated in a single, comma separated (without any spaces in between) string service_endpoint += ",".join(keys) # Build the service dictionary from the endpoint already built service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='tenant_admin'), service_endpoint=service_endpoint) # Query the remote API try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format( str(service_dict['url'])) log.error(error_msg) raise utils.ServiceEndpointException(message=ce) # If a response was returned, check the HTTP return code if response.status_code != 200: error_msg = "Request not successful: Received an HTTP " + str( eval(response.text)['status']) + " with message: " + str( eval(response.text)['message']) log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # Got a valid result. Format the returned objects for return data_to_return = eval(utils.translate_postgres_to_python( response.text)) if len(data_to_return) is 0: # Nothing to return then. Send back a None instead return None # If the request was alright, I've received the following Response Body (after eval) # data_to_return = # [ # { # "lastUpdateTs": int, # "key": str, # "value": str # }, # ... # { # "lastUpdateTs": int, # "key": str, # "value": str # } # ] # # So I need to transform this into the return structure defined above attribute_dictionary = {} for attribute_pair in data_to_return: # Use this opportunity to filter out any attribute returned that is not part of the measurement list desired if attribute_pair['value'] not in proj_config.ontology_names: # If the attribute value is not one in the 'official list of names', skip it continue # Create the entries defined in the man entry of this method from the list elements returned from the remote API attribute_dictionary[ attribute_pair['key']] = attribute_pair['value'] # All done. Return the attributes dictionary return attribute_dictionary
def getLatestTimeseries(device_name, timeseries_keys_filter=None): """ This method is analogous to the previous one, i.e., it also retrieves Timeseries data that is associated to the device identified by 'device_name', but in this particular case only one timestamp/value pair is returned for each of the device's measurements, namely the last one recorded by the Thingsboard installation that oversees that device. This method is very useful to: 1. Determine if a device is working by retrieving the last recorded data. 2. Determine the timeseries keys associated to the device, as well the last timestamp associated to them. 3. Determine the most recent end_date possible for that device in a direct way - once this parameter is known, a more complete and insightful getTimeseries call can then be placed. @:param device_name (str) - The name of the device to which the latest associated timeseries should be retrieved by this method. @:param timeseries_keys_filter (list of str) - A list with the names of the timeseries keys to be returned by the remote API. If a valid list is provided, only data for the keys specified in this list are going to be returned. This method validates this list against any associated timeseriesKeys for the device: mismatched elements from this list are to be ignored. @:raise utils.InputValidationException - If any of the inputs fails initial validation @:raise utils.ServiceEndpointException - If errors occur when invoking any remote API services @:raise mysql_utils.MySQLDatabaseException - If errors occur when accessing the database @:return None if the API returns an empty set, otherwise returns a dictionary with the following format: device_data = { "timeseriesKey_1": [ { "ts": int, "value": str } ], "timeseriesKey_2": [ { "ts": int, "value": str } ], ... "timeseriesKey_N": [ { "ts": int, "value": str } ] } """ log = ambi_logger.get_logger(__name__) utils.validate_input_type(device_name, str) if timeseries_keys_filter: utils.validate_input_type(timeseries_keys_filter, list) for ts_key in timeseries_keys_filter: utils.validate_input_type(ts_key, str) # Grab the device credentials at this point. If the method returns anything (not None), than assume that its the following tuple: (entityType, entityId, timeseriesKeys_list) device_cred = mysql_device_controller.get_device_credentials( device_name=device_name) if device_cred is None: error_msg = "Could not get valid credentials for device '{0}'. Cannot continue...".format( str(device_name)) log.error(error_msg) raise mysql_utils.MySQLDatabaseException(message=error_msg) # Validate the timeseries keys, if any were provided keys = None if timeseries_keys_filter: valid_keys = [] for ts_filter_key in timeseries_keys_filter: # Filter out only the valid keys, i.e., the ones with a correspondence in the list returned from the database if ts_filter_key in device_cred[2]: valid_keys.append(ts_filter_key) # Check if at least one of the proposed keys made it to the valid list. If not, default to the list returned from the database (if this one is also not empty) if not len(valid_keys): log.warning( "Could not validate any of the filter keys ({0}) provided as argument!" .format(str(timeseries_keys_filter))) # Check if the timeseriesKeys element returned from the device credentials request was a single element list with an empty string inside it if len(device_cred[2]) == 1 and device_cred[2][0] == "": log.warning( "The database didn't return any valid set of timeseriesKeys. Omitting this argument in the API call" ) # Don't do anything else. The 'keys' parameter is already None. Keep it as that then else: keys = ",".join(valid_keys) # I have all I need to execute the remote API call service_endpoint = "/api/plugins/telemetry/{0}/{1}/values/timeseries".format( str(device_cred[0]), str(device_cred[1])) # Add the keys filter, if it was provided if keys is not None: service_endpoint += "?keys={0}".format(str(keys)) # Service endpoint is done. Grab the service calling dictionary service_dict = utils.build_service_calling_info( auth_token=mac.get_auth_token(user_type='tenant_admin'), service_endpoint=service_endpoint) # Execute the remote call finally try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout): error_msg = "Unable to establish a connection with {0}...".format( str(service_dict['url'])) log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) # Check the HTTP status code in the response if response.status_code != 200: error_msg = "Request unsuccessful: Received HTTP {0} with message {1}!".format( str(response.status_code), str(eval(response.text)['message'])) log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # Send back the response already in dictionary form return eval(response.text)
def getTimeseries(device_name, end_date, start_date=None, time_interval=None, interval=None, limit=100, agg=None, timeseries_keys_filter=None): """This method is the real deal, at least to establish a base methodology to retrieve hard data from the remote API server. Unlike other API based methods so far, this one requires some data to be present in the MySQL server already because that is where the actual method call input data is going to come from. The remote API service that retrieves the requested data requires 5 mandatory elements (the optional arguments are explicit in the calling signature of this method where they are set to their default values already, in case that down the line there is a need to use them): entityType, entityId, keys, startTs and endTs. The first 3 parameters are going to be retrieved with a call to the MySQL thingsboard_devices_table and the timestamp ones are going to be determined from the triplet start_time (mandatory), ent_time and time_interval (only one of these is required). The method returns a dictionary with a list of timestamp, value pairs that can or cannot be limited by the limit value @:type user_types allowed for this service: TENANT_ADMIN, CUSTOMER_USER @:param device_name (str) - The name of the device to retrieve data from (e.g., 'Thermometer A-1', 'Water Meter A-1', etc... whatever the string used when registering the device in the ThingsBoard system). This value is certainly easier to retained and/or memorized from the user than the id string, for instance. @:param end_date (datetime.datetime) - A datetime.datetime object, i.e., in the format YYYY-MM-DD hh:mm:ss but that belongs to the datetime.datetime class. This is the latest value of the interval and, to avoid invalid dates into the input ( like future dates and such) this one is mandatory. The interval to be considered is going to be defined by either start_time (earliest) -> end_time (latest) or end_time - time_interval (in seconds) -> end_time, but one of the next two input arguments has to be provided. @:param start_date (datetime.datetime) - A datetime.datetime object delimiting the earliest point of the time interval for data retrieval @:param time_interval (int) - An interval, in seconds, to be subtracted to the end_time datetime object in order to define the time window to return data from @:param interval (int) - This is an OPTIONAL API side only parameter whose use still eludes me... so far I've tried to place calls to the remote service with all sorts of values in this field and I'm still to discover any influence of it in the returned results. NOTE: My initial assumption was it to be able to be set as a API side version of my time_interval. Yet, that is not the case because the API requires both the end and start timestamps to be provided by default. @:param limit (int) - The number of results to return in the request. Device data can be quite a lot to process and that's why this parameter, though optional, is set to 100 by default. Two things with this value: though the API doesn't explicitly says so, it doesn't like limit <= 0. It doesn't return an error per se but instead the service gets stuck until eventually an HTTP 503 - Service Unavailable is thrown instead. As such I'm going to validate this input accordingly. Also, unlike other limit parameters so far, there's no indication in the response structure if the number of results returned were limited by this value or by the time interval defined. To provide the user with more helpful information in this regard, this method is going to count the number of returned results and, if they do match the limit value provided, warn the user about it. @:param agg (str) - No idea what this one does too... The API testing interface has it set to NONE by default, though it is an optional parameter whose effect on the returned results is still yet to be understood. ALl I know so far is that the remote API expects a string on it @:param timeseries_keys_filter (list of str) - A list with strings with the keys to be returned from the remote API. Some devices contain multiple sensors, which means that there are going to be multiple records from different variables under the same device ID. To limit the returned results to a sub set of all parameters, provide a list in this argument with the correct names to limit the entries to be returned. Omitting this parameter (which defaults to None) returns all timeseries keys under the provided device ID @:return result_list (list of tuple) - The returned results are going to be processed and returned as a list of 2 element-tuples: a timestamp and the associated value for the timeseriesKey provided. @:raise utils.InputValidationException - If any of the inputs provided fails validation @:raise utils.ServiceEndpointException - If something goes wrong with any of the external service calls to the remote API executed in the method @:raise mysql_utils.MySQLDatabaseException - For errors derived from the MySQL database accesses @:raise Exception - For any other detected errors during the method's execution """ timeseries_log = ambi_logger.get_logger(__name__) # Before moving forward, check if at least one of the start_time, time_interval inputs was provided. NOTE: If both inputs are present, i.e., not None, the method validates both and if both are valid it prioritizes start_time over # time_interval. If one of them happens to be invalid, the method execution is not stopped but the user gets warned (through the logger) about this and how the method is going to be operated. But at this stage, I'm only moving forward if I # have the conditions to setup a valid time window for the API request if not start_date and not time_interval: error_msg = "Please provide at least one valid start_time (datetime.datetime) or a time_interval (int). Cannot compute a time window for data retrieval otherwise.." timeseries_log.error(error_msg) raise utils.InputValidationException(message=error_msg) # Time for validate inputs utils.validate_input_type(device_name, str) utils.validate_input_type(end_date, datetime.datetime) # Limit is OPTIONAL but, because of what is explained in this method's man entry, I need this value to be sort of mandatory. This verification, given that the input is already set with a decent default value, is just to protect the # method's execution against a user setting it to None by whatever reason that may be utils.validate_input_type(limit, int) if start_date: utils.validate_input_type(start_date, datetime.datetime) if time_interval: utils.validate_input_type(time_interval, int) if interval: utils.validate_input_type(interval, int) if agg: utils.validate_input_type(agg, str) # Validate the argument against the list type if timeseries_keys_filter: utils.validate_input_type(timeseries_keys_filter, list) if len(timeseries_keys_filter) <= 0: timeseries_log.warning( "Invalid timeseries keys filter provided: empty list. This filter is going to be ignored" ) timeseries_keys_filter = None else: # And each of its elements against the expected str type for timeseries in timeseries_keys_filter: utils.validate_input_type(timeseries, str) # Data type validation done. Now for the functional validations error_msg = None if limit <= 0: error_msg = "Invalid limit value: {0}. Please provide a greater than zero integer for this argument.".format( str(limit)) elif end_date > datetime.datetime.now(): error_msg = "Invalid end_date date provided: {0}! The date hasn't happen yet (future date). Please provide a valid datetime value!".format( str(end_date)) elif start_date and not time_interval and start_date >= end_date: error_msg = "Invalid start_date date! The start_date provided ({0}) is newer/equal than/to the end_date date ({1}): invalid time window defined!".format( str(start_date), str(end_date)) elif time_interval and not end_date and time_interval <= 0: error_msg = "Invalid time interval ({0})! Please provide a greater than zero value for this argument (the number of seconds to subtract from end_date).".format( str(time_interval)) elif start_date and time_interval and start_date >= end_date and time_interval <= 0: error_msg = "Both start_date and time_interval arguments provided are invalid!\nThe start_date provided ({0}) is newer than the end_date indicated ({1}) and the time_interval as an invalid value ({2}).\n" \ "Please provide a valid (older) start_date or a valid (greater than 0) time_interval".format(str(start_date), str(end_date), str(time_interval)) if error_msg: timeseries_log.error(error_msg) raise utils.InputValidationException(message=error_msg) # And now for the cases where both valid start_time and time_interval were provided. The previous validation bundle made sure that, if only one of these two parameters was provided, it was valid. If I got to this point I can have both of these # parameter set to valid inputs but I need to warn the user that I'm only going to use one to define the time window if start_date and time_interval: timeseries_log.warning( "Both start_time and time_interval provided arguments are valid but only start_time is going to be considered moving on. Set this argument to None/Invalid to use the time_interval instead" ) # So, if I'm dropping the time_interval, I need to signal this somehow moving forward: time_interval = None # Retrieve the device's credentials using the appropriate method device_cred = mysql_device_controller.get_device_credentials( device_name=device_name) # Check if a valid set of credentials was found if device_cred is None: error_msg = "Unable to retrieve a set of valid credentials to device '{0}'".format( str(device_name)) timeseries_log.error(error_msg) raise mysql_utils.MySQLDatabaseException(message=error_msg) # The first 3 elements that I need to build the service endpoint are valid and retrieved. Lets deal with the time window then. The service endpoint requires that the limits of this window (startTs, endTs) to be passed in that weird POSIX # timestamp-like format that the ThingsBoard PostGres database adopted, i.e, a 13 digit number with no decimal point (10 digits for the integer part + 3 for the microseconds value... but with the decimal point omitted...). Fortunately I've # written the 'translate' functions already for this situation end_ts = mysql_utils.convert_datetime_to_timestamp_tb(end_date) # If the other end is defined by the start_time datetime.datetime object if start_date: # Easy start_ts = mysql_utils.convert_datetime_to_timestamp_tb(start_date) # If I got to this point in the code, given the brutality of validations undertaken so far, I can only get here with start_time = None and something valid in time_interval. Proceed accordingly else: # I need to convert this interval to a timedelta object to be able to subtract it to the end_time one time_interval = datetime.timedelta(seconds=int(time_interval)) start_time = end_date - time_interval start_ts = mysql_utils.convert_datetime_to_timestamp_tb(start_time) # Done with the validations. Start building the service endpoint then. service_endpoint = "/api/plugins/telemetry/" + str( device_cred[0]) + "/" + str(device_cred[1]) + "/values/timeseries?" url_elements = [] if interval: url_elements.append("interval=" + str(interval)) url_elements.append("limit=" + str(limit)) if agg: url_elements.append("agg=" + str(agg)) # The element in result[2] can be a string containing multiple timeseries keys (if the device in question is a multisensor one). If a timeseries filter was provided, it is now time to apply it to reduce the number of variable types returned if timeseries_keys_filter: # Grab the original string list to a single variable device_ts_keys_list = str(device_cred[2]) valid_keys = [] for timeseries_key in timeseries_keys_filter: # And now check if any of the elements passed in the filter list is in the initial list if timeseries_key in device_ts_keys_list: # Add it to the valid keys list if so valid_keys.append(timeseries_key) # Otherwise warn the user of the mismatch else: timeseries_log.warning( "The filter key '{0}' provided in the filter list is not a valid timeseries key. Ignoring it..." .format(str(timeseries_key))) # If the last loop didn't yield any valid results, warn the user and default to the original string list if not len(valid_keys): timeseries_log.warning( "Unable to apply timeseries key filter: none of the provided keys had a match. Defaulting to {0}..." .format(str(device_ts_keys_list))) valid_keys = device_ts_keys_list else: # And inform the user of the alteration timeseries_log.info( "Valid filter found. Running remote API query with keys: {0}". format(str(valid_keys))) url_elements.append("keys=" + ",".join(valid_keys)) else: # No filters required. Append the full timeseries elements then url_elements.append("keys=" + ",".join(device_cred[2])) url_elements.append("startTs=" + str(start_ts)) url_elements.append("endTs=" + str(end_ts)) # Done. Now mash up the whole thing into a '&' separated string service_endpoint += "&".join(url_elements) # I'm finally ready to query the remote endpoint. This service requires a REGULAR type authorization token service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='tenant_admin'), service_endpoint) try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout): error_msg = "Unable to establish a connection with {0}...".format( str(service_dict['url'])) timeseries_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) # Check first if the response came back with a HTTP 200 if response.status_code != 200: error_msg = "Request unsuccessful: Received HTTP {0} with message: {1}".format( str(response.status_code), str(eval(response.text)['message'])) timeseries_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # The results are going to be returned as a dictionary of dictionaries in the following format: # result_dict = { # "timeseries_key_1": [ # {'ts': int, 'value': str}, # {'ts': int, 'value': str}, # ... # {'ts': int, 'value': str} # ], # "timeseries_key_2": [ # {'ts': int, 'value': str}, # {'ts': int, 'value': str}, # ... # {'ts': int, 'value': str} # ], # ... # "timeseries_key_N": [ # {'ts': int, 'value': str}, # {'ts': int, 'value': str}, # ... # {'ts': int, 'value': str} # ] # } # Use this as a reference for when another method needs to consume data from this response. Its a over complicated structure, honestly, and its not hard to create a simple method to call after this to simplify it greatly. But there's no # point in doing that until we know exactly what is the format that need to be returned. # Apply the 'eval' base method just to transform the str that is returned into a dict result_dict = eval(response.text) # Finally, check if any of the entries in the returned dictionary matches the 'limit' parameter and warn the user of potential missing results if so for result_key in list(result_dict.keys()): if len(result_dict[result_key]) == limit: timeseries_log.warning( "Timeseries key '{0}' results were limited by the 'limit' parameter: got {1} valid results back" .format(str(result_key), str(limit))) # Return the result dictionary finally return result_dict
def getTimeseriesKeys(entityType, entityId): """This method executes the GET request that returns the name (the ThingsBoard PostGres database key associated to the Timeseries table) of the variable whose quantity is being produced by the element identified by the pair (entityType, entityId). This method is limited to 'DEVICE' type elements (it really doesn't make sense for any other type and that's why I should validate this against the allowed entityTypes). @:type user_types allowed for this service: TENANT_ADMIN, CUSTOMER_USER @:param entityType (str) - One of the elements in the config.thingsboard_supported_entityTypes dictionary, though for this particular method only 'DEVICE' type elements are allowed (the remote API returns an empty set otherwise) @:param entityId (str) - The associated id string. The expected format is 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', where x is an hexadecimal character. @:return response (str) - A string identifying the quantity being measured by the device identified by the input arguments (e.g., 'temperature', 'water consumption', etc..) @:raise utils.InputValidationException - If any of the inputs fails validation """ timeseries_key_log = ambi_logger.get_logger(__name__) # Input validation try: utils.validate_input_type(entityType, str) utils.validate_input_type(entityId, str) except utils.InputValidationException as ive: timeseries_key_log.error(ive.message) raise ive # For this case, I'm not even bothering checking if the entityType is one of the allowed one (config.thingsboard_supported_entityTypes) - This method only makes sense if the entityType is a DEVICE (including being all caps) error_msg = None expected_entity_type = 'DEVICE' if entityType.upper() != expected_entity_type: # Eliminate any potential issues with non-upper case characters passed in the entityType in one fell swoop error_msg = "The entityType provided is not {0}. This method is restricted to this entity Type!".format( str(expected_entity_type)) # Validate the entityId string: check the format and if its characters are indeed all hexadecimal # The id strings are very strict regarding their format. I can either place bogus calls to the remote API server and catch for a HTTP 500 response or I can use Python to build a more intuitive and helpful logic to achieve the desired format, # since this information was only obtained by direct observation of the field itself - the id field is a 36 (32 bytes of data + 4 bytes for the '-') byte one with specific characters ('-') at positions 8, 13, 18 and 23. elif len(entityId) != 36: error_msg = "The entityId string provided has the wrong format: Wrong number of characters ({0}). Please provide a 36 character string.".format( str(len(entityId))) elif entityId[8] != '-' or entityId[13] != '-' or entityId[ 18] != '-' or entityId[23] != '-': error_msg = "The entityId doesn't have the expected format (expect a '-' at character positions 8, 13, 18 and 23): {0}".format( str(entityId)) # Get out of the loop if as soon as an error is detected else: # Start by getting all the hexadecimal blocks in a nice list and removing the '-' character in the process segments = entityId.split('-') # The easiest way to test if a given string only has hexadecimal characters in it is to try to cast it to an integer forcing a base 16 in the process, i.e., doing a decoding operation assuming a base 16 reference. If any of the characters # in any of those segments is not hexadecimal (0-F), the operation throws a ValueError Exception that I can caught for segment in segments: try: int(segment, 16) except ValueError: error_msg = "One of the entityId string has non-hexadecimal characters in it: {0}. Please provide a 36 long, hexadecimal string for this field.".format( str(segment)) # End this loop as soon something wrong is detected break if error_msg: timeseries_key_log.error(error_msg) raise utils.InputValidationException(message=error_msg) # Validation done. Lets get data from the remote API then service_endpoint = "/api/plugins/telemetry/" service_endpoint += "{0}/{1}/keys/timeseries".format( str(expected_entity_type), str(entityId)) # NOTE: This particular service requires a REGULAR type authorization token, so admin=False service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='tenant_admin'), service_endpoint) try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format( str(service_dict['url'])) timeseries_key_log.error(error_msg) raise ce # Test the HTTP status code in the response (I'm only continuing if it is a 200 and, in this particular case, a single str element was returned) if response.status_code != 200: error_msg = "Request unsuccessful: Received an HTTP {0} with message: {1}".format( str(eval(response.text)['status']), str(eval(response.text)['message'])) timeseries_key_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # The objective here is to return the string with the key that I need to call another endpoint service to get actual data from the device. If the data is correct and there's a key associated to that, I should get a single list with a # string as its only element back. If the device exists but it still doesn't have a timeSeries associated to it, I would get an empty list back # Start to cast the response.text to a list result = eval(response.text) try: # Raise an Exception if the data type obtained is different from the expected utils.validate_input_type(result, list) except utils.InputValidationException as ive: timeseries_key_log.error(ive.message) raise ive # In some cases I may have a multi-sensor device (which is actually the case in our prototype setting), which means that this result may be an array of all the supported timeseries keys. In this case, the return needs to be processed that way # Check if, at least, one result was returned if not len(result): # Warn the user first timeseries_key_log.warning( "There are no keys associated to {0} with id {1} yet!".format( str(entityType), str(entityId))) # Return a result that is passable to be used by another calling method return None else: # If all goes well, return the expected value. NOTE: This method returns these results as is, i.e., just like they are returned from the remote API, which means that 'result' is going to be a list that potentially may have multiple # elements (if the DEVICE identified by the ID has multiple sensors measuring different variables at once). The method that consumes these results should take heed on this return result
def getTenants(textSearch=None, idOffset=None, textOffset=None, limit=10): """GET method to retrieve either all tenants registered in the thingsboard server or a specific tenant by providing the related search terms. @:param OPTIONAL textSearch (str) - A text search string to limit the number of tenants to be returned by this operation. This functionality is quite limited I may add. It only searches the title field and only returns any results if this element is EXACTLY equal to the title field. Eg. textSearch='Mr Ricardo Almeida' returns that tenant information but textSearch='Ricardo Almeida' return nothing even though this string matches exactly the 'name' field @:param OPTIONAL idOffset (str) - A possible search pattern for just the 'id' field. (Strangely enough, using the tenant's id in the textSearch parameter yields no results). A tenant id has a fixed format [8]-[4]-[4]-[4]-[8], that is, a 8 character block, a '-' character, then a 4 character block, another '-' character and so on. If the idOffset provided is anything but the first four blocks, including the last '-', the remote API returns a HTTP 400 - Invalid UUID string. Yet adding just one character after the last '-' returns a list of all registered tenants... again, I'm still failing to see the use of this field to be honest @:param OPTIONAL textOffset (any) - No idea what this field is used for. I've tried searches with matching and un-matching strings, ints, floats, etc... and I always get all the tenants back. Pointless field if I ever saw one... @:param limit (int) - The only required field in this methods. Limits the number of results to return @:return tenant_data (dict) - The return element is a complex one. If successful, the returned structure is as follows: tenant_data = { 'data': [ { tenant_data_1 }, { tenant_data_2 }, ... { tenant_data_n } ], 'nextPageLink': nextPageData, 'hasNext': bool } The latter is the overall framework of how the results are returned: a dictionary with 3 keys: 'data', 'nextPageLink' and 'hasNext'. The 'data' key contains a list of dictionaries, with as many items as either existing tenants or limit number, whatever is lower. Each item of the data list has the following format: tenant_data = { 'id': { 'entityType': str, 'id': str }, 'createdTime': int (POSIX timestamp), 'description': str, 'country': str, 'state', str, 'city': str, 'address': str, 'address2': str, 'zip': str, 'phone': str, 'email': str, 'title': 'str' 'region': str, 'name': str } The 'nextPageLink' is either set to 'None' if all existing tenant data was returned in the 'data' value or, if the limit argument has clipped the number or returned elements, then it has the following dictionary format: 'nextPageLink': { 'limit': int, 'textSearch': str, 'textSearchBound': str, 'textOffset': str, 'idOffset': str } while the 'hasNext' (a boolean) key is set to either False if the limit argument was high enough or True if there are still results left to return from the remote ThingsBoard API """ # Fetch a local logger for this method only tenant_log = ambi_logger.get_logger(__name__) # Validate inputs try: # Validate the mandatory inputs utils.validate_input_type(limit, int) # As for the optional ones, I need to check if they were passed with non-default values first if textSearch: utils.validate_input_type(textSearch, str) elif idOffset: utils.validate_input_type(idOffset, str) elif textOffset: utils.validate_input_type(textOffset, str) except utils.InputValidationException as ive: tenant_log.error(ive.message) raise ive # The limit parameter needs a bit more validation. Passing limit=0 also triggers an error from the remote API. By validating this parameter at this stage, there no need to deal with this potential error later on. if limit <= 0: error_msg = "Invalid limit provided: {0}. Please provide a positive, greater than zero limit value!".format( str(limit)) tenant_log.error(error_msg) raise utils.InputValidationException(message=error_msg) service_endpoint = "/api/tenants?" # This service point for this particular method has its arguments built-in using a standard URL format. Because of this, I need to make sure that # whatever characters are passed in this method's arguments, they are properly escaped before (URL strings are notoriously picky) being added to # URL string url_strings = [] if textSearch: # First encode the string into a safe character set (UTF-8) and only then do the escaping of all characters to URL charset. NOTE: By a some reason the following method escapes all problematic characters to URL-esque except one of # the worst ones in that regard: '/'. The forward slash, a basic character in URL strings to denote paths, should be escaped to '%2F' if it appears in an argument string but the method bellow ignores this, not sure really why... # Anyway, an easily solved problem by forcing a string replace for this character after executing the base function url_textSearch = "textSearch=" + urllib.parse.quote( textSearch.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_textSearch) if idOffset: url_idOffset = "idOffset=" + urllib.parse.quote( idOffset.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_idOffset) if textOffset: url_textOffset = "textOffset=" + urllib.parse.quote( textOffset.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_textOffset) # Add the mandatory parameter to the list as is url_strings.append("limit=" + str(limit)) service_endpoint += '&'.join(url_strings) service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='sys_admin'), service_endpoint) try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}..".format( str(service_dict['url'])) tenant_log.error(error_msg) raise ce # In order to continue, I'm only interested in HTTP 200. Whatever comes back different than that, I'm shutting down this thing if response.status_code != 200: # Capture the error message that is returned in the message body, as a dictionary encoded in a str (hence the eval to cast it from str back to dict) error_msg = "Received an HTTP " + str(eval( response.text)['status']) + " with the message: " + str( eval(response.text)['message']) tenant_log.error(error_msg) raise utils.ServiceEndpointException( message=error_msg, error_code=int(eval(response.text)['errorCode'])) else: # Replace the troublesome elements from the API side to Python-esque (Pass it just the text part of the response. I have no use for the rest of the object anyway) # At this point, I'm going to check the state of the 'hasNext' key in the response and warning the user if its set to True (means that the limit argument was set at value that left some records still on the API side) if eval(utils.translate_postgres_to_python(response.text))['hasNext']: # In this case, warn the user and carry on tenant_log.warning( "There are still more results to return from the API side. Increase the 'limit' argument value to obtain them." ) # I'm done. Send back the response data return response
def getTenantAssets(type=None, textSearch=None, idOffset=None, textOffset=None, limit=10): """This is the standard method to retrieve all ASSETs currently in the ThingsBoard installation database (regardless which database is implemented). As with all services of this type so far, this is the ThingsBoard side of the whole process, the one that places a request in the expected format to the ThingsBoard API. @:param type (str): OPTIONAL Use this argument to filter the results for a specific asset type (eg. 'building', 'room', 'floor', etc...) This is a free text field from the ThingsBoard side, which means any string can be set in this field. If you know a priori which exact ASSET type you're interested in, use this argument to narrow down the results to be returned @:param textSearch (str): OPTIONAL Use this argument to provide a str to be used to narrow down the returned results based on 'name' field from the ASSET description. As with similar methods, this field is quite limited: unless an exact match is found between the provided textSearch argument and the contents of the 'name' field, no filtering actually takes place. @:param idOffset (str): OPTIONAL Analogous field to the previous one but this one applies to the 'id' field. The filtering abilities of this argument are also quite limited. Check similar methods that use this argument too for more detailed descriptions. @:param textOffset (str): OPTIONAL So far, still no idea of what this does. Other than determining that it only accepts strings, I still have no clue to what is the actual purpose of this element. @:param limit (int): Use this field to limit the number of results returned from this service. If the argument in this field prevents the full scope of results to be returned, a specific set of structures, namely a 'nextPageLink' and 'hasNext' are also returned. In this event, the method warn the caller that are results left to return but in the end is up to the caller to specify an higher limit value to return them. @:return result (list of dict): If the API call was successful, this method returns an HTTP response object back with the following dictionary in its 'text' field: "data": [ # ASSET 1 data { "id": { "entityType": string, "id": string }, "createdTime": int, "additionalInfo": { "description": "A dummy building that I'm planning to fill with dead rats and cockroaches once I'm done with it" }, "tenantId": { "entityType": string, "id": string }, "customerId": { "entityType": string, "id": string }, "name": string, "type": string }, # ASSET 2 Data { "id": { "entityType": string, "id": string }, "createdTime": int, "additionalInfo": { "description": string }, "tenantId": { "entityType": string, "id": string }, "customerId": { "entityType": string, "id": string }, "name": string, "type": string }, . . . # ASSET N Data { "id": { "entityType": string, "id": string }, "createdTime": int, "additionalInfo": { "description": string }, "tenantId": { "entityType": string, "id": string }, "customerId": { "entityType": string, "id": string }, "name": string, "type": string } ] """ asset_control_log = ambi_logger.get_logger(__name__) # Validate mandatory inputs first utils.validate_input_type(limit, int) # And now for the OPTIONAL ones if type: utils.validate_input_type(type, str) if textSearch: utils.validate_input_type(textSearch, str) if idOffset: utils.validate_input_type(idOffset, str) if textOffset: utils.validate_input_type(textOffset, str) # Validate the limit a bit further if limit <= 0: error_msg = "Invalid limit provided: {0}. Please provide a positive, greater than zero limit value!".format(str(limit)) asset_control_log.error(error_msg) raise utils.InputValidationException(message=error_msg) # Setup the base endpoint service_endpoint = "/api/tenant/assets?" url_strings = [] # Check the provided inputs and add the necessary elements to the endpoint to call the service if type: url_type = "type=" + urllib.parse.quote(type.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_type) if textSearch: url_textSearch = "textSearch=" + urllib.parse.quote(textSearch.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_textSearch) if idOffset: url_idOffset = "idOffset=" + urllib.parse.quote(idOffset.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_idOffset) if textOffset: url_textOffset = "textOffset=" + urllib.parse.quote(textOffset.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_textOffset) url_strings.append("limit=" + str(limit)) # Concatenate the elements in the url_strings list into a single url string service_endpoint += '&'.join(url_strings) # Get the standard dictionary to call the remote service. It appears that different installations require different sets of user credentials... still trying to figure out what the hell is going on with this one service_dict = utils.build_service_calling_info(mac.get_auth_token('tenant_admin'), service_endpoint=service_endpoint) # And try to get a response from the remote API try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format(str(service_dict['url'])) asset_control_log.error(error_msg) raise ce # Check the HTTP response code first if response.status_code != 200: error_msg = "Request unsuccessful: Received an HTTP " + str(eval(response.text)['status']) + " with message: " + str(eval(response.text)['message']) asset_control_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # Check if the 'hasNext' flag is set, i.e., if there are still results to return from the ThingsBoard side of things. In any case, the result structure has that flag set to either 'true' or 'false', which are not recognized as proper # boolean values by Python (those are boolean natives from Postgres/Cassandra). As such, I need to 'translate' the returned text to Python-esque first using the method built for that purpose if eval(utils.translate_postgres_to_python(response.text))['hasNext']: asset_control_log.warning("Only {0} results returned. There are still more results to return from the remote API side. Increase the 'limit' argument to obtain them.".format(str(limit))) # But return the response nonetheless return response
def getCustomers(textSearch=None, idOffset=None, textOffset=None, limit=10): """This in one of the simplest GET methods in the customer-controller section of the ThingsBoard API. With it I only need to provide a valid limit number and I can request a list of all registered customers in the platform so far, so that I can then populate my own MySQL database table with them. All parameters initialized to None in the method signature are OPTIONAL (textSearch, idOffset and textOffset). Those that were set to specific values and data types are MANDATORY (limit) @:type user_types allowed for this service: CUSTOMER_USER @:param textSearch (str) - Use this field to narrow down results based only in the 'name' field (which in this case should be the same as 'title', though the textSearch field only goes to the former). Yet, in order to yield any results, the textSearch field has to be exactly equal to whatever is in the 'name' field in the remote API (put case sensitive in this case...) @:param idOffset (str) - A similar field as the one before in the sense that it the sense that its search scope is limited to the 'id' fields. It provides a bit more of flexibility than the last one - id string can be inserted with, at most, 11 of their last characters omitted and meaningful results are still returned. Any id string smaller than that results in a 'Invalid UUID string' errors. @:param textOffset (str) - I'm still at a loss as to what the hell this parameter does... This is the third API service that I process using a python module, with loads of testing using the ThingsBoard Swagger testing application and I still waiting for a test that can shed any light on what this... thing... really does. Leave it empty or write your favorite poem in it: its all the same for the remote API really... @:param limit (int) - Use this field to limit the number of results returned, regardless of other limiters around. If the limit field did truncates the set of returned results, the result dictionary is returned with its 'nextPageLink' key set to another dictionary describing just that and the 'hasNext' key is set to True. Otherwise, if all records were returned, 'nextPageLink' is set to NULL and 'hasNext' is returned set to False. @:raise utils.InputValidationException - For errors during the validation of inputs @:raise utils.ServiceEndpointException - For errors occurring during the interface with the remote API @:raise Exception - For any other types of errors @:return an HTTP response object containing the following result dictionary: { "data": [ { customer_data_1 }, { customer_data_2 }, ... { customer_data_n } ], "nextPageLink": null or dict, "hasNext": bool } Each "customer_data" sub dictionary has the following format: customer_data = { "id": { "entityType": str, "id": str }, "createTime": int, "additionalInfo": null or { "description": str }, "country": str, "state": str, "city": str, "address": str, "address2": str, "zip": str, "phone": str, "email": str, "title": str, "tenantId": { "entityType": str, "id": str }, "name": str } """ customer_log = ambi_logger.get_logger(__name__) # Validate inputs try: utils.validate_input_type(limit, int) if textSearch: utils.validate_input_type(textSearch, str) if idOffset: utils.validate_input_type(idOffset, str) if textOffset: utils.validate_input_type(textOffset, str) except utils.InputValidationException as ive: customer_log.error(ive.message) raise ive if limit <= 0: error_msg = "Invalid limit provided: {0}. Please provide a value greater than zero for the limit value!".format( str(limit)) customer_log.error(error_msg) raise utils.InputValidationException(message=error_msg) service_endpoint = "/api/customers?" url_strings = [] if textSearch: textSearch = urllib.parse.quote(textSearch.encode('UTF-8')).replace( '/', '%2F') url_strings.append("textSearch=" + str(textSearch)) if idOffset: idOffset = urllib.parse.quote(idOffset.encode('UTF-8')).replace( '/', '%2F') url_strings.append("idOffset=" + str(idOffset)) if textOffset: textOffset = urllib.parse.quote(textOffset.encode('UTF-8')).replace( '/', '%2F') url_strings.append("textOffset=" + str(textOffset)) url_strings.append("limit=" + str(limit)) # Create the endpoint request string service_endpoint += '&'.join(url_strings) # Place the HTTP GET request using a REGULAR type authorization token service_dict = utils.build_service_calling_info( mac.get_auth_token(user_type='tenant_admin'), service_endpoint) # Query the remote API try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a request from {0}...".format( str(service_dict['url'])) customer_log.error(error_msg) raise ce # Check the status code of the HTTP response before moving forward if response.status_code != 200: error_msg = "Request unsuccessful: Received an HTTP {0} with message {1}.".format( str(eval(response.text)['status']), str(eval(response.text)['message'])) customer_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # Check the status of the 'hasNext' parameter returned if eval(utils.translate_postgres_to_python(response.text))['hasNext']: customer_log.warning( "Only {0} results returned. There are still more results to return from the remote API side. Increase the 'limit' argument to obtain them." .format(str(limit))) return response
def getTenantDevices(type=None, textSearch=None, sortProperty=None, sortOrder=None, pageSize=10, page=10): """GET method to retrieve the list of devices with their associations, namely Tenants and Customers. The indexer of the returned list is the DEVICE (or its id to be more precise). @:type user_types allowed for this service: CUSTOMER_USER @:param type (str) - Use this field to narrow down the type of device to return. The type referred in this field is the custom device type defined by the user upon its creation (e.g., 'Thermometer', 'Water meter' and so on) and this field is ultra sensitive. If a device type is defined as 'Thermometer', providing type = 'thermometer' doesn't return any results just because the uppercase difference. So, in order to be used, the caller must know precisely which types of devices were defined in the system so far. @:param textSearch (str) - Use this field to narrow down results based only in the 'name' field. Like the previous parameter, the string inserted in this field has to be exactly identical to what is in a record's 'name' field to return any results. For example, if a device is named 'Water Meter A2', just using 'water Meter A2' instead of the exact string (upper/lower case respected) is enough to get an empty set as response @:param idOffset (str) - A similar field as the two before in the sense that its search scope is limited to 'id' fields. But in this particular case, since a device can be potentially associated to several types of other ids (a single device can be associated to multiple tenants and/or multiple customers, each having its id value explicit in the records), a search using this parameter can result in all devices with a given id, along with their associated tenants and customers if the id in the argument belongs to a device, or it can return all devices associated to a particular tenant or customer if the id string provided is of this type. Also, unlike the previous fields, this one allows searches for partial id strings (but only if part of the last segment of the id string are omitted. More than that yields no results whatsoever). @:param textOffset (str) - Still no clue what this field does... Leave it empty or write your whole life story in it and it always returns the full result set... (If none of the other fields are filled) @:param limit (int) - Use this field to limit the number of results returned, regardless of other limiters around (the other fields of the method). If the limit field did truncated the results returned, the result dictionary is returned with the 'nextPageLink' key set to another dictionary describing just that and the 'hasNext' key is set to True. Otherwise, if all record were returned, 'nextPageLink' is set to NULL and 'hasNext' comes back set to False. @:raise utils.InputValidationException - For errors during the validation of inputs @:raise utils.ServiceEndpointException - For errors during the API operation @:raise Exception - For any other types of errors @:return A HTTP response object containing the following result dictionary (if the API call was successful): { "data": [ { device_1_data }, { device_2_data }, ... { device_n_data }], "nextPageLink": null, "hasNext": false } Each element of the 'data' key associated list is the description of a single device in the database using the following format: { "id": { "entityType": str, "id": str }, "createdTime": int, "additionalInfo": str, "tenantId": { "entityType": str, "id": str }, "customerId": { "entityType": str, "id": str }, "name": str, "type": str, "label": str } The way that ThingsBoard manages these devices internally guarantees that a single device can only be associated to a single tenant and a single customer, which simplifies quite a lot the logic that I need to take to process this data later on """ tenant_device_log = ambi_logger.get_logger(__name__) # Validate inputs # Start by the mandatory ones (only the limit) # utils.validate_input_type(limit, int) utils.validate_input_type(pageSize, int) utils.validate_input_type(page, int) # And then go for the optional ones if type: utils.validate_input_type(type, str) if textSearch: utils.validate_input_type(textSearch, str) # if idOffset: # utils.validate_input_type(idOffset, str) # if textOffset: # utils.validate_input_type(textOffset, str) if sortProperty: utils.validate_input_type(sortProperty, str) if sortOrder: utils.validate_input_type(sortOrder, str) # Check the number passed in limit: zero or negative values are not allowed by the API # if limit <= 0: if pageSize <= 0 or page < 0: error_msg = "Invalid page or page size provided: {0}. Please provide a positive, greater than zero page or page size value!".format(str(pageSize)) tenant_device_log.error(error_msg) raise utils.InputValidationException(message=error_msg) # Start with the base endpoint service_endpoint = "/api/tenant/devices?" url_strings = [] # Lets start building the API request strings to add to the endpoint if type: # Escape the string to URL-esque before adding it to the main service endpoint URL string as well as the forward slashes to '%2F' given that the quote method doesn't do that url_type = "type=" + urllib.parse.quote(type.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_type) if textSearch: url_textSearch = "textSearch=" + urllib.parse.quote(textSearch.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_textSearch) """ if idOffset: url_idOffset = "idOffset=" + urllib.parse.quote(idOffset.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_idOffset) if textOffset: url_textOffset = "textOffset=" + urllib.parse.quote(textOffset.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_textOffset) """ if sortProperty: url_sortProperty = "sortProperty=" + urllib.parse.quote(sortProperty.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_sortProperty) if sortOrder: url_sortOrder = "sortOrder=" + urllib.parse.quote(sortOrder.encode('UTF-8')).replace('/', '%2F') url_strings.append(url_sortOrder) # url_strings.append("limit=" + str(limit)) url_strings.append("pageSize=" + str(pageSize)) url_strings.append("page=" + str(page)) # Concatenate all the url_strings elements into single string, each individual element separated by '&' as expected by the remote API and appended to the base service endpoint service_endpoint += '&'.join(url_strings) # Get the standard service dictionary from the utils method service_dict = utils.build_service_calling_info(mac.get_auth_token('tenant_admin'), service_endpoint) # Try to get a response from the remote API try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format(str(service_dict['url'])) tenant_device_log.error(error_msg) raise ce # If I got a response, check first if it was the expected HTTP 200 OK if response.status_code != 200: error_msg = "Request unsuccessful: Received an HTTP " + str(eval(response.text)['status']) + " with message: " + str(eval(response.text)['message']) tenant_device_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # Before sending the result back, check first the status of the 'hasNext' key in the result dictionary and inform the user that, if it is True, there are results still left to return in the remote API server if eval(utils.translate_postgres_to_python(response.text))['hasNext']: tenant_device_log.warning("Only {0} results returned. There are still more results to return from the remote API side. Increase the 'limit' argument to obtain them.".format(str(limit))) return response
def getCustomerDevices(customer_name, type=None, textSearch=None, idOffset=None, textOffset=None, limit=50): """Method that executes a GET request to the device-controller.getCustomerDevice service to the remote API in order to obtain a list of devices associated with the customer identified by 'customer_name'. For now, this method then sends that information to be used to update the ambiosensing_thingsboard.thingsboard_devices_tables. This method is but a subset of the getTenantDevices method from this own module in the sense that, by specifying a user during the method call, the list of devices returned is limited to just the devices assigned to this customer while the getTenantDevices returns the list of all devices, as long as they are assigned to a tenant, regardless of whom that tenant may be. @:type user_types allowed for this service: TENANT_ADMIN, CUSTOMER_USER @:param customer_name (str) - The name of the customer as it was defined in its registration in the ThingsBoard interface. This parameter is going to be use to perform SELECT operations in the MySQL database using 'LIKE' clauses so, unlike some of the fields in the API service requests, there's some flexibility here for using names that are not exactly identical to what is in the database. The search is going to be applied to the 'name' column of the thingsboard_customers_table. Retrieved customer records via this interface are then used to build the service call to the remote API @:param type (str) - Use this field to narrow down results based on the type of device to return. The type field is set during the device registration in the ThingsBoard platform and can then be used later to associate various devices to the same type (e.g., 'Thermometer', 'luximeter', etc..). The search operation is case-sensitive, i.e., only complete type matches are returned. @:param textSearch (str) - Use this field to narrow down the number of returned results based on the 'name' field. Like the previous field, this one is also case-sensitive (only identical matches return results) @:param idOffset (str) - Another search field based on the 'id' parameter this time. It does provide just a little bit of flexibility when compared with previous search fields, in the sense that it accepts and processes incomplete id strings, as long as some (but not all) of the 12 character segment of its last block are omitted. @:param textOffset (str) - Still no clue on what this might be used for... @:param limit (int) - Use this field to truncate the number of returned results. If the result set returned from the remote API was truncated for whatever reason, the result dictionary is returned with another dictionary under the 'nextPageLink' key detailing the results still to be returned and the 'hasNext' key set to True. Otherwise 'nextPageLink' is set to NULL and 'hasNext' to False @:raise utils.InputValidationException - For errors during the validation of inputs @:raise utils.ServiceEndpoointException - For error during the remote API access @:raise Exception - For any other errors @:return A HTTP response object containing the following result dictionary: { "data": [ { customer_device_1_data }, { customer_device_2_data }, ... { customer_device_n_data } ], "nextPageLink": null, "hasNext": false } Each customer_device_data element is a dictionary in the following format: customer_device_n_data = { "id": { "entityType": str, "id": str }, "createdTime": int, "additionalInfo": null or { "description": str }, "tenantId": { "entityType": str, "id": str }, "customerId": { "entityType": str, "id": str }, "name": str, "type": str, "label": str } """ customer_device_log = ambi_logger.get_logger(__name__) module_table_key = 'customers' columns_to_retrieve = ['id'] # Validate inputs try: # Start by the mandatory ones first utils.validate_input_type(customer_name, str) utils.validate_input_type(limit, int) if type: utils.validate_input_type(type, str) if textSearch: utils.validate_input_type(textSearch, str) if idOffset: utils.validate_input_type(idOffset, str) if textOffset: utils.validate_input_type(textOffset, str) except utils.InputValidationException as ive: customer_device_log.error(ive.message) raise ive # Check the number passed in the limit argument for consistency if limit <= 0: error_msg = "Invalid limit provided: {0}. Please provide a greater than zero limit value!".format(str(limit)) customer_device_log.error(error_msg) raise utils.InputValidationException(message=error_msg) # Before going any further, there a limiting factor here: the customer id. I need to use the customer_name parameter to fetch it through a database consultation. The customer search is going to be an exhaustive one: I'll first try to search # for the customer_name that was passed as is. If a single result is returned - the desired outcome - cool, move on. If not, try to add a wildcard character at the end of customer_name (customer_name%), then to just the beginning ( # %customer_name) and, if I still can't find a single result, try one last time with wildcard on both ends of the string (%customer_name%) in order to get an unique record (multiple records returned are also disregarded). If no clear answer is # obtained thus far, raise an Exception with this information # Connect to the MySQL database cnx = mysql_utils.connect_db(user_config.mysql_db_access['database']) # And get a buffered cursor to run SQL statements select_cursor = cnx.cursor(buffered=True) # Build the SQL SELECT statement to execute in the MySQL database context sql_select = """SELECT """ + ", ".join(columns_to_retrieve) + """ FROM """ + str(proj_config.mysql_db_tables[module_table_key]) + """ WHERE name LIKE %s;""" # Run the statement and check what comes back select_cursor = mysql_utils.run_sql_statement(select_cursor, sql_select, (str(customer_name),)) # If I got a single result from the last SQL execution, I don't need to retrieve the record itself to check it: the cursor retains the number of records found in the statement that was just executed in its rowcount internal variable (which is # effectively the same as running a SELECT COUNT(*) instead) if select_cursor.rowcount != 1: # If the last statement failed, try again with a wildcard character at the end of the customer_name customer_device_log.warning("Unable to get an unique result searching for a customer_name = {0} (got {1} results instead). Trying again using customer_name = {2}..." .format(str(customer_name), str(select_cursor.rowcount), str(customer_name + "%"))) select_cursor = mysql_utils.run_sql_statement(select_cursor, sql_select, (str(customer_name + "%"),)) if select_cursor.rowcount != 1: customer_device_log.warning("Unable to get an unique result searching for a customer_name = {0} (got {1} result instead). Trying again using customer_name = {2}..." .format(str(customer_name + "%"), str(select_cursor.rowcount), str("%" + customer_name))) select_cursor = mysql_utils.run_sql_statement(select_cursor, sql_select, (str("%" + customer_name),)) if select_cursor.rowcount != 1: customer_device_log.warning("Unable to get an unique result searching for a customer_name = {0} (got {1} result instead). Trying again using customer_name = {2}..." .format(str("%" + customer_name), str(select_cursor.rowcount), str("%" + customer_name + "%"))) select_cursor = mysql_utils.run_sql_statement(select_cursor, sql_select, (str("%" + customer_name + "%"),)) if select_cursor.rowcount != 1: error_msg = "The method was unable to retrieve an unique record for customer_name = {0} (got {1} results instead). Nowhere to go but out now..."\ .format(str("%" + customer_name + "%"), str(select_cursor.rowcount)) customer_device_log.error(error_msg) exit(-1) # If my select_cursor was able to go through the last flurry of validation, retrieve the result obtained result = select_cursor.fetchone() # The SQL SELECT result returns records as n-element tuples, n the number of columns returned. The SQL statement in this method queries for a single column: 'id', so any result returned should be a single element tuple customer_id = str(result[0]) # I now have everything that I need to place a call to the remote API service. Build the service endpoint service_endpoint = "/api/customer/{0}/devices?".format(customer_id) url_strings = [] if type: # Don't forget to escape the url strings characters to URL-compatible characters, including the '/' character for '%2F' url_strings.append("type=" + urllib.parse.quote(type.encode('UTF-8')).replace('/', '%2F')) if textSearch: url_strings.append("textSearch=" + urllib.parse.quote(textSearch.encode('UTF-8')).replace('/', '%2F')) if idOffset: url_strings.append("idOffset=" + urllib.parse.quote(idOffset.encode('UTF-8')).replace('/', '%2F')) if textOffset: url_strings.append("textOffset=" + urllib.parse.quote(textOffset.encode('UTF-8')).replace('/', '%2F')) url_strings.append("limit=" + str(limit)) # Concatenate all the gathered url_strings together with the rest of the service_endpoint, using '&' as a separator service_endpoint += '&'.join(url_strings) # Get the request dictionary using a REGULAR type authorization token service_dict = utils.build_service_calling_info(mac.get_auth_token(user_type='tenant_admin'), service_endpoint) # Query the remote API try: response = requests.get(url=service_dict['url'], headers=service_dict['headers']) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout) as ce: error_msg = "Could not get a response from {0}...".format(str(service_dict['url'])) customer_device_log.error(error_msg) raise utils.ServiceEndpointException(message=ce) # If I got a response, check first if it was the expected HTTP 200 OK if response.status_code != 200: error_msg = "Request unsuccessful: Received an HTTP " + str(eval(response.text)['status']) + " with message: " + str(eval(response.text)['message']) customer_device_log.error(error_msg) raise utils.ServiceEndpointException(message=error_msg) else: # I got a valid results, it appears. Check if the number of results returned was truncated by the limit parameter. If so, warn the user only (there's no need to raise Exceptions on this matter) # Translate the results to Python-speak first before going for the comparison given that this result set was returned from a MySQL backend if eval(utils.translate_postgres_to_python(response.text))['hasNext']: customer_device_log.warning("Only {0} results returned. There are still results to return from the remote API side. Increase the 'limit' argument to obtain them.".format(str(limit))) # I'm good then. Return the result set back return response