Skip to content

DmxBox.kt

src/jvmMain/kotlin/afield/io/dmx/DmxBox.kt · 170 lines

@file:afield.runtime.completion.CompletionCategory("Lighting")

package afield.io.dmx

import afield.document.graph.Box
import afield.document.graph.registerStopHook
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.SocketTimeoutException
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

Box-scoped ownership of DMX sockets.

Live coding re-runs a box many times and stops it at will; a socket must survive the first and die with the second. So each artnetOut / sacnOut / artnetIn / sacnIn call is keyed by box and endpoint: a re-run with the same arguments gets the same, still-sending object back (no rebind, no gap on the wire), different arguments close the old one first, and stopping the box closes it through Box.registerStopHook. Nothing is cached per port for the life of the process, so a rig stops when the box driving it stops.

Inputs are keyed by protocol and port only, because only one socket can own a UDP port: a second box asking for the same input shares the first one, and stopping either box closes it (the next call reopens it).

artPoll is the one call here that owns nothing. It sends a single ArtPoll and gathers the replies for a timeout, which is how to find the nodes on a network before patching to them. The user-facing page is Driving Lights: Art-Net and sACN.

object DmxSockets

The registry behind the box-scoped accessors: one DmxOutput per box and endpoint, one DmxInput per protocol and port. open lists what is live; closeAll shuts all of it.

object DmxSockets {
    private val outputs = ConcurrentHashMap<String, Pair<String, DmxOutput>>()
    private val inputs = ConcurrentHashMap<String, Pair<String, DmxInput>>()
    private val hooked = ConcurrentHashMap.newKeySet<String>()

val open: List<AutoCloseable> get()

Every open output and input, for a status display.

    val open: List<AutoCloseable> get() = outputs.values.map { it.second } + inputs.values.map { it.second }

internal fun output(box: Box, key: String, config: String, make: () -> DmxOutput): DmxOutput {

    internal fun output(box: Box, key: String, config: String, make: () -> DmxOutput): DmxOutput {
        val k = "${box.id}|$key"
        var created: DmxOutput? = null
        val entry = outputs.compute(k) { _, old ->
            if (old != null && old.first == config && !old.second.isClosed) old
            else {
                old?.second?.close()
                val o = make(); created = o
                config to o
            }
        }!!
        created?.let { o ->
            box.registerStopHook(Runnable { outputs.remove(k, entry); o.close() })
        }
        return entry.second
    }

internal fun input(box: Box, key: String, config: String, make: () -> DmxInput): DmxInput {

    internal fun input(box: Box, key: String, config: String, make: () -> DmxInput): DmxInput {
        var created: DmxInput? = null
        val entry = inputs.compute(key) { _, old ->
            if (old != null && old.first == config && !old.second.isClosed) old
            else {
                old?.second?.close()
                val i = make(); created = i
                config to i
            }
        }!!
        val i = entry.second
        // One hook per (box, input) instance: a box re-run twenty times must not queue twenty hooks.
        val hookKey = "${box.id}|$key|${System.identityHashCode(i)}"
        if (created != null || hooked.add(hookKey)) {
            hooked.add(hookKey)
            box.registerStopHook(Runnable { hooked.remove(hookKey); inputs.remove(key, entry); i.close() })
        }
        return i
    }

fun closeAll()

Close everything, e.g. from a "panic" box.

    fun closeAll() {
        outputs.values.forEach { it.second.close() }; outputs.clear()
        inputs.values.forEach { it.second.close() }; inputs.clear()
    }
}

fun Box.artnetOut

An Art-Net output owned by this box: sends to host (a node's IP, 127.0.0.1 for a local receiver, or a broadcast address such as 2.255.255.255) on port. Re-running the box returns the same output; stopping it closes the socket. See DmxOutput for the send policy.

fun Box.artnetOut(
    host: String = "127.0.0.1",
    port: Int = ArtNet.PORT,
    maxRateHz: Double = 44.0,
    sync: Boolean = false,
): DmxOutput = DmxSockets.output(this, "artnet>$host:$port", "$maxRateHz|$sync") {
    DmxOutput(DmxProtocol.ARTNET, host = host, port = port, maxRateHz = maxRateHz, sync = sync)
}

fun Box.sacnOut

An sACN (E1.31) output owned by this box. With host null, each universe goes to its multicast group 239.255.hi.lo; with a host, unicast. Stopping the box sends Stream_Terminated and closes the socket. The CID is derived from the box id, so a re-created output is recognised by receivers as the same source.

fun Box.sacnOut(
    host: String? = null,
    port: Int = Sacn.PORT,
    sourceName: String = "Field4 ${name.ifBlank { id }}",
    priority: Int = Sacn.DEFAULT_PRIORITY,
    maxRateHz: Double = 44.0,
    sync: Boolean = false,
): DmxOutput = DmxSockets.output(this, "sacn>${host ?: "multicast"}:$port", "$sourceName|$priority|$maxRateHz|$sync") {
    DmxOutput(
        DmxProtocol.SACN, host = host, port = port, sourceName = sourceName, priority = priority,
        cid = UUID.nameUUIDFromBytes("afield.dmx:$id".toByteArray()), maxRateHz = maxRateHz, sync = sync,
    )
}

fun Box.artnetIn(port: Int = ArtNet.PORT, merge: MergeMode = MergeMode.HTP): DmxInput

An Art-Net input on port: Field becomes a node, merges what it receives (merge) and answers ArtPoll. Closed when this box stops.

fun Box.artnetIn(port: Int = ArtNet.PORT, merge: MergeMode = MergeMode.HTP): DmxInput =
    DmxSockets.input(this, "artnet<$port", merge.name) { DmxInput(DmxProtocol.ARTNET, port = port, merge = merge) }

fun Box.sacnIn(port: Int = Sacn.PORT, universes: List<Int> = emptyList(), merge: MergeMode = MergeMode.HTP): DmxInput

An sACN input on port. Pass universes to join their multicast groups; unicast traffic arrives regardless. Closed when this box stops.

fun Box.sacnIn(port: Int = Sacn.PORT, universes: List<Int> = emptyList(), merge: MergeMode = MergeMode.HTP): DmxInput =
    DmxSockets.input(this, "sacn<$port", "${merge.name}|$universes") {
        DmxInput(DmxProtocol.SACN, port = port, merge = merge, multicastUniverses = universes)
    }

fun artPoll(host: String = "255.255.255.255", port: Int = ArtNet.PORT, timeoutMs: Long = 1000): List<ArtNet.PollReply>

Art-Net discovery: send one ArtPoll to host:port and collect the ArtPollReplies that come back within timeoutMs. Blocks for the whole timeout. Use a broadcast address (e.g. 255.255.255.255 or your subnet's) to find every node on a network; 127.0.0.1 finds a Field DmxInput on this machine.

fun artPoll(host: String = "255.255.255.255", port: Int = ArtNet.PORT, timeoutMs: Long = 1000): List<ArtNet.PollReply> {
    val found = LinkedHashMap<String, ArtNet.PollReply>()
    DatagramSocket().use { socket ->
        socket.broadcast = true
        val poll = ByteArray(ArtNet.POLL_LENGTH)
        val n = ArtNet.encodePoll(poll)
        socket.send(DatagramPacket(poll, n, InetSocketAddress(InetAddress.getByName(host), port)))
        val deadline = System.currentTimeMillis() + timeoutMs
        val buf = ByteArray(1500)
        while (true) {
            val left = deadline - System.currentTimeMillis()
            if (left <= 0) break
            socket.soTimeout = left.toInt().coerceAtLeast(1)
            val p = DatagramPacket(buf, buf.size)
            try {
                socket.receive(p)
            } catch (_: SocketTimeoutException) {
                break
            }
            ArtNet.decodePollReply(buf, p.length)?.let { found["${p.address.hostAddress}#${it.bindIndex}"] = it }
        }
    }
    return found.values.toList()
}