DmxInput.kt
src/jvmMain/kotlin/afield/io/dmx/DmxInput.kt · 440 lines
@file:afield.runtime.completion.CompletionCategory("Lighting")
package afield.io.dmx
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.MulticastSocket
import java.net.SocketTimeoutException
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
DMX reception: Field as an Art-Net or sACN node on the network.
DmxInput binds a UDP port, keeps the most recent frame from every source on every universe, and
merges them on demand. MergeMode chooses highest-takes-precedence or latest-takes-precedence,
under sACN's priority rule, its sequence-number rule (SacnSequence) and both protocols' source
timeouts. universe(n) returns the merged 512 slots, channel(u, c) a single slot, and
version(n) a counter that moves whenever a universe does, which is how a loop or a test waits
for the next frame without sleeping for a guessed interval.
Field also answers the discovery traffic it hears: an ArtPoll gets back an ArtPollReply
describing ArtNet.NodeInfo, so consoles and discovery tools list Field alongside real nodes,
and sACN universe-discovery pages from other sources are kept rather than dropped.
What arrived is slots, not colours; Fixture decode reads a fixture's look back out of them in
its own profile, which is what makes a round trip measurable (SceneOnARig). In a box, use
artnetIn or sacnIn, which close the socket when the box stops. The user-facing page is
Driving Lights: Art-Net and sACN.
enum class MergeMode
How a DmxInput combines several sources sending the same universe.
enum class MergeMode {
Highest takes precedence: each slot is the maximum over live sources.
HTP,
Latest takes precedence: the universe is whatever the most recent packet said.
LTP,
}
class DmxInput
A DMX receiver: Field as an Art-Net or sACN node.
A daemon thread receives packets on port, keeps the latest frame per source per universe, and
universe returns the merged result. Merging follows the protocols:
- sACN — only sources at the highest live priority count (E1.31 §6.2.3); among those,
mergedecides. A packet failing the §6.7.2 sequence rule is discarded (see SacnSequence). A source is dropped when it sends Stream_Terminated, or after E131_NETWORK_DATA_LOSS_TIMEOUT (2.5 s) of silence. Data carrying a synchronization address is held until the matching sync packet arrives (E1.31 §6.3.3.1). Preview data is ignored unlessacceptPreview. - Art-Net — sources are told apart by IP and port;
mergedecides; a source silent for 10 s is dropped, which is the Art-Net 4 merge timeout. Once an ArtSync has arrived, ArtDmx frames are held and applied together on the next ArtSync, until 4 s pass without one (Art-Net 4's synchronous mode). An ArtPoll is answered with an ArtPollReply describingnode, so consoles and discovery tools list Field as a node.
Multicast sACN needs the socket to join each universe's group: pass the universes to watch in
multicastUniverses. Unicast sACN (a sender pointed at this machine's IP or 127.0.0.1) needs
nothing.
In a box, use artnetIn / sacnIn: they close the socket when the box is stopped.
declaration — 42 lines, from line 68
class DmxInput(
/**
* Which wire format is understood. Packets of the other protocol arriving on this port are
* counted in [packetsDiscarded] rather than decoded. It also sets the default [port] and how
* long a silent source is kept: 2.5 s for sACN, 10 s for Art-Net.
*/
val protocol: DmxProtocol,
/**
* UDP port to bind: 6454 for Art-Net, 5568 for sACN. Pass 0 to let the system pick a free one
* and read [localPort] back, which is how a sender and a receiver run side by side in one
* process without claiming a port something else may hold.
*/
val port: Int = if (protocol == DmxProtocol.ARTNET) ArtNet.PORT else Sacn.PORT,
/**
* How several live sources on one universe are combined. Under sACN this applies within the
* highest priority live on that universe, since lower-priority sources do not count at all.
*/
val merge: MergeMode = MergeMode.HTP,
/**
* Universes whose sACN multicast group the socket joins, which is what it takes to hear
* multicast traffic at all. A sender unicasting to this machine needs none of them, and Art-Net
* ignores the list.
*/
val multicastUniverses: List<Int> = emptyList(),
/**
* What Field says about itself when it answers an ArtPoll. The net, subnet and port list of the
* reply are replaced by the universes actually arriving, so what this carries to the wire is the
* identity: the short and long name, the status report string, the advertised IP and MAC.
*/
val node: ArtNet.NodeInfo = ArtNet.NodeInfo(),
/**
* Answer an ArtPoll with an ArtPollReply, so consoles and discovery tools list Field as a node.
* Art-Net only; turn it off to listen without advertising.
*/
val answerPolls: Boolean = true,
/**
* Accept sACN data whose preview bit is set. Preview data is meant for visualisers rather than
* for driving lights, so by default it is counted in [packetsDiscarded] and otherwise ignored.
*/
val acceptPreview: Boolean = false,
bindAddress: String = "0.0.0.0",
) : AutoCloseable {
class Source(val key: String, val name: String, val universe: Int)
What is known about one source on one universe.
internal val data = ByteArray(512)
internal val pending = ByteArray(512)
internal var pendingSync = 0
internal var pendingSinceNanos = 0L
internal val sequence = SacnSequence()
var length
Slots in the most recent packet.
@Volatile
var length = 0
internal set
var priority
sACN priority (Art-Net sources report 100).
@Volatile
var priority = Sacn.DEFAULT_PRIORITY
internal set
var lastSeenNanos
System.nanoTime() of the last accepted packet.
@Volatile
var lastSeenNanos = 0L
internal set
var packets
Packets accepted from this source.
@Volatile
var packets = 0L
internal set
override fun toString()
override fun toString() = "Source($name @ $key, universe $universe, prio $priority, $packets packets)"
}
private val sources = ConcurrentHashMap<Int, ConcurrentHashMap<String, Source>>()
@Volatile
private var lastArtSyncNanos = Long.MIN_VALUE / 2
private val versions = ConcurrentHashMap<Int, AtomicLong>()
private val listeners = CopyOnWriteArrayList<(Int, ByteArray) -> Unit>()
private val received = AtomicLong()
private val discarded = AtomicLong()
val pollReplies = ConcurrentHashMap<String, ArtNet.PollReply>()
ArtPollReplies seen by this input (nodes that answered someone's ArtPoll), keyed by IP.
val discoveredSources = ConcurrentHashMap<UUID, Sacn.Discovery>()
sACN universe-discovery pages seen, keyed by source CID.
private val socket: DatagramSocket
private val thread: Thread
var isClosed
True once close has run. The receive thread stops at the next loop and the socket is released; a closed input does not reopen.
@Volatile
var isClosed = false
private set
val localPort: Int get() = socket.localPort
The UDP port this input is actually bound to: port, unless that was 0, in which case the
system chose a free one — the way to run a sender and a receiver side by side without picking
a number that something else on the machine may already hold.
var lastError: Throwable?
The last receive failure, or null.
@Volatile
var lastError: Throwable? = null
private set
val packetsReceived: Long get() = received.get()
Datagrams the socket handed over, of any kind, decoded or not.
val packetsDiscarded: Long get() = discarded.get()
Packets that arrived and were not used: a malformed or truncated one, an OpCode or vector this
input does not handle, an sACN sequence number the E1.31 §6.7.2 rule rejects, or preview data
with acceptPreview off. Every datagram counts in packetsReceived first, so this rising one
for one with that usually means the other protocol is on the port.
var pollsAnswered
ArtPollReplies this input has sent in answer to ArtPolls.
@Volatile
var pollsAnswered = 0
private set
init
init {
val addr = InetSocketAddress(InetAddress.getByName(bindAddress), port)
socket = if (protocol == DmxProtocol.SACN && multicastUniverses.isNotEmpty()) {
MulticastSocket(null).apply {
reuseAddress = true
bind(addr)
for (u in multicastUniverses) {
runCatching { joinGroup(InetSocketAddress(Sacn.multicastAddress(u), 0), null) }
.onFailure { lastError = it }
}
}
} else {
DatagramSocket(null).apply {
reuseAddress = true
bind(addr)
}
}
socket.soTimeout = 250
thread = Thread({ receiveLoop() }, "afield-dmx-${protocol.name.lowercase()}-in:$port").apply {
isDaemon = true
start()
}
}
private fun receiveLoop() {
val buf = ByteArray(1500)
val packet = DatagramPacket(buf, buf.size)
while (!isClosed) {
try {
packet.setData(buf, 0, buf.size)
socket.receive(packet)
received.incrementAndGet()
handle(buf, packet.length, packet.address, packet.port)
} catch (_: SocketTimeoutException) {
} catch (t: Throwable) {
if (!isClosed) lastError = t
}
}
}
fun handle(buf: ByteArray, length: Int, from: InetAddress, fromPort: Int)
Process one datagram as if it had arrived from from:fromPort. The receive thread calls this;
it is public so tests can feed packets without a socket.
body — 126 lines, from line 240
fun handle(buf: ByteArray, length: Int, from: InetAddress, fromPort: Int) {
when (protocol) {
DmxProtocol.ARTNET -> handleArtNet(buf, length, from, fromPort)
DmxProtocol.SACN -> handleSacn(buf, length)
}
}
private fun handleArtNet(buf: ByteArray, length: Int, from: InetAddress, fromPort: Int) {
when (ArtNet.opCode(buf, length)) {
ArtNet.OP_DMX -> {
val n = ((buf[16].toInt() and 0xff) shl 8) or (buf[17].toInt() and 0xff)
if (n > 512 || length < ArtNet.DMX_HEADER + n) { discarded.incrementAndGet(); return }
val key = "${from.hostAddress}:$fromPort"
val universe = ((buf[15].toInt() and 0x7f) shl 8) or (buf[14].toInt() and 0xff)
val src = sourceFor(universe, key, key)
val now = System.nanoTime()
val synchronous = now - lastArtSyncNanos < ART_SYNC_TIMEOUT_NANOS
val target = if (synchronous) src.pending else src.data
ArtNet.decodeDmxInto(buf, length, target)
if (n < 512) java.util.Arrays.fill(target, n, 512, 0)
if (synchronous) {
// Synchronous mode: hold the frame until the next ArtSync (see OP_SYNC below).
src.pendingSync = ART_SYNC_PENDING
src.pendingSinceNanos = now
src.length = n
src.lastSeenNanos = now
src.packets++
} else {
src.pendingSync = 0 // a frame held from synchronous mode is older than this one
accepted(universe, src, n, Sacn.DEFAULT_PRIORITY)
}
}
ArtNet.OP_POLL -> if (answerPolls) {
val reply = ByteArray(ArtNet.POLL_REPLY_LENGTH)
// Advertise what is actually arriving (up to the four ports one reply can carry); an
// idle node advertises its configured ports. Net and SubNet come from the first one.
val known = sources.keys.sorted().ifEmpty { node.ports.map { it.universe } }.take(4)
val first = PortAddress(known.firstOrNull()?.coerceIn(0, 0x7fff) ?: 0)
val info = node.copy(net = first.net, subNet = first.subNet, ports = known.map { ArtNet.NodePort(it and 0x0f) })
val n = ArtNet.encodePollReply(reply, info)
runCatching { socket.send(DatagramPacket(reply, n, InetSocketAddress(from, fromPort))) }
.onSuccess { pollsAnswered++ }
.onFailure { lastError = it }
}
ArtNet.OP_POLL_REPLY -> ArtNet.decodePollReply(buf, length)?.let { pollReplies[it.address] = it }
ArtNet.OP_SYNC -> {
// Art-Net 4: a node that has seen ArtSync buffers ArtDmx and outputs it on the next
// ArtSync, and drops back to immediate output after 4 s without one.
lastArtSyncNanos = System.nanoTime()
for ((u, table) in sources) for (src in table.values) {
if (src.pendingSync == ART_SYNC_PENDING) {
System.arraycopy(src.pending, 0, src.data, 0, 512)
src.pendingSync = 0
bump(u)
}
}
}
else -> discarded.incrementAndGet()
}
}
private fun handleSacn(buf: ByteArray, length: Int) {
when (Sacn.kind(buf, length)) {
Sacn.Kind.DATA -> {
val d = Sacn.decodeData(buf, length)
if (d == null || d.startCode != 0 || (d.preview && !acceptPreview)) { discarded.incrementAndGet(); return }
val key = d.cid.toString()
val table = sources.computeIfAbsent(d.universe) { ConcurrentHashMap() }
if (d.streamTerminated) {
if (table.remove(key) != null) bump(d.universe)
return
}
val src = table.computeIfAbsent(key) { Source(key, d.sourceName, d.universe) }
if (!src.sequence.accept(d.sequence)) { discarded.incrementAndGet(); return }
if (d.syncAddress != 0) {
System.arraycopy(d.data, 0, src.pending, 0, d.data.size)
if (d.data.size < 512) java.util.Arrays.fill(src.pending, d.data.size, 512, 0)
src.pendingSync = d.syncAddress
src.pendingSinceNanos = System.nanoTime()
src.length = d.data.size
src.priority = d.priority
src.lastSeenNanos = System.nanoTime()
src.packets++
return
}
System.arraycopy(d.data, 0, src.data, 0, d.data.size)
if (d.data.size < 512) java.util.Arrays.fill(src.data, d.data.size, 512, 0)
src.pendingSync = 0 // anything still held for a sync is older than this
accepted(d.universe, src, d.data.size, d.priority)
}
Sacn.Kind.SYNC -> {
val s = Sacn.decodeSync(buf, length) ?: return
for ((u, table) in sources) for (src in table.values) {
if (src.pendingSync == s.syncAddress) {
System.arraycopy(src.pending, 0, src.data, 0, 512)
src.pendingSync = 0
bump(u)
}
}
}
Sacn.Kind.DISCOVERY -> Sacn.decodeDiscovery(buf, length)?.let { discoveredSources[it.cid] = it }
null -> discarded.incrementAndGet()
}
}
private fun sourceFor(universe: Int, key: String, name: String): Source =
sources.computeIfAbsent(universe) { ConcurrentHashMap() }.computeIfAbsent(key) { Source(key, name, universe) }
private fun accepted(universe: Int, src: Source, length: Int, priority: Int) {
src.length = length
src.priority = priority
src.lastSeenNanos = System.nanoTime()
src.packets++
bump(universe)
if (listeners.isNotEmpty()) {
val merged = universe(universe)
listeners.forEach { l -> runCatching { l(universe, merged) }.onFailure { lastError = it } }
}
}
private fun bump(universe: Int) {
versions.computeIfAbsent(universe) { AtomicLong() }.incrementAndGet()
}
private val timeoutNanos: Long
get() = (if (protocol == DmxProtocol.SACN) Sacn.NETWORK_DATA_LOSS_TIMEOUT_MS else 10_000L) * 1_000_000
fun sources(universe: Int, nowNanos: Long = System.nanoTime()): List<Source>
Sources currently live on universe (timed-out ones are pruned here).
fun sources(universe: Int, nowNanos: Long = System.nanoTime()): List<Source> {
val table = sources[universe] ?: return emptyList()
table.values.removeIf { nowNanos - it.lastSeenNanos > timeoutNanos }
// A sync address nobody ever syncs must not freeze the universe forever: after the data-loss
// timeout's worth of waiting, commit what is pending.
for (s in table.values) if (s.pendingSync != 0 && nowNanos - s.pendingSinceNanos > timeoutNanos / 2) {
System.arraycopy(s.pending, 0, s.data, 0, 512)
s.pendingSync = 0
}
return table.values.toList()
}
val universes: List<Int> get() = sources.keys.filter { sources(it).isNotEmpty() }.sorted()
Every universe any live source has sent, ascending.
fun universe(universe: Int, into: ByteArray = ByteArray(512)): ByteArray
The merged 512 slots of universe (a fresh array; all zeros if nothing is live). Use into to reuse a buffer on a per-frame path.
fun universe(universe: Int, into: ByteArray = ByteArray(512)): ByteArray {
java.util.Arrays.fill(into, 0)
val live = sources(universe)
if (live.isEmpty()) return into
val top = if (protocol == DmxProtocol.SACN) live.maxOf { it.priority } else Int.MIN_VALUE
val eligible = if (protocol == DmxProtocol.SACN) live.filter { it.priority == top } else live
when (merge) {
MergeMode.LTP -> System.arraycopy(eligible.maxBy { it.lastSeenNanos }.data, 0, into, 0, 512)
MergeMode.HTP -> for (s in eligible) for (i in 0 until 512) {
val v = s.data[i].toInt() and 0xff
if (v > (into[i].toInt() and 0xff)) into[i] = v.toByte()
}
}
return into
}
fun channel(universe: Int, channel: Int): Int = universe(universe)[channel - 1].toInt() and 0xff
Value of 1-based channel of the merged universe, 0..255.
fun version(universe: Int): Long = versions[universe]?.get() ?: 0L
Increments whenever universe's merged data may have changed; cheap to poll per frame.
fun onFrame(listener: (universe: Int, data: ByteArray) -> Unit) {
Call listener with (universe, merged slots) on the receive thread after every accepted data
packet. Keep it short; hand work to the main loop if it touches Field state.
fun onFrame(listener: (universe: Int, data: ByteArray) -> Unit) {
listeners += listener
}
override fun close()
Stop receiving and release the socket. Idempotent.
override fun close() {
if (isClosed) return
isClosed = true
runCatching { socket.close() }
runCatching { thread.join(500) }
}
private companion object {
/** `Source.pendingSync` value meaning "held for the next ArtSync" (sACN uses the sync universe). */
const val ART_SYNC_PENDING = -1
const val ART_SYNC_TIMEOUT_NANOS = 4_000_000_000L
}
override fun toString()
The protocol and port, the merge mode, the universes with a live source right now, and
packetsReceived. An empty universe list with a rising count is the signature of traffic that
arrives and is not accepted. The port shown is the one asked for, so it reads 0 when the
system chose one; localPort has the number actually bound.
override fun toString() =
"DmxInput(${protocol.name} :$port, ${merge.name}, universes=$universes, received=$packetsReceived${if (isClosed) ", closed" else ""})"
}