This document describes the internal architecture of clj-artnet for contributors and users who want to understand how the library works.
clj-artnet implements the functional core and imperative shell pattern:
┌───────────────────────────────────────────────────────────────────────────┐
│ PUBLIC API (clj-artnet) │
│ start-node! | stop-node! | send-dmx! | send-rdm! | send-sync! | state │
└─────────────────────────┬─────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────────────────┐
│ IMPERATIVE SHELL │ │ FUNCTIONAL CORE │
│ (impl/shell/*) │ │ (impl/protocol/*) │
├─────────────────────────┤ ├─────────────────────────────────────────────┤
│ • UDP send/receive │ │ • Pure state machine (machine.clj) │
│ • Buffer pools │ │ • Codec encoder/decoder (codec/*) │
│ • DatagramChannel │ │ • Packet specifications (codec/spec.clj) │
│ • Flow graph wiring │ │ • Discovery logic (discovery.clj) │
│ • Callback dispatch │ │ • DMX sync/merge (dmx_helpers.clj) │
│ • Lifecycle management │ │ • Failsafe playback (dmx_helpers.clj) │
│ • Effect translation │ │ • Diagnostics (diagnostics.clj) │
└─────────────────────────┘ │ • Node state (node_state.clj) │
│ • Addressing (addressing.clj) │
│ • Timing (timing.clj) │
└─────────────────────────────────────────────┘
Testability. The core state machine can be tested without network I/O. Given a state and an event, verify the resulting state and effects.
Predictability. Pure functions always produce the same outputs for the same inputs. No hidden state, no race conditions in the core logic.
Debugging. Effects are explicit data structures. You can log, inspect, and mock every side effect.
Extensibility. New effect types can be added without modifying the core. The shell handles effect dispatch.
The heart of clj-artnet is a pure state machine in machine.clj:
(defn step
"Transitions state based on event.
Returns {:state new-state :effects [effect-1 effect-2 ...]}"
[state event]
(case (:type event)
:rx-packet (handle-packet state event)
:tick (handle-tick state event)
:command (handle-command state event)
:snapshot (handle-command state (assoc event :command :snapshot))
(result state)))
| Type | Source | Description |
|---|---|---|
:rx-packet | UDP Receiver | Incoming Art-Net packet |
:tick | Failsafe Timer | Periodic heartbeat |
:command | User API | DMX/RDM/Sync commands |
:snapshot | State API | Request state snapshot |
| Effect | Description |
|---|---|
{:effect :tx-packet} | Transmit Art-Net packet |
{:effect :callback} | Invoke user callback |
{:effect :schedule} | Schedule delayed action |
{:effect :log} | Log event |
The state is a map containing:
{:node {...} ; ArtPollReply configuration
:network {...} ; Bind address, default target
:callbacks {...} ; User callback functions
:dmx {...} ; Per-port-address DMX state
:sync {...} ; Sync buffer manager
:failsafe {...} ; Failsafe timing
:discovery {...} ; Peer tracking, subscribers
:diagnostics {...} ; Diagnostic subscribers
:rdm {...} ; RDM state
:timing {...}} ; Timestamps
Packet formats are defined as data in codec/spec.clj:
(def art-dmx-spec
"ArtDmx packet specification - 18-byte header + up to 512-byte payload"
[{:name :id, :type :fixed-string, :length 8, :value artnet-id}
{:name :op-code, :type :u16le, :value op-dmx}
{:name :prot-ver-hi, :type :u8, :value 0}
{:name :prot-ver-lo, :type :u8, :value 14}
{:name :sequence, :type :u8}
{:name :physical, :type :u8}
{:name :sub-uni, :type :u8}
{:name :net, :type :u8}
{:name :length, :type :u16be}])
The compiler in codec/compiler.clj transforms specifications into optimized functions:
(def encode-art-dmx (compile-encoder art-dmx-spec))
(def decode-art-dmx (compile-decoder art-dmx-spec))
Generated encoders:
Generated decoders:
| Type | Size | Description |
|---|---|---|
:u8 | 1 | Unsigned 8-bit integer |
:u16le | 2 | Unsigned 16-bit little-endian |
:u16be | 2 | Unsigned 16-bit big-endian |
:u32le | 4 | Unsigned 32-bit little-endian |
:fixed-string | N | Fixed-length ASCII string |
:bytes | N | Raw byte array |
:ip4 | 4 | IPv4 address as 4 bytes |
:mac | 6 | MAC address as 6 bytes |
Decoders don't copy payload data. They slice the ByteBuffer:
;; Decoder returns a view into the original buffer
{:data (buffer-slice buf offset length)}
This avoids allocation for large DMX payloads.
The shell uses core.async.flow to wire processes:
┌──────────────────┐
│ UDP Receiver │
│ (receiver.clj) │
└────────┬─────────┘
│ {:type :rx :packet {...}}
▼
┌────────────────┐ {:type :command} ┌────────────────────────────────┐
│ User Commands │ ─────────────────────▶│ Logic Process │
│ (commands.clj) │ │ (graph.clj) │
└────────────────┘ │ │
│ ┌──────────────────────────┐ │
┌────────────────┐ {:type :tick} │ │ Protocol State Machine │ │
│ Failsafe Timer │ ─────────────────────▶│ │ (machine.clj) │ │
└────────────────┘ │ │ │ │
│ │ [state, event] │ │
│ │ ↓ │ │
│ │ {:state s' :effects e} │ │
│ └──────────────────────────┘ │
└────────────────┬───────────────┘
│ actions
▼
┌───────────────────────────────────────────┐
│ Action Router │
└───┬───────────────┬───────────────┬───────┘
│ │ │
{:type :send} {:type :callback} {:type :release}
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌────────────┐
│ Sender │ │ Callbacks │ │ Buffer │
│ (sender) │ │ (user) │ │ Release │
└──────────┘ └───────────┘ └────────────┘
Each process is a step function with four arities:
(defn logic-step
([] {:params {} :ins {:recv :cmds :ticks} :outs {:actions}}) ; describe
([args] (init-state args)) ; init
([state transition] (handle-transition state transition)) ; transition
([state input msg] (handle-message state input msg))) ; transform
| Workload | Thread Type | Use Case |
|---|---|---|
:io | Virtual Thread | Network I/O, blocking |
:compute | Fork/Join Pool | CPU-intensive work |
:mixed | Platform Thread | General purpose |
The receiver and sender use :io workload for Virtual Thread execution.
Channels have bounded buffers. When a channel is full:
put! blocks (in go blocks, parks).At startup, we allocate pools of ByteBuffer objects:
{:rx-buffer {:count 256 :size 2048}
:tx-buffer {:count 128 :size 2048}}
┌─────────────┐ acquire ┌─────────────┐
│ Buffer Pool │ ───────────────▶ │ Buffer │
│ │ ◀─────────────── │ (in use) │
└─────────────┘ release └─────────────┘
When an ArtPoll arrives:
{:discovery
{:reply-on-change-subscribers
#{{:host "192.168.1.100" :port 6454 :added-at 123456789}}
:reply-on-change-limit 10
:reply-on-change-policy :prefer-existing}}
When limit is reached:
:prefer-existing — reject new subscriber:prefer-latest — evict oldest subscriberFor gateways with many ports:
Port 0-3: BindIndex 0
Port 4-7: BindIndex 1
Port 8-11: BindIndex 2
...
Each ArtPollReply contains up to four ports. Multiple replies sent for large gateways.
When sync mode is :art-sync:
{:sync
{:mode :art-sync
:buffer-ttl-ns 200000000
:buffers {port-address {:data ByteBuffer
:received-at timestamp}}}}
┌─────────────┐
│ Active │
│ (receiving) │
└──────┬──────┘
│ no DMX for idle-timeout
▼
┌─────────────┐
│ Failsafe │
│ (outputting)│
└──────┬──────┘
│ DMX received
▼
┌─────────────┐
│ Active │
└─────────────┘
Failsafe modes:
:hold — last known values:zero — all zeros:full — all 255:scene — pre-recorded sceneArt-Net supports merging from multiple controllers:
Currently tracked per port-address with source IP/timestamp.
{:rdm
{:tod {port-address #{uid1 uid2 uid3}}
:tod-requests {}}}
ArtRdm packets contain:
The shell extracts and routes to hardware.
Test state transitions in isolation:
(deftest handle-art-poll-test
(let [state (init-state config)
event {:type :rx-packet :packet art-poll-packet}
result (step state event)]
(is (= 1 (count (:effects result))))
(is (= :tx-packet (:effect (first (:effects result)))))))
Fuzz testing for codecs:
(defspec art-dmx-round-trip 100
(prop/for-all [packet (gen-art-dmx-packet)]
(= packet (decode-art-dmx (encode-art-dmx packet)))))
Full node lifecycle:
(deftest node-lifecycle-test
(let [node (start-node! config)]
(try
(is (some? (state node)))
(finally
(stop-node! node)))))
This section addresses common questions about why clj-artnet is built the way it is.
Component and Integrant are excellent libraries for applications with complex dependency graphs. clj-artnet's lifecycle needs are simpler:
A stop function with compare-and-set! and cleanup closures suffices. Adding Component would introduce:
clj-artnet's codec/spec.clj contains data specifications (vectors of field descriptors), not clojure.spec
schemas. We don't use spec for packet validation because:
The "spec" in spec.clj refers to "specification" in the sense of "packet format specification," not the clojure.spec
library.
The codec compiler uses higher-order functions, not macros:
;; At load time, not macro expansion time
(def decode-artdmx (compile-decoder art-dmx-spec :artdmx))
Benefits:
art-dmx-spec at the REPL.For performance in the hot path:
| Operation | Approach | Reason |
|---|---|---|
| DMX merge | aset-byte, aclone | Avoiding per-channel allocation |
| Buffer pools | LinkedBlockingQueue | Thread-safe, zero-allocation borrow/release |
| Payload access | ByteBuffer .slice | Zero-copy view |
The public interface is immutable: users receive read-only ByteBuffer views, and the state machine returns effect data structures. Mutation is confined to the shell layer.
A single-threaded blocking loop:
while(running){
channel.
receive(buffer);
process(buffer);
}
This works for simple use cases but lacks:
| Need | Why core.async.flow is better |
|---|---|
| Backpressure | Blocking loops either block indefinitely or drop packets |
| Timers | Failsafe requires concurrent timer ticks |
| User commands | send-dmx! must not block on packet receive |
| Lifecycle | pause/resume for REPL development |
The flow graph adds ~50 lines of wiring code in graph.clj for these capabilities.
We use Java NIO's ByteBuffer directly with type hints:
(defn put-u16-le! [^ByteBuffer buf ^long v]
(.put buf (unchecked-byte (bit-and v 0xFF)))
(.put buf (unchecked-byte (bit-and (unsigned-bit-shift-right v 8) 0xFF))))
Alternatives like gloss or octet are excellent libraries, but:
codec/spec.clj.codec/dispatch.clj.machine.clj.shell/effects.clj.Effects are routed by the :effect key. Add new handlers in the shell:
(defmethod handle-effect :my-custom-effect
[effect context]
(process-my-effect effect))
The shell abstracts transport. Replace DatagramChannel with:
src/clj_artnet/
├── impl/
│ ├── protocol/ # FUNCTIONAL CORE
│ │ ├── machine.clj # State machine (903 lines)
│ │ ├── codec/ # Packet codec
│ │ │ ├── compiler.clj # Spec → functions
│ │ │ ├── spec.clj # Packet specs
│ │ │ ├── dispatch.clj # OpCode routing
│ │ │ └── domain/ # Per-packet logic
│ │ ├── addressing.clj # Port-Address math
│ │ ├── discovery.clj # Poll/Reply logic
│ │ ├── dmx.clj # DMX state
│ │ ├── dmx_helpers.clj # Sync/failsafe
│ │ ├── diagnostics.clj # DiagData
│ │ ├── effects.clj # Effect constructors
│ │ └── ...
│ └── shell/ # IMPERATIVE SHELL
│ ├── graph.clj # Flow graph
│ ├── receiver.clj # UDP receive
│ ├── sender.clj # UDP send
│ ├── buffers.clj # Buffer pools
│ ├── commands.clj # Command builders
│ ├── effects.clj # Effect handlers
│ └── ...
└── clj_artnet.clj # Public API
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 |