helin-edge-sdk 0.0.1__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.
- helin_edge_sdk-0.0.1/LICENSE +21 -0
- helin_edge_sdk-0.0.1/PKG-INFO +144 -0
- helin_edge_sdk-0.0.1/README.md +121 -0
- helin_edge_sdk-0.0.1/pyproject.toml +60 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/__init__.py +36 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/auth.py +183 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/client.py +47 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/compression.py +41 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/config.py +113 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/errors.py +21 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/handler.py +52 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/logger.py +12 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/py.typed +0 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/responses.py +58 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/topics.py +51 -0
- helin_edge_sdk-0.0.1/src/helin_edge_sdk/transport.py +162 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Helin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: helin-edge-sdk
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Receive and respond to Helin Platform edge-module-requests.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: iot,azure-iot-edge,edge,helin,helin platform,configuration
|
|
8
|
+
Author: Helin
|
|
9
|
+
Author-email: support@helindata.com
|
|
10
|
+
Requires-Python: >=3.11,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Communications
|
|
20
|
+
Requires-Dist: paho-mqtt (>=2.1.0,<3.0.0)
|
|
21
|
+
Project-URL: Homepage, https://www.helindata.com
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# helin-edge-sdk
|
|
25
|
+
Using this SDK in your module allows you to securely and reliably receive and respond to the Helin Platform's **edge-module-requests** API: `get_configuration`, `set_configuration`and `get_health` deployed on Nodes on the edge.
|
|
26
|
+
|
|
27
|
+
For more information see https://docs.helinplatform.com
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install helin-edge-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Implement the three methods and run the client:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from typing import Any
|
|
41
|
+
from helin_edge_sdk import EdgeModuleRequestsHandler, EdgeModuleClient, ConfigurationFailedError
|
|
42
|
+
|
|
43
|
+
class MyModule(EdgeModuleRequestsHandler):
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self.configuration = {"opcua_ip": "123.34.233.1", "opcua_port": 4840, "poll_interval_ms": 1000}
|
|
46
|
+
|
|
47
|
+
def on_get_configuration(self, payload: dict[str, Any]) -> Any:
|
|
48
|
+
return self.configuration
|
|
49
|
+
|
|
50
|
+
def on_set_configuration(self, payload: dict[str, Any]) -> Any:
|
|
51
|
+
cfg = payload.get("configuration")
|
|
52
|
+
if cfg is None:
|
|
53
|
+
raise ConfigurationFailedError("missing configuration", status_code=400,
|
|
54
|
+
current_configuration=self.configuration)
|
|
55
|
+
self.configuration = cfg
|
|
56
|
+
return {"configuration": cfg}
|
|
57
|
+
|
|
58
|
+
def on_get_health(self, payload: dict[str, Any]) -> Any:
|
|
59
|
+
return {"metrics": [{"name": "opcua_connected", "value": 1}]}
|
|
60
|
+
|
|
61
|
+
EdgeModuleClient.from_edge_environment(MyModule()).run() #Fetches all required env variables on your provisioned Node
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each callback receives the **decompressed** request payload and returns a
|
|
65
|
+
JSON-serializable value, which is wrapped as
|
|
66
|
+
```{"status": 200, "response": <value>}```
|
|
67
|
+
|
|
68
|
+
compressed, and published back to the caller.
|
|
69
|
+
|
|
70
|
+
Raise `ConfigurationFailedError` to return the configuration-failure envelope:
|
|
71
|
+
|
|
72
|
+
```{"status": <code>, "response": {"error": ..., "current_configuration": ...}}```.
|
|
73
|
+
|
|
74
|
+
## Status codes
|
|
75
|
+
|
|
76
|
+
| Handler outcome | Status |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Returns a value | 200 |
|
|
79
|
+
| Raises `ValueError` | 400 |
|
|
80
|
+
| Raises `ConnectionError` | 400 |
|
|
81
|
+
| Unknown method | 400 |
|
|
82
|
+
| Raises `FileNotFoundError` | 404 |
|
|
83
|
+
| Raises `ConfigurationFailedError` | its `status_code` (default 409) |
|
|
84
|
+
| Raises any other exception | 500 |
|
|
85
|
+
|
|
86
|
+
## Local testing
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# 1. start a plaintext broker
|
|
90
|
+
docker compose -f docker/docker-compose.test.yml up -d
|
|
91
|
+
|
|
92
|
+
# 2. run the module against it
|
|
93
|
+
python -m examples.run_local
|
|
94
|
+
|
|
95
|
+
# 3. invoke a method (simulates the Platform API)
|
|
96
|
+
python -m examples.publish_method get_health
|
|
97
|
+
python -m examples.publish_method set_configuration '{"configuration": {"opcua_ip": "10.0.0.5", "opcua_port": 4840}}'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Develop
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
poetry install
|
|
104
|
+
poetry run pytest -m "not integration" # unit tests, no broker
|
|
105
|
+
poetry run pytest # all tests (needs the broker above)
|
|
106
|
+
poetry run ruff check
|
|
107
|
+
poetry run ty check
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Architecture
|
|
111
|
+
|
|
112
|
+
```mermaid
|
|
113
|
+
flowchart TB
|
|
114
|
+
subgraph platform["Cloud"]
|
|
115
|
+
API["Platform API <br/> POST /api/v1/edge-module-requests/nodes/{node_id}/modules/{module_id} <br/> GET /api/v1/edge-module-requests/nodes/{node_id}/modules/{module_id} <br/> GET /api/v1/edge-module-requests/nodes/{node_id}/modules/{module_id}/health"]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
subgraph hub["Azure IoT Hub (Cloud)"]
|
|
119
|
+
IOTHUB
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
subgraph edge["Node — Edge runtime"]
|
|
123
|
+
EDGEHUB["edgeHub<br/>MQTT broker :8883 (TLS)"]
|
|
124
|
+
WL["Edge workload API<br/>unix:///…/workload.sock<br/>/sign · /trust-bundle"]
|
|
125
|
+
|
|
126
|
+
subgraph mod["Module using helin-edge-sdk"]
|
|
127
|
+
direction TB
|
|
128
|
+
CLIENT["EdgeModuleClient<br/>from_edge_environment()"]
|
|
129
|
+
LISTENER["MqttModuleListener<br/>(paho-mqtt transport)"]
|
|
130
|
+
HANDLER["EdgeModuleRequestsHandler<br/>(your on_* callbacks)"]
|
|
131
|
+
CLIENT --> LISTENER
|
|
132
|
+
LISTENER -- "dispatch(method, payload)" --> HANDLER
|
|
133
|
+
HANDLER -- "result / ConfigurationFailedError" --> LISTENER
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
API <== "edge-module-requests<br/>(gzip payload)" ==> IOTHUB
|
|
138
|
+
IOTHUB <== "cloud uplink" ==> EDGEHUB
|
|
139
|
+
|
|
140
|
+
EDGEHUB -- "SUB - (gzip payload)" --> LISTENER
|
|
141
|
+
LISTENER -- "PUB - (gzip payload)" --> EDGEHUB
|
|
142
|
+
|
|
143
|
+
LISTENER -- "SAS token + trust bundle" --> WL
|
|
144
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# helin-edge-sdk
|
|
2
|
+
Using this SDK in your module allows you to securely and reliably receive and respond to the Helin Platform's **edge-module-requests** API: `get_configuration`, `set_configuration`and `get_health` deployed on Nodes on the edge.
|
|
3
|
+
|
|
4
|
+
For more information see https://docs.helinplatform.com
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install helin-edge-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Implement the three methods and run the client:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from typing import Any
|
|
18
|
+
from helin_edge_sdk import EdgeModuleRequestsHandler, EdgeModuleClient, ConfigurationFailedError
|
|
19
|
+
|
|
20
|
+
class MyModule(EdgeModuleRequestsHandler):
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.configuration = {"opcua_ip": "123.34.233.1", "opcua_port": 4840, "poll_interval_ms": 1000}
|
|
23
|
+
|
|
24
|
+
def on_get_configuration(self, payload: dict[str, Any]) -> Any:
|
|
25
|
+
return self.configuration
|
|
26
|
+
|
|
27
|
+
def on_set_configuration(self, payload: dict[str, Any]) -> Any:
|
|
28
|
+
cfg = payload.get("configuration")
|
|
29
|
+
if cfg is None:
|
|
30
|
+
raise ConfigurationFailedError("missing configuration", status_code=400,
|
|
31
|
+
current_configuration=self.configuration)
|
|
32
|
+
self.configuration = cfg
|
|
33
|
+
return {"configuration": cfg}
|
|
34
|
+
|
|
35
|
+
def on_get_health(self, payload: dict[str, Any]) -> Any:
|
|
36
|
+
return {"metrics": [{"name": "opcua_connected", "value": 1}]}
|
|
37
|
+
|
|
38
|
+
EdgeModuleClient.from_edge_environment(MyModule()).run() #Fetches all required env variables on your provisioned Node
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Each callback receives the **decompressed** request payload and returns a
|
|
42
|
+
JSON-serializable value, which is wrapped as
|
|
43
|
+
```{"status": 200, "response": <value>}```
|
|
44
|
+
|
|
45
|
+
compressed, and published back to the caller.
|
|
46
|
+
|
|
47
|
+
Raise `ConfigurationFailedError` to return the configuration-failure envelope:
|
|
48
|
+
|
|
49
|
+
```{"status": <code>, "response": {"error": ..., "current_configuration": ...}}```.
|
|
50
|
+
|
|
51
|
+
## Status codes
|
|
52
|
+
|
|
53
|
+
| Handler outcome | Status |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| Returns a value | 200 |
|
|
56
|
+
| Raises `ValueError` | 400 |
|
|
57
|
+
| Raises `ConnectionError` | 400 |
|
|
58
|
+
| Unknown method | 400 |
|
|
59
|
+
| Raises `FileNotFoundError` | 404 |
|
|
60
|
+
| Raises `ConfigurationFailedError` | its `status_code` (default 409) |
|
|
61
|
+
| Raises any other exception | 500 |
|
|
62
|
+
|
|
63
|
+
## Local testing
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 1. start a plaintext broker
|
|
67
|
+
docker compose -f docker/docker-compose.test.yml up -d
|
|
68
|
+
|
|
69
|
+
# 2. run the module against it
|
|
70
|
+
python -m examples.run_local
|
|
71
|
+
|
|
72
|
+
# 3. invoke a method (simulates the Platform API)
|
|
73
|
+
python -m examples.publish_method get_health
|
|
74
|
+
python -m examples.publish_method set_configuration '{"configuration": {"opcua_ip": "10.0.0.5", "opcua_port": 4840}}'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Develop
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
poetry install
|
|
81
|
+
poetry run pytest -m "not integration" # unit tests, no broker
|
|
82
|
+
poetry run pytest # all tests (needs the broker above)
|
|
83
|
+
poetry run ruff check
|
|
84
|
+
poetry run ty check
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Architecture
|
|
88
|
+
|
|
89
|
+
```mermaid
|
|
90
|
+
flowchart TB
|
|
91
|
+
subgraph platform["Cloud"]
|
|
92
|
+
API["Platform API <br/> POST /api/v1/edge-module-requests/nodes/{node_id}/modules/{module_id} <br/> GET /api/v1/edge-module-requests/nodes/{node_id}/modules/{module_id} <br/> GET /api/v1/edge-module-requests/nodes/{node_id}/modules/{module_id}/health"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
subgraph hub["Azure IoT Hub (Cloud)"]
|
|
96
|
+
IOTHUB
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
subgraph edge["Node — Edge runtime"]
|
|
100
|
+
EDGEHUB["edgeHub<br/>MQTT broker :8883 (TLS)"]
|
|
101
|
+
WL["Edge workload API<br/>unix:///…/workload.sock<br/>/sign · /trust-bundle"]
|
|
102
|
+
|
|
103
|
+
subgraph mod["Module using helin-edge-sdk"]
|
|
104
|
+
direction TB
|
|
105
|
+
CLIENT["EdgeModuleClient<br/>from_edge_environment()"]
|
|
106
|
+
LISTENER["MqttModuleListener<br/>(paho-mqtt transport)"]
|
|
107
|
+
HANDLER["EdgeModuleRequestsHandler<br/>(your on_* callbacks)"]
|
|
108
|
+
CLIENT --> LISTENER
|
|
109
|
+
LISTENER -- "dispatch(method, payload)" --> HANDLER
|
|
110
|
+
HANDLER -- "result / ConfigurationFailedError" --> LISTENER
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
API <== "edge-module-requests<br/>(gzip payload)" ==> IOTHUB
|
|
115
|
+
IOTHUB <== "cloud uplink" ==> EDGEHUB
|
|
116
|
+
|
|
117
|
+
EDGEHUB -- "SUB - (gzip payload)" --> LISTENER
|
|
118
|
+
LISTENER -- "PUB - (gzip payload)" --> EDGEHUB
|
|
119
|
+
|
|
120
|
+
LISTENER -- "SAS token + trust bundle" --> WL
|
|
121
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "helin-edge-sdk"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Receive and respond to Helin Platform edge-module-requests."
|
|
5
|
+
authors = ["Helin <support@helindata.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
keywords = ["iot", "azure-iot-edge", "edge", "helin", "helin platform", "configuration"]
|
|
9
|
+
packages = [{ include = "helin_edge_sdk", from = "src" }]
|
|
10
|
+
exclude = ["platform-demo", "examples", "docker", "tests"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Topic :: Communications",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.poetry.urls]
|
|
22
|
+
Homepage = "https://www.helindata.com"
|
|
23
|
+
|
|
24
|
+
[tool.poetry.dependencies]
|
|
25
|
+
python = "^3.11"
|
|
26
|
+
paho-mqtt = "^2.1.0"
|
|
27
|
+
|
|
28
|
+
[tool.poetry.group.dev.dependencies]
|
|
29
|
+
pytest = "^9.0.0"
|
|
30
|
+
pytest-cov = ">7"
|
|
31
|
+
ruff = "^0.15.0"
|
|
32
|
+
ty = ">=0.0.49"
|
|
33
|
+
invoke = "^2.0.0"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["poetry-core>=1.0.0"]
|
|
37
|
+
build-backend = "poetry.core.masonry.api"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
src = ["src", "tests"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
45
|
+
|
|
46
|
+
[tool.ty.environment]
|
|
47
|
+
python-version = "3.11"
|
|
48
|
+
root = ["src", "."]
|
|
49
|
+
|
|
50
|
+
[tool.ty.src]
|
|
51
|
+
include = ["src", "tests", "examples"]
|
|
52
|
+
|
|
53
|
+
[tool.coverage.run]
|
|
54
|
+
relative_files = true
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
markers = [
|
|
59
|
+
"integration: tests that require a running MQTT broker (deselect with -m 'not integration')",
|
|
60
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""helin-edge-sdk: receive and respond to Helin Platform edge-module-requests on an edge module."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from helin_edge_sdk.client import EdgeModuleClient
|
|
6
|
+
from helin_edge_sdk.compression import compress_payload, decompress_payload
|
|
7
|
+
from helin_edge_sdk.config import ConnectionConfig
|
|
8
|
+
from helin_edge_sdk.errors import ConfigurationFailedError
|
|
9
|
+
from helin_edge_sdk.handler import (
|
|
10
|
+
GET_CONFIGURATION,
|
|
11
|
+
GET_HEALTH,
|
|
12
|
+
SET_CONFIGURATION,
|
|
13
|
+
SUPPORTED_METHODS,
|
|
14
|
+
EdgeModuleRequestsHandler,
|
|
15
|
+
)
|
|
16
|
+
from helin_edge_sdk.responses import ResponseStatus
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = version("helin-edge-sdk")
|
|
20
|
+
except PackageNotFoundError: # running from a source checkout without installed metadata
|
|
21
|
+
__version__ = "0.0.0+unknown"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"EdgeModuleClient",
|
|
25
|
+
"ConnectionConfig",
|
|
26
|
+
"EdgeModuleRequestsHandler",
|
|
27
|
+
"ConfigurationFailedError",
|
|
28
|
+
"ResponseStatus",
|
|
29
|
+
"compress_payload",
|
|
30
|
+
"decompress_payload",
|
|
31
|
+
"GET_CONFIGURATION",
|
|
32
|
+
"SET_CONFIGURATION",
|
|
33
|
+
"GET_HEALTH",
|
|
34
|
+
"SUPPORTED_METHODS",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Authentication primitives for connecting to edgeHub over MQTT.
|
|
2
|
+
|
|
3
|
+
Two ways to obtain the MQTT password (a SAS token):
|
|
4
|
+
|
|
5
|
+
* :class:`EdgeWorkloadSigner` — production. The module holds no key; it asks the
|
|
6
|
+
IoT Edge workload API (the ``IOTEDGE_WORKLOADURI`` unix socket) to sign the
|
|
7
|
+
token and to hand over the trust-bundle CA so TLS to edgeHub can be verified.
|
|
8
|
+
* :class:`LocalKeySigner` — local/testing. Signs with a shared access key taken
|
|
9
|
+
from a connection string, using HMAC-SHA256 locally.
|
|
10
|
+
|
|
11
|
+
The wire formats (SAS token string, sign string, workload endpoints) match the
|
|
12
|
+
Microsoft ``azure-iot-device`` SDK, so edgeHub accepts our connection unchanged.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import http.client
|
|
19
|
+
import json
|
|
20
|
+
import socket
|
|
21
|
+
import time
|
|
22
|
+
import urllib.parse
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
DEFAULT_SAS_TTL_SECONDS = 3600
|
|
27
|
+
_WORKLOAD_TIMEOUT_SECONDS = 30
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Signer(Protocol):
|
|
31
|
+
def sign(self, data: str) -> str:
|
|
32
|
+
"""Return the base64-encoded HMAC-SHA256 signature of ``data``."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocalKeySigner:
|
|
37
|
+
def __init__(self, shared_access_key_b64: str) -> None:
|
|
38
|
+
self._key = base64.b64decode(shared_access_key_b64)
|
|
39
|
+
|
|
40
|
+
def sign(self, data: str) -> str:
|
|
41
|
+
digest = hmac.new(self._key, data.encode("utf-8"), hashlib.sha256).digest()
|
|
42
|
+
return base64.b64encode(digest).decode("ascii")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IoTEdgeError(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EdgeWorkloadSigner:
|
|
50
|
+
"""Signs SAS tokens and fetches the trust bundle via the IoT Edge workload API."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, module_id: str, generation_id: str, workload_uri: str, api_version: str
|
|
54
|
+
) -> None:
|
|
55
|
+
self.module_id = urllib.parse.quote(module_id, safe="")
|
|
56
|
+
self.generation_id = generation_id
|
|
57
|
+
self.api_version = api_version
|
|
58
|
+
self.workload_uri = workload_uri
|
|
59
|
+
|
|
60
|
+
def sign(self, data: str) -> str:
|
|
61
|
+
encoded = base64.b64encode(data.encode("utf-8")).decode()
|
|
62
|
+
body = {"keyId": "primary", "algo": "HMACSHA256", "data": encoded}
|
|
63
|
+
response = self._request(
|
|
64
|
+
"POST", f"modules/{self.module_id}/genid/{self.generation_id}/sign", body=body
|
|
65
|
+
)
|
|
66
|
+
digest = response.get("digest")
|
|
67
|
+
if not digest:
|
|
68
|
+
raise IoTEdgeError("No digest in workload sign response")
|
|
69
|
+
return str(digest)
|
|
70
|
+
|
|
71
|
+
def get_certificate(self) -> str:
|
|
72
|
+
response = self._request("GET", "trust-bundle")
|
|
73
|
+
certificate = response.get("certificate")
|
|
74
|
+
if not certificate:
|
|
75
|
+
raise IoTEdgeError("No certificate in trust bundle")
|
|
76
|
+
return str(certificate)
|
|
77
|
+
|
|
78
|
+
def _request(
|
|
79
|
+
self, method: str, sub_path: str, body: dict[str, Any] | None = None
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
return _workload_request(
|
|
82
|
+
self.workload_uri, method, sub_path, {"api-version": self.api_version}, body
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_sas_token(resource_uri: str, signer: Signer, expiry: int) -> str:
|
|
87
|
+
"""Build a ``SharedAccessSignature sr=...&sig=...&se=...`` token for ``resource_uri``."""
|
|
88
|
+
quoted_uri = urllib.parse.quote(resource_uri, safe="")
|
|
89
|
+
signature = signer.sign(f"{quoted_uri}\n{expiry}")
|
|
90
|
+
quoted_signature = urllib.parse.quote(signature, safe="")
|
|
91
|
+
return f"SharedAccessSignature sr={quoted_uri}&sig={quoted_signature}&se={expiry}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sas_password_provider(
|
|
95
|
+
resource_uri: str, signer: Signer, ttl: int = DEFAULT_SAS_TTL_SECONDS
|
|
96
|
+
) -> Callable[[], str]:
|
|
97
|
+
"""Return a zero-arg callable that mints a fresh SAS token on each call."""
|
|
98
|
+
|
|
99
|
+
def provider() -> str:
|
|
100
|
+
return build_sas_token(resource_uri, signer, int(time.time()) + ttl)
|
|
101
|
+
|
|
102
|
+
return provider
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_connection_string(connection_string: str) -> dict[str, str]:
|
|
106
|
+
parts = dict(
|
|
107
|
+
segment.split("=", 1) for segment in connection_string.split(";") if "=" in segment
|
|
108
|
+
)
|
|
109
|
+
return parts
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _UnixHTTPConnection(http.client.HTTPConnection):
|
|
113
|
+
"""HTTPConnection that talks plain HTTP over a unix domain socket."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, socket_path: str) -> None:
|
|
116
|
+
super().__init__("localhost", timeout=_WORKLOAD_TIMEOUT_SECONDS)
|
|
117
|
+
self._socket_path = socket_path
|
|
118
|
+
|
|
119
|
+
def connect(self) -> None:
|
|
120
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
121
|
+
sock.settimeout(self.timeout)
|
|
122
|
+
sock.connect(self._socket_path)
|
|
123
|
+
self.sock = sock
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _open_workload_connection(
|
|
127
|
+
parsed: urllib.parse.ParseResult, sub_path: str, query: str
|
|
128
|
+
) -> tuple[http.client.HTTPConnection, str]:
|
|
129
|
+
"""Build the (connection, request path) pair for the workload endpoint.
|
|
130
|
+
|
|
131
|
+
Supports a unix-socket URI (the usual edge case) and http/https URIs (for
|
|
132
|
+
non-socket environments). The connection is not yet opened.
|
|
133
|
+
"""
|
|
134
|
+
if parsed.scheme == "unix":
|
|
135
|
+
return _UnixHTTPConnection(parsed.path), f"/{sub_path}{query}"
|
|
136
|
+
|
|
137
|
+
request_path = f"{parsed.path.rstrip('/')}/{sub_path}{query}"
|
|
138
|
+
if parsed.scheme == "https":
|
|
139
|
+
connection: http.client.HTTPConnection = http.client.HTTPSConnection(
|
|
140
|
+
parsed.hostname or "", parsed.port, timeout=_WORKLOAD_TIMEOUT_SECONDS
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
connection = http.client.HTTPConnection(
|
|
144
|
+
parsed.hostname or "", parsed.port, timeout=_WORKLOAD_TIMEOUT_SECONDS
|
|
145
|
+
)
|
|
146
|
+
return connection, request_path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _workload_request(
|
|
150
|
+
workload_uri: str,
|
|
151
|
+
method: str,
|
|
152
|
+
sub_path: str,
|
|
153
|
+
params: dict[str, str],
|
|
154
|
+
body: dict[str, Any] | None,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Call the IoT Edge workload API over its unix socket (or http/https) endpoint.
|
|
157
|
+
|
|
158
|
+
``IOTEDGE_WORKLOADURI`` is typically ``unix:///var/run/iotedge/workload.sock`` on a
|
|
159
|
+
Linux edge node; http(s) URIs are also handled for non-socket environments.
|
|
160
|
+
"""
|
|
161
|
+
parsed = urllib.parse.urlparse(workload_uri)
|
|
162
|
+
query = "?" + urllib.parse.urlencode(params) if params else ""
|
|
163
|
+
connection, request_path = _open_workload_connection(parsed, sub_path, query)
|
|
164
|
+
|
|
165
|
+
headers = {}
|
|
166
|
+
payload = None
|
|
167
|
+
if body is not None:
|
|
168
|
+
payload = json.dumps(body)
|
|
169
|
+
headers["Content-Type"] = "application/json"
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
connection.request(method, request_path, body=payload, headers=headers)
|
|
173
|
+
response = connection.getresponse()
|
|
174
|
+
data = response.read()
|
|
175
|
+
if response.status >= 300:
|
|
176
|
+
raise IoTEdgeError(
|
|
177
|
+
f"Workload API {method} {sub_path} returned {response.status}: {data!r}"
|
|
178
|
+
)
|
|
179
|
+
return json.loads(data) # type: ignore[no-any-return]
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
raise IoTEdgeError(f"Workload API {method} {sub_path} failed: {exc}") from exc
|
|
182
|
+
finally:
|
|
183
|
+
connection.close()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Public entry point: tie a :class:`EdgeModuleRequestsHandler` to an MQTT connection."""
|
|
2
|
+
|
|
3
|
+
from helin_edge_sdk.config import ConnectionConfig
|
|
4
|
+
from helin_edge_sdk.handler import EdgeModuleRequestsHandler
|
|
5
|
+
from helin_edge_sdk.transport import MqttModuleListener
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EdgeModuleClient:
|
|
9
|
+
def __init__(self, config: ConnectionConfig, handler: EdgeModuleRequestsHandler) -> None:
|
|
10
|
+
self._listener = MqttModuleListener(config, handler)
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_edge_environment(cls, handler: EdgeModuleRequestsHandler) -> "EdgeModuleClient":
|
|
14
|
+
"""Production constructor: read the ``IOTEDGE_*`` envs injected by edgeHub."""
|
|
15
|
+
return cls(ConnectionConfig.from_edge_environment(), handler)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def manual(
|
|
19
|
+
cls,
|
|
20
|
+
handler: EdgeModuleRequestsHandler,
|
|
21
|
+
host: str,
|
|
22
|
+
port: int = 1883,
|
|
23
|
+
tls: bool = False,
|
|
24
|
+
connection_string: str | None = None,
|
|
25
|
+
ca_cert_path: str | None = None,
|
|
26
|
+
client_id: str = "helin-edge-sdk",
|
|
27
|
+
) -> "EdgeModuleClient":
|
|
28
|
+
"""Local/testing constructor: point at a broker by host/port (e.g. mosquitto)."""
|
|
29
|
+
config = ConnectionConfig.manual(
|
|
30
|
+
host=host,
|
|
31
|
+
port=port,
|
|
32
|
+
tls=tls,
|
|
33
|
+
connection_string=connection_string,
|
|
34
|
+
ca_cert_path=ca_cert_path,
|
|
35
|
+
client_id=client_id,
|
|
36
|
+
)
|
|
37
|
+
return cls(config, handler)
|
|
38
|
+
|
|
39
|
+
def run(self) -> None:
|
|
40
|
+
"""Block, listening for direct methods until interrupted."""
|
|
41
|
+
self._listener.run()
|
|
42
|
+
|
|
43
|
+
def start(self) -> None:
|
|
44
|
+
self._listener.start()
|
|
45
|
+
|
|
46
|
+
def stop(self) -> None:
|
|
47
|
+
self._listener.stop()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Gzip transport envelope for edge-module-requests of the Helin Platform.
|
|
2
|
+
|
|
3
|
+
Wrapped as:
|
|
4
|
+
``{"encoding": "gzip", "data": "<base64 of gzip-compressed minified JSON>"}``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import gzip
|
|
8
|
+
import json
|
|
9
|
+
from base64 import b64decode, b64encode
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
IOTHUB_DIRECT_METHOD_MAX_PAYLOAD_BYTES = 128 * 1024
|
|
13
|
+
|
|
14
|
+
_ENCODING_KEY = "encoding"
|
|
15
|
+
_DATA_KEY = "data"
|
|
16
|
+
_GZIP_ENCODING = "gzip"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compress_payload(payload: Any) -> dict[str, str]:
|
|
20
|
+
"""Minify, gzip (level 9) and base64-encode ``payload`` into a transport envelope."""
|
|
21
|
+
json_bytes = json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
|
|
22
|
+
compressed = gzip.compress(json_bytes, compresslevel=9)
|
|
23
|
+
encoded = b64encode(compressed).decode("ascii")
|
|
24
|
+
return {_ENCODING_KEY: _GZIP_ENCODING, _DATA_KEY: encoded}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decompress_payload(payload: Any) -> Any:
|
|
28
|
+
"""Reverse :func:`compress_payload`."""
|
|
29
|
+
if not isinstance(payload, dict):
|
|
30
|
+
return payload
|
|
31
|
+
if _DATA_KEY not in payload:
|
|
32
|
+
return payload
|
|
33
|
+
compressed = b64decode(payload[_DATA_KEY])
|
|
34
|
+
decompressed = gzip.decompress(compressed)
|
|
35
|
+
return json.loads(decompressed)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def exceeds_max_payload_size(compressed_envelope: dict[str, str]) -> bool:
|
|
39
|
+
"""Whether the serialized envelope exceeds the 128 KB limit."""
|
|
40
|
+
serialized = json.dumps(compressed_envelope, separators=(",", ":")).encode("utf-8")
|
|
41
|
+
return len(serialized) > IOTHUB_DIRECT_METHOD_MAX_PAYLOAD_BYTES
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Resolve MQTT connection parameters for edge-environment and manual modes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from helin_edge_sdk.auth import (
|
|
9
|
+
DEFAULT_SAS_TTL_SECONDS,
|
|
10
|
+
EdgeWorkloadSigner,
|
|
11
|
+
LocalKeySigner,
|
|
12
|
+
parse_connection_string,
|
|
13
|
+
sas_password_provider,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
MQTT_API_VERSION = "2019-10-01"
|
|
17
|
+
MQTT_TLS_PORT = 8883
|
|
18
|
+
MQTT_PLAINTEXT_PORT = 1883
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ConnectionConfig:
|
|
23
|
+
broker_host: str
|
|
24
|
+
broker_port: int
|
|
25
|
+
client_id: str
|
|
26
|
+
tls: bool
|
|
27
|
+
username: str | None = None
|
|
28
|
+
password_provider: Callable[[], str | None] | None = None
|
|
29
|
+
ca_cert_path: str | None = None
|
|
30
|
+
# TTL of the SAS token minted by ``password_provider``; lets the listener renew
|
|
31
|
+
# proactively before expiry. ``None`` when no token auth is used (anonymous broker).
|
|
32
|
+
sas_ttl_seconds: int | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_edge_environment(cls) -> "ConnectionConfig":
|
|
36
|
+
"""Build the production config from the ``IOTEDGE_*`` variables edgeHub injects."""
|
|
37
|
+
hostname = os.environ["IOTEDGE_IOTHUBHOSTNAME"]
|
|
38
|
+
device_id = os.environ["IOTEDGE_DEVICEID"]
|
|
39
|
+
module_id = os.environ["IOTEDGE_MODULEID"]
|
|
40
|
+
gateway_hostname = os.environ["IOTEDGE_GATEWAYHOSTNAME"]
|
|
41
|
+
generation_id = os.environ["IOTEDGE_MODULEGENERATIONID"]
|
|
42
|
+
workload_uri = os.environ["IOTEDGE_WORKLOADURI"]
|
|
43
|
+
api_version = os.environ["IOTEDGE_APIVERSION"]
|
|
44
|
+
|
|
45
|
+
signer = EdgeWorkloadSigner(module_id, generation_id, workload_uri, api_version)
|
|
46
|
+
ca_cert_path = _write_temp_cert(signer.get_certificate())
|
|
47
|
+
resource_uri = f"{hostname}/devices/{device_id}/modules/{module_id}"
|
|
48
|
+
|
|
49
|
+
return cls(
|
|
50
|
+
broker_host=gateway_hostname,
|
|
51
|
+
broker_port=MQTT_TLS_PORT,
|
|
52
|
+
client_id=f"{device_id}/{module_id}",
|
|
53
|
+
tls=True,
|
|
54
|
+
username=_build_username(hostname, device_id, module_id),
|
|
55
|
+
password_provider=sas_password_provider(resource_uri, signer),
|
|
56
|
+
ca_cert_path=ca_cert_path,
|
|
57
|
+
sas_ttl_seconds=DEFAULT_SAS_TTL_SECONDS,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def manual(
|
|
62
|
+
cls,
|
|
63
|
+
host: str,
|
|
64
|
+
port: int = MQTT_PLAINTEXT_PORT,
|
|
65
|
+
tls: bool = False,
|
|
66
|
+
connection_string: str | None = None,
|
|
67
|
+
ca_cert_path: str | None = None,
|
|
68
|
+
client_id: str = "helin-edge-sdk",
|
|
69
|
+
) -> "ConnectionConfig":
|
|
70
|
+
"""Build a config for local testing.
|
|
71
|
+
|
|
72
|
+
With ``connection_string`` the module authenticates with a locally-signed SAS
|
|
73
|
+
token; without it the connection is anonymous (for a plaintext broker such as
|
|
74
|
+
a local mosquitto).
|
|
75
|
+
"""
|
|
76
|
+
if connection_string is None:
|
|
77
|
+
return cls(
|
|
78
|
+
broker_host=host,
|
|
79
|
+
broker_port=port,
|
|
80
|
+
client_id=client_id,
|
|
81
|
+
tls=tls,
|
|
82
|
+
ca_cert_path=ca_cert_path,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
parts = parse_connection_string(connection_string)
|
|
86
|
+
hostname = parts["HostName"]
|
|
87
|
+
device_id = parts["DeviceId"]
|
|
88
|
+
module_id = parts["ModuleId"]
|
|
89
|
+
signer = LocalKeySigner(parts["SharedAccessKey"])
|
|
90
|
+
resource_uri = f"{hostname}/devices/{device_id}/modules/{module_id}"
|
|
91
|
+
return cls(
|
|
92
|
+
broker_host=parts.get("GatewayHostName", host),
|
|
93
|
+
broker_port=port,
|
|
94
|
+
client_id=f"{device_id}/{module_id}",
|
|
95
|
+
tls=tls,
|
|
96
|
+
username=_build_username(hostname, device_id, module_id),
|
|
97
|
+
password_provider=sas_password_provider(resource_uri, signer),
|
|
98
|
+
ca_cert_path=ca_cert_path,
|
|
99
|
+
sas_ttl_seconds=DEFAULT_SAS_TTL_SECONDS,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_username(hostname: str, device_id: str, module_id: str) -> str:
|
|
104
|
+
return f"{hostname}/{device_id}/{module_id}/?api-version={MQTT_API_VERSION}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _write_temp_cert(pem: str) -> str:
|
|
108
|
+
handle = tempfile.NamedTemporaryFile(
|
|
109
|
+
mode="w", suffix=".pem", prefix="edge-trust-bundle-", delete=False
|
|
110
|
+
)
|
|
111
|
+
with handle:
|
|
112
|
+
handle.write(pem)
|
|
113
|
+
return handle.name
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions the SDK maps to direct-method response status codes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigurationFailedError(Exception):
|
|
7
|
+
"""A ``set_configuration`` attempt failed but the module is still healthy.
|
|
8
|
+
|
|
9
|
+
The response can carry the configuration state as it stands after the failed
|
|
10
|
+
attempt, under the ``current_configuration`` key.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
status_code: int = 409,
|
|
17
|
+
current_configuration: dict[str, Any] | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.current_configuration = current_configuration
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The three edge-module-requests methods a module must implement.
|
|
2
|
+
|
|
3
|
+
The Helin Platform's edge-module-requests API invokes exactly three methods on a
|
|
4
|
+
module: ``get_configuration``, ``set_configuration`` and ``get_health`` (the wire
|
|
5
|
+
names are singular). Subclass :class:`EdgeModuleRequestsHandler`, implement the three
|
|
6
|
+
callbacks, and hand it to :class:`~helin_edge_sdk.client.EdgeModuleClient`.
|
|
7
|
+
|
|
8
|
+
Each callback receives the already-decompressed request payload (a dict) and
|
|
9
|
+
returns any JSON-serializable value, which the SDK wraps as
|
|
10
|
+
``{"status": 200, "response": <return value>}``. Raise
|
|
11
|
+
:class:`~helin_edge_sdk.errors.ConfigurationFailedError` from
|
|
12
|
+
``on_set_configuration`` to return the configuration-failure envelope.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
GET_CONFIGURATION = "get_configuration"
|
|
19
|
+
SET_CONFIGURATION = "set_configuration"
|
|
20
|
+
GET_HEALTH = "get_health"
|
|
21
|
+
|
|
22
|
+
SUPPORTED_METHODS = (GET_CONFIGURATION, SET_CONFIGURATION, GET_HEALTH)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EdgeModuleRequestsHandler(ABC):
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def on_get_configuration(self, payload: dict[str, Any]) -> Any: ...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def on_set_configuration(self, payload: dict[str, Any]) -> Any: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def on_get_health(self, payload: dict[str, Any]) -> Any: ...
|
|
34
|
+
|
|
35
|
+
def dispatch(self, method_name: str, payload: dict[str, Any]) -> Any:
|
|
36
|
+
"""Route ``method_name`` to the matching callback.
|
|
37
|
+
|
|
38
|
+
:raises UnknownMethodError: if the method is not one of the three supported.
|
|
39
|
+
"""
|
|
40
|
+
if method_name == GET_CONFIGURATION:
|
|
41
|
+
return self.on_get_configuration(payload)
|
|
42
|
+
if method_name == SET_CONFIGURATION:
|
|
43
|
+
return self.on_set_configuration(payload)
|
|
44
|
+
if method_name == GET_HEALTH:
|
|
45
|
+
return self.on_get_health(payload)
|
|
46
|
+
raise UnknownMethodError(method_name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnknownMethodError(Exception):
|
|
50
|
+
def __init__(self, method_name: str) -> None:
|
|
51
|
+
super().__init__(f"Unsupported direct method: {method_name!r}")
|
|
52
|
+
self.method_name = method_name
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_logger(name: str) -> logging.Logger:
|
|
6
|
+
logger = logging.getLogger(name)
|
|
7
|
+
if not logging.getLogger().handlers and not logger.handlers:
|
|
8
|
+
logging.basicConfig(
|
|
9
|
+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
|
10
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
11
|
+
)
|
|
12
|
+
return logger
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Direct-method response envelopes."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from helin_edge_sdk.errors import ConfigurationFailedError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResponseStatus(IntEnum):
|
|
10
|
+
OK = 200
|
|
11
|
+
BAD_REQUEST = 400
|
|
12
|
+
UNAUTHORIZED = 401
|
|
13
|
+
NOT_FOUND = 404
|
|
14
|
+
CONFLICT = 409
|
|
15
|
+
INTERNAL_SERVER_ERROR = 500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_ok(response: Any) -> dict[str, Any]:
|
|
19
|
+
return {"status": ResponseStatus.OK, "response": response}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_error(status: int, message: str, reason: int = 0) -> dict[str, Any]:
|
|
23
|
+
"""Legacy error envelope used for every error except a configuration failure."""
|
|
24
|
+
return {"status": status, "error": message, "reason": reason}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_config_failed(error: ConfigurationFailedError) -> dict[str, Any]:
|
|
28
|
+
"""Configuration-failure envelope carrying the module's current configuration."""
|
|
29
|
+
return {
|
|
30
|
+
"status": error.status_code,
|
|
31
|
+
"response": {
|
|
32
|
+
"error": str(error),
|
|
33
|
+
"current_configuration": error.current_configuration,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def status_for_exception(exception: Exception) -> ResponseStatus:
|
|
39
|
+
"""Map a handler exception to a response status."""
|
|
40
|
+
if isinstance(exception, ConfigurationFailedError):
|
|
41
|
+
try:
|
|
42
|
+
return ResponseStatus(exception.status_code)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return ResponseStatus.INTERNAL_SERVER_ERROR
|
|
45
|
+
if isinstance(exception, ValueError | ConnectionError):
|
|
46
|
+
return ResponseStatus.BAD_REQUEST
|
|
47
|
+
if isinstance(exception, FileNotFoundError):
|
|
48
|
+
return ResponseStatus.NOT_FOUND
|
|
49
|
+
return ResponseStatus.INTERNAL_SERVER_ERROR
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_for_exception(exception: Exception) -> tuple[ResponseStatus, dict[str, Any]]:
|
|
53
|
+
"""Build the (status, envelope) pair for a handler exception."""
|
|
54
|
+
if isinstance(exception, ConfigurationFailedError):
|
|
55
|
+
envelope = build_config_failed(exception)
|
|
56
|
+
return ResponseStatus(envelope["status"]), envelope
|
|
57
|
+
status = status_for_exception(exception)
|
|
58
|
+
return status, build_error(status, str(exception))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""MQTT topic helpers for IoT Hub / edgeHub direct methods.
|
|
2
|
+
|
|
3
|
+
Requests arrive on ``$iothub/methods/POST/{method}/?$rid={id}`` and responses are
|
|
4
|
+
published to ``$iothub/methods/res/{status}/?$rid={id}`` — the same topics the
|
|
5
|
+
Microsoft SDK uses, so edgeHub routes them unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import urllib.parse
|
|
9
|
+
|
|
10
|
+
METHOD_SUBSCRIBE_TOPIC = "$iothub/methods/POST/#"
|
|
11
|
+
|
|
12
|
+
_POST_PREFIX = "$iothub/methods/POST/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_method_topic(topic: str) -> bool:
|
|
16
|
+
return topic.startswith(_POST_PREFIX)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_method_topic(topic: str) -> tuple[str, str]:
|
|
20
|
+
"""Extract ``(method_name, request_id)`` from an incoming method-request topic.
|
|
21
|
+
|
|
22
|
+
:raises ValueError: if the topic is not a well-formed method-request topic.
|
|
23
|
+
"""
|
|
24
|
+
if not is_method_topic(topic):
|
|
25
|
+
raise ValueError(f"Not a method topic: {topic!r}")
|
|
26
|
+
parts = topic.split("/")
|
|
27
|
+
if len(parts) < 4 or "?" not in topic:
|
|
28
|
+
raise ValueError(f"Malformed method topic: {topic!r}")
|
|
29
|
+
method_name = urllib.parse.unquote(parts[3].split("?")[0])
|
|
30
|
+
query = topic.split("?", 1)[1]
|
|
31
|
+
properties = _parse_query(query)
|
|
32
|
+
if "rid" not in properties:
|
|
33
|
+
raise ValueError(f"Method topic missing $rid: {topic!r}")
|
|
34
|
+
return method_name, properties["rid"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_response_topic(request_id: str, status: int) -> str:
|
|
38
|
+
return "$iothub/methods/res/{status}/?$rid={rid}".format(
|
|
39
|
+
status=urllib.parse.quote(str(status), safe=""),
|
|
40
|
+
rid=urllib.parse.quote(str(request_id), safe=""),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_query(query: str) -> dict[str, str]:
|
|
45
|
+
result: dict[str, str] = {}
|
|
46
|
+
for entry in query.split("&"):
|
|
47
|
+
if not entry:
|
|
48
|
+
continue
|
|
49
|
+
key, _, value = entry.partition("=")
|
|
50
|
+
result[urllib.parse.unquote(key).lstrip("$")] = urllib.parse.unquote(value)
|
|
51
|
+
return result
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""paho-mqtt transport: subscribe, dispatch a direct method, publish the response."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import paho.mqtt.client as mqtt
|
|
8
|
+
from paho.mqtt.enums import CallbackAPIVersion
|
|
9
|
+
|
|
10
|
+
from helin_edge_sdk.compression import (
|
|
11
|
+
IOTHUB_DIRECT_METHOD_MAX_PAYLOAD_BYTES,
|
|
12
|
+
compress_payload,
|
|
13
|
+
decompress_payload,
|
|
14
|
+
exceeds_max_payload_size,
|
|
15
|
+
)
|
|
16
|
+
from helin_edge_sdk.config import ConnectionConfig
|
|
17
|
+
from helin_edge_sdk.handler import EdgeModuleRequestsHandler, UnknownMethodError
|
|
18
|
+
from helin_edge_sdk.logger import get_logger
|
|
19
|
+
from helin_edge_sdk.responses import ResponseStatus, build_error, build_for_exception, build_ok
|
|
20
|
+
from helin_edge_sdk.topics import (
|
|
21
|
+
METHOD_SUBSCRIBE_TOPIC,
|
|
22
|
+
build_response_topic,
|
|
23
|
+
is_method_topic,
|
|
24
|
+
parse_method_topic,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
# Renew the SAS token after this fraction of its TTL
|
|
30
|
+
RENEWAL_FRACTION = 0.8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MqttModuleListener:
|
|
34
|
+
def __init__(self, config: ConnectionConfig, handler: EdgeModuleRequestsHandler) -> None:
|
|
35
|
+
self._config = config
|
|
36
|
+
self._handler = handler
|
|
37
|
+
self._renewal_timer: threading.Timer | None = None
|
|
38
|
+
self._client = mqtt.Client(
|
|
39
|
+
callback_api_version=CallbackAPIVersion.VERSION2,
|
|
40
|
+
client_id=config.client_id,
|
|
41
|
+
protocol=mqtt.MQTTv311,
|
|
42
|
+
)
|
|
43
|
+
self._client.on_connect = self._on_connect
|
|
44
|
+
self._client.on_message = self._on_message
|
|
45
|
+
self._client.on_disconnect = self._on_disconnect
|
|
46
|
+
self._client.reconnect_delay_set(min_delay=1, max_delay=30)
|
|
47
|
+
self._apply_credentials()
|
|
48
|
+
if config.tls:
|
|
49
|
+
self._client.tls_set(ca_certs=config.ca_cert_path)
|
|
50
|
+
|
|
51
|
+
def run(self) -> None:
|
|
52
|
+
"""Connect and block forever, reconnecting on failure."""
|
|
53
|
+
self._client.connect(self._config.broker_host, self._config.broker_port)
|
|
54
|
+
self._client.loop_forever()
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
"""Connect and service the network loop on a background thread (for tests)."""
|
|
58
|
+
self._client.connect(self._config.broker_host, self._config.broker_port)
|
|
59
|
+
self._client.loop_start()
|
|
60
|
+
|
|
61
|
+
def stop(self) -> None:
|
|
62
|
+
self._cancel_renewal()
|
|
63
|
+
self._client.loop_stop()
|
|
64
|
+
self._client.disconnect()
|
|
65
|
+
|
|
66
|
+
def _apply_credentials(self) -> None:
|
|
67
|
+
if self._config.username is not None:
|
|
68
|
+
password = self._config.password_provider() if self._config.password_provider else None
|
|
69
|
+
self._client.username_pw_set(self._config.username, password)
|
|
70
|
+
|
|
71
|
+
def _renewal_delay(self) -> float | None:
|
|
72
|
+
"""Seconds until the SAS token should be renewed, or ``None`` if no token auth."""
|
|
73
|
+
ttl = self._config.sas_ttl_seconds
|
|
74
|
+
if ttl is None or self._config.password_provider is None:
|
|
75
|
+
return None
|
|
76
|
+
return ttl * RENEWAL_FRACTION
|
|
77
|
+
|
|
78
|
+
def _schedule_renewal(self) -> None:
|
|
79
|
+
"""(Re)arm the proactive token-renewal timer; no-op when not using token auth."""
|
|
80
|
+
self._cancel_renewal()
|
|
81
|
+
delay = self._renewal_delay()
|
|
82
|
+
if delay is None:
|
|
83
|
+
return
|
|
84
|
+
self._renewal_timer = threading.Timer(delay, self._renew)
|
|
85
|
+
self._renewal_timer.daemon = True
|
|
86
|
+
self._renewal_timer.start()
|
|
87
|
+
|
|
88
|
+
def _cancel_renewal(self) -> None:
|
|
89
|
+
if self._renewal_timer is not None:
|
|
90
|
+
self._renewal_timer.cancel()
|
|
91
|
+
self._renewal_timer = None
|
|
92
|
+
|
|
93
|
+
def _renew(self) -> None:
|
|
94
|
+
"""Mint a fresh SAS token and reconnect on our own schedule, before expiry."""
|
|
95
|
+
try:
|
|
96
|
+
logger.info("Renewing SAS token and reconnecting")
|
|
97
|
+
self._apply_credentials()
|
|
98
|
+
self._client.reconnect()
|
|
99
|
+
except Exception: # noqa: BLE001 - a failed renewal just defers to the next reconnect
|
|
100
|
+
logger.exception("Proactive SAS renewal failed; will retry on next reconnect")
|
|
101
|
+
|
|
102
|
+
def _on_connect(
|
|
103
|
+
self, client: mqtt.Client, userdata: Any, flags: Any, reason_code: Any, properties: Any
|
|
104
|
+
) -> None: # noqa: E501
|
|
105
|
+
if reason_code != 0:
|
|
106
|
+
logger.error("MQTT connect failed: %s", reason_code)
|
|
107
|
+
return
|
|
108
|
+
logger.info("Connected to broker %s; subscribing", self._config.broker_host)
|
|
109
|
+
client.subscribe(METHOD_SUBSCRIBE_TOPIC, qos=0)
|
|
110
|
+
# Arm proactive token renewal for this connection's lifetime.
|
|
111
|
+
self._schedule_renewal()
|
|
112
|
+
|
|
113
|
+
def _on_disconnect(
|
|
114
|
+
self, client: mqtt.Client, userdata: Any, flags: Any, reason_code: Any, properties: Any
|
|
115
|
+
) -> None: # noqa: E501
|
|
116
|
+
logger.info("Disconnected from broker (%s); reconnecting", reason_code)
|
|
117
|
+
# Refresh the SAS token so the automatic reconnect uses a non-expired credential.
|
|
118
|
+
self._apply_credentials()
|
|
119
|
+
|
|
120
|
+
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
|
|
121
|
+
topic = message.topic
|
|
122
|
+
if not is_method_topic(topic):
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
method_name, request_id = parse_method_topic(topic)
|
|
126
|
+
except ValueError:
|
|
127
|
+
logger.exception("Ignoring malformed method topic: %s", topic)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
status, response_payload = self._handle(method_name, message.payload)
|
|
131
|
+
envelope = compress_payload(response_payload)
|
|
132
|
+
if exceeds_max_payload_size(envelope):
|
|
133
|
+
logger.error(
|
|
134
|
+
"Response to %s (rid=%s) exceeds the %d-byte direct-method limit; "
|
|
135
|
+
"returning 500",
|
|
136
|
+
method_name,
|
|
137
|
+
request_id,
|
|
138
|
+
IOTHUB_DIRECT_METHOD_MAX_PAYLOAD_BYTES,
|
|
139
|
+
)
|
|
140
|
+
status = ResponseStatus.INTERNAL_SERVER_ERROR
|
|
141
|
+
envelope = compress_payload(
|
|
142
|
+
build_error(status, "response exceeds the 128 KB edge-module-requests limit")
|
|
143
|
+
)
|
|
144
|
+
client.publish(build_response_topic(request_id, status), json.dumps(envelope), qos=0)
|
|
145
|
+
logger.info("Responded to %s (rid=%s) with status %s", method_name, request_id, status)
|
|
146
|
+
|
|
147
|
+
def _handle(
|
|
148
|
+
self, method_name: str, raw_payload: bytes
|
|
149
|
+
) -> tuple[ResponseStatus, dict[str, Any]]: # noqa: E501
|
|
150
|
+
try:
|
|
151
|
+
decoded = json.loads(raw_payload.decode("utf-8") or "null")
|
|
152
|
+
payload = decompress_payload(decoded)
|
|
153
|
+
if payload is None:
|
|
154
|
+
payload = {}
|
|
155
|
+
result = self._handler.dispatch(method_name, payload)
|
|
156
|
+
return ResponseStatus.OK, build_ok(result)
|
|
157
|
+
except UnknownMethodError as exc:
|
|
158
|
+
logger.warning("%s", exc)
|
|
159
|
+
return ResponseStatus.BAD_REQUEST, build_error(ResponseStatus.BAD_REQUEST, str(exc))
|
|
160
|
+
except Exception as exc: # noqa: BLE001 - handler errors become method responses
|
|
161
|
+
logger.exception("Error handling method %s", method_name)
|
|
162
|
+
return build_for_exception(exc)
|