python-oa3-client 0.1.0__tar.gz → 0.2.0__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.
- python_oa3_client-0.2.0/.github/workflows/lint.yml +18 -0
- python_oa3_client-0.2.0/.pre-commit-config.yaml +7 -0
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/PKG-INFO +3 -2
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/pyproject.toml +26 -3
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/__init__.py +8 -8
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/base.py +10 -9
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/discovery.py +9 -12
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/mqtt.py +9 -8
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/notifications.py +3 -2
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/ven.py +46 -31
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/webhook.py +10 -10
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_base.py +1 -1
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_bl.py +1 -1
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_discovery.py +36 -17
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_mqtt.py +2 -5
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_notifications.py +16 -3
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_ven.py +53 -26
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_webhook.py +2 -3
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/.github/workflows/publish.yml +0 -0
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/.gitignore +0 -0
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/LICENSE +0 -0
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/README.md +0 -0
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/doc/ven-bl-client-guide.md +0 -0
- {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/bl.py +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Lint
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ruff:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
13
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- run: pip install "ruff>=0.15.0"
|
|
17
|
+
- run: ruff check src tests
|
|
18
|
+
- run: ruff format --check src tests
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-oa3-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: OpenADR 3 companion client with VEN registration, MQTT notifications, and lifecycle management
|
|
5
|
-
Project-URL: Homepage, https://
|
|
5
|
+
Project-URL: Homepage, https://grid-coordination.energy
|
|
6
6
|
Project-URL: Repository, https://github.com/grid-coordination/python-oa3-client
|
|
7
7
|
Project-URL: Issues, https://github.com/grid-coordination/python-oa3-client/issues
|
|
8
8
|
Author: Grid-Coordination Contributors
|
|
@@ -28,6 +28,7 @@ Provides-Extra: dev
|
|
|
28
28
|
Requires-Dist: ebus-mqtt-client>=0.1.0; extra == 'dev'
|
|
29
29
|
Requires-Dist: flask>=3.0.0; extra == 'dev'
|
|
30
30
|
Requires-Dist: pytest; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.15.0; extra == 'dev'
|
|
31
32
|
Requires-Dist: zeroconf>=0.131.0; extra == 'dev'
|
|
32
33
|
Provides-Extra: mdns
|
|
33
34
|
Requires-Dist: zeroconf>=0.131.0; extra == 'mdns'
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-oa3-client"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "OpenADR 3 companion client with VEN registration, MQTT notifications, and lifecycle management"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -33,10 +33,10 @@ mqtt = ["ebus-mqtt-client>=0.1.0"]
|
|
|
33
33
|
webhooks = ["flask>=3.0.0"]
|
|
34
34
|
mdns = ["zeroconf>=0.131.0"]
|
|
35
35
|
all = ["ebus-mqtt-client>=0.1.0", "flask>=3.0.0", "zeroconf>=0.131.0"]
|
|
36
|
-
dev = ["pytest", "ebus-mqtt-client>=0.1.0", "flask>=3.0.0", "zeroconf>=0.131.0"]
|
|
36
|
+
dev = ["pytest", "ruff>=0.15.0", "ebus-mqtt-client>=0.1.0", "flask>=3.0.0", "zeroconf>=0.131.0"]
|
|
37
37
|
|
|
38
38
|
[project.urls]
|
|
39
|
-
Homepage = "https://
|
|
39
|
+
Homepage = "https://grid-coordination.energy"
|
|
40
40
|
Repository = "https://github.com/grid-coordination/python-oa3-client"
|
|
41
41
|
Issues = "https://github.com/grid-coordination/python-oa3-client/issues"
|
|
42
42
|
|
|
@@ -51,3 +51,26 @@ exclude = [
|
|
|
51
51
|
".beads/",
|
|
52
52
|
"examples/",
|
|
53
53
|
]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
target-version = "py310"
|
|
58
|
+
extend-exclude = [".beads", "examples"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = [
|
|
62
|
+
"E", # pycodestyle errors
|
|
63
|
+
"W", # pycodestyle warnings
|
|
64
|
+
"F", # pyflakes
|
|
65
|
+
"I", # isort
|
|
66
|
+
"B", # flake8-bugbear
|
|
67
|
+
"UP", # pyupgrade
|
|
68
|
+
"SIM", # flake8-simplify
|
|
69
|
+
]
|
|
70
|
+
ignore = [
|
|
71
|
+
"E501", # line-too-long (handled by formatter)
|
|
72
|
+
"B008", # do not perform function calls in argument defaults
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint.per-file-ignores]
|
|
76
|
+
"tests/*" = ["B011"] # allow assert False in tests
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
from openadr3_client.base import BaseClient
|
|
2
|
-
from openadr3_client.ven import VenClient, extract_topics
|
|
3
2
|
from openadr3_client.bl import BlClient
|
|
4
|
-
from openadr3_client.notifications import (
|
|
5
|
-
MqttChannel,
|
|
6
|
-
NotificationChannel,
|
|
7
|
-
WebhookChannel,
|
|
8
|
-
)
|
|
9
|
-
from openadr3_client.mqtt import MQTTConnection, MQTTMessage, normalize_broker_uri
|
|
10
|
-
from openadr3_client.webhook import WebhookReceiver, WebhookMessage, detect_lan_ip
|
|
11
3
|
from openadr3_client.discovery import (
|
|
12
4
|
DiscoveredVTN,
|
|
13
5
|
DiscoveryMode,
|
|
14
6
|
advertise_vtn,
|
|
15
7
|
discover_vtns,
|
|
16
8
|
)
|
|
9
|
+
from openadr3_client.mqtt import MQTTConnection, MQTTMessage, normalize_broker_uri
|
|
10
|
+
from openadr3_client.notifications import (
|
|
11
|
+
MqttChannel,
|
|
12
|
+
NotificationChannel,
|
|
13
|
+
WebhookChannel,
|
|
14
|
+
)
|
|
15
|
+
from openadr3_client.ven import VenClient, extract_topics
|
|
16
|
+
from openadr3_client.webhook import WebhookMessage, WebhookReceiver, detect_lan_ip
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
# Clients
|
|
@@ -73,12 +73,15 @@ class BaseClient:
|
|
|
73
73
|
if self._api:
|
|
74
74
|
log.info(
|
|
75
75
|
"%s already started: url=%s",
|
|
76
|
-
type(self).__name__,
|
|
76
|
+
type(self).__name__,
|
|
77
|
+
self._resolved_url,
|
|
77
78
|
)
|
|
78
79
|
return self
|
|
79
80
|
|
|
80
81
|
self._resolved_url = resolve_url(
|
|
81
|
-
self.discovery_mode,
|
|
82
|
+
self.discovery_mode,
|
|
83
|
+
self.url,
|
|
84
|
+
self.discovery_timeout,
|
|
82
85
|
)
|
|
83
86
|
|
|
84
87
|
if not self.token:
|
|
@@ -98,7 +101,9 @@ class BaseClient:
|
|
|
98
101
|
)
|
|
99
102
|
log.info(
|
|
100
103
|
"%s started: type=%s url=%s",
|
|
101
|
-
type(self).__name__,
|
|
104
|
+
type(self).__name__,
|
|
105
|
+
self._client_type,
|
|
106
|
+
self._resolved_url,
|
|
102
107
|
)
|
|
103
108
|
return self
|
|
104
109
|
|
|
@@ -120,9 +125,7 @@ class BaseClient:
|
|
|
120
125
|
def api(self) -> OpenADRClient:
|
|
121
126
|
"""The underlying OpenADRClient. Raises if not started."""
|
|
122
127
|
if not self._api:
|
|
123
|
-
raise RuntimeError(
|
|
124
|
-
f"{type(self).__name__} not started. Call start() first."
|
|
125
|
-
)
|
|
128
|
+
raise RuntimeError(f"{type(self).__name__} not started. Call start() first.")
|
|
126
129
|
return self._api
|
|
127
130
|
|
|
128
131
|
# -- __getattr__ delegation --
|
|
@@ -136,6 +139,4 @@ class BaseClient:
|
|
|
136
139
|
return getattr(api, name)
|
|
137
140
|
except AttributeError:
|
|
138
141
|
pass
|
|
139
|
-
raise AttributeError(
|
|
140
|
-
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
141
|
-
)
|
|
142
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
13
|
import threading
|
|
14
|
-
from dataclasses import dataclass
|
|
14
|
+
from dataclasses import dataclass
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
@@ -71,8 +71,10 @@ class DiscoveredVTN:
|
|
|
71
71
|
"""Build from a ``zeroconf.ServiceInfo`` object."""
|
|
72
72
|
props = _parse_txt_properties(info.properties or {})
|
|
73
73
|
# Prefer .server (the .local hostname) over parsed addresses
|
|
74
|
-
host =
|
|
75
|
-
info.
|
|
74
|
+
host = (
|
|
75
|
+
info.server.rstrip(".")
|
|
76
|
+
if info.server
|
|
77
|
+
else (info.parsed_addresses()[0] if info.parsed_addresses() else "localhost")
|
|
76
78
|
)
|
|
77
79
|
return cls(
|
|
78
80
|
name=info.name,
|
|
@@ -91,6 +93,7 @@ def _import_zeroconf():
|
|
|
91
93
|
"""Lazy-import zeroconf with a helpful error message."""
|
|
92
94
|
try:
|
|
93
95
|
import zeroconf # noqa: F811
|
|
96
|
+
|
|
94
97
|
return zeroconf
|
|
95
98
|
except ImportError:
|
|
96
99
|
raise ImportError(
|
|
@@ -157,9 +160,7 @@ def resolve_url(
|
|
|
157
160
|
|
|
158
161
|
if mode == DiscoveryMode.REQUIRE_LOCAL:
|
|
159
162
|
if not discovered_url:
|
|
160
|
-
raise RuntimeError(
|
|
161
|
-
"discovery='require_local' but no VTN found via mDNS"
|
|
162
|
-
)
|
|
163
|
+
raise RuntimeError("discovery='require_local' but no VTN found via mDNS")
|
|
163
164
|
return discovered_url
|
|
164
165
|
|
|
165
166
|
if mode == DiscoveryMode.PREFER_LOCAL:
|
|
@@ -167,9 +168,7 @@ def resolve_url(
|
|
|
167
168
|
return discovered_url
|
|
168
169
|
if configured_url:
|
|
169
170
|
return configured_url
|
|
170
|
-
raise RuntimeError(
|
|
171
|
-
"discovery='prefer_local': no VTN found via mDNS and no url configured"
|
|
172
|
-
)
|
|
171
|
+
raise RuntimeError("discovery='prefer_local': no VTN found via mDNS and no url configured")
|
|
173
172
|
|
|
174
173
|
# LOCAL_WITH_FALLBACK
|
|
175
174
|
if discovered_url:
|
|
@@ -241,9 +240,7 @@ def advertise_vtn(
|
|
|
241
240
|
server=f"{host}.",
|
|
242
241
|
port=port,
|
|
243
242
|
properties=properties,
|
|
244
|
-
addresses=[socket.inet_aton(
|
|
245
|
-
"127.0.0.1" if host in ("localhost", "127.0.0.1") else host
|
|
246
|
-
)],
|
|
243
|
+
addresses=[socket.inet_aton("127.0.0.1" if host in ("localhost", "127.0.0.1") else host)],
|
|
247
244
|
)
|
|
248
245
|
|
|
249
246
|
zc = Zeroconf()
|
|
@@ -9,15 +9,18 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
|
-
import re
|
|
13
12
|
import threading
|
|
14
13
|
import time
|
|
15
|
-
from
|
|
16
|
-
from
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
17
|
from urllib.parse import urlparse
|
|
18
18
|
|
|
19
19
|
from openadr3.entities import coerce_notification, is_notification
|
|
20
20
|
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ebus_mqtt_client import MqttClient
|
|
23
|
+
|
|
21
24
|
log = logging.getLogger(__name__)
|
|
22
25
|
|
|
23
26
|
|
|
@@ -54,9 +57,7 @@ def _parse_payload(raw: bytes, topic: str) -> Any:
|
|
|
54
57
|
return s
|
|
55
58
|
|
|
56
59
|
if isinstance(parsed, dict) and is_notification(parsed):
|
|
57
|
-
return coerce_notification(
|
|
58
|
-
parsed, {"openadr/channel": "mqtt", "openadr/topic": topic}
|
|
59
|
-
)
|
|
60
|
+
return coerce_notification(parsed, {"openadr/channel": "mqtt", "openadr/topic": topic})
|
|
60
61
|
return parsed
|
|
61
62
|
|
|
62
63
|
|
|
@@ -96,11 +97,11 @@ class MQTTConnection:
|
|
|
96
97
|
"""Connect to the MQTT broker."""
|
|
97
98
|
try:
|
|
98
99
|
from ebus_mqtt_client import MqttClient
|
|
99
|
-
except ImportError:
|
|
100
|
+
except ImportError as err:
|
|
100
101
|
raise ImportError(
|
|
101
102
|
"ebus-mqtt-client is required for MQTT support. "
|
|
102
103
|
"Install it with: pip install python-oa3-client[mqtt]"
|
|
103
|
-
)
|
|
104
|
+
) from err
|
|
104
105
|
|
|
105
106
|
host, port, use_tls = normalize_broker_uri(self.broker_url)
|
|
106
107
|
self._client = MqttClient(
|
|
@@ -7,10 +7,11 @@ the lower-level MQTTConnection and WebhookReceiver.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
|
-
from
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
12
|
|
|
12
13
|
from openadr3_client.mqtt import MQTTConnection, MQTTMessage
|
|
13
|
-
from openadr3_client.webhook import
|
|
14
|
+
from openadr3_client.webhook import WebhookMessage, WebhookReceiver
|
|
14
15
|
|
|
15
16
|
log = logging.getLogger(__name__)
|
|
16
17
|
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
|
-
|
|
10
|
-
from openadr3.
|
|
10
|
+
from openadr3.api import success
|
|
11
|
+
from openadr3.entities import coerce
|
|
12
|
+
from openadr3.entities.models import Event, Program
|
|
11
13
|
|
|
12
14
|
from openadr3_client.base import BaseClient
|
|
13
15
|
from openadr3_client.notifications import (
|
|
@@ -83,16 +85,16 @@ class VenClient(BaseClient):
|
|
|
83
85
|
vid = existing["id"]
|
|
84
86
|
log.info("VEN found, reusing: name=%s id=%s", ven_name, vid)
|
|
85
87
|
else:
|
|
86
|
-
resp = self.api.create_ven(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
resp = self.api.create_ven(
|
|
89
|
+
{
|
|
90
|
+
"objectType": "VEN_VEN_REQUEST",
|
|
91
|
+
"venName": ven_name,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
90
94
|
resp.raise_for_status()
|
|
91
95
|
vid = resp.json().get("id")
|
|
92
96
|
if not vid:
|
|
93
|
-
raise RuntimeError(
|
|
94
|
-
f"VEN registration failed: {resp.status_code} {resp.text}"
|
|
95
|
-
)
|
|
97
|
+
raise RuntimeError(f"VEN registration failed: {resp.status_code} {resp.text}")
|
|
96
98
|
log.info("VEN registered: name=%s id=%s", ven_name, vid)
|
|
97
99
|
self._ven_id = vid
|
|
98
100
|
self._ven_name = ven_name
|
|
@@ -100,12 +102,23 @@ class VenClient(BaseClient):
|
|
|
100
102
|
|
|
101
103
|
# -- Program lookup --
|
|
102
104
|
|
|
103
|
-
def find_program_by_name(self, name: str) ->
|
|
104
|
-
"""Query VTN for a program by programName. Caches the ID on success.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
def find_program_by_name(self, name: str) -> Program | None:
|
|
106
|
+
"""Query VTN for a program by programName. Caches the ID on success.
|
|
107
|
+
|
|
108
|
+
Returns a coerced Program model (or None), matching the shape of
|
|
109
|
+
programs() and poll_events() which also return coerced models.
|
|
110
|
+
"""
|
|
111
|
+
raw = self.api.find_program_by_name(name)
|
|
112
|
+
if not raw:
|
|
113
|
+
return None
|
|
114
|
+
program = coerce(raw)
|
|
115
|
+
if not isinstance(program, Program):
|
|
116
|
+
raise TypeError(
|
|
117
|
+
f"Expected Program from find_program_by_name, got {type(program).__name__}"
|
|
118
|
+
)
|
|
119
|
+
if program.id:
|
|
120
|
+
self._program_cache[name] = program.id
|
|
121
|
+
return program
|
|
109
122
|
|
|
110
123
|
def resolve_program_id(self, name: str) -> str:
|
|
111
124
|
"""Cached name→ID lookup. Queries VTN if not cached.
|
|
@@ -135,9 +148,7 @@ class VenClient(BaseClient):
|
|
|
135
148
|
return False
|
|
136
149
|
# VTN-RI returns a list of notifier dicts with "transport" field
|
|
137
150
|
if isinstance(notifiers, list):
|
|
138
|
-
return any(
|
|
139
|
-
n.get("transport", "").upper() == "MQTT" for n in notifiers
|
|
140
|
-
)
|
|
151
|
+
return any(n.get("transport", "").upper() == "MQTT" for n in notifiers)
|
|
141
152
|
# Or it might be a dict with transport info
|
|
142
153
|
return "mqtt" in str(notifiers).lower()
|
|
143
154
|
|
|
@@ -213,23 +224,27 @@ class VenClient(BaseClient):
|
|
|
213
224
|
all_topics.extend(topics)
|
|
214
225
|
elif isinstance(channel, WebhookChannel):
|
|
215
226
|
# Create a VTN subscription pointing to the webhook
|
|
216
|
-
self.api.create_subscription(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
self.api.create_subscription(
|
|
228
|
+
{
|
|
229
|
+
"clientName": self._ven_name or "ven-client",
|
|
230
|
+
"programID": program_id,
|
|
231
|
+
"objectOperations": [
|
|
232
|
+
{
|
|
233
|
+
"objects": objects,
|
|
234
|
+
"operations": operations,
|
|
235
|
+
"callbackUrl": channel.callback_url,
|
|
236
|
+
"bearerToken": channel._receiver.bearer_token,
|
|
237
|
+
}
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
)
|
|
226
241
|
|
|
227
242
|
return all_topics
|
|
228
243
|
|
|
229
244
|
# -- Poll events --
|
|
230
245
|
|
|
231
|
-
def poll_events(self, program_name: str) -> list:
|
|
232
|
-
"""GET events filtered by program name."""
|
|
246
|
+
def poll_events(self, program_name: str) -> list[Event]:
|
|
247
|
+
"""GET events filtered by program name. Returns coerced Event models."""
|
|
233
248
|
program_id = self.resolve_program_id(program_name)
|
|
234
249
|
return self.api.events(programID=program_id)
|
|
235
250
|
|
|
@@ -11,12 +11,12 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
13
|
import logging
|
|
14
|
+
import socket
|
|
14
15
|
import threading
|
|
15
16
|
import time
|
|
17
|
+
from collections.abc import Callable
|
|
16
18
|
from dataclasses import dataclass
|
|
17
|
-
from typing import Any
|
|
18
|
-
|
|
19
|
-
import socket
|
|
19
|
+
from typing import Any
|
|
20
20
|
|
|
21
21
|
from openadr3.entities import coerce_notification, is_notification
|
|
22
22
|
|
|
@@ -66,9 +66,7 @@ def _parse_webhook_payload(raw: bytes, path: str) -> Any:
|
|
|
66
66
|
return s
|
|
67
67
|
|
|
68
68
|
if isinstance(parsed, dict) and is_notification(parsed):
|
|
69
|
-
return coerce_notification(
|
|
70
|
-
parsed, {"openadr/channel": "webhook", "openadr/path": path}
|
|
71
|
-
)
|
|
69
|
+
return coerce_notification(parsed, {"openadr/channel": "webhook", "openadr/path": path})
|
|
72
70
|
return parsed
|
|
73
71
|
|
|
74
72
|
|
|
@@ -120,12 +118,12 @@ class WebhookReceiver:
|
|
|
120
118
|
def start(self) -> None:
|
|
121
119
|
"""Start the webhook server in a background thread."""
|
|
122
120
|
try:
|
|
123
|
-
from flask import Flask,
|
|
124
|
-
except ImportError:
|
|
121
|
+
from flask import Flask, abort, request
|
|
122
|
+
except ImportError as err:
|
|
125
123
|
raise ImportError(
|
|
126
124
|
"Flask is required for webhook support. "
|
|
127
125
|
"Install it with: pip install python-oa3-client[webhooks]"
|
|
128
|
-
)
|
|
126
|
+
) from err
|
|
129
127
|
|
|
130
128
|
app = Flask(__name__)
|
|
131
129
|
# Suppress Flask/werkzeug request logging
|
|
@@ -177,7 +175,9 @@ class WebhookReceiver:
|
|
|
177
175
|
self._server_thread.start()
|
|
178
176
|
log.info(
|
|
179
177
|
"Webhook server started: %s (bind=%s:%d)",
|
|
180
|
-
self.callback_url,
|
|
178
|
+
self.callback_url,
|
|
179
|
+
self.host,
|
|
180
|
+
self.port,
|
|
181
181
|
)
|
|
182
182
|
|
|
183
183
|
def stop(self) -> None:
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
"""Tests for openadr3_client.discovery — mDNS/DNS-SD VTN discovery."""
|
|
2
2
|
|
|
3
|
-
from unittest.mock import
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from openadr3_client.discovery import (
|
|
8
|
+
SERVICE_TYPE,
|
|
8
9
|
DiscoveredVTN,
|
|
9
10
|
DiscoveryMode,
|
|
10
|
-
SERVICE_TYPE,
|
|
11
11
|
_parse_txt_properties,
|
|
12
|
+
advertise_vtn,
|
|
12
13
|
discover_vtns,
|
|
13
14
|
resolve_url,
|
|
14
|
-
advertise_vtn,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
|
|
18
17
|
# -- _parse_txt_properties --
|
|
19
18
|
|
|
20
19
|
|
|
@@ -50,14 +49,18 @@ class TestParseTxtProperties:
|
|
|
50
49
|
class TestDiscoveredVTN:
|
|
51
50
|
def test_url_from_local_url(self):
|
|
52
51
|
vtn = DiscoveredVTN(
|
|
53
|
-
name="test",
|
|
52
|
+
name="test",
|
|
53
|
+
host="vtn.local",
|
|
54
|
+
port=8080,
|
|
54
55
|
local_url="http://vtn.local:8080/openadr3",
|
|
55
56
|
)
|
|
56
57
|
assert vtn.url == "http://vtn.local:8080/openadr3"
|
|
57
58
|
|
|
58
59
|
def test_url_strips_trailing_slash(self):
|
|
59
60
|
vtn = DiscoveredVTN(
|
|
60
|
-
name="test",
|
|
61
|
+
name="test",
|
|
62
|
+
host="vtn.local",
|
|
63
|
+
port=8080,
|
|
61
64
|
local_url="http://vtn.local:8080/openadr3/",
|
|
62
65
|
)
|
|
63
66
|
assert vtn.url == "http://vtn.local:8080/openadr3"
|
|
@@ -68,7 +71,9 @@ class TestDiscoveredVTN:
|
|
|
68
71
|
|
|
69
72
|
def test_url_constructed_with_base_path(self):
|
|
70
73
|
vtn = DiscoveredVTN(
|
|
71
|
-
name="test",
|
|
74
|
+
name="test",
|
|
75
|
+
host="vtn.local",
|
|
76
|
+
port=8080,
|
|
72
77
|
base_path="/openadr3",
|
|
73
78
|
)
|
|
74
79
|
assert vtn.url == "http://vtn.local:8080/openadr3"
|
|
@@ -197,8 +202,9 @@ class TestResolveUrl:
|
|
|
197
202
|
|
|
198
203
|
@patch("openadr3_client.discovery.discover_vtns")
|
|
199
204
|
def test_require_local_found(self, mock_discover):
|
|
200
|
-
vtn = DiscoveredVTN(
|
|
201
|
-
|
|
205
|
+
vtn = DiscoveredVTN(
|
|
206
|
+
name="v", host="vtn.local", port=8080, local_url="http://vtn.local:8080"
|
|
207
|
+
)
|
|
202
208
|
mock_discover.return_value = [vtn]
|
|
203
209
|
assert resolve_url("require_local", None) == "http://vtn.local:8080"
|
|
204
210
|
|
|
@@ -210,8 +216,9 @@ class TestResolveUrl:
|
|
|
210
216
|
|
|
211
217
|
@patch("openadr3_client.discovery.discover_vtns")
|
|
212
218
|
def test_prefer_local_found(self, mock_discover):
|
|
213
|
-
vtn = DiscoveredVTN(
|
|
214
|
-
|
|
219
|
+
vtn = DiscoveredVTN(
|
|
220
|
+
name="v", host="vtn.local", port=8080, local_url="http://vtn.local:8080"
|
|
221
|
+
)
|
|
215
222
|
mock_discover.return_value = [vtn]
|
|
216
223
|
assert resolve_url("prefer_local", "http://cloud.vtn.com") == "http://vtn.local:8080"
|
|
217
224
|
|
|
@@ -228,8 +235,9 @@ class TestResolveUrl:
|
|
|
228
235
|
|
|
229
236
|
@patch("openadr3_client.discovery.discover_vtns")
|
|
230
237
|
def test_local_with_fallback_found(self, mock_discover):
|
|
231
|
-
vtn = DiscoveredVTN(
|
|
232
|
-
|
|
238
|
+
vtn = DiscoveredVTN(
|
|
239
|
+
name="v", host="vtn.local", port=8080, local_url="http://vtn.local:8080"
|
|
240
|
+
)
|
|
233
241
|
mock_discover.return_value = [vtn]
|
|
234
242
|
assert resolve_url("local_with_fallback", "http://cloud.vtn.com") == "http://vtn.local:8080"
|
|
235
243
|
|
|
@@ -269,7 +277,7 @@ class TestAdvertiseVtn:
|
|
|
269
277
|
mock_zc_instance = MagicMock()
|
|
270
278
|
mock_zc_mod.Zeroconf.return_value = mock_zc_instance
|
|
271
279
|
|
|
272
|
-
with advertise_vtn("127.0.0.1", 8080)
|
|
280
|
+
with advertise_vtn("127.0.0.1", 8080):
|
|
273
281
|
mock_zc_instance.register_service.assert_called_once()
|
|
274
282
|
mock_zc_instance.unregister_service.assert_called_once()
|
|
275
283
|
mock_zc_instance.close.assert_called_once()
|
|
@@ -282,7 +290,8 @@ class TestAdvertiseVtn:
|
|
|
282
290
|
mock_zc_mod.Zeroconf.return_value = mock_zc_instance
|
|
283
291
|
|
|
284
292
|
advertise_vtn(
|
|
285
|
-
"127.0.0.1",
|
|
293
|
+
"127.0.0.1",
|
|
294
|
+
8080,
|
|
286
295
|
base_path="/openadr3",
|
|
287
296
|
version="3.1.0",
|
|
288
297
|
program_names="prog1,prog2",
|
|
@@ -302,22 +311,26 @@ class TestAdvertiseVtn:
|
|
|
302
311
|
class TestBaseClientDiscovery:
|
|
303
312
|
def test_default_discovery_never_requires_url(self):
|
|
304
313
|
from openadr3_client.base import BaseClient
|
|
314
|
+
|
|
305
315
|
with pytest.raises(ValueError, match="url is required"):
|
|
306
316
|
BaseClient(token="tok")
|
|
307
317
|
|
|
308
318
|
def test_local_with_fallback_requires_url(self):
|
|
309
319
|
from openadr3_client.base import BaseClient
|
|
320
|
+
|
|
310
321
|
with pytest.raises(ValueError, match="url is required.*local_with_fallback"):
|
|
311
322
|
BaseClient(token="tok", discovery="local_with_fallback")
|
|
312
323
|
|
|
313
324
|
def test_require_local_no_url_ok(self):
|
|
314
325
|
from openadr3_client.base import BaseClient
|
|
326
|
+
|
|
315
327
|
c = BaseClient(token="tok", discovery="require_local")
|
|
316
328
|
assert c.url is None
|
|
317
329
|
assert c.discovery_mode == DiscoveryMode.REQUIRE_LOCAL
|
|
318
330
|
|
|
319
331
|
def test_prefer_local_no_url_ok(self):
|
|
320
332
|
from openadr3_client.base import BaseClient
|
|
333
|
+
|
|
321
334
|
c = BaseClient(token="tok", discovery="prefer_local")
|
|
322
335
|
assert c.url is None
|
|
323
336
|
|
|
@@ -325,13 +338,16 @@ class TestBaseClientDiscovery:
|
|
|
325
338
|
@patch("openadr3_client.base.create_ven_client")
|
|
326
339
|
def test_start_uses_resolved_url(self, mock_create, mock_resolve):
|
|
327
340
|
from openadr3_client.base import BaseClient
|
|
341
|
+
|
|
328
342
|
mock_create.return_value = MagicMock()
|
|
329
343
|
|
|
330
344
|
c = BaseClient(token="tok", discovery="require_local")
|
|
331
345
|
c.start()
|
|
332
346
|
|
|
333
347
|
mock_resolve.assert_called_once_with(
|
|
334
|
-
DiscoveryMode.REQUIRE_LOCAL,
|
|
348
|
+
DiscoveryMode.REQUIRE_LOCAL,
|
|
349
|
+
None,
|
|
350
|
+
3.0,
|
|
335
351
|
)
|
|
336
352
|
mock_create.assert_called_once()
|
|
337
353
|
assert mock_create.call_args[1]["base_url"] == "http://discovered:8080"
|
|
@@ -341,11 +357,14 @@ class TestBaseClientDiscovery:
|
|
|
341
357
|
@patch("openadr3_client.base.create_ven_client")
|
|
342
358
|
def test_start_never_mode_passes_url(self, mock_create, mock_resolve):
|
|
343
359
|
from openadr3_client.base import BaseClient
|
|
360
|
+
|
|
344
361
|
mock_create.return_value = MagicMock()
|
|
345
362
|
|
|
346
363
|
c = BaseClient(url="http://test", token="tok")
|
|
347
364
|
c.start()
|
|
348
365
|
|
|
349
366
|
mock_resolve.assert_called_once_with(
|
|
350
|
-
DiscoveryMode.NEVER,
|
|
367
|
+
DiscoveryMode.NEVER,
|
|
368
|
+
"http://test",
|
|
369
|
+
3.0,
|
|
351
370
|
)
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
"""Tests for openadr3_client.mqtt."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import
|
|
5
|
-
import threading
|
|
6
|
-
from unittest.mock import patch, MagicMock
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
7
5
|
|
|
8
6
|
import pytest
|
|
9
7
|
|
|
10
8
|
from openadr3_client.mqtt import (
|
|
11
9
|
MQTTConnection,
|
|
12
|
-
MQTTMessage,
|
|
13
|
-
normalize_broker_uri,
|
|
14
10
|
_parse_payload,
|
|
11
|
+
normalize_broker_uri,
|
|
15
12
|
)
|
|
16
13
|
|
|
17
14
|
|
|
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch
|
|
|
4
4
|
|
|
5
5
|
from openadr3_client.notifications import (
|
|
6
6
|
MqttChannel,
|
|
7
|
-
NotificationChannel,
|
|
8
7
|
WebhookChannel,
|
|
9
8
|
)
|
|
10
9
|
|
|
@@ -12,12 +11,26 @@ from openadr3_client.notifications import (
|
|
|
12
11
|
class TestNotificationChannelProtocol:
|
|
13
12
|
def test_mqtt_channel_has_protocol_methods(self):
|
|
14
13
|
"""MqttChannel has all NotificationChannel methods."""
|
|
15
|
-
for name in (
|
|
14
|
+
for name in (
|
|
15
|
+
"start",
|
|
16
|
+
"stop",
|
|
17
|
+
"subscribe_topics",
|
|
18
|
+
"messages",
|
|
19
|
+
"await_messages",
|
|
20
|
+
"clear_messages",
|
|
21
|
+
):
|
|
16
22
|
assert hasattr(MqttChannel, name), f"MqttChannel missing {name}"
|
|
17
23
|
|
|
18
24
|
def test_webhook_channel_has_protocol_methods(self):
|
|
19
25
|
"""WebhookChannel has all NotificationChannel methods."""
|
|
20
|
-
for name in (
|
|
26
|
+
for name in (
|
|
27
|
+
"start",
|
|
28
|
+
"stop",
|
|
29
|
+
"subscribe_topics",
|
|
30
|
+
"messages",
|
|
31
|
+
"await_messages",
|
|
32
|
+
"clear_messages",
|
|
33
|
+
):
|
|
21
34
|
assert hasattr(WebhookChannel, name), f"WebhookChannel missing {name}"
|
|
22
35
|
|
|
23
36
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Tests for openadr3_client.ven — VenClient registration, program lookup, subscribe."""
|
|
2
2
|
|
|
3
|
-
from unittest.mock import
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
import pytest
|
|
7
|
+
from openadr3.entities.models import Program
|
|
7
8
|
|
|
8
9
|
from openadr3_client.ven import VenClient, extract_topics
|
|
9
10
|
|
|
@@ -54,16 +55,20 @@ class TestVenClientRegistration:
|
|
|
54
55
|
def test_register_new_ven(self, mock_create):
|
|
55
56
|
mock_api = MagicMock()
|
|
56
57
|
mock_api.find_ven_by_name.return_value = None
|
|
57
|
-
mock_api.create_ven.return_value = _make_response(
|
|
58
|
+
mock_api.create_ven.return_value = _make_response(
|
|
59
|
+
201, {"id": "new-ven-456", "venName": "my-ven"}
|
|
60
|
+
)
|
|
58
61
|
mock_create.return_value = mock_api
|
|
59
62
|
|
|
60
63
|
with VenClient(url="http://test", token="tok") as ven:
|
|
61
64
|
ven.register("my-ven")
|
|
62
65
|
assert ven.ven_id == "new-ven-456"
|
|
63
|
-
mock_api.create_ven.assert_called_once_with(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
mock_api.create_ven.assert_called_once_with(
|
|
67
|
+
{
|
|
68
|
+
"objectType": "VEN_VEN_REQUEST",
|
|
69
|
+
"venName": "my-ven",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
67
72
|
|
|
68
73
|
def test_require_ven_id_not_registered(self):
|
|
69
74
|
ven = VenClient(url="http://test", token="tok")
|
|
@@ -76,13 +81,17 @@ class TestVenClientProgramLookup:
|
|
|
76
81
|
def test_find_program_by_name(self, mock_create):
|
|
77
82
|
mock_api = MagicMock()
|
|
78
83
|
mock_api.find_program_by_name.return_value = {
|
|
79
|
-
"
|
|
84
|
+
"objectType": "PROGRAM",
|
|
85
|
+
"id": "prog-1",
|
|
86
|
+
"programName": "pricing",
|
|
80
87
|
}
|
|
81
88
|
mock_create.return_value = mock_api
|
|
82
89
|
|
|
83
90
|
with VenClient(url="http://test", token="tok") as ven:
|
|
84
91
|
result = ven.find_program_by_name("pricing")
|
|
85
|
-
assert result
|
|
92
|
+
assert isinstance(result, Program)
|
|
93
|
+
assert result.id == "prog-1"
|
|
94
|
+
assert result.program_name == "pricing"
|
|
86
95
|
assert ven._program_cache["pricing"] == "prog-1"
|
|
87
96
|
|
|
88
97
|
@patch("openadr3_client.base.create_ven_client")
|
|
@@ -111,7 +120,9 @@ class TestVenClientProgramLookup:
|
|
|
111
120
|
def test_resolve_program_id_queries(self, mock_create):
|
|
112
121
|
mock_api = MagicMock()
|
|
113
122
|
mock_api.find_program_by_name.return_value = {
|
|
114
|
-
"
|
|
123
|
+
"objectType": "PROGRAM",
|
|
124
|
+
"id": "prog-2",
|
|
125
|
+
"programName": "dr-program",
|
|
115
126
|
}
|
|
116
127
|
mock_create.return_value = mock_api
|
|
117
128
|
|
|
@@ -125,18 +136,23 @@ class TestVenClientProgramLookup:
|
|
|
125
136
|
mock_api.find_program_by_name.return_value = None
|
|
126
137
|
mock_create.return_value = mock_api
|
|
127
138
|
|
|
128
|
-
with
|
|
129
|
-
|
|
130
|
-
|
|
139
|
+
with (
|
|
140
|
+
VenClient(url="http://test", token="tok") as ven,
|
|
141
|
+
pytest.raises(KeyError, match="Program not found"),
|
|
142
|
+
):
|
|
143
|
+
ven.resolve_program_id("missing")
|
|
131
144
|
|
|
132
145
|
|
|
133
146
|
class TestVenClientNotifiers:
|
|
134
147
|
@patch("openadr3_client.base.create_ven_client")
|
|
135
148
|
def test_discover_notifiers(self, mock_create):
|
|
136
149
|
mock_api = MagicMock()
|
|
137
|
-
mock_api.get_notifiers.return_value = _make_response(
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
mock_api.get_notifiers.return_value = _make_response(
|
|
151
|
+
200,
|
|
152
|
+
[
|
|
153
|
+
{"transport": "MQTT", "url": "mqtt://broker:1883"},
|
|
154
|
+
],
|
|
155
|
+
)
|
|
140
156
|
mock_create.return_value = mock_api
|
|
141
157
|
|
|
142
158
|
with VenClient(url="http://test", token="tok") as ven:
|
|
@@ -147,9 +163,12 @@ class TestVenClientNotifiers:
|
|
|
147
163
|
@patch("openadr3_client.base.create_ven_client")
|
|
148
164
|
def test_vtn_supports_mqtt_true(self, mock_create):
|
|
149
165
|
mock_api = MagicMock()
|
|
150
|
-
mock_api.get_notifiers.return_value = _make_response(
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
mock_api.get_notifiers.return_value = _make_response(
|
|
167
|
+
200,
|
|
168
|
+
[
|
|
169
|
+
{"transport": "MQTT", "url": "mqtt://broker:1883"},
|
|
170
|
+
],
|
|
171
|
+
)
|
|
153
172
|
mock_create.return_value = mock_api
|
|
154
173
|
|
|
155
174
|
with VenClient(url="http://test", token="tok") as ven:
|
|
@@ -158,9 +177,12 @@ class TestVenClientNotifiers:
|
|
|
158
177
|
@patch("openadr3_client.base.create_ven_client")
|
|
159
178
|
def test_vtn_supports_mqtt_false(self, mock_create):
|
|
160
179
|
mock_api = MagicMock()
|
|
161
|
-
mock_api.get_notifiers.return_value = _make_response(
|
|
162
|
-
|
|
163
|
-
|
|
180
|
+
mock_api.get_notifiers.return_value = _make_response(
|
|
181
|
+
200,
|
|
182
|
+
[
|
|
183
|
+
{"transport": "HTTP", "url": "http://example.com"},
|
|
184
|
+
],
|
|
185
|
+
)
|
|
164
186
|
mock_create.return_value = mock_api
|
|
165
187
|
|
|
166
188
|
with VenClient(url="http://test", token="tok") as ven:
|
|
@@ -200,9 +222,11 @@ class TestVenClientMqttTopics:
|
|
|
200
222
|
@patch("openadr3_client.base.create_ven_client")
|
|
201
223
|
def test_ven_scoped_not_registered(self, mock_create):
|
|
202
224
|
mock_create.return_value = MagicMock()
|
|
203
|
-
with
|
|
204
|
-
|
|
205
|
-
|
|
225
|
+
with (
|
|
226
|
+
VenClient(url="http://test", token="tok") as ven,
|
|
227
|
+
pytest.raises(RuntimeError, match="VEN not registered"),
|
|
228
|
+
):
|
|
229
|
+
ven.get_mqtt_topics_ven()
|
|
206
230
|
|
|
207
231
|
|
|
208
232
|
class TestVenClientGetattr:
|
|
@@ -228,7 +252,9 @@ class TestVenClientSubscribe:
|
|
|
228
252
|
def test_subscribe_mqtt(self, mock_create):
|
|
229
253
|
mock_api = MagicMock()
|
|
230
254
|
mock_api.find_program_by_name.return_value = {
|
|
231
|
-
"
|
|
255
|
+
"objectType": "PROGRAM",
|
|
256
|
+
"id": "prog-1",
|
|
257
|
+
"programName": "pricing",
|
|
232
258
|
}
|
|
233
259
|
mock_api.get_mqtt_topics_program_events.return_value = _make_response(
|
|
234
260
|
200, {"topics": {"a": "openadr3/programs/prog-1/events"}}
|
|
@@ -236,9 +262,10 @@ class TestVenClientSubscribe:
|
|
|
236
262
|
mock_create.return_value = mock_api
|
|
237
263
|
|
|
238
264
|
with VenClient(url="http://test", token="tok") as ven:
|
|
239
|
-
from openadr3_client.notifications import MqttChannel
|
|
240
265
|
from unittest.mock import patch as p
|
|
241
266
|
|
|
267
|
+
from openadr3_client.notifications import MqttChannel
|
|
268
|
+
|
|
242
269
|
with p.object(MqttChannel, "subscribe_topics") as mock_sub:
|
|
243
270
|
ch = MqttChannel.__new__(MqttChannel)
|
|
244
271
|
ch._conn = MagicMock()
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"""Tests for openadr3_client.webhook."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import time
|
|
5
4
|
import threading
|
|
5
|
+
import time
|
|
6
6
|
|
|
7
|
-
import pytest
|
|
8
7
|
import httpx
|
|
8
|
+
import pytest
|
|
9
9
|
|
|
10
10
|
from openadr3_client.webhook import (
|
|
11
11
|
WebhookReceiver,
|
|
12
|
-
WebhookMessage,
|
|
13
12
|
_parse_webhook_payload,
|
|
14
13
|
detect_lan_ip,
|
|
15
14
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|