Sacn.kt
src/jvmMain/kotlin/afield/io/dmx/Sacn.kt · 368 lines
@file:afield.runtime.completion.CompletionCategory("Lighting")
package afield.io.dmx
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.UUID
Streaming ACN (ANSI E1.31-2016, "sACN") packet encoding and decoding — the wire format only.
sACN carries DMX512 universes over UDP port 5568 in three nested ACN PDUs (root layer, framing
layer, DMP layer), each opening with a 16-bit "flags and length" word whose top nibble is 0x7
and whose low 12 bits count the bytes from that word to the end of the packet. All multi-byte
fields are big-endian. This file implements:
- Data packets (root vector
0x04, framing vector0x02): 126-byte header plus a START code and up to 512 slots — 638 bytes at most. - Synchronization packets (root
0x08, framing0x01, 49 bytes): E1.31 §6.3 universe sync. A data packet whoseSynchronization Addressis non-zero is held by receivers until a sync packet for that address arrives. - Universe discovery packets (root
0x08, framing0x02, §8): the list of universes a source is transmitting, sent every 10 s (DISCOVERY_INTERVAL_MS) to universe 64214.
Universes are 1..63999; multicast traffic for universe u goes to 239.255.(u >> 8).(u & 0xff)
(multicastAddress). Every source identifies itself by a 16-byte CID (a UUID), which is also how
a receiver tells two sources on the same universe apart for priority and merging.
SacnSequence is the §6.7.2 sequence-number rule a receiver applies per source per universe.
DmxOutput and DmxInput are built on these codecs; a box reaches them through sacnOut and
sacnIn. The user-facing page is
Driving Lights: Art-Net and sACN.
object Sacn
The sACN codecs: encoders and decoders for data, synchronization and discovery packets, and the E1.31 constants (ports, vectors, option bits, timeouts, default priority) they use.
object Sacn {
const val PORT = 5568
UDP port for all E1.31 traffic: 5568 (ACN_SDT_MULTICAST_PORT).
const val VECTOR_ROOT_E131_DATA = 0x00000004
Root-layer vector of a data packet, at byte 18: 0x04.
const val VECTOR_ROOT_E131_EXTENDED = 0x00000008
Root-layer vector of a synchronization or a discovery packet, at byte 18: 0x08. E1.31 calls
both of those extended packets, and the framing vector below says which one it is.
const val VECTOR_E131_DATA_PACKET = 0x00000002
Framing-layer vector of a data packet, at byte 40: 0x02.
const val VECTOR_E131_EXTENDED_SYNCHRONIZATION = 0x00000001
Framing-layer vector of a synchronization packet, at byte 40: 0x01.
const val VECTOR_E131_EXTENDED_DISCOVERY = 0x00000002
Framing-layer vector of a universe discovery packet, at byte 40: 0x02.
A data packet carries the same framing vector, so neither byte 18 nor byte 40 identifies a packet alone; reading the pair is what tells the three kinds apart.
const val VECTOR_DMP_SET_PROPERTY = 0x02
DMP-layer vector of a data packet, at byte 117: 0x02, Set Property. It is the only DMP
vector E1.31 defines for streaming DMX, and a data packet that does not carry it is dropped.
const val VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST = 0x00000001
Vector of a discovery packet's innermost PDU, at byte 114: 0x01, a list of universes.
const val OPTION_PREVIEW = 0x80
Options bit 7: preview data — for visualisers, not for driving lights.
const val OPTION_STREAM_TERMINATED = 0x40
Options bit 6: stream terminated — the source is going away; drop it now.
const val OPTION_FORCE_SYNC = 0x20
Options bit 5: force synchronization.
const val DATA_HEADER = 126
Bytes before the first slot (the START code is byte 125).
const val DATA_MAX_PACKET = DATA_HEADER + 512
Largest data packet: 126 header bytes + 512 slots.
const val SYNC_LENGTH = 49
Synchronization packets are fixed length.
const val DISCOVERY_UNIVERSE = 64214
Universe used for discovery packets (E1.31 §8, E131_DISCOVERY_UNIVERSE).
const val DISCOVERY_INTERVAL_MS = 10_000L
E131_UNIVERSE_DISCOVERY_INTERVAL (E1.31 Appendix A): 10 s.
const val NETWORK_DATA_LOSS_TIMEOUT_MS = 2_500L
E131_NETWORK_DATA_LOSS_TIMEOUT (E1.31 Appendix A): a source silent this long is gone.
const val DEFAULT_PRIORITY = 100
Default priority for a source that does not say otherwise (E1.31 §6.2.3: 0..200, default 100).
private val ACN_ID = byteArrayOf(0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00)
fun multicastAddress(universe: Int): InetAddress
The multicast group for universe: 239.255.hi.lo.
fun multicastAddress(universe: Int): InetAddress {
require(universe in 1..63999 || universe == DISCOVERY_UNIVERSE) { "sACN universe must be 1..63999, got $universe" }
return InetAddress.getByAddress(byteArrayOf(239.toByte(), 255.toByte(), (universe shr 8).toByte(), universe.toByte()))
}
fun cidBytes(cid: UUID): ByteArray
A CID as the 16 bytes E1.31 puts on the wire (UUID, most significant byte first).
fun cidBytes(cid: UUID): ByteArray =
ByteBuffer.allocate(16).putLong(cid.mostSignificantBits).putLong(cid.leastSignificantBits).array()
private fun cidOf(buf: ByteArray, at: Int): UUID {
val bb = ByteBuffer.wrap(buf, at, 16)
return UUID(bb.long, bb.long)
}
private fun put16(buf: ByteArray, at: Int, v: Int) {
buf[at] = (v shr 8).toByte(); buf[at + 1] = v.toByte()
}
private fun put32(buf: ByteArray, at: Int, v: Int) {
buf[at] = (v shr 24).toByte(); buf[at + 1] = (v shr 16).toByte()
buf[at + 2] = (v shr 8).toByte(); buf[at + 3] = v.toByte()
}
private fun get16(buf: ByteArray, at: Int) = ((buf[at].toInt() and 0xff) shl 8) or (buf[at + 1].toInt() and 0xff)
private fun get32(buf: ByteArray, at: Int) =
((buf[at].toInt() and 0xff) shl 24) or ((buf[at + 1].toInt() and 0xff) shl 16) or
((buf[at + 2].toInt() and 0xff) shl 8) or (buf[at + 3].toInt() and 0xff)
Flags-and-length word: 0x7 in the top nibble, 12-bit length below.
private fun flagsLength(length: Int) = 0x7000 or (length and 0x0fff)
private fun writeRoot(buf: ByteArray, total: Int, vector: Int, cid: ByteArray) {
put16(buf, 0, 0x0010) // preamble size
put16(buf, 2, 0x0000) // post-amble size
System.arraycopy(ACN_ID, 0, buf, 4, 12)
put16(buf, 16, flagsLength(total - 16))
put32(buf, 18, vector)
System.arraycopy(cid, 0, buf, 22, 16)
}
private fun writeSourceName(buf: ByteArray, at: Int, name: String) {
java.util.Arrays.fill(buf, at, at + 64, 0)
val bytes = name.toByteArray(Charsets.UTF_8)
System.arraycopy(bytes, 0, buf, at, minOf(bytes.size, 63)) // null-terminated, 64 bytes
}
fun encodeData
Encode an E1.31 data packet into buf and return its length (126 + length).
@param cid the 16-byte source CID, see cidBytes; encode it once and keep it. @param sequence 0..255, incremented by the sender per universe per packet. @param options bit flags: OPTION_PREVIEW, OPTION_STREAM_TERMINATED, OPTION_FORCE_SYNC. @param syncAddress universe of the sync packets this data waits for; 0 = unsynchronised. @param startCode 0x00 for dimmer data; 0xDD is the widely used per-channel-priority extension.
fun encodeData(
buf: ByteArray,
cid: ByteArray,
sourceName: String,
universe: Int,
data: ByteArray,
length: Int = 512,
sequence: Int = 0,
priority: Int = DEFAULT_PRIORITY,
options: Int = 0,
syncAddress: Int = 0,
startCode: Int = 0,
dataOffset: Int = 0,
): Int {
require(length in 0..512) { "an E1.31 data packet carries 0..512 slots, got $length" }
require(universe in 1..63999) { "sACN universe must be 1..63999, got $universe" }
require(priority in 0..200) { "sACN priority is 0..200, got $priority" }
val total = DATA_HEADER + length
require(buf.size >= total)
writeRoot(buf, total, VECTOR_ROOT_E131_DATA, cid)
// Framing layer
put16(buf, 38, flagsLength(total - 38))
put32(buf, 40, VECTOR_E131_DATA_PACKET)
writeSourceName(buf, 44, sourceName)
buf[108] = priority.toByte()
put16(buf, 109, syncAddress)
buf[111] = sequence.toByte()
buf[112] = options.toByte()
put16(buf, 113, universe)
// DMP layer
put16(buf, 115, flagsLength(total - 115))
buf[117] = VECTOR_DMP_SET_PROPERTY.toByte()
buf[118] = 0xa1.toByte() // address type & data type
put16(buf, 119, 0x0000) // first property address
put16(buf, 121, 0x0001) // address increment
put16(buf, 123, length + 1) // property value count: START code + slots
buf[125] = startCode.toByte()
System.arraycopy(data, dataOffset, buf, DATA_HEADER, length)
return total
}
fun encodeSync(buf: ByteArray, cid: ByteArray, syncAddress: Int, sequence: Int): Int
Encode an E1.31 synchronization packet (49 bytes) for syncAddress and return its length.
fun encodeSync(buf: ByteArray, cid: ByteArray, syncAddress: Int, sequence: Int): Int {
require(syncAddress in 1..63999)
require(buf.size >= SYNC_LENGTH)
writeRoot(buf, SYNC_LENGTH, VECTOR_ROOT_E131_EXTENDED, cid)
put16(buf, 38, flagsLength(SYNC_LENGTH - 38))
put32(buf, 40, VECTOR_E131_EXTENDED_SYNCHRONIZATION)
buf[44] = sequence.toByte()
put16(buf, 45, syncAddress)
buf[47] = 0; buf[48] = 0 // reserved
return SYNC_LENGTH
}
fun encodeDiscovery
Encode one page of a universe discovery packet and return its length (120 + 2n).
universes must be sorted ascending and at most 512 long (one page).
fun encodeDiscovery(
buf: ByteArray,
cid: ByteArray,
sourceName: String,
universes: IntArray,
page: Int = 0,
lastPage: Int = 0,
): Int {
require(universes.size <= 512) { "one discovery page lists at most 512 universes" }
val total = 120 + 2 * universes.size
require(buf.size >= total)
writeRoot(buf, total, VECTOR_ROOT_E131_EXTENDED, cid)
put16(buf, 38, flagsLength(total - 38))
put32(buf, 40, VECTOR_E131_EXTENDED_DISCOVERY)
writeSourceName(buf, 44, sourceName)
put32(buf, 108, 0) // reserved
put16(buf, 112, flagsLength(total - 112))
put32(buf, 114, VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST)
buf[118] = page.toByte()
buf[119] = lastPage.toByte()
universes.forEachIndexed { i, u -> put16(buf, 120 + 2 * i, u) }
return total
}
private fun isAcn(buf: ByteArray, length: Int): Boolean {
if (length < 38) return false
if (get16(buf, 0) != 0x0010) return false
for (i in 0 until 12) if (buf[4 + i] != ACN_ID[i]) return false
return true
}
fun kind(buf: ByteArray, length: Int = buf.size): Kind?
What kind of E1.31 packet buf holds, or null if it is not one we understand.
fun kind(buf: ByteArray, length: Int = buf.size): Kind? {
if (!isAcn(buf, length) || length < 44) return null
val root = get32(buf, 18)
val framing = get32(buf, 40)
return when {
root == VECTOR_ROOT_E131_DATA && framing == VECTOR_E131_DATA_PACKET -> Kind.DATA
root == VECTOR_ROOT_E131_EXTENDED && framing == VECTOR_E131_EXTENDED_SYNCHRONIZATION -> Kind.SYNC
root == VECTOR_ROOT_E131_EXTENDED && framing == VECTOR_E131_EXTENDED_DISCOVERY -> Kind.DISCOVERY
else -> null
}
}
enum class Kind { DATA, SYNC, DISCOVERY }
Packet kinds this decoder recognises.
fun decodeData(buf: ByteArray, length: Int = buf.size): Data?
Decode a data packet; Data.data holds exactly the slots after the START code.
fun decodeData(buf: ByteArray, length: Int = buf.size): Data? {
if (kind(buf, length) != Kind.DATA || length < DATA_HEADER) return null
if (buf[117].toInt() != VECTOR_DMP_SET_PROPERTY || (buf[118].toInt() and 0xff) != 0xa1) return null
val count = get16(buf, 123) - 1
if (count < 0 || count > 512 || DATA_HEADER + count > length) return null
val nameEnd = (44 until 108).firstOrNull { buf[it] == 0.toByte() } ?: 108
return Data(
cid = cidOf(buf, 22),
sourceName = String(buf, 44, nameEnd - 44, Charsets.UTF_8),
priority = buf[108].toInt() and 0xff,
syncAddress = get16(buf, 109),
sequence = buf[111].toInt() and 0xff,
options = buf[112].toInt() and 0xff,
universe = get16(buf, 113),
startCode = buf[125].toInt() and 0xff,
data = buf.copyOfRange(DATA_HEADER, DATA_HEADER + count),
)
}
fun decodeSync(buf: ByteArray, length: Int = buf.size): Sync?
Decode a synchronization packet.
fun decodeSync(buf: ByteArray, length: Int = buf.size): Sync? {
if (kind(buf, length) != Kind.SYNC || length < SYNC_LENGTH) return null
return Sync(cidOf(buf, 22), buf[44].toInt() and 0xff, get16(buf, 45))
}
fun decodeDiscovery(buf: ByteArray, length: Int = buf.size): Discovery?
Decode one page of a universe discovery packet.
fun decodeDiscovery(buf: ByteArray, length: Int = buf.size): Discovery? {
if (kind(buf, length) != Kind.DISCOVERY || length < 120) return null
if (get32(buf, 114) != VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST) return null
val n = (length - 120) / 2
val nameEnd = (44 until 108).firstOrNull { buf[it] == 0.toByte() } ?: 108
return Discovery(
cid = cidOf(buf, 22),
sourceName = String(buf, 44, nameEnd - 44, Charsets.UTF_8),
page = buf[118].toInt() and 0xff,
lastPage = buf[119].toInt() and 0xff,
universes = IntArray(n) { get16(buf, 120 + 2 * it) },
)
}
class Data
A decoded data packet.
class Data(
val cid: UUID,
val sourceName: String,
val priority: Int,
val syncAddress: Int,
val sequence: Int,
val options: Int,
val universe: Int,
val startCode: Int,
val data: ByteArray,
) {
val preview: Boolean get() = options and OPTION_PREVIEW != 0
val streamTerminated: Boolean get() = options and OPTION_STREAM_TERMINATED != 0
override fun toString()
override fun toString() = "sACN(universe=$universe, \"$sourceName\", prio=$priority, seq=$sequence, ${data.size} slots)"
}
data class Sync(val cid: UUID, val sequence: Int, val syncAddress: Int)
A decoded synchronization packet.
class Discovery(val cid: UUID, val sourceName: String, val page: Int, val lastPage: Int, val universes: IntArray)
A decoded discovery page.
override fun toString()
override fun toString() = "sACN discovery(\"$sourceName\", page $page/$lastPage, ${universes.toList()})"
}
}
class SacnSequence
The E1.31 §6.7.2 sequence-number rule, per source per universe.
Sequence numbers are 8-bit and wrap, so "older" is decided on the signed 8-bit difference
new - last: a packet is discarded when that difference is in -19..0 — a duplicate, or one
that arrived out of order within a 20-packet window. Anything else is accepted, which is what lets
a source that restarted with a random sequence number be heard again after at most one packet.
class SacnSequence {
private var last = -1
fun accept(sequence: Int): Boolean
Returns true to accept sequence (and remember it), false to discard the packet.
fun accept(sequence: Int): Boolean {
val s = sequence and 0xff
if (last < 0) { last = s; return true }
val diff = (s - last).toByte().toInt() // signed 8-bit difference
if (diff <= 0 && diff > -20) return false
last = s
return true
}
fun reset() { last = -1 }
Forget the last sequence number, e.g. after the source timed out.
companion object
companion object {
fun isNewer(last: Int, next: Int): Boolean
The stateless form of the rule: would next be accepted after last?
fun isNewer(last: Int, next: Int): Boolean {
val diff = ((next and 0xff) - (last and 0xff)).toByte().toInt()
return !(diff <= 0 && diff > -20)
}
}
}