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.
@@ -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)