Liking cljdoc? Tell your friends :D

clj-oa3-client

Clojars Project md-docs build-provenance

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.

Installation

Add to your deps.edn:

{:deps {energy.grid-coordination/clj-oa3-client {:mvn/version "0.4.0"}}}

Features

  • Separate VEN/BL clients — purpose-built components with role-specific capabilities
  • NotificationChannel protocol — unified interface for MQTT and webhook notifications
  • mDNS discovery — discover VTNs on the local network via _openadr._tcp service type
  • Component lifecycle — clients, channels, and discoverer all implement component/Lifecycle
  • Config-driven construction — specify URL, token (or OAuth2 credentials), and spec version
  • Automatic spec resolution — just say "3.1.0" and the correct OpenAPI spec is found on the classpath
  • Full API delegation — all openadr3.api functions available through the client
  • Both raw and coerced access — HTTP responses or namespaced Clojure entities with :openadr/raw metadata

Architecture

┌────────────────────────────────────────────────────┐
│  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                     │
└────────────────────────────────────────────────────┘

Prerequisites

  • clj-oa3 — available from Clojars
  • clj-mdns, only to use openadr3.discovery: add it to your own deps.edn (available from Clojars)
  • The OpenADR 3 specification must be on the classpath (handled automatically via clj-oa3's resources/ symlink)

Quick Start

VEN Client

(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)

BL Client

(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)

Notification Channels

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.

MQTT via VenClient

;; 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

Webhook via VenClient

;; 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 as Components

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)

VEN-Specific Features

VEN Registration

;; 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"

Program ID Resolution (with caching)

(ven/resolve-program-id my-ven "MyProgram")  ;=> "42" (API call)
(ven/resolve-program-id my-ven "MyProgram")  ;=> "42" (cache hit)

Notifier Discovery

(ven/discover-notifiers my-ven)     ;=> {:MQTT {:URIS [...]} ...}
(ven/vtn-supports-mqtt? my-ven)     ;=> true
(ven/mqtt-broker-urls my-ven)       ;=> ["tcp://broker:1883"]

Event Polling

(ven/poll-events my-ven)
(ven/poll-events my-ven {:program-id "42"})

VEN-Scoped Topic Queries

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)

mDNS Discovery

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"}}}

Standalone Discovery

(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)

Wired into VenClient

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)

Options

KeyTypeDefaultDescription
:service-typestring"_openadr._tcp.local."mDNS service type to discover
:bind-addressInetAddressauto-detected LAN IPNetwork interface to bind JmDNS to

Multiple Clients

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)

Client Options

KeyTypeRequiredDefaultDescription
:urlstringyes*—VTN base URL (omit when using mDNS discovery)
:tokenstringone of—Bearer auth token
:client-idstringone of—OAuth2 client ID (used with :client-secret)
:client-secretstringone of—OAuth2 client secret (used with :client-id)
:spec-versionstringno"3.1.0"OpenAPI spec version
:user-agentstringno—Custom User-Agent suffix for self-identification
:connect-timeout-msintno5000HttpClient connect timeout
:request-timeout-msintnononePer-request timeout; a request that exceeds it throws an ExceptionInfo whose cause is java.net.http.HttpTimeoutException
:query-param-extensionsmapno—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"

User-Agent

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.

API Reference

All openadr3.api functions are available through openadr3.client.base. The client is always the first argument.

Raw CRUD (returns {: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

Coerced Entities

(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)

Response Helpers

(base/success? resp)  ;; true if 2xx
(base/body resp)      ;; extract :body

MQTT Topics

(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

Introspection

(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

Time and timezones

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 Guide

NamespacePurpose
openadr3.client.venVenClient component, registration, channels, VEN operations
openadr3.client.blBlClient component
openadr3.client.baseShared: token fetch, API delegation
openadr3.channelNotificationChannel protocol, MqttChannel, WebhookChannel (all Components)
openadr3.discoveryMdnsDiscoverer Component for VTN discovery via mDNS
openadr3.mqttLow-level MQTT broker connection and subscription
openadr3.webhookLow-level webhook HTTP server
openadr3.netNetwork utilities (LAN IP detection, interface enumeration)

Component Lifecycle

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

Development

Run Tests

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.

Start nREPL

clojure -M:nrepl
# nREPL port written to .nrepl-port

Dev Helpers

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})

Dependencies

LibraryPurpose
clj-oa3Pure OpenADR 3 client library
clj-mdnsmDNS service discovery (optional; add it to use openadr3.discovery)
ComponentLifecycle management
machine_headMQTT client (Paho wrapper)
hatoHTTP client (OAuth2 token fetch)
mulogStructured event logging
medleyUtility functions

Related Repos

RepoDescription
clj-oa3Pure client library (dependency)
clj-mdnsmDNS discovery library (optional dependency)
clj-oa3-testOpenADR 3 integration tests

Contributing

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

  • Questions, component-design discussion, wiring patterns → Discussions
  • Confirmed bugs in lifecycle, channels, mDNS, or VEN helpers; doc errors → Issues
  • Patches → pull requests; please open a Discussion or Issue first for non-trivial changes (new channel transports, new component types, new lifecycle behavior, new dependencies)

Wire-format, schema, or datetime-coercion concerns belong upstream in clj-oa3 — not here.

License

MIT License — Copyright (c) 2026 Clark Communications Corporation

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close