Skip to main content

python-oa3-client

PyPI version Python versions Lint Ruff License: MIT

OpenADR 3 companion client with VEN/BL client framework, lifecycle management, and optional MQTT and webhook notification channels.

Built on top of openadr3 (Pydantic models, httpx HTTP client).

Install

pip install python-oa3-client            # core: VEN/BL clients, API access
pip install python-oa3-client[mqtt]      # + MQTT notifications
pip install python-oa3-client[webhooks]  # + webhook receiver
pip install python-oa3-client[mdns]      # + mDNS/DNS-SD VTN discovery
pip install python-oa3-client[all]       # everything

The core package depends only on openadr3. Notification channels are optional extras:

Extra Adds Dependency
mqtt MQTT broker connection, topic discovery, message collection ebus-mqtt-client (paho-mqtt v2)
webhooks HTTP webhook receiver for VTN callbacks Flask
mdns mDNS/DNS-SD VTN discovery (_openadr3._tcp.) zeroconf
all All of the above

Architecture

BaseClient          — auth, lifecycle, __getattr__ delegation to OpenADRClient
├── VenClient       — VEN registration, program lookup, notification subscribe
└── BlClient        — thin wrapper, client_type="bl", no VEN concepts

All OpenADRClient methods (raw HTTP, coerced entities, introspection) are available directly on both client types via __getattr__ delegation — no explicit delegation methods needed.

Authentication

Two auth modes:

Direct token — provide a Bearer token directly:

ven = VenClient(url=vtn_url, token=my_token)

OAuth2 client credentials — token fetched automatically on start():

ven = VenClient(
    url=vtn_url,
    client_id="my_client",
    client_secret="my_secret",
)

For the OpenADR 3 VTN Reference Implementation, the default auth uses basic credentials encoded as base64(client_id:secret):

import base64
bl_token = base64.b64encode(b"bl_client:1001").decode()
ven_token = base64.b64encode(b"ven_client:999").decode()

User-Agent

Clients send a composed User-Agent header for server-side log identification:

python-oa3-client/0.4.0 openadr3/0.3.0 (node=a1b2c3d4e5f6)

Add your own identifier with the user_agent parameter:

ven = VenClient(
    url=vtn_url,
    token=token,
    user_agent="my-thermostat/1.0 (contact@example.com)",
)
# UA: python-oa3-client/0.4.0 openadr3/0.3.0 (node=...) my-thermostat/1.0 (contact@example.com)

mDNS/DNS-SD Discovery

Requires: pip install python-oa3-client[mdns]

The OpenADR 3.1.0 spec defines mDNS service discovery for local VTNs using service type _openadr3._tcp.. Clients can discover VTNs on the local network without a configured URL.

Discovery modes

Mode Behavior
"never" (default) Skip mDNS, use configured url. Current behavior.
"prefer_local" Try mDNS; use discovered VTN if found; fall back to url; raise if neither.
"local_with_fallback" Try mDNS; fall back to configured url if not found (requires url).
"require_local" Try mDNS; raise if no VTN found. No url needed.

Zero-config VEN

from openadr3_client import VenClient

# No URL needed — discovers VTN on the local network
with VenClient(token=token, discovery="require_local") as ven:
    ven.register("my-thermostat")
    events = ven.events()

Discovery with cloud fallback

with VenClient(
    url="https://cloud-vtn.example.com/openadr3/3.1.0",
    token=token,
    discovery="local_with_fallback",
    discovery_timeout=3.0,
) as ven:
    # Uses local VTN if found, otherwise cloud URL
    ven.register("my-thermostat")

Standalone discovery

from openadr3_client import discover_vtns

vtns = discover_vtns(timeout=3.0)
for v in vtns:
    print(f"{v.name} at {v.url} (version={v.version})")

Advertising a VTN for testing

For testing mDNS discovery without modifying the VTN itself:

from openadr3_client import advertise_vtn

with advertise_vtn(
    host="127.0.0.1",
    port=8080,
    base_path="/openadr3/3.1.0",
    local_url="http://127.0.0.1:8080/openadr3/3.1.0",
    version="3.1.0",
) as adv:
    # VTN is now visible via mDNS
    # ... run discovery tests ...
# Service unregistered on exit

VEN Client

VenClient is the primary interface for VEN developers:

from openadr3_client import VenClient

with VenClient(url="http://vtn:8080/openadr3/3.1.0", token=token) as ven:
    # Register VEN (idempotent — finds existing or creates new)
    ven.register("my-thermostat-ven")

    # Find a specific program
    pricing = ven.find_program_by_name("residential-pricing")

    # Check notification support
    if ven.vtn_supports_mqtt():
        mqtt = ven.add_mqtt("mqtts://broker:8883")
        mqtt.start()
        ven.subscribe(
            program_names=["residential-pricing"],
            objects=["EVENT"],
            operations=["CREATE", "UPDATE"],
            channel=mqtt,
        )
        msgs = mqtt.await_messages(1, timeout=30.0)
    else:
        events = ven.poll_events(program_name="residential-pricing")

    # All OpenADRClient methods work via __getattr__
    resp = ven.get_subscriptions()
    reports = ven.reports()

VEN registration

ven.register("my-ven")
print(ven.ven_id)    # "ven-abc-123"
print(ven.ven_name)  # "my-ven"

Program lookup

# Query by name (caches ID)
program = ven.find_program_by_name("residential-pricing")

# Cached name→ID resolution
pid = ven.resolve_program_id("residential-pricing")

Notifier discovery

notifiers = ven.discover_notifiers()
supports_mqtt = ven.vtn_supports_mqtt()

# Extract broker URIs from /notifiers — handles both spec
# ({"MQTT": {"URIS": [...]}}) and VTN-RI ([{"transport": "MQTT", "url": ...}])
# response shapes. Returns [] if MQTT isn't advertised.
uris = ven.get_mqtt_broker_uris()
if uris:
    mqtt = ven.add_mqtt(uris[0])

URIs from /notifiers may use any of the mqtt://, mqtts://, tcp://, or ssl:// schemes (or no scheme at all — broker.example.com:1883). MqttChannel and MQTTConnection accept all of these via normalize_broker_uri.

VEN-scoped topic methods

Default to the registered ven_id when called without arguments:

ven.register("my-ven")
resp = ven.get_mqtt_topics_ven()           # uses registered ven_id
resp = ven.get_mqtt_topics_ven_events()
resp = ven.get_mqtt_topics_ven("other-id") # explicit ven_id

BL Client

For business logic (creating programs, events):

from openadr3_client import BlClient

with BlClient(url=vtn_url, token=bl_token) as bl:
    bl.create_program({
        "programName": "tariff-program",
        "programType": "PRICING_TARIFF",
        "country": "US",
        "principalSubdivision": "CA",
        "intervalPeriod": {"start": "2024-01-01T00:00:00Z", "duration": "P1Y"},
    })
    bl.create_event({...})

Notification Channels

MqttChannel

Requires: pip install python-oa3-client[mqtt]

mqtt = ven.add_mqtt("mqtt://broker:1883", client_id="my-ven-mqtt")
mqtt.start()

# Manual topic subscription
mqtt.subscribe_topics(["openadr3/#"])

# Or use ven.subscribe() for program-aware subscription
ven.subscribe(
    program_names=["residential-pricing"],
    objects=["EVENT"],
    operations=["CREATE", "UPDATE"],
    channel=mqtt,
)

msgs = mqtt.await_messages(n=1, timeout=10.0)
for msg in msgs:
    print(msg.topic, msg.payload)

mqtt.stop()

TLS connections: use mqtts:// scheme (default port 8883).

WebhookChannel

Requires: pip install python-oa3-client[webhooks]

webhook = ven.add_webhook(
    port=0,                        # OS-assigned ephemeral port
    bearer_token="my-secret",
    callback_host="192.168.1.50",  # IP reachable from VTN
)
webhook.start()
print(webhook.callback_url)  # "http://192.168.1.50:54321/notifications"

# Subscribe creates VTN subscription with callback URL
ven.subscribe(
    program_names=["residential-pricing"],
    objects=["EVENT"],
    operations=["CREATE", "UPDATE"],
    channel=webhook,
)

msgs = webhook.await_messages(n=1, timeout=10.0)
webhook.stop()

Channel lifecycle

Channels are created but not started automatically. You control the lifecycle:

mqtt = ven.add_mqtt(broker_url)  # Created, not connected
mqtt.start()                      # Connected
# ... use ...
mqtt.stop()                       # Disconnected

When VenClient stops (via stop() or context manager exit), all channels are stopped automatically.

Message types

MQTTMessage:

Field Type Description
topic str MQTT topic
payload Any Parsed JSON, or coerced Notification
time float Unix timestamp
raw_payload bytes Original bytes

WebhookMessage:

Field Type Description
path str URL path
payload Any Parsed JSON, or coerced Notification
time float Unix timestamp
raw_payload bytes Original request body

Time and timezones

python-oa3-client does no datetime parsing of its own — all time handling is delegated to openadr3, which is the reference compliant implementation of the cross-implementation zone-handling rule.

  • Datetime fields on coerced entities (e.g. event.created, interval_period.start) and on coerced notification payloads (delivered by MqttChannel and WebhookChannel) are surfaced as zone-aware pendulum.DateTime.
  • The wire string's UTC offset is the source of truth and is preserved end-to-end. Z, +00:00, -07:00, +05:30 round-trip exactly — no normalization. See python-oa3 README — Time and Timezones for the canonical specification and round-trip table.
  • Cross-implementation parity with clj-oa3-client (which surfaces java.time.ZonedDateTime under the same rule).

Propagation through this client's MQTT and webhook parse paths is locked in by tests/test_time_propagation.py.

Direct API access

All OpenADRClient methods are available on both VenClient and BlClient via __getattr__:

# Raw HTTP (returns httpx.Response)
resp = ven.get_programs(skip=0, limit=10)
resp = ven.create_subscription({...})

# Coerced entities (returns Pydantic models)
programs = ven.programs()
event = ven.event("evt-001")
reports = ven.reports()
subscriptions = ven.subscriptions()

# Introspection (requires spec_path)
routes = ven.all_routes()
scopes = ven.endpoint_scopes("/programs", "get")

Low-level components

The standalone MQTTConnection, WebhookReceiver, extract_topics, normalize_broker_uri, and detect_lan_ip are still exported for direct use.

Examples

Development

git clone https://github.com/grid-coordination/python-oa3-client
cd python-oa3-client
pip install -e ".[dev]"
pytest tests/ -v

Contributing

Issues, Discussions, and pull requests are welcome — see CONTRIBUTING.md for the workflow (and the dev commands: tests, lint, format, pre-commit). In short:

  • Questions, API/design discussion, VTN behavior gapsDiscussions
  • Confirmed bugs, channel/discovery fixes, doc errorsIssues
  • Patches → pull requests; please open a Discussion or Issue first for non-trivial changes (new channel types, new discovery modes, new auth modes, new lifecycle hooks)

Bugs in raw HTTP, coerced entities, or spec-level shapes likely belong in python-oa3 rather than here.

License

MIT License — Copyright (c) 2026 Clark Communications Corporation

Release files for python-oa3-client 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for python-oa3-client 0.5.0
File Size Uploaded
python_oa3_client-0.5.0.tar.gz 37.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for python-oa3-client 0.5.0
File Interpreter ABI Platform
python_oa3_client-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 61.7 kB

Release files / python_oa3_client-0.5.0.tar.gz

Download URL python_oa3_client-0.5.0.tar.gz
Size 37.7 kB
Tags Source
SHA-256 checksum
How to use checksums
c1a9c7a29a1fd1d29a472d3a1b085dea210af0bbd8dd1542dcee1488146b1011
BLAKE2b-256 checksum
How to use checksums
a8a4a38806f3ab5f1df6287b5b2474d992c4d8d6fbadb72d4d69c0d0d0c0401d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on May 25, 2026.

Transparency log

Release files / python_oa3_client-0.5.0-py3-none-any.whl

Download URL python_oa3_client-0.5.0-py3-none-any.whl
Size 24.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d6dd5eb5595f94687c13d3a59f4040fc07bc02f2cd82ce873dcb37aaff9a15c6
BLAKE2b-256 checksum
How to use checksums
60cc410507fd6efa1f4ed01fa3cd93664b9e16bbe6f9f317fd744e16740bc85d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on May 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page