Component-based OpenADR 3 client framework built on clj-oa3.
Provides VenClient and BlClient components with Stuart Sierra's Component lifecycle management, a NotificationChannel protocol for MQTT and webhook notifications, and mDNS discovery for local VTNs.
Add to your deps.edn:
{:deps {energy.grid-coordination/clj-oa3-client {:mvn/version "0.4.0"}}}
_openadr._tcp service typecomponent/Lifecycle"3.1.0" and the correct OpenAPI spec is found on the classpathopenadr3.api functions available through the client:openadr/raw metadata┌────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ (base/programs my-ven) │
│ (ven/subscribe my-ven :mqtt topic-fn) │
│ (ch/channel-messages mqtt-ch) │
├────────────────────────────────────────────────────┤
│ openadr3.client.ven openadr3.client.bl │
│ VenClient Component BlClient Component │
│ • VEN registration • Admin access │
│ • Channel management • Full API │
│ • Program caching │
│ • Notifier discovery │
│ • mDNS VTN discovery │
├────────────────────────────────────────────────────┤
│ openadr3.channel openadr3.discovery │
│ NotificationChannel MdnsDiscoverer │
│ MqttChannel (Component) Component wrapping │
│ WebhookChannel (Component) clj-mdns │
├────────────────────────────────────────────────────┤
│ openadr3.client.base │
│ Spec resolution, token fetch, API delegation │
├────────────────────────────────────────────────────┤
│ clj-oa3 (openadr3.api + openadr3.entities) │
│ Martian + Hato + OpenAPI spec │
└────────────────────────────────────────────────────┘
openadr3.discovery: add it to your own deps.edn (available from Clojars)resources/ symlink)(require '[com.stuartsierra.component :as component]
'[openadr3.client.ven :as ven]
'[openadr3.client.base :as base])
;; Create and start a VEN client
(def my-ven
(component/start
(ven/ven-client {:url "http://localhost:8080/openadr3/3.1.0"
:token "my-ven-token"
:user-agent "my-app/1.0 (contact@example.com)"})))
;; Register with the VTN
(ven/register! my-ven "my-ven-name")
(ven/ven-id my-ven) ;=> "abc-123"
;; API access (via base namespace)
(base/programs my-ven)
(base/get-events my-ven)
;; Stop when done (auto-stops any channels)
(component/stop my-ven)
(require '[openadr3.client.bl :as bl])
(def my-bl
(component/start
(bl/bl-client {:url "http://localhost:8080/openadr3/3.1.0"
:token "my-bl-token"})))
(base/programs my-bl)
(base/create-program my-bl {:programName "My Program"})
(component/stop my-bl)
The NotificationChannel protocol provides a unified interface for MQTT and webhook notifications. Channels wrap the underlying transport (openadr3.mqtt / openadr3.webhook) behind a common API.
;; Add an MQTT channel (creates and starts it)
(ven/add-mqtt my-ven "tcp://broker:1883" {:client-id "my-ven"})
;; With broker authentication (e.g. Mosquitto dynsec)
(ven/add-mqtt my-ven "tcp://broker:1883" {:username "ven-123" :password "secret"})
;; Subscribe to VEN-scoped topics
(ven/subscribe my-ven :mqtt #(ven/get-mqtt-topics-ven %))
;; Check messages
(require '[openadr3.channel :as ch])
(def mqtt-ch (ven/get-channel my-ven :mqtt))
(ch/channel-messages mqtt-ch)
(ch/await-channel-messages mqtt-ch 3 5000)
(ch/clear-channel-messages! mqtt-ch)
;; Channels auto-stop on component/stop
;; Add a webhook channel (creates and starts HTTP server)
(ven/add-webhook my-ven {:port 0 :callback-host "192.168.1.50"})
;; Get the callback URL to register with the VTN
(def wh-ch (ven/get-channel my-ven :webhook))
(ch/callback-url wh-ch)
;; => "http://192.168.1.50:54321/notifications"
;; Check messages
(ch/channel-messages wh-ch)
(ch/await-channel-messages wh-ch 1 10000)
Channels implement both NotificationChannel and component/Lifecycle, so they
work standalone in a component system or managed by VenClient:
;; As Components (component/start delegates to channel-start)
(def mqtt (component/start (ch/mqtt-channel "tcp://broker:1883")))
(ch/subscribe-topics mqtt ["programs/+" "events/+"])
(ch/channel-messages mqtt)
(component/stop mqtt)
;; With broker authentication
(def mqtt (component/start
(ch/mqtt-channel "tcp://broker:1883"
{:username "ven-123" :password "secret"})))
;; Or via the protocol directly
(def wh (-> (ch/webhook-channel {:port 0 :callback-host "192.168.1.50"})
ch/channel-start))
(ch/callback-url wh)
(ch/channel-stop wh)
;; Idempotent — finds existing VEN by name or creates a new one
(ven/register! my-ven "my-ven-name")
(ven/ven-id my-ven) ;=> "abc-123"
(ven/ven-name my-ven) ;=> "my-ven-name"
(ven/resolve-program-id my-ven "MyProgram") ;=> "42" (API call)
(ven/resolve-program-id my-ven "MyProgram") ;=> "42" (cache hit)
(ven/discover-notifiers my-ven) ;=> {:MQTT {:URIS [...]} ...}
(ven/vtn-supports-mqtt? my-ven) ;=> true
(ven/mqtt-broker-urls my-ven) ;=> ["tcp://broker:1883"]
(ven/poll-events my-ven)
(ven/poll-events my-ven {:program-id "42"})
These auto-use the registered VEN ID when called with one argument:
(ven/get-mqtt-topics-ven my-ven) ;; uses registered ven-id
(ven/get-mqtt-topics-ven my-ven "other-id") ;; explicit ID
(ven/get-mqtt-topics-ven-programs my-ven)
(ven/get-mqtt-topics-ven-events my-ven)
(ven/get-mqtt-topics-ven-resources my-ven)
The MdnsDiscoverer component discovers VTNs on the local network via mDNS
(service type _openadr._tcp). It implements component/Lifecycle.
openadr3.discovery needs clj-mdns, which this library does not pull in:
{:deps {energy.grid-coordination/clj-oa3-client {:mvn/version "0.4.0"}
energy.grid-coordination/clj-mdns {:mvn/version "0.1.0-SNAPSHOT"}}}
(require '[openadr3.discovery :as disc])
(def d (component/start (disc/mdns-discoverer)))
(disc/discover-vtns d) ;; sync query, blocks 5s
(disc/discovered-services d) ;; async — services trickle in
(disc/vtn-urls d) ;; extract URLs from discovered services
(component/stop d)
When a VenClient has no :url, it resolves one from the injected discoverer on start:
(def system
(component/start-system
{:discovery (disc/mdns-discoverer)
:ven (component/using
(ven/ven-client {:token "ven_token"}) ;; no URL needed
[:discovery])}))
;; VenClient resolved URL from mDNS
(:url (:ven system)) ;=> "http://192.168.1.10:8080/openadr3/3.1.0"
(component/stop-system system)
| Key | Type | Default | Description |
|---|---|---|---|
:service-type | string | "_openadr._tcp.local." | mDNS service type to discover |
:bind-address | InetAddress | auto-detected LAN IP | Network interface to bind JmDNS to |
Use component/start-system to manage multiple clients as a system:
(def system
(component/start-system
{:ven (ven/ven-client {:url vtn-url :token "ven_token"})
:bl (bl/bl-client {:url vtn-url :token "bl_token"})}))
(base/programs (:bl system))
(base/events (:ven system))
;; Introspection
(base/client-type (:ven system)) ;=> :ven
(base/scopes (:bl system)) ;=> #{"read_all" "read_bl" ...}
(base/authorized? (:ven system) :search-all-events)
(component/stop-system system)
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
:url | string | yes* | — | VTN base URL (omit when using mDNS discovery) |
:token | string | one of | — | Bearer auth token |
:client-id | string | one of | — | OAuth2 client ID (used with :client-secret) |
:client-secret | string | one of | — | OAuth2 client secret (used with :client-id) |
:spec-version | string | no | "3.1.0" | OpenAPI spec version |
:user-agent | string | no | — | Custom User-Agent suffix for self-identification |
:connect-timeout-ms | int | no | 5000 | HttpClient connect timeout |
:request-timeout-ms | int | no | none | Per-request timeout; a request that exceeds it throws an ExceptionInfo whose cause is java.net.http.HttpTimeoutException |
:query-param-extensions | map | no | — | Query params to send although the spec does not declare them, keyed by route, e.g. {:search-all-events [:dateStart :dateEnd] :search-all-programs [:programName]}. Those keys then pass through base/search-events and base/search-programs. |
Either :token or both :client-id and :client-secret must be provided. When using client credentials, the token is fetched during component/start via the VTN's /auth/server endpoint.
Available spec versions: "3.0.0", "3.0.1", "3.1.0", "3.1.1"
Clients send a User-Agent header on every request. When :user-agent is provided, the
final header is composed from all layers:
clj-oa3-client/0.4.0 my-app/1.0 (contact@example.com) clj-oa3/0.5.0 (mac=...)
When omitted, only the library identities are sent. Voluntary UA identification helps server operators understand who is using their service from access logs.
All openadr3.api functions are available through openadr3.client.base. The client is always the first argument.
{:status :body});; Programs
(base/get-programs c)
(base/get-program-by-id c "program-id")
(base/search-programs c {:skip 0 :limit 10})
(base/create-program c {:programName "My Program"})
(base/update-program c "program-id" {:programName "Updated"})
(base/delete-program c "program-id")
(base/find-program-by-name c "My Program")
;; Events, VENs, Resources, Reports, Subscriptions — same pattern
(base/programs c) ;; all programs
(base/program c "id") ;; single program
(base/events c)
(base/vens c)
(base/reports c)
(base/subscriptions c)
;; Access raw data from any coerced entity
(-> (first (base/programs c)) meta :openadr/raw)
(base/success? resp) ;; true if 2xx
(base/body resp) ;; extract :body
(base/get-mqtt-topics-programs c)
(base/get-mqtt-topics-program c "program-id")
(base/get-mqtt-topics-events c)
(base/get-mqtt-topics-reports c)
;; ... 12 topic endpoints total
(base/all-routes c) ;; all 45 route keywords
(base/client-type c) ;=> :ven
(base/scopes c) ;=> #{"read_all" ...}
(base/endpoint-scopes c :search-all-events) ;=> #{"read_all"}
(base/authorized? c :search-all-events) ;=> truthy if allowed
clj-oa3-client has no datetime handling of its own — body parsing and entity coercion is delegated entirely to clj-oa3. Every datetime field surfaced through this library — whether from a synchronous API call, an MQTT notification, or a webhook callback — is a java.time.ZonedDateTime zoned to the offset present on the wire.
For VEN and BL authors, the practical implication is:
;; Synchronous API call
(-> (first (base/events ven)) :openadr.event/interval-period :tick/beginning)
;; => #time/zoned-date-time "2026-05-03T00:00-07:00"
;; Webhook notification (parsed from POST body)
(-> (ch/channel-messages wh-ch) first :payload :openadr.notification/object
:openadr.event/interval-period :tick/beginning)
;; => #time/zoned-date-time "2026-05-03T00:00-07:00"
;; MQTT notification (parsed from broker payload)
(-> (ch/channel-messages mqtt-ch) first :payload :openadr.notification/object
:openadr.event/interval-period :tick/beginning)
;; => #time/zoned-date-time "2026-05-03T00:00-07:00"
In all three channels, :openadr/created, :openadr/modified, :openadr.interval-period/start, :tick/beginning, and :tick/end are ZonedDateTime instances anchored to the numeric offset emitted by the VTN. Callers that want a java.time.Instant can call .toInstant on the value. See the clj-oa3 README for the full discussion of why ZonedDateTime over Instant, RFC 3339 wire grammar, and the VTN-RI space-separated workaround.
| Namespace | Purpose |
|---|---|
openadr3.client.ven | VenClient component, registration, channels, VEN operations |
openadr3.client.bl | BlClient component |
openadr3.client.base | Shared: token fetch, API delegation |
openadr3.channel | NotificationChannel protocol, MqttChannel, WebhookChannel (all Components) |
openadr3.discovery | MdnsDiscoverer Component for VTN discovery via mDNS |
openadr3.mqtt | Low-level MQTT broker connection and subscription |
openadr3.webhook | Low-level webhook HTTP server |
openadr3.net | Network utilities (LAN IP detection, interface enumeration) |
Both VenClient and BlClient implement component/Lifecycle:
start — Resolves the OpenAPI spec version, optionally fetches an OAuth2 token, bootstraps a Martian HTTP client. Idempotent.stop — Clears the :martian key. VenClient also auto-stops all notification channels. Idempotent.(def c (ven/ven-client {:url url :token token}))
(:martian c) ;=> nil (not started)
(def started (component/start c))
(:martian started) ;=> Martian client instance
(def stopped (component/stop started))
(:martian stopped) ;=> nil
clojure -M:test
Unit tests use kaocha and cover pure functions (URI normalization, topic extraction, webhook parsing, etc.). Integration tests against a live VTN are in clj-oa3-test.
clojure -M:nrepl
# nREPL port written to .nrepl-port
The dev/user.clj namespace provides a system atom with convenience functions:
(start!) ; Start system with VEN + BL clients
(stop!) ; Stop system
(client/programs (bl)) ; Use the BL client
(client/events (ven)) ; Use the VEN client
;; Or with a different VTN
(start! {:url "https://my-vtn.example.com/openadr3/3.1.0"})
A mulog console publisher starts automatically in dev mode, so structured log events print to the REPL. To add a publisher in your own application:
(require '[com.brunobonacci.mulog :as mu])
(mu/start-publisher! {:type :console :pretty? true})
| Library | Purpose |
|---|---|
| clj-oa3 | Pure OpenADR 3 client library |
| clj-mdns | mDNS service discovery (optional; add it to use openadr3.discovery) |
| Component | Lifecycle management |
| machine_head | MQTT client (Paho wrapper) |
| hato | HTTP client (OAuth2 token fetch) |
| mulog | Structured event logging |
| medley | Utility functions |
| Repo | Description |
|---|---|
| clj-oa3 | Pure client library (dependency) |
| clj-mdns | mDNS discovery library (optional dependency) |
| clj-oa3-test | OpenADR 3 integration tests |
Issues, Discussions, and pull requests are welcome — see CONTRIBUTING.md for the workflow (and the dev commands: tests, lint, nREPL). In short:
Wire-format, schema, or datetime-coercion concerns belong upstream in clj-oa3 — not here.
MIT License — Copyright (c) 2026 Clark Communications Corporation
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |