switch-api 0.6.17__tar.gz → 0.6.18__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.17 → switch_api-0.6.18}/HISTORY.md +13 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/PKG-INFO +37 -6
- switch_api-0.6.18/pyproject.toml +6 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/setup.py +2 -2
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/__init__.py +4 -2
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/realtime/__init__.py +2 -1
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/realtime/_mqtt.py +27 -8
- switch_api-0.6.18/switch_api/realtime/_redis.py +429 -0
- switch_api-0.6.18/switch_api/realtime/realtime.py +397 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/PKG-INFO +38 -7
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/SOURCES.txt +7 -1
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/requires.txt +1 -0
- switch_api-0.6.18/tests/test_dataset.py +7 -0
- switch_api-0.6.18/tests/test_error_handlers.py +196 -0
- switch_api-0.6.18/tests/test_initialize.py +5 -0
- switch_api-0.6.18/tests/test_integration.py +37 -0
- switch_api-0.6.18/tests/test_pipeline.py +6 -0
- switch_api-0.6.17/pyproject.toml +0 -6
- switch_api-0.6.17/switch_api/realtime/realtime.py +0 -235
- {switch_api-0.6.17 → switch_api-0.6.18}/AUTHORS.rst +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/LICENCE +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/MANIFEST.in +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/README.md +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/setup.cfg +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_authentication.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_credentials_store/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_credentials_store/_credentials_store.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_msal/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_msal/_custom_application.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_commands.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_compute.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_iotservice.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_models.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_utils.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_guide/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_guide/main.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_guide/processor.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_constants.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_marketplace.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_mqtt.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_platform.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_utils.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/analytics/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/analytics/analytics.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/cache/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/cache/cache.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/_constants.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/_enums.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/_mqtt.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/controls.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/dataset/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/dataset/dataset.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/email/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/email/email_sender.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/error_handlers/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/error_handlers/error_handlers.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/extensions.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/field_meta.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/helpers.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/pipeline.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/initialize.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/_utils.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/helpers.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/integration.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/automation.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/definitions.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/pipeline.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/platform_insights/__init__.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/platform_insights/platform_insights.py +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/dependency_links.txt +0 -0
- {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# History
|
|
2
2
|
|
|
3
|
+
#0.6.18
|
|
4
|
+
### Changed
|
|
5
|
+
In the `realtime` module:
|
|
6
|
+
- Updated `submit_realtime_updates()` to use the passed `timeout` value when waiting for a retained MQTT message.
|
|
7
|
+
- Added Redis-backed optimistic concurrency support for realtime dataset updates using WATCH/MULTI/EXEC.
|
|
8
|
+
- Added `set_realtime_redis_config()` to configure Redis as the authoritative state store before publishing updates to MQTT.
|
|
9
|
+
- Added `redis` package dependency for Redis-backed realtime state updates.
|
|
10
|
+
- Added a maximum of 3 retained-message subscription attempts for connection or subscribe failures in the MQTT fallback path.
|
|
11
|
+
- Preserved new-topic behavior: when no retained message exists and Redis is not configured, the provided dataframe is used and published as the initial retained dataset.
|
|
12
|
+
- Updated `subscribe_with_retained_sync()` to apply the passed timeout to the MQTT connection, check subscribe failures, and log when no retained topics are received within the wait period.
|
|
13
|
+
- Added Redis timeout retry handling with a default maximum of 3 attempts for Redis realtime update operations.
|
|
14
|
+
|
|
15
|
+
|
|
3
16
|
#0.6.17
|
|
4
17
|
### Changed
|
|
5
18
|
In the `integration` module:
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: switch_api
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.18
|
|
4
4
|
Summary: A complete package for data ingestion into the Switch Automation Platform.
|
|
5
|
-
Home-page: UNKNOWN
|
|
6
5
|
Author: Switch Automation Pty Ltd.
|
|
7
6
|
License: MIT License
|
|
8
|
-
Platform: UNKNOWN
|
|
9
7
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
11
9
|
Classifier: Intended Audience :: Other Audience
|
|
@@ -17,6 +15,28 @@ Requires-Python: >=3.8.0
|
|
|
17
15
|
Description-Content-Type: text/markdown
|
|
18
16
|
License-File: LICENCE
|
|
19
17
|
License-File: AUTHORS.rst
|
|
18
|
+
Requires-Dist: pandas==1.5.3
|
|
19
|
+
Requires-Dist: requests
|
|
20
|
+
Requires-Dist: azure-storage-blob
|
|
21
|
+
Requires-Dist: pandera[io]==0.7.1
|
|
22
|
+
Requires-Dist: azure-servicebus
|
|
23
|
+
Requires-Dist: msal>=1.11.0
|
|
24
|
+
Requires-Dist: paho-mqtt==1.6.1
|
|
25
|
+
Requires-Dist: uvicorn==0.22.0
|
|
26
|
+
Requires-Dist: fastapi==0.98.0
|
|
27
|
+
Requires-Dist: pyodbc==4.0.39
|
|
28
|
+
Requires-Dist: cachetools==5.5.0
|
|
29
|
+
Requires-Dist: numexpr==2.10.2
|
|
30
|
+
Requires-Dist: redis<6,>=4.5.5
|
|
31
|
+
Dynamic: author
|
|
32
|
+
Dynamic: classifier
|
|
33
|
+
Dynamic: description
|
|
34
|
+
Dynamic: description-content-type
|
|
35
|
+
Dynamic: license
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
Dynamic: requires-dist
|
|
38
|
+
Dynamic: requires-python
|
|
39
|
+
Dynamic: summary
|
|
20
40
|
|
|
21
41
|
# Switch Automation library for Python
|
|
22
42
|
This is a package for data ingestion into the Switch Automation software platform.
|
|
@@ -38,6 +58,19 @@ pip install switch_api
|
|
|
38
58
|
|
|
39
59
|
# History
|
|
40
60
|
|
|
61
|
+
#0.6.18
|
|
62
|
+
### Changed
|
|
63
|
+
In the `realtime` module:
|
|
64
|
+
- Updated `submit_realtime_updates()` to use the passed `timeout` value when waiting for a retained MQTT message.
|
|
65
|
+
- Added Redis-backed optimistic concurrency support for realtime dataset updates using WATCH/MULTI/EXEC.
|
|
66
|
+
- Added `set_realtime_redis_config()` to configure Redis as the authoritative state store before publishing updates to MQTT.
|
|
67
|
+
- Added `redis` package dependency for Redis-backed realtime state updates.
|
|
68
|
+
- Added a maximum of 3 retained-message subscription attempts for connection or subscribe failures in the MQTT fallback path.
|
|
69
|
+
- Preserved new-topic behavior: when no retained message exists and Redis is not configured, the provided dataframe is used and published as the initial retained dataset.
|
|
70
|
+
- Updated `subscribe_with_retained_sync()` to apply the passed timeout to the MQTT connection, check subscribe failures, and log when no retained topics are received within the wait period.
|
|
71
|
+
- Added Redis timeout retry handling with a default maximum of 3 attempts for Redis realtime update operations.
|
|
72
|
+
|
|
73
|
+
|
|
41
74
|
#0.6.17
|
|
42
75
|
### Changed
|
|
43
76
|
In the `integration` module:
|
|
@@ -1196,5 +1229,3 @@ Removed the `name_as_filename` and `treat_as_timeseries` parameter from the foll
|
|
|
1196
1229
|
- `switch.integration.replace_data()`
|
|
1197
1230
|
- `switch.integration.append_data()`
|
|
1198
1231
|
- `switch.integration.upload_data()`
|
|
1199
|
-
|
|
1200
|
-
|
|
@@ -16,11 +16,11 @@ 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.18",
|
|
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',
|
|
23
|
-
'numexpr==2.10.2'],
|
|
23
|
+
'numexpr==2.10.2', 'redis>=4.5.5,<6'],
|
|
24
24
|
python_requires=">=3.8.0",
|
|
25
25
|
classifiers=[
|
|
26
26
|
'Development Status :: 2 - Pre-Alpha',
|
|
@@ -10,7 +10,8 @@ Data Ingestion into the Switch Automation
|
|
|
10
10
|
Complete package for ingestion data into the Switch Automation Platform.
|
|
11
11
|
"""
|
|
12
12
|
__all__ = ['dataset', 'analytics', 'pipeline', 'error_handlers', 'integration',
|
|
13
|
-
'initialize', 'cache', 'extensions', 'email', 'controls', 'realtime', '_compute', 'generate_filepath',
|
|
13
|
+
'initialize', 'cache', 'extensions', 'email', 'controls', 'realtime', '_compute', 'generate_filepath',
|
|
14
|
+
'set_mqtt_broker_config', 'set_realtime_redis_config']
|
|
14
15
|
|
|
15
16
|
from .initialize import initialize
|
|
16
17
|
from . import dataset
|
|
@@ -27,6 +28,7 @@ from . import realtime
|
|
|
27
28
|
from . import _compute
|
|
28
29
|
from ._utils._utils import generate_filepath
|
|
29
30
|
from ._utils._mqtt import set_mqtt_broker_config
|
|
31
|
+
from .realtime import set_realtime_redis_config
|
|
30
32
|
from . import _guide
|
|
31
33
|
|
|
32
34
|
|
|
@@ -42,4 +44,4 @@ from . import _guide
|
|
|
42
44
|
# log.addHandler(_ch)
|
|
43
45
|
# https://dev.to/joaomcteixeira/setting-up-python-logging-for-a-library-app-6ml
|
|
44
46
|
|
|
45
|
-
__version__ = "0.6.
|
|
47
|
+
__version__ = "0.6.18"
|
|
@@ -8,5 +8,6 @@ A module for sending control request of sensors.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from .realtime import submit_realtime_updates
|
|
11
|
+
from ._redis import set_realtime_redis_config
|
|
11
12
|
|
|
12
|
-
__all__ = ['submit_realtime_updates']
|
|
13
|
+
__all__ = ['submit_realtime_updates', 'set_realtime_redis_config']
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
A module containing the SwithRealtime class and associated methods referenced by the controls module in the package. This
|
|
8
8
|
module is not directly referenced by end users.
|
|
9
9
|
"""
|
|
10
|
-
import json
|
|
11
10
|
import logging
|
|
12
11
|
import sys
|
|
13
12
|
import threading
|
|
@@ -109,8 +108,6 @@ class SwitchRealtime:
|
|
|
109
108
|
if topics is None:
|
|
110
109
|
raise ValueError("topics cannot be None")
|
|
111
110
|
|
|
112
|
-
self.connect()
|
|
113
|
-
|
|
114
111
|
topics = list(topics)
|
|
115
112
|
|
|
116
113
|
if not topics:
|
|
@@ -122,6 +119,11 @@ class SwitchRealtime:
|
|
|
122
119
|
if not isinstance(timeout, int) or timeout <= 0:
|
|
123
120
|
raise ValueError("timeout must be a positive integer")
|
|
124
121
|
|
|
122
|
+
if not self.connect(timeout=timeout):
|
|
123
|
+
raise TimeoutError(
|
|
124
|
+
f"Could not connect to MQTT Broker within {timeout} seconds."
|
|
125
|
+
)
|
|
126
|
+
|
|
125
127
|
# --- Initialize tracking ---
|
|
126
128
|
self._expected_topics = set(topics)
|
|
127
129
|
self._received_retained = set()
|
|
@@ -129,19 +131,32 @@ class SwitchRealtime:
|
|
|
129
131
|
|
|
130
132
|
# --- Subscribe ---
|
|
131
133
|
for topic in topics:
|
|
132
|
-
self.client.subscribe(topic)
|
|
134
|
+
result, _ = self.client.subscribe(topic)
|
|
135
|
+
if result != mqtt.MQTT_ERR_SUCCESS:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"Failed to subscribe to {topic}. MQTT result code: {result}"
|
|
138
|
+
)
|
|
133
139
|
logger.info(f"Subscribed to = {topic}")
|
|
134
140
|
|
|
135
141
|
# --- Wait until all retained received OR timeout ---
|
|
136
|
-
self._retained_event.wait(timeout=timeout)
|
|
142
|
+
received_all = self._retained_event.wait(timeout=timeout)
|
|
137
143
|
|
|
138
144
|
# --- Return only retained dataset ---
|
|
139
|
-
|
|
145
|
+
retained_dataset = {
|
|
140
146
|
topic: self.topic_state[topic]["payload"]
|
|
141
147
|
for topic in self._received_retained
|
|
142
148
|
if topic in self.topic_state
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
if not received_all:
|
|
152
|
+
missing_topics = self._expected_topics - self._received_retained
|
|
153
|
+
logger.info(
|
|
154
|
+
f"No retained messages received within {timeout} seconds "
|
|
155
|
+
f"for topics: {sorted(missing_topics)}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return retained_dataset
|
|
159
|
+
|
|
145
160
|
|
|
146
161
|
def publish(self, topic: str, payload: str, qos: int = 1, retain: bool = True):
|
|
147
162
|
"""
|
|
@@ -151,7 +166,11 @@ class SwitchRealtime:
|
|
|
151
166
|
result = self.client.publish(topic, payload, qos=qos, retain=retain)
|
|
152
167
|
|
|
153
168
|
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
|
154
|
-
|
|
169
|
+
message = f"Failed to publish to {topic}. MQTT result code: {result.rc}"
|
|
170
|
+
logger.error(message)
|
|
171
|
+
raise RuntimeError(message)
|
|
172
|
+
|
|
173
|
+
return result
|
|
155
174
|
|
|
156
175
|
|
|
157
176
|
|
|
@@ -202,4 +221,4 @@ class SwitchRealtime:
|
|
|
202
221
|
self._retained_event.set()
|
|
203
222
|
|
|
204
223
|
else:
|
|
205
|
-
logger.info(f'MQTT Message - {topic} : {payload}')
|
|
224
|
+
logger.info(f'MQTT Message - {topic} : {payload}')
|
|
@@ -0,0 +1,429 @@
|
|
|
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
|
+
Redis state helpers for concurrency-safe realtime dataset updates.
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
# -----------------------------------------------------------------------------
|
|
16
|
+
# Logging Setup
|
|
17
|
+
# -----------------------------------------------------------------------------
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
logger.setLevel(logging.DEBUG)
|
|
20
|
+
|
|
21
|
+
if not logger.handlers:
|
|
22
|
+
console_handler = logging.StreamHandler(stream=sys.stdout)
|
|
23
|
+
console_handler.setLevel(logging.INFO)
|
|
24
|
+
|
|
25
|
+
formatter = logging.Formatter(
|
|
26
|
+
"%(asctime)s %(name)s.%(funcName)s %(levelname)s: %(message)s",
|
|
27
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
28
|
+
)
|
|
29
|
+
console_handler.setFormatter(formatter)
|
|
30
|
+
logger.addHandler(console_handler)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
global _redis_config
|
|
34
|
+
global _redis_client
|
|
35
|
+
|
|
36
|
+
_redis_config = {
|
|
37
|
+
"host": "",
|
|
38
|
+
"port": 6380,
|
|
39
|
+
"username": None,
|
|
40
|
+
"password": "",
|
|
41
|
+
"ssl": True,
|
|
42
|
+
"db": 0,
|
|
43
|
+
"key_prefix": "mqtt-state",
|
|
44
|
+
"max_retries": 10,
|
|
45
|
+
"timeout_max_retries": 3,
|
|
46
|
+
"retry_min_delay": 0.01,
|
|
47
|
+
"retry_max_delay": 0.05,
|
|
48
|
+
"socket_timeout": None,
|
|
49
|
+
"socket_connect_timeout": None,
|
|
50
|
+
}
|
|
51
|
+
_redis_client = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _log_json_value(label: str, value):
|
|
55
|
+
if value is None:
|
|
56
|
+
logger.info(f"{label}: <None>")
|
|
57
|
+
else:
|
|
58
|
+
logger.info(f"{label}: {value}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_redis_modules():
|
|
62
|
+
try:
|
|
63
|
+
import redis
|
|
64
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
65
|
+
from redis.exceptions import WatchError
|
|
66
|
+
except ImportError as e:
|
|
67
|
+
raise ImportError(
|
|
68
|
+
"Redis realtime state support requires the 'redis' package."
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
return redis, WatchError, RedisTimeoutError
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_redis_timeout_error(error: Exception):
|
|
75
|
+
_, _, RedisTimeoutError = _get_redis_modules()
|
|
76
|
+
return isinstance(error, (RedisTimeoutError, TimeoutError))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reset_redis_client():
|
|
80
|
+
global _redis_client
|
|
81
|
+
|
|
82
|
+
if _redis_client is not None:
|
|
83
|
+
try:
|
|
84
|
+
_redis_client.close()
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(f"Error closing Redis realtime client after timeout: {e}")
|
|
87
|
+
|
|
88
|
+
_redis_client = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_realtime_redis_config(
|
|
92
|
+
host: str,
|
|
93
|
+
password: str = "",
|
|
94
|
+
port: int = 6380,
|
|
95
|
+
username: str = None,
|
|
96
|
+
ssl: bool = True,
|
|
97
|
+
db: int = 0,
|
|
98
|
+
key_prefix: str = "mqtt-state",
|
|
99
|
+
max_retries: int = 10,
|
|
100
|
+
timeout_max_retries: int = 3,
|
|
101
|
+
retry_min_delay: float = 0.01,
|
|
102
|
+
retry_max_delay: float = 0.05,
|
|
103
|
+
socket_timeout: int = None,
|
|
104
|
+
socket_connect_timeout: int = None,
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
Configure Redis state storage for realtime MQTT dataset updates.
|
|
108
|
+
|
|
109
|
+
This method enables Redis-backed concurrency protection for
|
|
110
|
+
`submit_realtime_updates()`.
|
|
111
|
+
|
|
112
|
+
When Redis is configured, `submit_realtime_updates()` treats Redis as the
|
|
113
|
+
authoritative dataset store. Dataset updates are applied using Redis
|
|
114
|
+
optimistic concurrency with WATCH/MULTI/EXEC, then the final serialized
|
|
115
|
+
dataset is published back to MQTT.
|
|
116
|
+
|
|
117
|
+
When Redis is not configured, `submit_realtime_updates()` falls back to
|
|
118
|
+
reading the retained MQTT message before publishing the updated dataset.
|
|
119
|
+
|
|
120
|
+
Redis Key Format
|
|
121
|
+
----------------
|
|
122
|
+
A single Redis key is constructed in the format:
|
|
123
|
+
|
|
124
|
+
{key_prefix}:{topic}
|
|
125
|
+
|
|
126
|
+
The default key prefix is:
|
|
127
|
+
|
|
128
|
+
mqtt-state
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
If key_prefix = "mqtt-state"
|
|
132
|
+
and topic = "Realtime/123/temperature"
|
|
133
|
+
|
|
134
|
+
The resulting Redis key will be:
|
|
135
|
+
mqtt-state:Realtime/123/temperature
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
host : str
|
|
140
|
+
Redis host name.
|
|
141
|
+
For Azure Redis, this is typically the cache host name, such as:
|
|
142
|
+
your-cache.redis.cache.windows.net
|
|
143
|
+
Must be a non-empty string.
|
|
144
|
+
|
|
145
|
+
password : str, optional
|
|
146
|
+
Redis access key or password.
|
|
147
|
+
Defaults to an empty string.
|
|
148
|
+
|
|
149
|
+
port : int, optional
|
|
150
|
+
Redis port.
|
|
151
|
+
Defaults to 6380, which is the SSL port commonly used by Azure Redis.
|
|
152
|
+
Must be greater than 0.
|
|
153
|
+
|
|
154
|
+
username : str, optional
|
|
155
|
+
Redis username.
|
|
156
|
+
Defaults to None.
|
|
157
|
+
|
|
158
|
+
ssl : bool, optional
|
|
159
|
+
Indicates whether SSL should be used for the Redis connection.
|
|
160
|
+
Defaults to True.
|
|
161
|
+
|
|
162
|
+
db : int, optional
|
|
163
|
+
Redis database number.
|
|
164
|
+
Defaults to 0.
|
|
165
|
+
Must be zero or a positive integer.
|
|
166
|
+
|
|
167
|
+
key_prefix : str, optional
|
|
168
|
+
Prefix used when constructing Redis dataset keys.
|
|
169
|
+
Defaults to "mqtt-state".
|
|
170
|
+
Must be a non-empty string.
|
|
171
|
+
|
|
172
|
+
max_retries : int, optional
|
|
173
|
+
Maximum number of optimistic concurrency retry attempts when Redis
|
|
174
|
+
detects that another service updated the dataset during the current
|
|
175
|
+
update attempt.
|
|
176
|
+
Defaults to 10.
|
|
177
|
+
Must be greater than 0.
|
|
178
|
+
|
|
179
|
+
timeout_max_retries : int, optional
|
|
180
|
+
Maximum number of Redis timeout retry attempts when a Redis operation
|
|
181
|
+
exceeds the configured socket timeout.
|
|
182
|
+
Defaults to 3.
|
|
183
|
+
Must be greater than 0.
|
|
184
|
+
|
|
185
|
+
retry_min_delay : float, optional
|
|
186
|
+
Minimum randomized delay, in seconds, before retrying after a Redis
|
|
187
|
+
WATCH conflict or Redis timeout.
|
|
188
|
+
Defaults to 0.01.
|
|
189
|
+
|
|
190
|
+
retry_max_delay : float, optional
|
|
191
|
+
Maximum randomized delay, in seconds, before retrying after a Redis
|
|
192
|
+
WATCH conflict or Redis timeout.
|
|
193
|
+
Defaults to 0.05.
|
|
194
|
+
|
|
195
|
+
socket_timeout : int, optional
|
|
196
|
+
Redis socket read timeout in seconds.
|
|
197
|
+
Defaults to None.
|
|
198
|
+
|
|
199
|
+
socket_connect_timeout : int, optional
|
|
200
|
+
Redis socket connection timeout in seconds.
|
|
201
|
+
Defaults to None.
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
None
|
|
206
|
+
The Redis configuration is stored globally for realtime dataset
|
|
207
|
+
updates.
|
|
208
|
+
|
|
209
|
+
Raises
|
|
210
|
+
------
|
|
211
|
+
ValueError
|
|
212
|
+
If required Redis configuration values are invalid.
|
|
213
|
+
"""
|
|
214
|
+
global _redis_config
|
|
215
|
+
global _redis_client
|
|
216
|
+
|
|
217
|
+
if not isinstance(host, str) or not host.strip():
|
|
218
|
+
raise ValueError("host must be a non-empty string.")
|
|
219
|
+
|
|
220
|
+
if not isinstance(port, int) or port < 1:
|
|
221
|
+
raise ValueError("port must be a positive integer.")
|
|
222
|
+
|
|
223
|
+
if not isinstance(db, int) or db < 0:
|
|
224
|
+
raise ValueError("db must be zero or a positive integer.")
|
|
225
|
+
|
|
226
|
+
if not isinstance(key_prefix, str) or not key_prefix.strip():
|
|
227
|
+
raise ValueError("key_prefix must be a non-empty string.")
|
|
228
|
+
|
|
229
|
+
if not isinstance(max_retries, int) or max_retries < 1:
|
|
230
|
+
raise ValueError("max_retries must be a positive integer.")
|
|
231
|
+
|
|
232
|
+
if not isinstance(timeout_max_retries, int) or timeout_max_retries < 1:
|
|
233
|
+
raise ValueError("timeout_max_retries must be a positive integer.")
|
|
234
|
+
|
|
235
|
+
if not isinstance(retry_min_delay, (int, float)) or not isinstance(retry_max_delay, (int, float)):
|
|
236
|
+
raise ValueError("retry delays must be numeric.")
|
|
237
|
+
|
|
238
|
+
if retry_min_delay < 0 or retry_max_delay < 0:
|
|
239
|
+
raise ValueError("retry delays must be zero or positive.")
|
|
240
|
+
|
|
241
|
+
if retry_min_delay > retry_max_delay:
|
|
242
|
+
raise ValueError("retry_min_delay cannot be greater than retry_max_delay.")
|
|
243
|
+
|
|
244
|
+
_redis_config = {
|
|
245
|
+
"host": host,
|
|
246
|
+
"port": port,
|
|
247
|
+
"username": username,
|
|
248
|
+
"password": password,
|
|
249
|
+
"ssl": ssl,
|
|
250
|
+
"db": db,
|
|
251
|
+
"key_prefix": key_prefix,
|
|
252
|
+
"max_retries": max_retries,
|
|
253
|
+
"timeout_max_retries": timeout_max_retries,
|
|
254
|
+
"retry_min_delay": retry_min_delay,
|
|
255
|
+
"retry_max_delay": retry_max_delay,
|
|
256
|
+
"socket_timeout": socket_timeout,
|
|
257
|
+
"socket_connect_timeout": socket_connect_timeout,
|
|
258
|
+
}
|
|
259
|
+
_redis_client = None
|
|
260
|
+
logger.info(
|
|
261
|
+
f"Configured Redis realtime state. host={host}, port={port}, "
|
|
262
|
+
f"ssl={ssl}, db={db}, key_prefix={key_prefix}, "
|
|
263
|
+
f"max_retries={max_retries}, timeout_max_retries={timeout_max_retries}, "
|
|
264
|
+
f"retry_min_delay={retry_min_delay}, retry_max_delay={retry_max_delay}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def get_realtime_redis_config():
|
|
269
|
+
"""
|
|
270
|
+
Return the current Redis realtime state configuration.
|
|
271
|
+
"""
|
|
272
|
+
return dict(_redis_config)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def is_realtime_redis_configured():
|
|
276
|
+
"""
|
|
277
|
+
Return True when Redis realtime state has been configured.
|
|
278
|
+
"""
|
|
279
|
+
return bool(_redis_config["host"])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_realtime_redis_client():
|
|
283
|
+
"""
|
|
284
|
+
Return a cached redis-py client using the configured realtime state settings.
|
|
285
|
+
"""
|
|
286
|
+
global _redis_client
|
|
287
|
+
|
|
288
|
+
if not is_realtime_redis_configured():
|
|
289
|
+
raise ValueError("Redis realtime state is not configured.")
|
|
290
|
+
|
|
291
|
+
if _redis_client is None:
|
|
292
|
+
redis, _, _ = _get_redis_modules()
|
|
293
|
+
|
|
294
|
+
logger.info(
|
|
295
|
+
f"Creating Redis realtime client. host={_redis_config['host']}, "
|
|
296
|
+
f"port={_redis_config['port']}, ssl={_redis_config['ssl']}, "
|
|
297
|
+
f"db={_redis_config['db']}"
|
|
298
|
+
)
|
|
299
|
+
_redis_client = redis.Redis(
|
|
300
|
+
host=_redis_config["host"],
|
|
301
|
+
port=_redis_config["port"],
|
|
302
|
+
username=_redis_config["username"],
|
|
303
|
+
password=_redis_config["password"],
|
|
304
|
+
ssl=_redis_config["ssl"],
|
|
305
|
+
db=_redis_config["db"],
|
|
306
|
+
decode_responses=True,
|
|
307
|
+
socket_timeout=_redis_config["socket_timeout"],
|
|
308
|
+
socket_connect_timeout=_redis_config["socket_connect_timeout"],
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return _redis_client
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def realtime_state_key(topic: str):
|
|
315
|
+
"""
|
|
316
|
+
Build the Redis key used to store the authoritative dataset for a topic.
|
|
317
|
+
"""
|
|
318
|
+
if not isinstance(topic, str) or not topic.strip():
|
|
319
|
+
raise ValueError("topic must be a non-empty string.")
|
|
320
|
+
|
|
321
|
+
return f"{_redis_config['key_prefix']}:{topic}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _update_realtime_dataset_once(topic: str, update_func: Callable[[str], str]):
|
|
325
|
+
"""
|
|
326
|
+
Atomically update the Redis dataset for a topic using WATCH/MULTI/EXEC.
|
|
327
|
+
"""
|
|
328
|
+
_, WatchError, _ = _get_redis_modules()
|
|
329
|
+
|
|
330
|
+
client = get_realtime_redis_client()
|
|
331
|
+
key = realtime_state_key(topic)
|
|
332
|
+
max_retries = _redis_config["max_retries"]
|
|
333
|
+
logger.info(
|
|
334
|
+
f"Starting Redis realtime dataset update. topic={topic}, key={key}, "
|
|
335
|
+
f"max_retries={max_retries}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
for attempt in range(1, max_retries + 1):
|
|
339
|
+
try:
|
|
340
|
+
with client.pipeline() as pipe:
|
|
341
|
+
logger.info(
|
|
342
|
+
f"Watching Redis realtime key={key} "
|
|
343
|
+
f"(attempt {attempt}/{max_retries})"
|
|
344
|
+
)
|
|
345
|
+
pipe.watch(key)
|
|
346
|
+
existing_json = pipe.get(key)
|
|
347
|
+
_log_json_value(f"Redis current cache for key={key}", existing_json)
|
|
348
|
+
current_json = existing_json if existing_json else "[]"
|
|
349
|
+
updated_json = update_func(current_json)
|
|
350
|
+
if not isinstance(updated_json, str):
|
|
351
|
+
raise ValueError("update_func must return a JSON string.")
|
|
352
|
+
|
|
353
|
+
_log_json_value(f"Redis value to set for key={key}", updated_json)
|
|
354
|
+
pipe.multi()
|
|
355
|
+
pipe.set(key, updated_json)
|
|
356
|
+
pipe.execute()
|
|
357
|
+
|
|
358
|
+
latest_json = client.get(key)
|
|
359
|
+
logger.info(f"Updated Redis realtime state for key={key}")
|
|
360
|
+
_log_json_value(f"Redis cache after update for key={key}", latest_json)
|
|
361
|
+
return updated_json
|
|
362
|
+
|
|
363
|
+
except WatchError:
|
|
364
|
+
if attempt >= max_retries:
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
delay = random.uniform(
|
|
368
|
+
_redis_config["retry_min_delay"],
|
|
369
|
+
_redis_config["retry_max_delay"],
|
|
370
|
+
)
|
|
371
|
+
logger.info(
|
|
372
|
+
f"Redis realtime state changed during update for {key}. "
|
|
373
|
+
f"Retrying attempt {attempt + 1}/{max_retries} after {delay:.3f}s."
|
|
374
|
+
)
|
|
375
|
+
time.sleep(delay)
|
|
376
|
+
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
f"Failed to update Redis realtime state after {max_retries} retries "
|
|
379
|
+
f"for topic: {topic}"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def update_realtime_dataset(
|
|
384
|
+
topic: str,
|
|
385
|
+
update_func: Callable[[str], str],
|
|
386
|
+
):
|
|
387
|
+
"""
|
|
388
|
+
Retry Redis realtime updates when redis-py raises a timeout error.
|
|
389
|
+
"""
|
|
390
|
+
key = realtime_state_key(topic)
|
|
391
|
+
timeout_max_retries = _redis_config["timeout_max_retries"]
|
|
392
|
+
last_error = None
|
|
393
|
+
|
|
394
|
+
for attempt in range(1, timeout_max_retries + 1):
|
|
395
|
+
try:
|
|
396
|
+
logger.info(
|
|
397
|
+
f"Starting Redis timeout-protected update for key={key} "
|
|
398
|
+
f"(attempt {attempt}/{timeout_max_retries})"
|
|
399
|
+
)
|
|
400
|
+
return _update_realtime_dataset_once(topic=topic, update_func=update_func)
|
|
401
|
+
|
|
402
|
+
except Exception as e:
|
|
403
|
+
if not _is_redis_timeout_error(e):
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
last_error = e
|
|
407
|
+
logger.error(
|
|
408
|
+
f"Redis timeout during realtime update for key={key} "
|
|
409
|
+
f"(attempt {attempt}/{timeout_max_retries}): {e}"
|
|
410
|
+
)
|
|
411
|
+
_reset_redis_client()
|
|
412
|
+
|
|
413
|
+
if attempt >= timeout_max_retries:
|
|
414
|
+
break
|
|
415
|
+
|
|
416
|
+
delay = random.uniform(
|
|
417
|
+
_redis_config["retry_min_delay"],
|
|
418
|
+
_redis_config["retry_max_delay"],
|
|
419
|
+
)
|
|
420
|
+
logger.info(
|
|
421
|
+
f"Retrying Redis realtime update for key={key} after "
|
|
422
|
+
f"{delay:.3f}s due to timeout."
|
|
423
|
+
)
|
|
424
|
+
time.sleep(delay)
|
|
425
|
+
|
|
426
|
+
raise RuntimeError(
|
|
427
|
+
f"Redis realtime update timed out after {timeout_max_retries} attempts "
|
|
428
|
+
f"for topic: {topic}. Last error: {last_error}"
|
|
429
|
+
) from last_error
|