Skip to content

OscQueryServer.kt

src/jvmMain/kotlin/afield/io/osc/query/OscQueryServer.kt · 386 lines

package and imports — 42 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Networking")

package afield.io.osc.query

import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
import io.ktor.server.request.queryString
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.io.Closeable
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.SocketException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList

The OSCQuery service Field publishes: a namespace of typed parameters served as JSON over HTTP, set over OSC UDP, and pushed to WebSocket listeners.

OscQueryParam is one address with its type tags, value, range and set callbacks; OscQueryServer holds the namespace and runs the HTTP/WebSocket and UDP sides. Box code usually reaches it through Box.oscQuery in OscQueryBox.kt rather than constructing a server directly.

enum class OscAccess(val code: Int) { NONE(0), READ(1), WRITE(2), READ_WRITE(3) }

OSCQuery ACCESS: what a remote may do with a node.

class OscQueryParam internal constructor

One parameter in an OscQueryServer's namespace: an OSC address with a type, a current value, an optional range, and the callbacks that run when a remote sets it.

type is an OSC type-tag string, one tag per value: "f" for a float, "i" for an int, "s" for a string, "T" for a boolean, "ff" for a 2-vector. value always holds one entry per tag, coerced to that tag.

class OscQueryParam internal constructor(
    val server: OscQueryServer,
    val path: String,
    val type: String,
    initial: List<Any?>,
    /** One `(min, max)` per value, or empty. Either end may be null. */
    val range: List<Pair<Double?, Double?>>,
    /** Allowed values (`RANGE: [{"VALS": [...]}]`), for menus; empty when any value is allowed. */
    val values: List<Any?>,
    val access: OscAccess,
    val description: String?,
) {

var value: List<Any?>

    @Volatile
    var value: List<Any?> = coerce(initial)
        private set

internal val listeners = CopyOnWriteArrayList<(List<Any?>) -> Unit>()

val number: Double get()

First value as a Double (0.0 when it is not a number); the common case of a one-float parameter.

    val number: Double get() = when (val v = value.firstOrNull()) {
        is Number -> v.toDouble()
        is Boolean -> if (v) 1.0 else 0.0
        else -> 0.0
    }

fun set(vararg v: Any?) = server.update(this, v.toList(), fromRemote = false)

Set the value from Field. Remote listeners (LISTEN over the WebSocket) are told; this parameter's own onSet callbacks are not, since Field already knows.

fun onSet(handler: (List<Any?>) -> Unit) { listeners += handler }

Run handler whenever a remote sets this parameter.

internal fun store(v: List<Any?>): Boolean

    internal fun store(v: List<Any?>): Boolean {
        val c = coerce(v)
        if (c == value) return false
        value = c
        return true
    }

internal fun coerce(v: List<Any?>): List<Any?>

    internal fun coerce(v: List<Any?>): List<Any?> = type.mapIndexed { i, tag ->
        val raw = v.getOrNull(i) ?: value0(tag)
        val clipped = when (tag) {
            'f' -> (raw as? Number)?.toFloat() ?: (raw as? Boolean)?.let { if (it) 1f else 0f } ?: raw.toString().toFloatOrNull() ?: 0f
            'd' -> (raw as? Number)?.toDouble() ?: raw.toString().toDoubleOrNull() ?: 0.0
            'i' -> (raw as? Number)?.toInt() ?: (raw as? Boolean)?.let { if (it) 1 else 0 } ?: raw.toString().toDoubleOrNull()?.toInt() ?: 0
            'h' -> (raw as? Number)?.toLong() ?: raw.toString().toLongOrNull() ?: 0L
            'T', 'F' -> when (raw) { is Boolean -> raw; is Number -> raw.toDouble() != 0.0; else -> raw.toString() == "true" }
            else -> raw.toString()
        }
        clip(clipped, range.getOrNull(i))
    }

    private fun value0(tag: Char): Any = when (tag) { 'f' -> 0f; 'd' -> 0.0; 'i' -> 0; 'h' -> 0L; 'T', 'F' -> false; else -> "" }

    private fun clip(v: Any, r: Pair<Double?, Double?>?): Any {
        if (r == null) return v
        val (lo, hi) = r
        return when (v) {
            is Float -> v.coerceIn(lo?.toFloat() ?: Float.NEGATIVE_INFINITY, hi?.toFloat() ?: Float.POSITIVE_INFINITY)
            is Double -> v.coerceIn(lo ?: Double.NEGATIVE_INFINITY, hi ?: Double.POSITIVE_INFINITY)
            is Int -> v.coerceIn(lo?.toInt() ?: Int.MIN_VALUE, hi?.toInt() ?: Int.MAX_VALUE)
            else -> v
        }
    }

internal fun wireArguments(): List<Any?> = value

The OSC tags actually sent for the current value (T/F follow the boolean).

class OscQueryServer

An OSCQuery service: the namespace of params, served as JSON over HTTP at http://<host>:<httpPort>/ (with a WebSocket on the same port for LISTEN), set over OSC UDP at oscPort, and advertised over DNS-SD as _oscjson._tcp and _osc._udp when advertise is on.

Implements the parts of the vidvox OSCQuery proposal that TouchDesigner, Max, Chataigne, VDMX and the python/JS libraries use: FULL_PATH, CONTENTS, TYPE, VALUE, RANGE (MIN/MAX and VALS), ACCESS, DESCRIPTION, attribute queries (/path?VALUE), HOST_INFO, and the WebSocket LISTEN/IGNORE commands with PATH_ADDED/PATH_REMOVED. Values pushed to listeners are binary OSC frames; a remote may also set a value by sending one.

Callbacks run through dispatch (directly by default; Box.oscQuery routes them to the main Runloop). Port 0 picks a free port; read the result from httpPort / oscPort.

class OscQueryServer(
    val name: String = "Field",
    httpPort: Int = 0,
    oscPort: Int = 0,
    val bindAddress: String = "0.0.0.0",
    advertise: Boolean = true,
    var dispatch: (Runnable) -> Unit = { it.run() },
) : Closeable {

    private val params = ConcurrentHashMap<String, OscQueryParam>()
    private val descriptions = ConcurrentHashMap<String, String>()
    private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, MutableSet<String>>()

    private val udp = DatagramSocket(null).apply {
        reuseAddress = true
        bind(InetSocketAddress(InetAddress.getByName(bindAddress), oscPort))
    }

val oscPort: Int = udp.localPort

    private val http: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> =
        embeddedServer(Netty, port = httpPort, host = bindAddress) {
            install(WebSockets)
            routing {
                webSocket("/") { serveSocket(this) }
                get("{path...}") {
                    val path = "/" + (call.parameters.getAll("path") ?: emptyList()).joinToString("/")
                    val query = call.request.queryString()
                    val (status, body) = answer(path, query)
                    if (body == null) call.respond(status)
                    else call.respondText(json.encodeToString(JsonElement.serializer(), body), ContentType.Application.Json, status)
                }
            }
        }.also { it.start(wait = false) }

val httpPort: Int = runBlocking { http.engine.resolvedConnectors().first().port }

    private val receiver = Thread({ receiveLoop() }, "oscquery-udp-$oscPort").apply { isDaemon = true; start() }

    private val adverts = mutableListOf<Closeable>()

init

    init {
        if (advertise) {
            try {
                adverts += Mdns.advertise(name, "_oscjson._tcp", this.httpPort)
                adverts += Mdns.advertise(name, "_osc._udp", this.oscPort)
            } catch (t: Throwable) {
                println("OSCQuery: DNS-SD advertisement failed (${t.message}); connect by host:port instead")
            }
        }
    }

val all: Map<String, OscQueryParam> get() = params.toMap()

Every parameter, by path.

operator fun get(path: String): OscQueryParam? = params[normalize(path)]

fun add

Add (or replace) a parameter. Replacing keeps the current value when the type is unchanged, so re-running a box does not reset what a remote has set.

    fun add(
        path: String,
        type: String,
        initial: List<Any?>,
        range: List<Pair<Double?, Double?>> = emptyList(),
        values: List<Any?> = emptyList(),
        access: OscAccess = OscAccess.READ_WRITE,
        description: String? = null,
    ): OscQueryParam {
        val p = normalize(path)
        require(p != "/") { "an OSCQuery parameter needs a path below /" }
        require(type.isNotEmpty() && type.all { it in "ifdhsTF" }) { "unsupported OSCQuery type \"$type\"" }
        val previous = params[p]
        val param = OscQueryParam(this, p, type, initial, range, values, access, description)
        if (previous != null && previous.type == type) param.store(previous.value)
        params[p] = param
        if (previous == null) broadcast("PATH_ADDED", p) else broadcast("PATH_CHANGED", p)
        return param
    }

fun describe(path: String, description: String) { descriptions[normalize(path)] = description }

Describe a container node (/synth), shown by browsers next to its contents.

fun remove(path: String)

    fun remove(path: String) {
        val p = normalize(path)
        if (params.remove(p) != null) broadcast("PATH_REMOVED", p)
    }

fun setFromRemote(path: String, args: List<Any?>)

Set path as though a remote had: callbacks run, listeners hear it.

    fun setFromRemote(path: String, args: List<Any?>) {
        val p = params[normalize(path)] ?: return
        if (p.access == OscAccess.NONE || p.access == OscAccess.READ) return
        update(p, args, fromRemote = true)
    }

internal fun update(p: OscQueryParam, args: List<Any?>, fromRemote: Boolean)

    internal fun update(p: OscQueryParam, args: List<Any?>, fromRemote: Boolean) {
        if (!p.store(args) && !fromRemote) return
        val v = p.value
        if (fromRemote) for (l in p.listeners) dispatch(Runnable { l(v) })
        val frame = OscQueryCodec.encode(p.path, p.wireArguments())
        for ((session, listening) in sessions) {
            if (p.path in listening) session.outgoing.trySend(Frame.Binary(true, frame))
        }
    }

HTTP

internal fun answer(path: String, query: String): Pair<HttpStatusCode, JsonElement?>

    internal fun answer(path: String, query: String): Pair<HttpStatusCode, JsonElement?> {
        val p = normalize(path)
        if (query == "HOST_INFO") return HttpStatusCode.OK to hostInfo()
        val node = node(p) ?: return HttpStatusCode.NotFound to null
        if (query.isEmpty()) return HttpStatusCode.OK to node
        val attr = query.substringBefore('&')
        val v = node[attr] ?: return HttpStatusCode.NoContent to null
        return HttpStatusCode.OK to buildJsonObject { put(attr, v) }
    }

fun hostInfo(): JsonObject

    fun hostInfo(): JsonObject = buildJsonObject {
        put("NAME", name)
        put("OSC_PORT", oscPort)
        put("OSC_TRANSPORT", "UDP")
        if (bindAddress != "0.0.0.0") put("OSC_IP", bindAddress)
        put("EXTENSIONS", buildJsonObject {
            for (e in listOf("ACCESS", "VALUE", "RANGE", "DESCRIPTION", "LISTEN", "PATH_CHANGED", "PATH_ADDED", "PATH_REMOVED")) put(e, true)
            for (e in listOf("TAGS", "EXTENDED_TYPE", "UNIT", "CRITICAL", "CLIPMODE", "PATH_RENAMED", "HTML")) put(e, false)
        })
    }

fun node(path: String): JsonObject?

The JSON for path: a leaf parameter, or a container whose CONTENTS are built from paths below it.

    fun node(path: String): JsonObject? {
        val p = normalize(path)
        params[p]?.let { return leaf(it) }
        val prefix = if (p == "/") "/" else "$p/"
        val below = params.keys.filter { it.startsWith(prefix) }
        if (below.isEmpty() && p != "/") return null
        val children = below.map { it.removePrefix(prefix).substringBefore('/') }.toSortedSet()
        return buildJsonObject {
            put("FULL_PATH", p)
            put("ACCESS", 0)
            descriptions[p]?.let { put("DESCRIPTION", it) }
            put("CONTENTS", buildJsonObject {
                for (c in children) put(c, node(prefix + c)!!)
            })
        }
    }

    private fun leaf(p: OscQueryParam): JsonObject = buildJsonObject {
        put("FULL_PATH", p.path)
        put("TYPE", p.type)
        put("ACCESS", p.access.code)
        if (p.access == OscAccess.READ || p.access == OscAccess.READ_WRITE) put("VALUE", JsonArray(p.value.map(::toJson)))
        if (p.values.isNotEmpty()) put("RANGE", buildJsonArray { add(buildJsonObject { put("VALS", JsonArray(p.values.map(::toJson))) }) })
        else if (p.range.isNotEmpty()) put("RANGE", JsonArray(p.range.map { (lo, hi) ->
            buildJsonObject { lo?.let { put("MIN", it) }; hi?.let { put("MAX", it) } }
        }))
        p.description?.let { put("DESCRIPTION", it) }
    }

WebSocket

    private suspend fun serveSocket(session: DefaultWebSocketServerSession) {
        val listening = ConcurrentHashMap.newKeySet<String>()
        sessions[session] = listening
        try {
            for (frame in session.incoming) {
                when (frame) {
                    is Frame.Text -> {
                        val cmd = try { Json.parseToJsonElement(frame.readText()).jsonObject } catch (_: Exception) { continue }
                        val command = cmd["COMMAND"]?.jsonPrimitive?.content
                        val data = (cmd["DATA"] as? JsonPrimitive)?.content ?: continue
                        when (command) {
                            "LISTEN" -> listening += normalize(data)
                            "IGNORE" -> listening -= normalize(data)
                        }
                    }
                    is Frame.Binary -> handlePacket(frame.readBytes())
                    else -> {}
                }
            }
        } finally {
            sessions.remove(session)
        }
    }

    private fun broadcast(command: String, path: String) {
        val text = buildJsonObject { put("COMMAND", command); put("DATA", path) }.toString()
        for (session in sessions.keys) session.outgoing.trySend(Frame.Text(text))
    }

UDP

    private fun receiveLoop() {
        val buf = ByteArray(65536)
        while (!udp.isClosed) {
            val packet = DatagramPacket(buf, buf.size)
            try { udp.receive(packet) } catch (_: SocketException) { break } catch (_: Exception) { continue }
            handlePacket(packet.data.copyOf(packet.length))
        }
    }

internal fun handlePacket(bytes: ByteArray)

    internal fun handlePacket(bytes: ByteArray) {
        for (m in OscQueryCodec.decodeAll(bytes)) {
            try { setFromRemote(m.address, m.arguments) }
            catch (t: Throwable) { println("OSCQuery: set ${m.address} failed: $t") }
        }
    }

var isClosed

    @Volatile
    var isClosed = false
        private set

override fun close()

    override fun close() {
        if (isClosed) return
        isClosed = true
        adverts.forEach { try { it.close() } catch (_: Throwable) {} }
        udp.close()
        for (s in sessions.keys) s.outgoing.close()
        http.stop(100, 500)
    }

companion object

internal val json = Json { prettyPrint = false }

internal fun normalize(path: String): String

        internal fun normalize(path: String): String {
            val p = "/" + path.trim().trim('/')
            return p
        }

internal fun toJson(v: Any?): JsonElement

        internal fun toJson(v: Any?): JsonElement = when (v) {
            null -> JsonNull
            is Boolean -> JsonPrimitive(v)
            is Float -> JsonPrimitive(v.toString().toDouble()) // 0.3f, not 0.30000001192092896
            is Number -> JsonPrimitive(v)
            else -> JsonPrimitive(v.toString())
        }
    }
}