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.
- {switch_api-0.6.29 → switch_api-0.6.31}/HISTORY.md +34 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/PKG-INFO +35 -1
- {switch_api-0.6.29 → switch_api-0.6.31}/setup.py +1 -1
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/__init__.py +3 -2
- switch_api-0.6.31/switch_api/_utils/_sql.py +104 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_utils.py +91 -2
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/controls.py +18 -7
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/__init__.py +3 -3
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/helpers.py +144 -1
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/integration.py +296 -1
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/automation.py +12 -1
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/PKG-INFO +35 -1
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/SOURCES.txt +4 -1
- switch_api-0.6.31/tests/test_controls.py +136 -0
- switch_api-0.6.31/tests/test_timer_deployment.py +62 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/AUTHORS.rst +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/LICENCE +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/MANIFEST.in +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/README.md +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/pyproject.toml +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/setup.cfg +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_authentication.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_credentials_store/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_credentials_store/_credentials_store.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_msal/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_authentication/_msal/_custom_application.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_commands.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_compute.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_iotservice.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_models.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_compute/_utils.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_guide/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_guide/main.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_guide/processor.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_constants.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_marketplace.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_mqtt.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/_utils/_platform.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/analytics/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/analytics/analytics.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/cache/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/cache/cache.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/_constants.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/_enums.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/controls/_mqtt.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/dataset/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/dataset/dataset.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/email/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/email/email_sender.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/error_handlers/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/error_handlers/error_handlers.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/extensions.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/field_meta.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/helpers.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/extensions/pipeline.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/initialize.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/integration/_utils.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/definitions.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/pipeline/pipeline.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/platform_insights/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/platform_insights/platform_insights.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/__init__.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/_mqtt.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/_redis.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api/realtime/realtime.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/dependency_links.txt +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/requires.txt +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/switch_api.egg-info/top_level.txt +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_authentication.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_dataset.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_error_handlers.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_initialize.py +0 -0
- {switch_api-0.6.29 → switch_api-0.6.31}/tests/test_integration.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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',
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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.")
|