switch-api 0.6.29__tar.gz → 0.6.31__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {switch_api-0.6.29 → switch_api-0.6.31}/HISTORY.md +34 -0
  2. {switch_api-0.6.29 → switch_api-0.6.31}/PKG-INFO +35 -1
  3. {switch_api-0.6.29 → switch_api-0.6.31}/setup.py +1 -1
  4. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/__init__.py +3 -2
  5. switch_api-0.6.31/switch_api/_utils/_sql.py +104 -0
  6. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_utils.py +91 -2
  7. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/controls.py +18 -7
  8. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/__init__.py +3 -3
  9. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/helpers.py +144 -1
  10. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/integration.py +296 -1
  11. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/automation.py +12 -1
  12. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/PKG-INFO +35 -1
  13. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/SOURCES.txt +4 -1
  14. switch_api-0.6.31/tests/test_controls.py +136 -0
  15. switch_api-0.6.31/tests/test_timer_deployment.py +62 -0
  16. {switch_api-0.6.29 → switch_api-0.6.31}/AUTHORS.rst +0 -0
  17. {switch_api-0.6.29 → switch_api-0.6.31}/LICENCE +0 -0
  18. {switch_api-0.6.29 → switch_api-0.6.31}/MANIFEST.in +0 -0
  19. {switch_api-0.6.29 → switch_api-0.6.31}/README.md +0 -0
  20. {switch_api-0.6.29 → switch_api-0.6.31}/pyproject.toml +0 -0
  21. {switch_api-0.6.29 → switch_api-0.6.31}/setup.cfg +0 -0
  22. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/__init__.py +0 -0
  23. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_authentication.py +0 -0
  24. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_credentials_store/__init__.py +0 -0
  25. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_credentials_store/_credentials_store.py +0 -0
  26. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_msal/__init__.py +0 -0
  27. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_msal/_custom_application.py +0 -0
  28. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/__init__.py +0 -0
  29. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_commands.py +0 -0
  30. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_compute.py +0 -0
  31. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_iotservice.py +0 -0
  32. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_models.py +0 -0
  33. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_utils.py +0 -0
  34. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_guide/__init__.py +0 -0
  35. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_guide/main.py +0 -0
  36. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_guide/processor.py +0 -0
  37. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/__init__.py +0 -0
  38. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_constants.py +0 -0
  39. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_marketplace.py +0 -0
  40. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_mqtt.py +0 -0
  41. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_platform.py +0 -0
  42. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/analytics/__init__.py +0 -0
  43. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/analytics/analytics.py +0 -0
  44. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/cache/__init__.py +0 -0
  45. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/cache/cache.py +0 -0
  46. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/__init__.py +0 -0
  47. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/_constants.py +0 -0
  48. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/_enums.py +0 -0
  49. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/_mqtt.py +0 -0
  50. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/dataset/__init__.py +0 -0
  51. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/dataset/dataset.py +0 -0
  52. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/email/__init__.py +0 -0
  53. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/email/email_sender.py +0 -0
  54. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/error_handlers/__init__.py +0 -0
  55. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/error_handlers/error_handlers.py +0 -0
  56. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/__init__.py +0 -0
  57. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/extensions.py +0 -0
  58. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/field_meta.py +0 -0
  59. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/helpers.py +0 -0
  60. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/pipeline.py +0 -0
  61. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/initialize.py +0 -0
  62. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/_utils.py +0 -0
  63. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/__init__.py +0 -0
  64. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/definitions.py +0 -0
  65. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/pipeline.py +0 -0
  66. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/platform_insights/__init__.py +0 -0
  67. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/platform_insights/platform_insights.py +0 -0
  68. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/__init__.py +0 -0
  69. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/_mqtt.py +0 -0
  70. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/_redis.py +0 -0
  71. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/realtime.py +0 -0
  72. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/dependency_links.txt +0 -0
  73. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/requires.txt +0 -0
  74. {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/top_level.txt +0 -0
  75. {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_authentication.py +0 -0
  76. {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_dataset.py +0 -0
  77. {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_error_handlers.py +0 -0
  78. {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_initialize.py +0 -0
  79. {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_integration.py +0 -0
  80. {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_pipeline.py +0 -0
@@ -1,5 +1,39 @@
1
1
  # History
2
2
 
3
+ #0.6.31
4
+
5
+ ### Added
6
+
7
+ In the `pipeline` module:
8
+
9
+ - Added optional `email`, `ftp`, `upload`, and `webhook` delivery settings to `Automation.deploy_on_timer()`, allowing scheduled data feeds to retain additional delivery methods.
10
+
11
+ ### Changed
12
+
13
+ - Timer deployment requests include all four delivery settings while retaining `sourceType: Timer`. Omitted settings, `None`, or empty dictionaries clear the corresponding stored configuration; resupply each setting that should remain enabled.
14
+ - Existing positional arguments and driver `settings` retain their meaning.
15
+
16
+ #0.6.30
17
+
18
+ ### Added
19
+
20
+ In the `integration` module:
21
+
22
+ - Added `upsert_sensor_state_history()` to append sensor state history records with site-local, UTC, or named-timezone timestamp handling.
23
+ - Added the `update_current_state` option to `upsert_sensor_state_history()` to update each sensor's current state from its latest history record.
24
+ - Added `update_sensor_state()` to update current sensor states in SQL using validated, batched writes with retry handling.
25
+
26
+ In the `_utils` module:
27
+
28
+ - Added `set_sql_write_config()` and cached SQL write connection helpers using `pyodbc`.
29
+ - Added `get_connection_string_from_local_file()` to load datacentre-specific MQTT, Redis, and SQL write configuration from a local credentials file.
30
+
31
+ ### Changed
32
+
33
+ In the `controls` module:
34
+
35
+ - Updated control components to accept either a single integer priority or a DataFrame column name containing row-level priority values.
36
+
3
37
  #0.6.29
4
38
 
5
39
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: switch_api
3
- Version: 0.6.29
3
+ Version: 0.6.31
4
4
  Summary: A complete package for data ingestion into the Switch Automation Platform.
5
5
  Author: Switch Automation Pty Ltd.
6
6
  License: MIT License
@@ -58,6 +58,40 @@ pip install switch_api
58
58
 
59
59
  # History
60
60
 
61
+ #0.6.31
62
+
63
+ ### Added
64
+
65
+ In the `pipeline` module:
66
+
67
+ - Added optional `email`, `ftp`, `upload`, and `webhook` delivery settings to `Automation.deploy_on_timer()`, allowing scheduled data feeds to retain additional delivery methods.
68
+
69
+ ### Changed
70
+
71
+ - Timer deployment requests include all four delivery settings while retaining `sourceType: Timer`. Omitted settings, `None`, or empty dictionaries clear the corresponding stored configuration; resupply each setting that should remain enabled.
72
+ - Existing positional arguments and driver `settings` retain their meaning.
73
+
74
+ #0.6.30
75
+
76
+ ### Added
77
+
78
+ In the `integration` module:
79
+
80
+ - Added `upsert_sensor_state_history()` to append sensor state history records with site-local, UTC, or named-timezone timestamp handling.
81
+ - Added the `update_current_state` option to `upsert_sensor_state_history()` to update each sensor's current state from its latest history record.
82
+ - Added `update_sensor_state()` to update current sensor states in SQL using validated, batched writes with retry handling.
83
+
84
+ In the `_utils` module:
85
+
86
+ - Added `set_sql_write_config()` and cached SQL write connection helpers using `pyodbc`.
87
+ - Added `get_connection_string_from_local_file()` to load datacentre-specific MQTT, Redis, and SQL write configuration from a local credentials file.
88
+
89
+ ### Changed
90
+
91
+ In the `controls` module:
92
+
93
+ - Updated control components to accept either a single integer priority or a DataFrame column name containing row-level priority values.
94
+
61
95
  #0.6.29
62
96
 
63
97
  ### Changed
@@ -16,7 +16,7 @@ setup(
16
16
  long_description_content_type='text/markdown',
17
17
  license='MIT License',
18
18
  name="switch_api",
19
- version="0.6.29",
19
+ version="0.6.31",
20
20
  packages=find_packages(include=["switch_api", "switch_api.*"]),
21
21
  install_requires=['pandas==1.5.3', 'requests', 'azure-storage-blob', 'pandera[io]==0.7.1', 'azure-servicebus',
22
22
  'msal>=1.11.0', 'paho-mqtt==1.6.1', 'uvicorn==0.22.0', 'fastapi==0.98.0', 'pyodbc==4.0.39', 'cachetools==5.5.0',
@@ -11,7 +11,7 @@ Complete package for ingestion data into the Switch Automation Platform.
11
11
  """
12
12
  __all__ = ['dataset', 'analytics', 'pipeline', 'error_handlers', 'integration',
13
13
  'initialize', 'cache', 'extensions', 'email', 'controls', 'realtime', '_compute', 'generate_filepath',
14
- 'set_mqtt_broker_config', 'set_realtime_redis_config']
14
+ 'set_mqtt_broker_config', 'set_realtime_redis_config', 'set_sql_write_config']
15
15
 
16
16
  from .initialize import initialize
17
17
  from . import dataset
@@ -28,6 +28,7 @@ from . import realtime
28
28
  from . import _compute
29
29
  from ._utils._utils import generate_filepath
30
30
  from ._utils._mqtt import set_mqtt_broker_config
31
+ from ._utils._sql import set_sql_write_config
31
32
  from .realtime import set_realtime_redis_config
32
33
  from . import _guide
33
34
 
@@ -44,4 +45,4 @@ from . import _guide
44
45
  # log.addHandler(_ch)
45
46
  # https://dev.to/joaomcteixeira/setting-up-python-logging-for-a-library-app-6ml
46
47
 
47
- __version__ = "0.6.29"
48
+ __version__ = "0.6.31"
@@ -0,0 +1,104 @@
1
+ # -------------------------------------------------------------------------
2
+ # Copyright (c) Switch Automation Pty Ltd. All rights reserved.
3
+ # Licensed under the MIT License. See License.txt in the project root for
4
+ # license information.
5
+ # --------------------------------------------------------------------------
6
+ """
7
+ SQL write configuration and connection helpers.
8
+ """
9
+ import logging
10
+ import sys
11
+
12
+ import pyodbc
13
+
14
+ logger = logging.getLogger(__name__)
15
+ logger.setLevel(logging.DEBUG)
16
+ consoleHandler = logging.StreamHandler(stream=sys.stdout)
17
+ consoleHandler.setLevel(logging.INFO)
18
+
19
+ logger.addHandler(consoleHandler)
20
+ formatter = logging.Formatter('%(asctime)s switch_api.%(module)s.%(funcName)s %(levelname)s: %(message)s',
21
+ datefmt='%Y-%m-%dT%H:%M:%S')
22
+ consoleHandler.setFormatter(formatter)
23
+
24
+
25
+ global _sql_write_config
26
+ global _sql_write_conn
27
+
28
+ _sql_write_config = {
29
+ "connection_string": "",
30
+ }
31
+ _sql_write_conn = None
32
+
33
+
34
+ def _reset_sql_write_conn():
35
+ """
36
+ Close the cached SQL write connection and reset it to None.
37
+ """
38
+ global _sql_write_conn
39
+
40
+ if _sql_write_conn is not None:
41
+ try:
42
+ _sql_write_conn.close()
43
+ except Exception as e:
44
+ logger.warning(f"Failed to close cached SQL write connection: {e}")
45
+
46
+ _sql_write_conn = None
47
+
48
+
49
+ def set_sql_write_config(connection_string: str):
50
+ """
51
+ Set SQL configuration.
52
+
53
+ Parameters
54
+ ----------
55
+ connection_string : str
56
+ The pyodbc connection string used for SQL write operations.
57
+
58
+ Raises
59
+ ------
60
+ ValueError
61
+ If connection_string is not a non-empty string.
62
+ """
63
+ global _sql_write_config
64
+
65
+ if not isinstance(connection_string, str) or not connection_string.strip():
66
+ raise ValueError("connection_string must be a non-empty string.")
67
+
68
+ _reset_sql_write_conn()
69
+ _sql_write_config = {
70
+ "connection_string": connection_string,
71
+ }
72
+ logger.info("Set SQL configuration")
73
+
74
+
75
+ def is_sql_write_configured():
76
+ """
77
+ Return whether SQL write configuration has been set.
78
+ """
79
+ return bool(_sql_write_config["connection_string"])
80
+
81
+
82
+ def get_sql_write_config():
83
+ """
84
+ Return a copy of the current SQL write configuration.
85
+ """
86
+ return dict(_sql_write_config)
87
+
88
+
89
+ def _get_sql_conn():
90
+ """
91
+ Return the cached SQL write connection, creating it when required.
92
+ """
93
+ global _sql_write_conn
94
+
95
+ if not is_sql_write_configured():
96
+ raise ValueError("SQL write configuration has not been set.")
97
+
98
+ if _sql_write_conn is None:
99
+ logger.info("Creating SQL write connection.")
100
+ _sql_write_conn = pyodbc.connect(
101
+ _sql_write_config["connection_string"]
102
+ )
103
+
104
+ return _sql_write_conn
@@ -27,7 +27,8 @@ from .._utils._constants import (WORK_ORDER_STATUS, WORK_ORDER_CATEGORY, WORK_OR
27
27
  PERFORMANCE_STATISTIC_METRIC_SYSTEM, RESERVATION_STATUS, RESOURCE_TYPE)
28
28
 
29
29
  __all__ = ['generate_password', 'convert_to_sqm', 'convert_to_pascal_case', 'ValidatedSetterProperty', 'ApiInputs',
30
- 'DiscoveryIntegrationInput', 'DataFeedFileProcessOutput', 'requests_retry_session', 'is_valid_uuid', 'convert_bytes']
30
+ 'DiscoveryIntegrationInput', 'DataFeedFileProcessOutput', 'requests_retry_session', 'is_valid_uuid',
31
+ 'convert_bytes', 'get_connection_string_from_local_file']
31
32
 
32
33
  logger = logging.getLogger(__name__)
33
34
  logger.setLevel(logging.DEBUG)
@@ -132,6 +133,94 @@ DataFeedFileProcessOutput = namedtuple('DataFeedFileProcessOutput',
132
133
  ])
133
134
 
134
135
 
136
+ def get_connection_string_from_local_file(filepath: str, api_inputs: ApiInputs):
137
+ """Load local MQTT, Redis, and SQL write credentials for a datacentre.
138
+
139
+ The credentials file must contain ``KEY==VALUE`` entries for the AU and
140
+ US datacentres. The datacentre is determined from ``api_inputs.api_base_url``.
141
+
142
+ Parameters
143
+ ----------
144
+ filepath : str
145
+ Path to the local credentials file.
146
+ api_inputs : ApiInputs
147
+ Initialized API inputs containing the API base URL.
148
+
149
+ Returns
150
+ -------
151
+ dict or bool
152
+ MQTT, Redis, and SQL write configuration dictionaries, or False when
153
+ the file or datacentre configuration is invalid.
154
+ """
155
+ if not os.path.isfile(filepath):
156
+ logger.error(f"The provided filepath: {filepath} is not valid")
157
+ return False
158
+
159
+ try:
160
+ with open(filepath, 'r') as file:
161
+ file_content = file.read().splitlines()
162
+
163
+ tmp = dict(
164
+ line.strip().split('==', 1)
165
+ for line in file_content
166
+ if line.strip()
167
+ )
168
+ except (OSError, ValueError) as exc:
169
+ logger.error(f"Unable to read credentials from filepath: {filepath}. {exc}")
170
+ return False
171
+
172
+ try:
173
+ dc = api_inputs.api_base_url.split('-', 1)[1].split('.')[0]
174
+ except (AttributeError, IndexError):
175
+ logger.error("Unable to determine datacentre from ApiInputs['api_base_url']")
176
+ return False
177
+
178
+ if dc == "ae":
179
+ suffix = "_AU"
180
+ elif dc == "us":
181
+ suffix = "_US"
182
+ else:
183
+ logger.error("Unable to determine datacentre from ApiInputs['api_base_url']")
184
+ return False
185
+
186
+ try:
187
+ controls_platform_iot_endpoint = tmp['PLATFORM_IOT_ENDPOINT' + suffix]
188
+ controls_mqtt_host = tmp['MQTT_HOST' + suffix]
189
+ controls_mqtt_port = tmp['MQTT_PORT' + suffix]
190
+ controls_mqtt_username = tmp['MQTT_USERNAME' + suffix]
191
+ controls_mqtt_password = tmp['MQTT_PASSWORD' + suffix]
192
+ controls_max_timeout = tmp['CONTROLS_MAX_TIMEOUT' + suffix]
193
+
194
+ redis_host = tmp['REDIS_HOST' + suffix]
195
+ redis_password = tmp['REDIS_PASSWORD' + suffix]
196
+ sql_write_connection_string = tmp['SQL_WRITE_CONNECTION_STRING' + suffix]
197
+
198
+ control_variables = {
199
+ 'iot_endpoint': controls_platform_iot_endpoint,
200
+ 'mqtt_host': controls_mqtt_host,
201
+ 'mqtt_username': controls_mqtt_username,
202
+ 'mqtt_password': controls_mqtt_password,
203
+ 'mqtt_port': int(controls_mqtt_port),
204
+ 'max_timeout': int(controls_max_timeout)
205
+ }
206
+ redis_variables = {
207
+ 'redis_host': redis_host,
208
+ 'redis_password': redis_password
209
+ }
210
+ sql_write_variables = {
211
+ 'connection_string': sql_write_connection_string
212
+ }
213
+ except (KeyError, TypeError, ValueError) as exc:
214
+ logger.error(f"Invalid local credentials for datacentre {dc}: {exc}")
215
+ return False
216
+
217
+ return {
218
+ 'mqtt_config': control_variables,
219
+ 'redis_config': redis_variables,
220
+ 'sql_write_config': sql_write_variables
221
+ }
222
+
223
+
135
224
  def generate_password():
136
225
  """Generate a ten-character alphanumeric password with at least one lowercase character, at least one uppercase
137
226
  character, and at least three digits.
@@ -530,4 +619,4 @@ def ensure_uuid(value, field_name):
530
619
  return uuid.UUID(value)
531
620
  except ValueError:
532
621
  pass
533
- raise ValueError(f"{field_name} must be a valid UUID or UUID string.")
622
+ raise ValueError(f"{field_name} must be a valid UUID or UUID string.")
@@ -218,6 +218,11 @@ def submit_control_continue(api_inputs: ApiInputs, data: pandas.DataFrame, infor
218
218
  f"Default Value column {column_with_default_value} not found in the DataFrame")
219
219
  raise ValueError(
220
220
  f"Default Value column {column_with_default_value} not found in the DataFrame")
221
+ elif isinstance(priority, str) and priority not in data.columns:
222
+ logger.error(
223
+ f"Priority column {priority} not found in the DataFrame")
224
+ raise ValueError(
225
+ f"Priority column {priority} not found in the DataFrame")
221
226
 
222
227
  data[column_with_value] = pandas.to_numeric(
223
228
  data[column_with_value], errors='coerce')
@@ -228,6 +233,7 @@ def submit_control_continue(api_inputs: ApiInputs, data: pandas.DataFrame, infor
228
233
 
229
234
  control_df = pandas.DataFrame()
230
235
  group[COL_CONTROL_NAME] = name
236
+ control_priority = group[priority] if isinstance(priority, str) else priority
231
237
 
232
238
  if column_with_default_value == None:
233
239
  control_df = pandas.DataFrame({
@@ -237,7 +243,7 @@ def submit_control_continue(api_inputs: ApiInputs, data: pandas.DataFrame, infor
237
243
  COL_CONTROL_VALUE: group[column_with_value],
238
244
  COL_CONTROL_CONTINUE_VALUE: group[column_with_continue],
239
245
  COL_CONTROL_TTL: timeout_in_seconds,
240
- COL_CONTROL_PRIORITY: priority
246
+ COL_CONTROL_PRIORITY: control_priority
241
247
  })
242
248
  else:
243
249
  control_df = pandas.DataFrame({
@@ -248,7 +254,7 @@ def submit_control_continue(api_inputs: ApiInputs, data: pandas.DataFrame, infor
248
254
  COL_CONTROL_CONTINUE_VALUE: group[column_with_continue],
249
255
  COL_CONTROL_DEFAULT_CONTROL_VALUE: group[column_with_default_value],
250
256
  COL_CONTROL_TTL: timeout_in_seconds,
251
- COL_CONTROL_PRIORITY: priority
257
+ COL_CONTROL_PRIORITY: control_priority
252
258
  })
253
259
 
254
260
  try:
@@ -958,7 +964,7 @@ def set_control_cache(api_inputs: ApiInputs, key: str, scope_id: str, data: any)
958
964
  f"An unexpected error occurred in setting Control cache: {e}")
959
965
  return pandas.DataFrame()
960
966
 
961
- def add_control_component(name: str, column_with_object_installation_id: str, column_with_object_property_id: str, column_with_label: str, column_with_value: str, column_with_continue: str = None, column_with_default_value: str = None, timeout_in_seconds: int = 0, priority: int = 8):
967
+ def add_control_component(name: str, column_with_object_installation_id: str, column_with_object_property_id: str, column_with_label: str, column_with_value: str, column_with_continue: str = None, column_with_default_value: str = None, timeout_in_seconds: int = 0, priority: Union[int, str] = 8):
962
968
  """Add Control Components similar to batching the dataframe with named columns.
963
969
 
964
970
  Parameters
@@ -981,8 +987,9 @@ def add_control_component(name: str, column_with_object_installation_id: str, co
981
987
  Time until relinquish happens in seconds. Defaults to 0.
982
988
  Setting value to 0 means the control will not relinquish.
983
989
  If there's value > 0 then it will always relinquish control.
984
- priority (int, optional):
985
- Priority value for the control write command. Defaults to 8.
990
+ priority (int or str, optional):
991
+ Priority value for the control write command, or the name of the
992
+ DataFrame column containing row-level priority values. Defaults to 8.
986
993
 
987
994
  Returns
988
995
  -------
@@ -1019,9 +1026,13 @@ def add_control_component(name: str, column_with_object_installation_id: str, co
1019
1026
  if not isinstance(timeout_in_seconds, int) or timeout_in_seconds < 0:
1020
1027
  raise ValueError(
1021
1028
  "The 'timeout_in_seconds' parameter must be a non-negative integer.")
1022
- if not isinstance(priority, int) or priority < 0:
1029
+ if isinstance(priority, str):
1030
+ if not priority:
1031
+ raise ValueError(
1032
+ "The 'priority' parameter must be a non-empty string or non-negative integer.")
1033
+ elif not isinstance(priority, int) or priority < 0:
1023
1034
  raise ValueError(
1024
- "The 'priority' parameter must be a non-negative integer.")
1035
+ "The 'priority' parameter must be a non-empty string or non-negative integer.")
1025
1036
 
1026
1037
  _control_components[name] = (
1027
1038
  column_with_object_installation_id,
@@ -10,12 +10,12 @@ A module for integrating asset creation, asset updates, data ingestion, etc into
10
10
  from .integration import (upsert_data, upsert_sites, upsert_workorders, upsert_device_sensors, upsert_device_sensors_ext,
11
11
  upsert_timeseries_ds, replace_data, append_data, upsert_file_row_count,
12
12
  upsert_discovered_records, upsert_timeseries, upsert_tags, upsert_device_metadata,
13
- upsert_reservations, upsert_device_sensors_iq)
13
+ upsert_reservations, upsert_device_sensors_iq, upsert_sensor_state_history)
14
14
 
15
15
  from .helpers import (get_sites, get_tag_groups, get_metadata_keys, get_data, get_device_sensors, get_templates,
16
16
  get_units_of_measure, get_states_by_country, get_operation_state, get_equipment_classes,
17
17
  load_data, get_timezones, amortise_across_days, get_metadata_where_clause, connect_to_sql,
18
- update_last_record_property_value)
18
+ update_last_record_property_value, update_sensor_state)
19
19
 
20
20
  __all__ = ['upsert_data', 'upsert_sites', 'upsert_workorders', 'upsert_device_sensors', 'upsert_device_sensors_ext',
21
21
  'upsert_timeseries_ds', 'replace_data', 'append_data', 'get_sites', 'get_tag_groups', 'get_metadata_keys',
@@ -23,4 +23,4 @@ __all__ = ['upsert_data', 'upsert_sites', 'upsert_workorders', 'upsert_device_se
23
23
  'get_operation_state', 'get_equipment_classes', 'load_data', 'upsert_file_row_count',
24
24
  'upsert_discovered_records', 'upsert_timeseries', 'upsert_tags', 'upsert_device_metadata', 'get_timezones',
25
25
  'amortise_across_days', 'get_metadata_where_clause', 'connect_to_sql', 'upsert_reservations', 'upsert_device_sensors_iq',
26
- 'update_last_record_property_value']
26
+ 'update_last_record_property_value', 'update_sensor_state', 'upsert_sensor_state_history']
@@ -16,12 +16,15 @@ import requests
16
16
  import logging
17
17
  import uuid
18
18
  import pyodbc
19
+ import time
19
20
  from datetime import timedelta
21
+ from numbers import Integral
20
22
  from typing import Union
21
23
 
22
24
  from ._utils import _get_sql_connection_string, _extract_sql_credentials
23
25
  from .._utils._constants import QUERY_LANGUAGE
24
26
  from .._utils._constants import RESPONSE_TYPE, AMORTISATION_METHOD
27
+ from .._utils._sql import get_sql_write_config, _get_sql_conn
25
28
  from .._utils._utils import ApiInputs, _column_name_cap, _with_func_attrs, requests_retry_session, requests_retry_session2
26
29
  from ..error_handlers import error_handlers
27
30
 
@@ -36,6 +39,11 @@ formatter = logging.Formatter('%(asctime)s switch_api.%(module)s.%(funcName)s
36
39
  consoleHandler.setFormatter(formatter)
37
40
 
38
41
 
42
+ global _sql_write_conn
43
+
44
+ _sql_write_conn = None
45
+
46
+
39
47
  def get_operation_state(upload_id: uuid.UUID, api_inputs: ApiInputs):
40
48
  """Get operation state
41
49
 
@@ -865,6 +873,141 @@ def connect_to_sql(api_inputs: ApiInputs):
865
873
  return conn
866
874
 
867
875
 
876
+ def update_sensor_state(df: pandas.DataFrame):
877
+ """Update sensor states for object properties in SQL.
878
+
879
+ The DataFrame must include the following columns:
880
+
881
+ - ObjectPropertyId : UUID
882
+ - SensorStateId : int
883
+
884
+ Updates are executed in batches of up to 1,000 rows and retried up to
885
+ three times when SQL execution fails.
886
+
887
+ Parameters
888
+ ----------
889
+ df : pandas.DataFrame
890
+ Object property identifiers and their corresponding sensor state
891
+ identifiers.
892
+
893
+ Returns
894
+ -------
895
+ bool
896
+ True when all sensor state updates succeed; otherwise, False.
897
+ """
898
+ global _sql_write_conn
899
+
900
+ required_columns = {"ObjectPropertyId", "SensorStateId"}
901
+ max_rows = 1000
902
+
903
+ if not isinstance(df, pandas.DataFrame):
904
+ logger.error("df must be a pandas.DataFrame.")
905
+ return False
906
+
907
+ missing_columns = required_columns.difference(df.columns)
908
+ if missing_columns:
909
+ logger.error(
910
+ "Missing required column(s): %s",
911
+ ", ".join(sorted(missing_columns)),
912
+ )
913
+ return False
914
+
915
+ if df.empty:
916
+ logger.info("No sensor states to update.")
917
+ return True
918
+
919
+ for index, row in df.loc[:, ["ObjectPropertyId", "SensorStateId"]].iterrows():
920
+ try:
921
+ uuid.UUID(str(row["ObjectPropertyId"]))
922
+ except (AttributeError, TypeError, ValueError):
923
+ logger.error("ObjectPropertyId must be a GUID at DataFrame index %s.", index)
924
+ return False
925
+
926
+ sensor_state_id = row["SensorStateId"]
927
+ if isinstance(sensor_state_id, bool) or not isinstance(sensor_state_id, Integral):
928
+ logger.error("SensorStateId must be an integer at DataFrame index %s.", index)
929
+ return False
930
+
931
+ create_table_statement = (
932
+ "BEGIN TRANSACTION\n"
933
+ " DECLARE @vObjectProperties TABLE"
934
+ " (ObjectPropertyId uniqueidentifier NOT NULL, "
935
+ "SensorStateId int NOT NULL);\n"
936
+ )
937
+ insert_statement = "INSERT INTO @vObjectProperties ([ObjectPropertyId],[SensorStateId]) VALUES "
938
+ update_statement = (
939
+ "UPDATE t SET "
940
+ "t.[SensorStateId] = s.[SensorStateId]"
941
+ " FROM dbo.ObjectProperties t"
942
+ " INNER JOIN @vObjectProperties s "
943
+ "ON t.[ObjectPropertyId] = s.[ObjectPropertyId];\n"
944
+ "COMMIT TRANSACTION"
945
+ )
946
+
947
+ try:
948
+ sql_write_config = get_sql_write_config()
949
+ if not sql_write_config["connection_string"]:
950
+ logger.error("SQL write configuration has not been set.")
951
+ return False
952
+
953
+ if _sql_write_conn is None:
954
+ _sql_write_conn = _get_sql_conn()
955
+
956
+ statement_list = list()
957
+ statement = ""
958
+ sql_values = ""
959
+ ra = df.loc[:, ["ObjectPropertyId", "SensorStateId"]].to_dict(
960
+ orient="records"
961
+ )
962
+ ctr = 0
963
+
964
+ while ctr < len(ra):
965
+ statement = create_table_statement + insert_statement
966
+
967
+ # Insert up to 1,000 rows, or until there are no more records.
968
+ sql_values = ""
969
+ for idx in range(max_rows):
970
+ if sql_values:
971
+ sql_values += ","
972
+
973
+ r = ra[ctr]
974
+ sql_values += (
975
+ f"('{r['ObjectPropertyId']}',"
976
+ f"'{r['SensorStateId']}')"
977
+ )
978
+
979
+ ctr += 1
980
+ if ctr >= len(ra):
981
+ break
982
+
983
+ statement += sql_values + ";\n"
984
+ statement += update_statement
985
+ statement_list.append(statement)
986
+
987
+ cursor = _sql_write_conn.cursor()
988
+ retry = 0
989
+ ok = False
990
+
991
+ while not ok and retry < 3:
992
+ try:
993
+ cursor.execute(statement)
994
+ logger.info(f"{cursor.rowcount} records updated. ")
995
+ ok = True
996
+ cursor.commit()
997
+ except Exception as execute_exc:
998
+ logger.exception(execute_exc)
999
+ retry += 1
1000
+ time.sleep(0.1)
1001
+
1002
+ if not ok:
1003
+ return False
1004
+
1005
+ return True
1006
+ except Exception as exc:
1007
+ logger.exception("Failed to update sensor states: %s", exc)
1008
+ return False
1009
+
1010
+
868
1011
  def amortise_across_days(raw_df: pandas.DataFrame, start_date_col: str, end_date_col: str, value_col: str,
869
1012
  amortise_method: AMORTISATION_METHOD = "Exclusive"):
870
1013
  """Amortise data across days in period.
@@ -1375,4 +1518,4 @@ def send_reading_summary(api_inputs: ApiInputs, data_frame: pandas.DataFrame):
1375
1518
  error_handlers.post_errors(api_inputs=api_inputs, errors=error_df,
1376
1519
  error_type='ReadingSummariesTimeout', process_status='ActionRequired')
1377
1520
  else:
1378
- logger.error(f"Error on chunk {i // CHUNK_SIZE + 1} after {retries} attempts.")
1521
+ logger.error(f"Error on chunk {i // CHUNK_SIZE + 1} after {retries} attempts.")