python-oa3-client 0.1.0__py3-none-any.whl
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.
- openadr3_client/__init__.py +41 -0
- openadr3_client/base.py +141 -0
- openadr3_client/bl.py +18 -0
- openadr3_client/discovery.py +253 -0
- openadr3_client/mqtt.py +190 -0
- openadr3_client/notifications.py +159 -0
- openadr3_client/ven.py +248 -0
- openadr3_client/webhook.py +232 -0
- python_oa3_client-0.1.0.dist-info/METADATA +387 -0
- python_oa3_client-0.1.0.dist-info/RECORD +12 -0
- python_oa3_client-0.1.0.dist-info/WHEEL +4 -0
- python_oa3_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""NotificationChannel protocol and channel implementations.
|
|
2
|
+
|
|
3
|
+
Provides a common interface for MqttChannel and WebhookChannel, wrapping
|
|
4
|
+
the lower-level MQTTConnection and WebhookReceiver.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Callable, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from openadr3_client.mqtt import MQTTConnection, MQTTMessage
|
|
13
|
+
from openadr3_client.webhook import WebhookReceiver, WebhookMessage
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Union of message types from either channel
|
|
18
|
+
ChannelMessage = MQTTMessage | WebhookMessage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class NotificationChannel(Protocol):
|
|
23
|
+
"""Protocol for notification channels (MQTT, Webhook)."""
|
|
24
|
+
|
|
25
|
+
def start(self) -> None: ...
|
|
26
|
+
def stop(self) -> None: ...
|
|
27
|
+
def subscribe_topics(self, topics: list[str]) -> None: ...
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def messages(self) -> list[ChannelMessage]: ...
|
|
31
|
+
|
|
32
|
+
def await_messages(self, n: int, timeout: float = 5.0) -> list[ChannelMessage]: ...
|
|
33
|
+
def clear_messages(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MqttChannel:
|
|
37
|
+
"""MQTT notification channel wrapping MQTTConnection.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
ch = MqttChannel("mqtt://broker:1883")
|
|
42
|
+
ch.start()
|
|
43
|
+
ch.subscribe_topics(["openadr3/programs/create"])
|
|
44
|
+
msgs = ch.await_messages(1, timeout=10.0)
|
|
45
|
+
ch.stop()
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
broker_url: str,
|
|
51
|
+
client_id: str | None = None,
|
|
52
|
+
on_message: Callable[[str, Any], None] | None = None,
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._conn = MQTTConnection(
|
|
56
|
+
broker_url=broker_url,
|
|
57
|
+
client_id=client_id,
|
|
58
|
+
on_message=on_message,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def start(self) -> None:
|
|
62
|
+
"""Connect to the MQTT broker."""
|
|
63
|
+
self._conn.connect()
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
"""Disconnect from the MQTT broker."""
|
|
67
|
+
self._conn.disconnect()
|
|
68
|
+
|
|
69
|
+
def subscribe_topics(self, topics: list[str]) -> None:
|
|
70
|
+
"""Subscribe to MQTT topics."""
|
|
71
|
+
self._conn.subscribe(topics)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def messages(self) -> list[MQTTMessage]:
|
|
75
|
+
return self._conn.messages
|
|
76
|
+
|
|
77
|
+
def messages_on_topic(self, topic: str) -> list[MQTTMessage]:
|
|
78
|
+
return self._conn.messages_on_topic(topic)
|
|
79
|
+
|
|
80
|
+
def await_messages(self, n: int, timeout: float = 5.0) -> list[MQTTMessage]:
|
|
81
|
+
return self._conn.await_messages(n, timeout)
|
|
82
|
+
|
|
83
|
+
def await_messages_on_topic(
|
|
84
|
+
self, topic: str, n: int, timeout: float = 5.0
|
|
85
|
+
) -> list[MQTTMessage]:
|
|
86
|
+
return self._conn.await_messages_on_topic(topic, n, timeout)
|
|
87
|
+
|
|
88
|
+
def clear_messages(self) -> None:
|
|
89
|
+
self._conn.clear_messages()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_connected(self) -> bool:
|
|
93
|
+
return self._conn.is_connected()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class WebhookChannel:
|
|
97
|
+
"""Webhook notification channel wrapping WebhookReceiver.
|
|
98
|
+
|
|
99
|
+
Usage::
|
|
100
|
+
|
|
101
|
+
ch = WebhookChannel(port=9000, bearer_token="secret")
|
|
102
|
+
ch.start()
|
|
103
|
+
print(ch.callback_url) # Register with VTN
|
|
104
|
+
msgs = ch.await_messages(1, timeout=10.0)
|
|
105
|
+
ch.stop()
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
host: str = "0.0.0.0",
|
|
111
|
+
port: int = 0,
|
|
112
|
+
bearer_token: str | None = None,
|
|
113
|
+
path: str = "/notifications",
|
|
114
|
+
callback_host: str | None = None,
|
|
115
|
+
on_message: Callable[[str, Any], None] | None = None,
|
|
116
|
+
**kwargs: Any,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._receiver = WebhookReceiver(
|
|
119
|
+
host=host,
|
|
120
|
+
port=port,
|
|
121
|
+
bearer_token=bearer_token,
|
|
122
|
+
path=path,
|
|
123
|
+
callback_host=callback_host,
|
|
124
|
+
on_message=on_message,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def start(self) -> None:
|
|
128
|
+
"""Start the webhook HTTP server."""
|
|
129
|
+
self._receiver.start()
|
|
130
|
+
|
|
131
|
+
def stop(self) -> None:
|
|
132
|
+
"""Stop the webhook HTTP server."""
|
|
133
|
+
self._receiver.stop()
|
|
134
|
+
|
|
135
|
+
def subscribe_topics(self, topics: list[str]) -> None:
|
|
136
|
+
"""No-op for webhooks — topics are managed via VTN subscriptions."""
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def callback_url(self) -> str:
|
|
141
|
+
return self._receiver.callback_url
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def messages(self) -> list[WebhookMessage]:
|
|
145
|
+
return self._receiver.messages
|
|
146
|
+
|
|
147
|
+
def messages_on_path(self, path: str) -> list[WebhookMessage]:
|
|
148
|
+
return self._receiver.messages_on_path(path)
|
|
149
|
+
|
|
150
|
+
def await_messages(self, n: int, timeout: float = 5.0) -> list[WebhookMessage]:
|
|
151
|
+
return self._receiver.await_messages(n, timeout)
|
|
152
|
+
|
|
153
|
+
def await_messages_on_path(
|
|
154
|
+
self, path: str, n: int, timeout: float = 5.0
|
|
155
|
+
) -> list[WebhookMessage]:
|
|
156
|
+
return self._receiver.await_messages_on_path(path, n, timeout)
|
|
157
|
+
|
|
158
|
+
def clear_messages(self) -> None:
|
|
159
|
+
self._receiver.clear_messages()
|
openadr3_client/ven.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""VenClient — VEN registration, program lookup, notification subscribe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from openadr3.api import success, body
|
|
11
|
+
|
|
12
|
+
from openadr3_client.base import BaseClient
|
|
13
|
+
from openadr3_client.notifications import (
|
|
14
|
+
MqttChannel,
|
|
15
|
+
NotificationChannel,
|
|
16
|
+
WebhookChannel,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_topics(resp: httpx.Response) -> list[str] | None:
|
|
23
|
+
"""Extract topic strings from a VTN MQTT topics response."""
|
|
24
|
+
if not success(resp):
|
|
25
|
+
return None
|
|
26
|
+
data = resp.json()
|
|
27
|
+
if not isinstance(data, dict):
|
|
28
|
+
return None
|
|
29
|
+
topics = data.get("topics", {})
|
|
30
|
+
return list(topics.values()) if topics else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VenClient(BaseClient):
|
|
34
|
+
"""OpenADR 3 VEN client with registration, program lookup, and notifications.
|
|
35
|
+
|
|
36
|
+
Extends BaseClient with VEN-specific capabilities:
|
|
37
|
+
- VEN registration (find-or-create by name)
|
|
38
|
+
- Program name→ID resolution with caching
|
|
39
|
+
- Notifier discovery and MQTT support detection
|
|
40
|
+
- Notification channel management (MQTT, webhook)
|
|
41
|
+
- subscribe() for topic-based notification subscription
|
|
42
|
+
- VEN-scoped topic methods that default to the registered ven_id
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_client_type = "ven"
|
|
46
|
+
|
|
47
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
48
|
+
super().__init__(**kwargs)
|
|
49
|
+
self._ven_id: str | None = None
|
|
50
|
+
self._ven_name: str | None = None
|
|
51
|
+
self._program_cache: dict[str, str] = {} # name → id
|
|
52
|
+
self._channels: list[NotificationChannel] = []
|
|
53
|
+
|
|
54
|
+
# -- Lifecycle --
|
|
55
|
+
|
|
56
|
+
def stop(self) -> BaseClient:
|
|
57
|
+
"""Stop all channels, then stop the base client."""
|
|
58
|
+
for ch in self._channels:
|
|
59
|
+
ch.stop()
|
|
60
|
+
self._channels.clear()
|
|
61
|
+
return super().stop()
|
|
62
|
+
|
|
63
|
+
# -- VEN registration --
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def ven_id(self) -> str | None:
|
|
67
|
+
return self._ven_id
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def ven_name(self) -> str | None:
|
|
71
|
+
return self._ven_name
|
|
72
|
+
|
|
73
|
+
def _require_ven_id(self) -> str:
|
|
74
|
+
if not self._ven_id:
|
|
75
|
+
raise RuntimeError("VEN not registered. Call register() first.")
|
|
76
|
+
return self._ven_id
|
|
77
|
+
|
|
78
|
+
def register(self, ven_name: str) -> VenClient:
|
|
79
|
+
"""Register this VEN with the VTN. Idempotent — finds existing or creates new."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
existing = self.api.find_ven_by_name(ven_name)
|
|
82
|
+
if existing:
|
|
83
|
+
vid = existing["id"]
|
|
84
|
+
log.info("VEN found, reusing: name=%s id=%s", ven_name, vid)
|
|
85
|
+
else:
|
|
86
|
+
resp = self.api.create_ven({
|
|
87
|
+
"objectType": "VEN_VEN_REQUEST",
|
|
88
|
+
"venName": ven_name,
|
|
89
|
+
})
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
vid = resp.json().get("id")
|
|
92
|
+
if not vid:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"VEN registration failed: {resp.status_code} {resp.text}"
|
|
95
|
+
)
|
|
96
|
+
log.info("VEN registered: name=%s id=%s", ven_name, vid)
|
|
97
|
+
self._ven_id = vid
|
|
98
|
+
self._ven_name = ven_name
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
# -- Program lookup --
|
|
102
|
+
|
|
103
|
+
def find_program_by_name(self, name: str) -> dict[str, Any] | None:
|
|
104
|
+
"""Query VTN for a program by programName. Caches the ID on success."""
|
|
105
|
+
result = self.api.find_program_by_name(name)
|
|
106
|
+
if result and "id" in result:
|
|
107
|
+
self._program_cache[name] = result["id"]
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def resolve_program_id(self, name: str) -> str:
|
|
111
|
+
"""Cached name→ID lookup. Queries VTN if not cached.
|
|
112
|
+
|
|
113
|
+
Raises KeyError if program not found.
|
|
114
|
+
"""
|
|
115
|
+
if name in self._program_cache:
|
|
116
|
+
return self._program_cache[name]
|
|
117
|
+
result = self.find_program_by_name(name)
|
|
118
|
+
if not result:
|
|
119
|
+
raise KeyError(f"Program not found: {name!r}")
|
|
120
|
+
return self._program_cache[name]
|
|
121
|
+
|
|
122
|
+
# -- Notifier discovery --
|
|
123
|
+
|
|
124
|
+
def discover_notifiers(self) -> dict[str, Any] | None:
|
|
125
|
+
"""GET /notifiers — discover VTN notification capabilities."""
|
|
126
|
+
resp = self.api.get_notifiers()
|
|
127
|
+
if success(resp):
|
|
128
|
+
return resp.json()
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def vtn_supports_mqtt(self) -> bool:
|
|
132
|
+
"""Check if the VTN advertises MQTT notification support."""
|
|
133
|
+
notifiers = self.discover_notifiers()
|
|
134
|
+
if not notifiers:
|
|
135
|
+
return False
|
|
136
|
+
# VTN-RI returns a list of notifier dicts with "transport" field
|
|
137
|
+
if isinstance(notifiers, list):
|
|
138
|
+
return any(
|
|
139
|
+
n.get("transport", "").upper() == "MQTT" for n in notifiers
|
|
140
|
+
)
|
|
141
|
+
# Or it might be a dict with transport info
|
|
142
|
+
return "mqtt" in str(notifiers).lower()
|
|
143
|
+
|
|
144
|
+
# -- Channel management --
|
|
145
|
+
|
|
146
|
+
def add_mqtt(
|
|
147
|
+
self,
|
|
148
|
+
broker_url: str,
|
|
149
|
+
client_id: str | None = None,
|
|
150
|
+
on_message: Callable[[str, Any], None] | None = None,
|
|
151
|
+
**kwargs: Any,
|
|
152
|
+
) -> MqttChannel:
|
|
153
|
+
"""Create an MqttChannel (not started yet)."""
|
|
154
|
+
ch = MqttChannel(
|
|
155
|
+
broker_url=broker_url,
|
|
156
|
+
client_id=client_id,
|
|
157
|
+
on_message=on_message,
|
|
158
|
+
**kwargs,
|
|
159
|
+
)
|
|
160
|
+
self._channels.append(ch)
|
|
161
|
+
return ch
|
|
162
|
+
|
|
163
|
+
def add_webhook(
|
|
164
|
+
self,
|
|
165
|
+
host: str = "0.0.0.0",
|
|
166
|
+
port: int = 0,
|
|
167
|
+
bearer_token: str | None = None,
|
|
168
|
+
path: str = "/notifications",
|
|
169
|
+
callback_host: str | None = None,
|
|
170
|
+
on_message: Callable[[str, Any], None] | None = None,
|
|
171
|
+
**kwargs: Any,
|
|
172
|
+
) -> WebhookChannel:
|
|
173
|
+
"""Create a WebhookChannel (not started yet)."""
|
|
174
|
+
ch = WebhookChannel(
|
|
175
|
+
host=host,
|
|
176
|
+
port=port,
|
|
177
|
+
bearer_token=bearer_token,
|
|
178
|
+
path=path,
|
|
179
|
+
callback_host=callback_host,
|
|
180
|
+
on_message=on_message,
|
|
181
|
+
**kwargs,
|
|
182
|
+
)
|
|
183
|
+
self._channels.append(ch)
|
|
184
|
+
return ch
|
|
185
|
+
|
|
186
|
+
# -- Subscribe --
|
|
187
|
+
|
|
188
|
+
def subscribe(
|
|
189
|
+
self,
|
|
190
|
+
program_names: list[str],
|
|
191
|
+
objects: list[str],
|
|
192
|
+
operations: list[str],
|
|
193
|
+
channel: NotificationChannel,
|
|
194
|
+
) -> list[str]:
|
|
195
|
+
"""Resolve program names to IDs, discover MQTT topics, and subscribe.
|
|
196
|
+
|
|
197
|
+
For MQTT channels: queries VTN for topics for each program and subscribes.
|
|
198
|
+
For webhook channels: creates VTN subscriptions with callback URLs.
|
|
199
|
+
|
|
200
|
+
Returns the list of topics subscribed to.
|
|
201
|
+
"""
|
|
202
|
+
all_topics = []
|
|
203
|
+
|
|
204
|
+
for name in program_names:
|
|
205
|
+
program_id = self.resolve_program_id(name)
|
|
206
|
+
|
|
207
|
+
if isinstance(channel, MqttChannel):
|
|
208
|
+
# Query VTN for MQTT topics for this program's events
|
|
209
|
+
resp = self.api.get_mqtt_topics_program_events(program_id)
|
|
210
|
+
topics = extract_topics(resp)
|
|
211
|
+
if topics:
|
|
212
|
+
channel.subscribe_topics(topics)
|
|
213
|
+
all_topics.extend(topics)
|
|
214
|
+
elif isinstance(channel, WebhookChannel):
|
|
215
|
+
# Create a VTN subscription pointing to the webhook
|
|
216
|
+
self.api.create_subscription({
|
|
217
|
+
"clientName": self._ven_name or "ven-client",
|
|
218
|
+
"programID": program_id,
|
|
219
|
+
"objectOperations": [{
|
|
220
|
+
"objects": objects,
|
|
221
|
+
"operations": operations,
|
|
222
|
+
"callbackUrl": channel.callback_url,
|
|
223
|
+
"bearerToken": channel._receiver.bearer_token,
|
|
224
|
+
}],
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
return all_topics
|
|
228
|
+
|
|
229
|
+
# -- Poll events --
|
|
230
|
+
|
|
231
|
+
def poll_events(self, program_name: str) -> list:
|
|
232
|
+
"""GET events filtered by program name."""
|
|
233
|
+
program_id = self.resolve_program_id(program_name)
|
|
234
|
+
return self.api.events(programID=program_id)
|
|
235
|
+
|
|
236
|
+
# -- VEN-scoped topic methods (default ven_id to registered) --
|
|
237
|
+
|
|
238
|
+
def get_mqtt_topics_ven(self, ven_id: str | None = None) -> httpx.Response:
|
|
239
|
+
return self.api.get_mqtt_topics_ven(ven_id or self._require_ven_id())
|
|
240
|
+
|
|
241
|
+
def get_mqtt_topics_ven_events(self, ven_id: str | None = None) -> httpx.Response:
|
|
242
|
+
return self.api.get_mqtt_topics_ven_events(ven_id or self._require_ven_id())
|
|
243
|
+
|
|
244
|
+
def get_mqtt_topics_ven_programs(self, ven_id: str | None = None) -> httpx.Response:
|
|
245
|
+
return self.api.get_mqtt_topics_ven_programs(ven_id or self._require_ven_id())
|
|
246
|
+
|
|
247
|
+
def get_mqtt_topics_ven_resources(self, ven_id: str | None = None) -> httpx.Response:
|
|
248
|
+
return self.api.get_mqtt_topics_ven_resources(ven_id or self._require_ven_id())
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Webhook notification receiver for OpenADR 3 clients.
|
|
2
|
+
|
|
3
|
+
Runs a Flask HTTP server in a background thread to receive POST
|
|
4
|
+
callbacks from the VTN. Shares the same message collection interface
|
|
5
|
+
as MQTTConnection.
|
|
6
|
+
|
|
7
|
+
Requires the ``webhooks`` extra: ``pip install python-oa3-client[webhooks]``
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
import socket
|
|
20
|
+
|
|
21
|
+
from openadr3.entities import coerce_notification, is_notification
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_lan_ip() -> str:
|
|
27
|
+
"""Detect this machine's LAN IP address.
|
|
28
|
+
|
|
29
|
+
Uses a UDP socket connect to a non-routable address to ask the OS
|
|
30
|
+
which network interface it would use for outbound traffic. No packets
|
|
31
|
+
are actually sent.
|
|
32
|
+
|
|
33
|
+
Returns the LAN IP as a string (e.g. "192.168.1.50").
|
|
34
|
+
Falls back to "127.0.0.1" if detection fails.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
38
|
+
s.connect(("10.255.255.255", 1))
|
|
39
|
+
ip = s.getsockname()[0]
|
|
40
|
+
s.close()
|
|
41
|
+
return ip
|
|
42
|
+
except Exception:
|
|
43
|
+
return "127.0.0.1"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class WebhookMessage:
|
|
48
|
+
"""A received webhook notification."""
|
|
49
|
+
|
|
50
|
+
path: str
|
|
51
|
+
payload: Any
|
|
52
|
+
time: float
|
|
53
|
+
raw_payload: bytes
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_webhook_payload(raw: bytes, path: str) -> Any:
|
|
57
|
+
"""Parse webhook body as JSON, coercing notifications."""
|
|
58
|
+
try:
|
|
59
|
+
s = raw.decode("utf-8")
|
|
60
|
+
except UnicodeDecodeError:
|
|
61
|
+
return raw
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
parsed = json.loads(s)
|
|
65
|
+
except (json.JSONDecodeError, ValueError):
|
|
66
|
+
return s
|
|
67
|
+
|
|
68
|
+
if isinstance(parsed, dict) and is_notification(parsed):
|
|
69
|
+
return coerce_notification(
|
|
70
|
+
parsed, {"openadr/channel": "webhook", "openadr/path": path}
|
|
71
|
+
)
|
|
72
|
+
return parsed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WebhookReceiver:
|
|
76
|
+
"""HTTP server that receives VTN webhook notifications.
|
|
77
|
+
|
|
78
|
+
Runs Flask in a daemon thread. The VTN POSTs notification JSON to
|
|
79
|
+
the callback URL, optionally authenticated with a Bearer token.
|
|
80
|
+
|
|
81
|
+
Usage::
|
|
82
|
+
|
|
83
|
+
receiver = WebhookReceiver(port=9000, bearer_token="my-secret")
|
|
84
|
+
receiver.start()
|
|
85
|
+
# callbackUrl = "http://my-host:9000/notifications"
|
|
86
|
+
# ... create subscription with VTN pointing to that URL ...
|
|
87
|
+
msgs = receiver.await_messages(n=1, timeout=10.0)
|
|
88
|
+
receiver.stop()
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
host: str = "0.0.0.0",
|
|
94
|
+
port: int = 0,
|
|
95
|
+
bearer_token: str | None = None,
|
|
96
|
+
path: str = "/notifications",
|
|
97
|
+
callback_host: str | None = None,
|
|
98
|
+
on_message: Callable[[str, Any], None] | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.host = host
|
|
101
|
+
self.port = port
|
|
102
|
+
self.bearer_token = bearer_token
|
|
103
|
+
self.path = path
|
|
104
|
+
self.callback_host = callback_host or "127.0.0.1"
|
|
105
|
+
self.on_message_callback = on_message
|
|
106
|
+
self._messages: list[WebhookMessage] = []
|
|
107
|
+
self._lock = threading.Lock()
|
|
108
|
+
self._server_thread: threading.Thread | None = None
|
|
109
|
+
self._server: Any = None # werkzeug Server instance
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def callback_url(self) -> str:
|
|
113
|
+
"""The URL the VTN should POST notifications to.
|
|
114
|
+
|
|
115
|
+
Uses callback_host (not bind host) and the actual listening port
|
|
116
|
+
(resolved after start() when port=0).
|
|
117
|
+
"""
|
|
118
|
+
return f"http://{self.callback_host}:{self.port}{self.path}"
|
|
119
|
+
|
|
120
|
+
def start(self) -> None:
|
|
121
|
+
"""Start the webhook server in a background thread."""
|
|
122
|
+
try:
|
|
123
|
+
from flask import Flask, request, abort
|
|
124
|
+
except ImportError:
|
|
125
|
+
raise ImportError(
|
|
126
|
+
"Flask is required for webhook support. "
|
|
127
|
+
"Install it with: pip install python-oa3-client[webhooks]"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
app = Flask(__name__)
|
|
131
|
+
# Suppress Flask/werkzeug request logging
|
|
132
|
+
flask_log = logging.getLogger("werkzeug")
|
|
133
|
+
flask_log.setLevel(logging.WARNING)
|
|
134
|
+
|
|
135
|
+
receiver = self # capture for closure
|
|
136
|
+
|
|
137
|
+
@app.route(self.path, methods=["POST"])
|
|
138
|
+
def receive_notification():
|
|
139
|
+
# Verify bearer token if configured
|
|
140
|
+
if receiver.bearer_token:
|
|
141
|
+
auth = request.headers.get("Authorization", "")
|
|
142
|
+
if auth != f"Bearer {receiver.bearer_token}":
|
|
143
|
+
abort(403)
|
|
144
|
+
|
|
145
|
+
raw = request.get_data()
|
|
146
|
+
path = request.path
|
|
147
|
+
parsed = _parse_webhook_payload(raw, path)
|
|
148
|
+
|
|
149
|
+
msg = WebhookMessage(
|
|
150
|
+
path=path,
|
|
151
|
+
payload=parsed,
|
|
152
|
+
time=time.time(),
|
|
153
|
+
raw_payload=raw,
|
|
154
|
+
)
|
|
155
|
+
with receiver._lock:
|
|
156
|
+
receiver._messages.append(msg)
|
|
157
|
+
log.debug("Webhook received: path=%s", path)
|
|
158
|
+
|
|
159
|
+
if receiver.on_message_callback:
|
|
160
|
+
receiver.on_message_callback(path, parsed)
|
|
161
|
+
|
|
162
|
+
return "", 200
|
|
163
|
+
|
|
164
|
+
@app.route(self.path, methods=["GET"])
|
|
165
|
+
def health():
|
|
166
|
+
return {"status": "ok"}, 200
|
|
167
|
+
|
|
168
|
+
from werkzeug.serving import make_server
|
|
169
|
+
|
|
170
|
+
self._server = make_server(self.host, self.port, app)
|
|
171
|
+
# Resolve actual port (important when port=0 for OS-assigned)
|
|
172
|
+
self.port = self._server.socket.getsockname()[1]
|
|
173
|
+
self._server_thread = threading.Thread(
|
|
174
|
+
target=self._server.serve_forever,
|
|
175
|
+
daemon=True,
|
|
176
|
+
)
|
|
177
|
+
self._server_thread.start()
|
|
178
|
+
log.info(
|
|
179
|
+
"Webhook server started: %s (bind=%s:%d)",
|
|
180
|
+
self.callback_url, self.host, self.port,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def stop(self) -> None:
|
|
184
|
+
"""Stop the webhook server."""
|
|
185
|
+
if self._server:
|
|
186
|
+
self._server.shutdown()
|
|
187
|
+
self._server = None
|
|
188
|
+
if self._server_thread:
|
|
189
|
+
self._server_thread.join(timeout=5.0)
|
|
190
|
+
self._server_thread = None
|
|
191
|
+
log.info("Webhook server stopped")
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def messages(self) -> list[WebhookMessage]:
|
|
195
|
+
"""All collected messages (snapshot)."""
|
|
196
|
+
with self._lock:
|
|
197
|
+
return list(self._messages)
|
|
198
|
+
|
|
199
|
+
def messages_on_path(self, path: str) -> list[WebhookMessage]:
|
|
200
|
+
"""Messages received on a specific path."""
|
|
201
|
+
with self._lock:
|
|
202
|
+
return [m for m in self._messages if m.path == path]
|
|
203
|
+
|
|
204
|
+
def clear_messages(self) -> None:
|
|
205
|
+
"""Clear collected messages."""
|
|
206
|
+
with self._lock:
|
|
207
|
+
self._messages.clear()
|
|
208
|
+
|
|
209
|
+
def await_messages(self, n: int, timeout: float = 5.0) -> list[WebhookMessage]:
|
|
210
|
+
"""Wait until at least n messages collected, or timeout."""
|
|
211
|
+
deadline = time.time() + timeout
|
|
212
|
+
while True:
|
|
213
|
+
with self._lock:
|
|
214
|
+
if len(self._messages) >= n:
|
|
215
|
+
return list(self._messages)
|
|
216
|
+
if time.time() >= deadline:
|
|
217
|
+
with self._lock:
|
|
218
|
+
return list(self._messages)
|
|
219
|
+
time.sleep(0.05)
|
|
220
|
+
|
|
221
|
+
def await_messages_on_path(
|
|
222
|
+
self, path: str, n: int, timeout: float = 5.0
|
|
223
|
+
) -> list[WebhookMessage]:
|
|
224
|
+
"""Wait until at least n messages on a specific path, or timeout."""
|
|
225
|
+
deadline = time.time() + timeout
|
|
226
|
+
while True:
|
|
227
|
+
msgs = self.messages_on_path(path)
|
|
228
|
+
if len(msgs) >= n:
|
|
229
|
+
return msgs
|
|
230
|
+
if time.time() >= deadline:
|
|
231
|
+
return msgs
|
|
232
|
+
time.sleep(0.05)
|