Compare commits
2 Commits
8f46a3e78a
...
180e4d2bf4
| Author | SHA1 | Date | |
|---|---|---|---|
| 180e4d2bf4 | |||
| f4ac82de05 |
@@ -2,6 +2,7 @@ package net.eksb.obsdc
|
|||||||
|
|
||||||
class Backoff {
|
class Backoff {
|
||||||
fun backoff() {
|
fun backoff() {
|
||||||
|
// TODO: actual bakoff
|
||||||
Thread.sleep(5_000)
|
Thread.sleep(5_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,45 +9,43 @@ import org.freedesktop.dbus.messages.DBusSignal
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.util.concurrent.BlockingQueue
|
import java.util.concurrent.BlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
class DBus(private val q: BlockingQueue<Op>) {
|
/**
|
||||||
|
* Listen to signals on the session DBUS, and send the ops to [q].
|
||||||
|
*
|
||||||
|
* To send an op signal:
|
||||||
|
* `dbus-send --session --type=signal --dest=net.eksb.obsdc / net.eksb.Obsdc.Signal string:{OP_NAME}`
|
||||||
|
*
|
||||||
|
* `{OP_NAME}` is the name of an [Op].
|
||||||
|
*
|
||||||
|
* `src/scripts/obs-signal` takes an [Op] name and sends the signal.
|
||||||
|
*/
|
||||||
|
class DBus(private val q: BlockingQueue<Op>): AutoCloseable {
|
||||||
|
|
||||||
private val thread = thread(
|
// To monitor DBUS: `dbus-monitor`
|
||||||
start = true,
|
// To see what is registered: `qdbus net.eksb.obsdc /`
|
||||||
isDaemon = false,
|
|
||||||
name = "dbus",
|
|
||||||
) {
|
|
||||||
DBusConnectionBuilder.forSessionBus().build().use { dbus ->
|
|
||||||
|
|
||||||
|
val dbus = DBusConnectionBuilder.forSessionBus().build()
|
||||||
|
|
||||||
|
init {
|
||||||
// These lines are not necessary to handle signals, but are necessary to register methods.
|
// These lines are not necessary to handle signals, but are necessary to register methods.
|
||||||
dbus.requestBusName("net.eksb.obsdc")
|
dbus.requestBusName("net.eksb.obsdc")
|
||||||
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
|
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
|
||||||
|
|
||||||
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
|
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
|
||||||
log.info("signal: ${signal.op}")
|
log.debug("signal: ${signal.op}")
|
||||||
val op = Op.valueOf(signal.op)
|
val op = Op.valueOf(signal.op)
|
||||||
log.info("op: ${op}")
|
log.debug("op: ${op}")
|
||||||
try {
|
try {
|
||||||
q.offer(op, 1, TimeUnit.SECONDS)
|
q.offer(op, 1, TimeUnit.SECONDS)
|
||||||
} catch (e: InterruptedException) {
|
} catch (e: InterruptedException) {
|
||||||
log.debug("queue offer interrupted")
|
log.debug("queue offer interrupted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(60_000)
|
|
||||||
} catch (e:InterruptedException) {
|
|
||||||
log.info("interrupted")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("done")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
override fun close() {
|
||||||
thread.interrupt()
|
dbus.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(DBus::class.java)
|
private val log = LoggerFactory.getLogger(DBus::class.java)
|
||||||
@@ -62,24 +60,10 @@ interface ObsdcDBusInterface: DBusInterface {
|
|||||||
fun echo(message:String): String
|
fun echo(message:String): String
|
||||||
class Signal(path:String, val op:String): DBusSignal(path, op)
|
class Signal(path:String, val op:String): DBusSignal(path, op)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ObsdcDBusInterfaceImpl: ObsdcDBusInterface {
|
class ObsdcDBusInterfaceImpl: ObsdcDBusInterface {
|
||||||
override fun echo(message: String):String {
|
override fun echo(message: String):String {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
override fun getObjectPath(): String = "/"
|
override fun getObjectPath(): String = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
Monitor:
|
|
||||||
`dbus-monitor`
|
|
||||||
|
|
||||||
See what is registered:
|
|
||||||
`qdbus net.eksb.obsdc /`
|
|
||||||
|
|
||||||
Send signal:
|
|
||||||
`dbus-send --session --type=signal --dest=net.eksb.obsdc / net.eksb.Obsdc.Signal string:a`
|
|
||||||
|
|
||||||
Call method:
|
|
||||||
`dbus-send --session --type=method_call --print-reply --dest=net.eksb.obsdc / net.eksb.Obsdc.echo string:b`
|
|
||||||
`qdbus net.eksb.obsdc / net.eksb.Obsdc.echo hello`
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -6,8 +6,21 @@ import java.util.concurrent.LinkedBlockingQueue
|
|||||||
object Main {
|
object Main {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
// Create a queue to send ops coming in from DBUS to OBS.
|
||||||
val q:BlockingQueue<Op> = LinkedBlockingQueue()
|
val q:BlockingQueue<Op> = LinkedBlockingQueue()
|
||||||
DBus(q) // forks non-daemon thread
|
|
||||||
Obs(q).start() // forks non-daemon thread
|
val config = CONFIG_FILE.properties()
|
||||||
|
|
||||||
|
// Listen for DBUS signals and send to q.
|
||||||
|
DBus(q)
|
||||||
|
|
||||||
|
// Send requests to OBS. Forks a non-daemon thread.
|
||||||
|
Obs(
|
||||||
|
q,
|
||||||
|
host = config.getProperty("host") ?: "localhost",
|
||||||
|
port = config.getProperty("port")?.toInt() ?: 4455,
|
||||||
|
password = config.getProperty("password") ?: error("config missing \"password\""),
|
||||||
|
connectionTimeout = config.getProperty("connectionTimeout")?.toInt() ?: 5
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,23 +14,39 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
// protocol docs: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
/**
|
||||||
class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
* Send ops from [q] to OBS.
|
||||||
|
*
|
||||||
|
* protocol docs: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||||
|
*/
|
||||||
|
class Obs(
|
||||||
|
private val q:BlockingQueue<Op>,
|
||||||
|
host:String = "localhost",
|
||||||
|
port:Int = 4455,
|
||||||
|
password:String,
|
||||||
|
connectionTimeout:Int = 5 // seconds
|
||||||
|
): AutoCloseable {
|
||||||
|
|
||||||
|
/** How much to pan. */
|
||||||
private val panAmount = 50F
|
private val panAmount = 50F
|
||||||
|
|
||||||
private val backoff = Backoff()
|
private val backoff = Backoff()
|
||||||
|
|
||||||
private val started = AtomicBoolean(false)
|
/** Set if this is closed. */
|
||||||
|
private val closed = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Set when connected, unset when disconnected. */
|
||||||
private val connected = AtomicBoolean(false)
|
private val connected = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Set when ready, unset on error or disconnect. */
|
||||||
private val ready = AtomicBoolean(false)
|
private val ready = AtomicBoolean(false)
|
||||||
|
|
||||||
private val controller = OBSRemoteController.builder()
|
private val controller = OBSRemoteController.builder()
|
||||||
.host("localhost")
|
.host(host)
|
||||||
.port(4455)
|
.port(port)
|
||||||
.password("R3tRkVXhFofJ2wRF") // TODO put this in a file
|
.password(password)
|
||||||
.autoConnect(false)
|
.autoConnect(true)
|
||||||
.connectionTimeout(3)
|
.connectionTimeout(connectionTimeout)
|
||||||
.lifecycle()
|
.lifecycle()
|
||||||
.onReady(::onReady)
|
.onReady(::onReady)
|
||||||
.onClose(::onClose)
|
.onClose(::onClose)
|
||||||
@@ -38,7 +54,7 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
|||||||
.onCommunicatorError(::onCommError)
|
.onCommunicatorError(::onCommError)
|
||||||
.onDisconnect(::onDisconnect)
|
.onDisconnect(::onDisconnect)
|
||||||
.onConnect {
|
.onConnect {
|
||||||
log.info("connected")
|
log.debug("connected")
|
||||||
connected.set(true)
|
connected.set(true)
|
||||||
}
|
}
|
||||||
.and()
|
.and()
|
||||||
@@ -50,47 +66,36 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (!started.compareAndExchange(false,true)) {
|
|
||||||
controller.connect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun stop() {
|
|
||||||
if (started.compareAndExchange(true,false)) {
|
|
||||||
controller.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onClose(e:WebSocketCloseCode) {
|
private fun onClose(e:WebSocketCloseCode) {
|
||||||
log.info("closed: ${e.code}")
|
log.debug("closed: ${e.code}")
|
||||||
ready.set(false)
|
ready.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onControllerError(e:ReasonThrowable) {
|
private fun onControllerError(e:ReasonThrowable) {
|
||||||
log.info("controller error - ${e.reason}",e.throwable)
|
log.debug("controller error - ${e.reason}",e.throwable)
|
||||||
if (started.get() && ! connected.get()) {
|
if (!closed.get() && !connected.get()) {
|
||||||
log.info("connection failed")
|
log.debug("connection failed")
|
||||||
backoff.backoff()
|
backoff.backoff()
|
||||||
log.info("reconnect after connection failed...")
|
log.debug("reconnect after connection failed...")
|
||||||
controller.connect()
|
controller.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun onCommError(e:ReasonThrowable) {
|
private fun onCommError(e:ReasonThrowable) {
|
||||||
log.info("comm error - ${e.reason}",e.throwable)
|
log.debug("comm error - ${e.reason}",e.throwable)
|
||||||
}
|
}
|
||||||
private fun onDisconnect() {
|
private fun onDisconnect() {
|
||||||
log.info("disconnected")
|
log.debug("disconnected")
|
||||||
ready.set(false)
|
ready.set(false)
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
if (started.get()) {
|
if (! closed.get()) {
|
||||||
backoff.backoff()
|
backoff.backoff()
|
||||||
log.info("reconnect after disconnected..")
|
log.debug("reconnect after disconnected..")
|
||||||
controller.connect()
|
controller.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onReady() {
|
private fun onReady() {
|
||||||
log.info("ready")
|
log.debug("ready")
|
||||||
ready.set(true)
|
ready.set(true)
|
||||||
// The docs say that you are only supposed to send requests from the [onReady] handler,
|
// The docs say that you are only supposed to send requests from the [onReady] handler,
|
||||||
// but you cannot block the [onReady] handler.
|
// but you cannot block the [onReady] handler.
|
||||||
@@ -100,26 +105,36 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
|||||||
// So we keep track of if it is ready, and make requests from another thread ([opThread]).
|
// So we keep track of if it is ready, and make requests from another thread ([opThread]).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread that polls [q], checks if ready, and calls [op] to send requests to OBS.
|
||||||
|
* If not ready, ops are dropped.
|
||||||
|
*/
|
||||||
private val opThread = thread(name="obs-op", isDaemon=false, start=true) {
|
private val opThread = thread(name="obs-op", isDaemon=false, start=true) {
|
||||||
while(true) {
|
while(true) {
|
||||||
val op = q.take()
|
val op = q.take()
|
||||||
log.info("got op: ${op}")
|
log.debug("got op: ${op}")
|
||||||
if (ready.get()) {
|
if (ready.get()) {
|
||||||
try {
|
try {
|
||||||
op(op)
|
op(op)
|
||||||
} catch (e:InterruptedException) {
|
} catch (e:InterruptedException) {
|
||||||
log.info("op thread interrupted")
|
log.debug("op thread interrupted")
|
||||||
break
|
break
|
||||||
} catch (e:Exception) {
|
} catch (e:Exception) {
|
||||||
log.error("op ${op} failed", e )
|
log.error("op ${op} failed", e )
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// This would be way more complicated if we had to buffer ops.
|
// This would be way more complicated if we had to buffer ops.
|
||||||
log.info("skipping op ${op} because not yet ready")
|
log.debug("skipping op ${op} because not yet ready")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the request to OBS for the op.
|
||||||
|
*
|
||||||
|
* When the op requires multiple chained requests, those requests are made here in response handlers,
|
||||||
|
* and this method blocks until all requests are complete.
|
||||||
|
*/
|
||||||
private fun op(op:Op) {
|
private fun op(op:Op) {
|
||||||
when(op) {
|
when(op) {
|
||||||
Op.SCENE_1 -> scene { scenes -> scenes.firstOrNull() }
|
Op.SCENE_1 -> scene { scenes -> scenes.firstOrNull() }
|
||||||
@@ -127,33 +142,40 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
|||||||
Op.SCENE_3 -> scene { scenes -> scenes.asSequence().drop(2).firstOrNull() }
|
Op.SCENE_3 -> scene { scenes -> scenes.asSequence().drop(2).firstOrNull() }
|
||||||
Op.STUDIO_TRANSITION -> {
|
Op.STUDIO_TRANSITION -> {
|
||||||
controller.triggerStudioModeTransition { response ->
|
controller.triggerStudioModeTransition { response ->
|
||||||
log.info("studio transitioned: ${response.isSuccessful}")
|
log.debug("studio transitioned: ${response.isSuccessful}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Op.PAN_UP -> pan { old -> positionY(old.positionY - panAmount ) }
|
Op.PAN_UP -> transform { old -> positionY(old.positionY - panAmount ) }
|
||||||
Op.PAN_DOWN -> pan { old -> positionY(old.positionY + panAmount ) }
|
Op.PAN_DOWN -> transform { old -> positionY(old.positionY + panAmount ) }
|
||||||
Op.PAN_LEFT -> pan { old -> positionX(old.positionX - panAmount ) }
|
Op.PAN_LEFT -> transform { old -> positionX(old.positionX - panAmount ) }
|
||||||
Op.PAN_RIGHT -> pan { old -> positionX(old.positionX + panAmount ) }
|
Op.PAN_RIGHT -> transform { old -> positionX(old.positionX + panAmount ) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a scene from the scene list with the supplied [selector] and set the selected scene (if any)
|
||||||
|
* as the current program scene.
|
||||||
|
*/
|
||||||
private fun scene(selector:(List<Scene>)->Scene?) {
|
private fun scene(selector:(List<Scene>)->Scene?) {
|
||||||
controller.getSceneList { response ->
|
controller.getSceneList { response ->
|
||||||
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed())
|
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed())
|
||||||
log.info("select scene ${scene?.sceneName} index:${scene?.sceneIndex}")
|
log.debug("select scene ${scene?.sceneName} index:${scene?.sceneIndex}")
|
||||||
if (scene != null) {
|
if (scene != null) {
|
||||||
controller.setCurrentProgramScene(scene.sceneName) { response ->
|
controller.setCurrentProgramScene(scene.sceneName) { response ->
|
||||||
log.info("selected scene ${scene.sceneName}: ${response.isSuccessful}")
|
log.debug("selected scene ${scene.sceneName}: ${response.isSuccessful}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan the bottom-most non-locked item.
|
/**
|
||||||
private fun pan(block:TransformBuilder.(Transform)->TransformBuilder) {
|
* Generate a transform for the lowest non-locked item in the current program scene with the
|
||||||
|
* supplied [transformBuilder], and apply that transform to the item.
|
||||||
|
*/
|
||||||
|
private fun transform(transformBuilder:TransformBuilder.(Transform)->TransformBuilder) {
|
||||||
controller.getCurrentProgramScene { response ->
|
controller.getCurrentProgramScene { response ->
|
||||||
val sceneName = response.currentProgramSceneName
|
val sceneName = response.currentProgramSceneName
|
||||||
log.info("scene name: ${sceneName}")
|
log.debug("scene name: ${sceneName}")
|
||||||
controller.getSceneItemList(sceneName) { response ->
|
controller.getSceneItemList(sceneName) { response ->
|
||||||
val items = response.sceneItems
|
val items = response.sceneItems
|
||||||
// Even though locked status is in the response from OBS, the library does not parse it.
|
// Even though locked status is in the response from OBS, the library does not parse it.
|
||||||
@@ -180,17 +202,17 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
|||||||
.map { (locked,item) -> item }
|
.map { (locked,item) -> item }
|
||||||
.sortedBy { item -> item.sceneItemIndex }
|
.sortedBy { item -> item.sceneItemIndex }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
log.info("item to pan: ${item?.sceneItemId}")
|
log.debug("item to pan: ${item?.sceneItemId}")
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
controller.getSceneItemTransform(sceneName, item.sceneItemId) { response ->
|
controller.getSceneItemTransform(sceneName, item.sceneItemId) { response ->
|
||||||
val transform = response.sceneItemTransform
|
val transform = response.sceneItemTransform
|
||||||
log.info("position: ${transform.positionX} x ${transform.positionY}")
|
log.debug("position: ${transform.positionX} x ${transform.positionY}")
|
||||||
val newTransform = block(Transform.builder(), transform).build()
|
val newTransform = transformBuilder(Transform.builder(), transform).build()
|
||||||
controller.setSceneItemTransform(sceneName, item.sceneItemId, newTransform) { response ->
|
controller.setSceneItemTransform(sceneName, item.sceneItemId, newTransform) { response ->
|
||||||
log.info("transform successful: ${response.isSuccessful}")
|
log.debug("transform successful: ${response.isSuccessful}")
|
||||||
// Have to set the current scene to take effect if in studio mode.
|
// Have to set the current scene to take effect if in studio mode.
|
||||||
controller.setCurrentProgramScene(sceneName) { response ->
|
controller.setCurrentProgramScene(sceneName) { response ->
|
||||||
log.info("set current program to ${sceneName}: ${response.isSuccessful}")
|
log.debug("set current program to ${sceneName}: ${response.isSuccessful}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,8 +223,8 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
log.info("close")
|
log.debug("close")
|
||||||
stop()
|
controller.disconnect()
|
||||||
opThread.interrupt()
|
opThread.interrupt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package net.eksb.obsdc
|
package net.eksb.obsdc
|
||||||
|
|
||||||
enum class Op {
|
enum class Op {
|
||||||
|
/** If in studio mode, transition between scenes. */
|
||||||
STUDIO_TRANSITION,
|
STUDIO_TRANSITION,
|
||||||
|
/** Activate the first scene. */
|
||||||
SCENE_1,
|
SCENE_1,
|
||||||
|
/** Activate the second scene. */
|
||||||
SCENE_2,
|
SCENE_2,
|
||||||
|
/** Activate the third scene. */
|
||||||
SCENE_3,
|
SCENE_3,
|
||||||
|
/** Move the bottom-most unlocked source in the active scene up. */
|
||||||
PAN_UP,
|
PAN_UP,
|
||||||
|
/** Move the bottom-most unlocked source in the active scene down. */
|
||||||
PAN_DOWN,
|
PAN_DOWN,
|
||||||
|
/** Move the bottom-most unlocked source in the active scene left. */
|
||||||
PAN_LEFT,
|
PAN_LEFT,
|
||||||
|
/** Move the bottom-most unlocked source in the active scene right. */
|
||||||
PAN_RIGHT,
|
PAN_RIGHT,
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
15
src/main/kotlin/net/eksb/obsdc/Util.kt
Normal file
15
src/main/kotlin/net/eksb/obsdc/Util.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package net.eksb.obsdc
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val HOME:File = System.getProperty("user.home")?.let(::File) ?: error("No user.home")
|
||||||
|
val CONFIG_HOME:File = System.getenv("XDG_CONFIG_HOME")?.let(::File) ?: File(HOME, ".config")
|
||||||
|
val CONFIG_FILE:File = File(CONFIG_HOME,"net.eksb.obsdc/config.properties")
|
||||||
|
|
||||||
|
fun File.properties(): Properties = Properties()
|
||||||
|
.also { properties ->
|
||||||
|
if (isFile) {
|
||||||
|
inputStream().use(properties::load)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user