Compare commits

...

11 Commits

Author SHA1 Message Date
74e44a07ba ignore .idea 2024-05-23 14:50:45 -04:00
6b8ad1f5d4 kotlin 2, dbus-java-core 5 2024-05-23 14:50:26 -04:00
e01589aab8 docs 2024-04-18 12:02:55 -04:00
265efd1db0 use String instead of Op; support SCENE_<number> 2024-03-13 15:31:20 -04:00
e0838be9b8 remove unused Backoff.kt 2024-03-13 15:27:39 -04:00
145bf4d7b1 Connect from connect thread. Avoids stack overflow 2023-11-30 14:59:36 -05:00
9ded3dfdc6 update rationale section in readme 2023-11-10 11:59:23 -05:00
a4886c8ae6 systemd unit file 2023-11-10 11:55:38 -05:00
fb9fc290ad add STUDIO_MODE_TOGGLE, SCENE_PREV, and SCENE_NEXT 2023-10-29 18:58:18 -04:00
d433179db4 better error handling and log messages 2023-10-24 08:17:13 -04:00
70a7f3768e fix Obs shutdown hook 2023-10-24 07:43:12 -04:00
11 changed files with 193 additions and 108 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ gradlew*
# Ignore Gradle build output directory
build
.idea

View File

@@ -2,16 +2,25 @@
OBS control from D-Bus messages
Send a D-Bus signal to perform an operation with `src/obsdc-signal`, which takes a single operation name
as an argument.
Send a D-Bus signal to perform an operation with `src/obsdc-signal`, which takes a single operation command
as an argument, and performs an action in OBS.
## Operations
See [Op](src/main/kotlin/net/eksb/obsdc/Op.kt) for a list of operations.
* `SCENE_NEXT` - switch to the next scene
* `SCENE_PREV` - switch to the previous scene
* `SCENE_#` - switch to the indicated scene
* replace `#` with scene index, starting with 1.
* `STUDIO_TRANSITION` - transition the studio preview and program
* `STUDIO_MODE_TOGGLE` - toggle studio mode
* `PAN_UP` - move the top unlocked source up 50px
* `PAN_DOWN` - move the top unlocked source down 50px
* `PAN_LEFT` - move the top unlocked source left 50px
* `PAN_RIGHT` - move the top unlocked source right 50pxt
## Configuration
Configuration is in `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
Configuration is read from `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
### Configuration options
@@ -19,6 +28,15 @@ Configuration is in `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
* `port` - OBS websocket port (default: `4455`)
* `password` - OBS websocket password (required)
### systemd
A systemd unit file ([obsdc.service](src/main/dist/obsdc.service)) is included.
1. Copy the file to `~/.config/systemd/user/`.
2. Edit the `ExecStart` line for where you have installed obsdc.
3. `systemctl --user enable obsdc`
4. `systemctl --user start obsdc`
## Build
`gradle build`
@@ -49,9 +67,13 @@ using an `Obs`.
I usually code on a 4k monitor in portrait mode. To show what I am doing on video calls,
I use OBS to share that monitor, but it is a 2160x4096 source in a 1920x1080 scene.
I have [a macropad](https://www.keebmonkey.com/products/megalodon-dual-layer-knob-macro-pad) with two knobs.
My [window manager](https://swaywm.org/) is configured to call `obsdc-signal` with `PAN_UP`/`PAN_DOWN` for the
keys emitted by one knob and `PAN_RIGHT`/`PAN_LEFT` for the keys emitted by the other knob.
I have
[a macropad](https://keeb.io/collections/bdn9-collection/products/bdn9-rev-2-3x3-9-key-macropad-rotary-encoder-and-rgb)
with three knobs.
My [window manager](https://swaywm.org/) is configured to call `obsdc-signal` with
`PAN_UP`/`PAN_DOWN` for the keys emitted by the left knob,
`PAN_RIGHT`/`PAN_LEFT` for the keys emitted by the right knob, and
`SCENE_NEXT`/`SCENE_PREV` for the keys emitted by the center knob.
The regular keys on the macropad are mapped to the other actions.
So now I can quickly control what people can see without having to focus (or even make visible) the OBS window.

View File

@@ -1,5 +1,5 @@
plugins {
id("org.jetbrains.kotlin.jvm") version "1.9.0"
id("org.jetbrains.kotlin.jvm") version "2.0.0"
application
}
@@ -7,8 +7,8 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.3")
implementation("com.github.hypfvieh:dbus-java-core:4.3.1")
runtimeOnly("com.github.hypfvieh:dbus-java-transport-native-unixsocket:4.3.1")
implementation("com.github.hypfvieh:dbus-java-core:5.0.0")
runtimeOnly("com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0")
implementation("io.obs-websocket.community:client:2.0.0")

10
src/main/dist/obsdc.service vendored Normal file
View File

@@ -0,0 +1,10 @@
[Unit]
Description=OBSDC
[Service]
Type=dbus
BusName=net.eksb.obsdc
ExecStart=/opt/obsdc/bin/obsdc
[Install]
WantedBy=default.target

View File

@@ -1,8 +0,0 @@
package net.eksb.obsdc
class Backoff {
fun backoff() {
// TODO: actual bakoff
Thread.sleep(5_000)
}
}

View File

@@ -3,6 +3,7 @@ package net.eksb.obsdc
import org.freedesktop.dbus.annotations.DBusInterfaceName
import org.freedesktop.dbus.connections.impl.DBusConnection
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
import org.freedesktop.dbus.exceptions.DBusException
import org.freedesktop.dbus.interfaces.DBusInterface
import org.freedesktop.dbus.interfaces.DBusSigHandler
import org.freedesktop.dbus.messages.DBusSignal
@@ -18,7 +19,7 @@ import org.slf4j.LoggerFactory
*
* `src/scripts/obsdc-signal` takes an [Op] name and sends the signal.
*/
class DBus(private val opRunner:OpRunner): AutoCloseable {
class DBus(private val opHandler:(String)->Unit): AutoCloseable {
// To monitor DBUS: `dbus-monitor`
// To see what is registered: `qdbus net.eksb.obsdc /`
@@ -27,22 +28,29 @@ class DBus(private val opRunner:OpRunner): AutoCloseable {
init {
// These lines are not necessary to handle signals, but are necessary to register methods.
dbus.requestBusName("net.eksb.obsdc")
try {
dbus.requestBusName(BUS_NAME)
} catch (e:DBusException) {
error("Error requesting bus. Already running?")
}
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
log.debug("signal: ${signal.op}")
val op = Op.valueOf(signal.op)
log.debug("op: ${op}")
opRunner.run(op)
opHandler.invoke(signal.op)
}
log.debug("DBUS initialized")
}
override fun close() {
dbus.releaseBusName(BUS_NAME)
dbus.close()
}
companion object {
private const val BUS_NAME = "net.eksb.obsdc"
private val log = LoggerFactory.getLogger(DBus::class.java)
}
}
inline fun <reified T: DBusSignal> DBusConnection.addSigHandler(handler: DBusSigHandler<T>) {

View File

@@ -1,8 +1,11 @@
package net.eksb.obsdc
import org.slf4j.LoggerFactory
object Main {
@JvmStatic
fun main(args: Array<String>) {
try {
val config = CONFIG_FILE.properties()
Obs(
host = config.getProperty("host") ?: "localhost",
@@ -14,5 +17,10 @@ object Main {
waitForShutdown()
}
}
} catch (e:Exception) {
log.error(e.message, e)
}
}
private val log = LoggerFactory.getLogger(Main::class.java)
}

View File

@@ -28,12 +28,14 @@ class Obs(
/** Queue of requests to run. */
private val q:BlockingQueue<Req> = LinkedBlockingQueue()
/** Backoff on errors. */
private val backoff = Backoff()
/** Flag to set when closed to stop queue poll loop. */
private val closed = AtomicBoolean(false)
/**
* Flag set when we start trying to connect, unset when disconnected.
* Used to determine if we should reconnect on controller error.
*/
private val connectingOrConnected = AtomicBoolean(false)
/**
* Flag to set when connected, unset when disconnected.
* Used to determine if we should reconnect on controller error.
@@ -48,7 +50,7 @@ class Obs(
.host(host)
.port(port)
.password(password)
.autoConnect(true)
.autoConnect(false)
.connectionTimeout(connectionTimeout)
.lifecycle()
.onReady(::onReady)
@@ -67,7 +69,23 @@ class Obs(
// OBSRemoteController starts a non-daemon thread. It probably should not do that.
// Kill it on shutdown.
addShutdownHook {
controller.stop()
close()
}
// Thread that connects if we are not connected/connecting.
thread(name="obs-connect", isDaemon=true, start=true) {
while(!closed.get()) {
if (connectingOrConnected.compareAndSet(false,true)) {
log.debug("Not closed, not connected. Try to connect...")
try {
// Only call connect from here; if you try to call connect from [onControllerError] or [onDisconnect]
// eventually you will get stack overflow.
controller.connect()
} catch (e:Exception) {
log.warn("failed to connect: ${e.message}", e)
}
}
Thread.sleep(connectionTimeout.toLong() * 1000L) // in case the error was immediate
}
}
}
@@ -78,11 +96,10 @@ class Obs(
private fun onControllerError(e:ReasonThrowable) {
log.debug("controller error - ${e.reason}",e.throwable)
if (!closed.get() && !connected.get()) {
log.debug("connection failed")
backoff.backoff()
log.debug("reconnect after connection failed...")
controller.connect()
// If we are not connected, a controller error means that connection failed and we will not connect.
// If we are connected, it does not mean we are/will be disconnected.
if (!connected.get()) {
connectingOrConnected.set(false)
}
}
private fun onCommError(e:ReasonThrowable) {
@@ -90,12 +107,8 @@ class Obs(
}
private fun onDisconnect() {
log.debug("disconnected")
connectingOrConnected.set(false)
connected.set(false)
if (! closed.get()) {
backoff.backoff()
log.debug("reconnect after disconnected..")
controller.connect()
}
}
private fun onReady() {
@@ -112,24 +125,26 @@ class Obs(
/**
* Thread that runs submitted requests from [q] when [ready].
*/
private val opThread = thread(name="obs-op", isDaemon=true, start=true) {
private val opThread = thread(name="obs-op", isDaemon=false, start=true) {
while(!closed.get()) {
val req = q.take()
val req = try {
q.take()
} catch (e:InterruptedException) {
log.debug("interrupted taking req")
continue
}
log.debug("got req: ${req}, wait for ready")
try {
ready.enter {
log.debug("ready")
if (!req.expired()) {
try {
req.block.invoke(controller)
} catch (e:InterruptedException) {
log.debug("interrupted")
throw e
}
}
} catch (e:Exception) {
log.error("req ${req} failed", e )
}
}
}
}
log.debug("done")
}
@@ -147,7 +162,6 @@ class Obs(
}
override fun close() {
log.debug("close")
closed.set(true)
opThread.interrupt()
controller.disconnect()

View File

@@ -1,21 +0,0 @@
package net.eksb.obsdc
enum class Op {
/** If in studio mode, transition between scenes. */
STUDIO_TRANSITION,
/** Activate the first scene. */
SCENE_1,
/** Activate the second scene. */
SCENE_2,
/** Activate the third scene. */
SCENE_3,
/** Move the bottom-most unlocked source in the active scene up. */
PAN_UP,
/** Move the bottom-most unlocked source in the active scene down. */
PAN_DOWN,
/** Move the bottom-most unlocked source in the active scene left. */
PAN_LEFT,
/** Move the bottom-most unlocked source in the active scene right. */
PAN_RIGHT,
;
}

View File

@@ -9,32 +9,74 @@ import io.obswebsocket.community.client.model.SceneItem.Transform.TransformBuild
import org.slf4j.LoggerFactory
/**
* Use an [obs] to run [Op]s.
* Use an [obs] to run an operation.
*
* Call [run] to run an OBS operation.
* Call [invoke] to run an OBS operation.
*/
class OpRunner(private val obs:Obs) {
class OpRunner(private val obs:Obs): (String)->Unit {
/** How much to pan. */
private val panAmount = 50F
private val controller = obs.controller
fun run(op:Op) {
/**
* Run the specified [op].
*
* @param op Operation to run.
*/
override fun invoke(op:String) {
obs.submit { controller ->
when(op) {
Op.SCENE_1 -> scene { scenes -> scenes.firstOrNull() }
Op.SCENE_2 -> scene { scenes -> scenes.asSequence().drop(1).firstOrNull() }
Op.SCENE_3 -> scene { scenes -> scenes.asSequence().drop(2).firstOrNull() }
Op.STUDIO_TRANSITION -> {
when {
op == "SCENE_NEXT" -> scene { scenes, current ->
if (current != null) {
scenes.asSequence()
.dropWhile { scene -> scene != current }
.drop(1)
.firstOrNull()
?: scenes.firstOrNull()
} else {
null
}
}
op == "SCENE_PREV" -> scene { scenes, current ->
if (current != null) {
scenes.reversed().asSequence()
.dropWhile { scene -> scene != current }
.drop(1)
.firstOrNull()
?: scenes.lastOrNull()
} else {
null
}
}
op.startsWith("SCENE_") -> scene { scenes, current ->
val drop = op.drop("SCENE_".length).toInt() - 1
scenes.asSequence().drop(drop).firstOrNull()
}
op == "STUDIO_TRANSITION" -> {
controller.triggerStudioModeTransition { response ->
log.debug("studio transitioned: ${response.isSuccessful}")
}
}
Op.PAN_UP -> transform { old -> positionY(old.positionY - panAmount ) }
Op.PAN_DOWN -> transform { old -> positionY(old.positionY + panAmount ) }
Op.PAN_LEFT -> transform { old -> positionX(old.positionX - panAmount ) }
Op.PAN_RIGHT -> transform { old -> positionX(old.positionX + panAmount ) }
op == "STUDIO_MODE_TOGGLE" -> studioModeToggle()
op == "PAN_UP" -> transform { old -> positionY(old.positionY - panAmount ) }
op == "PAN_DOWN" -> transform { old -> positionY(old.positionY + panAmount ) }
op == "PAN_LEFT" -> transform { old -> positionX(old.positionX - panAmount ) }
op == "PAN_RIGHT" -> transform { old -> positionX(old.positionX + panAmount ) }
else -> log.error("Unhandled op \"${op}\"")
}
}
}
private fun studioModeToggle() {
controller.getStudioModeEnabled { response ->
if (response.isSuccessful) {
val enable = !response.studioModeEnabled
log.debug("toggle studio mode: ${!enable}")
controller.setStudioModeEnabled(enable) { response ->
log.debug("toggled studio mode: ${enable}")
}
}
}
}
@@ -42,10 +84,17 @@ class OpRunner(private val obs:Obs) {
/**
* Select a scene from the scene list with the supplied [selector] and set the selected scene (if any)
* as the current program scene.
*
* @param selector Lambda that takes as arguments the list of scenes and the current scene (or null if the current
* scene is unknown), and returns the scene to select, or null to not change the scene.
*/
private fun scene(selector:(List<Scene>)->Scene?) {
private fun scene(selector:(List<Scene>,Scene?)->Scene?) {
controller.getCurrentProgramScene { response ->
val currentSceneName = response.currentProgramSceneName
controller.getSceneList { response ->
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed())
val scenes = response.scenes.sortedBy(Scene::getSceneIndex).reversed()
val currentScene = scenes.find { scene -> scene.sceneName == currentSceneName }
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed(), currentScene)
log.debug("select scene ${scene?.sceneName} index:${scene?.sceneIndex}")
if (scene != null) {
controller.setCurrentProgramScene(scene.sceneName) { response ->
@@ -54,6 +103,7 @@ class OpRunner(private val obs:Obs) {
}
}
}
}
/**
* Generate a transform for the lowest non-locked item in the current program scene with the

View File

@@ -1 +1,2 @@
org.slf4j.simpleLogger.log.net.eksb=debug
org.slf4j.simpleLogger.log.org.freedesktop.dbus=debug