├── .dockerignore ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docker ├── Dockerfile ├── build.sh ├── devenv │ ├── devenv.env │ └── secrets.env.example ├── docker-compose.yml ├── lint.sh ├── publish │ └── Dockerfile └── rundev.sh ├── docs └── assets │ └── readme │ ├── example_config.gif │ └── example_config.mkv └── src ├── .golangci.yaml ├── adapters └── config │ ├── config.go │ ├── parse.go │ └── validate.go ├── cmd └── main │ └── main.go ├── drivers ├── actioncomposer │ └── actioncomposer.go ├── messagestore │ ├── record.go │ └── store.go ├── obsremote │ ├── api.go │ ├── logger.go │ └── obsremote.go ├── osc_conditions │ ├── cond_and │ │ └── and.go │ ├── cond_not │ │ └── not.go │ ├── cond_or │ │ └── or.go │ ├── cond_osc_msg_match │ │ └── osc_message_match.go │ └── utils.go ├── osc_connections │ ├── console_bridge_l │ │ ├── console_bridge_l.go │ │ └── message.go │ ├── dummy_bridge │ │ ├── dummy_bridge.go │ │ └── message.go │ ├── http_bridge │ │ └── http_bridge.go │ ├── obs_bridge │ │ └── obs_bridge.go │ └── ticker │ │ └── ticker.go ├── osc_message │ ├── message.go │ └── message_argument.go ├── paramsanitizer │ └── paramsanitizer.go └── tasks │ ├── delay │ └── delay.go │ ├── httpreq │ └── http_request.go │ ├── obstasks │ ├── obs.go │ ├── scene_changer.go │ └── vendor_request.go │ ├── run_command │ └── run_command.go │ └── send_osc_message │ └── send_osc_message.go ├── entities ├── action.go ├── osc_connection_details.go └── prefixed_osc_message.go ├── go.mod ├── go.sum ├── pkg ├── chantools │ └── chantools.go ├── filetools │ └── filetools.go ├── logger │ ├── logger.go │ └── prefixer.go ├── maptools │ └── maptools.go ├── shelltools │ └── shelltools.go ├── slicetools │ └── slicetools.go └── stringtools │ └── stringtools.go └── usecase ├── context.go ├── osc_listener.go ├── osc_message_store_manager.go ├── usecase.go └── usecaseifs ├── depends.go └── provides.go /.dockerignore: -------------------------------------------------------------------------------- 1 | docker/devenv/secrets.env -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | #github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | #patreon: # Replace with a single Patreon username 5 | #open_collective: # Replace with a single Open Collective username 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | ####community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #otechie: # Replace with a single Otechie username 12 | #lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | custom: ["https://buymeacoffee.com/kcsaba"] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *fuse_hidden* 3 | docker/devenv/secrets.env 4 | testscripts/testfiles/* 5 | src/config.yml 6 | build/* 7 | config*.yml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kopiás Csaba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export DOCKER_BUILDKIT=1 2 | export BUILDKIT_PROGRESS=plain 3 | 4 | # Buildx needs this 5 | export DOCKER_CLI_EXPERIMENTAL=enabled 6 | export COMPOSE_DOCKER_CLI_BUILD=1 7 | #export DOCKER_DEFAULT_PLATFORM=linux/arm64 8 | 9 | export UID=$(id -u) 10 | export GID=$(id -g) 11 | 12 | SHELL := /bin/bash 13 | 14 | 15 | # HELP ================================================================================================================= 16 | # This will output the help for each task 17 | # thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 18 | .PHONY: help 19 | help: ## Display this help screen 20 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 21 | 22 | .PHONY: build 23 | build: _setup_buildx _dev_init ## Builds the production binaries, and exits 24 | @docker compose -f docker/docker-compose.yml run --entrypoint "bash -l /mnt/docker/build.sh" app-dev 25 | 26 | .PHONY: build_docker 27 | build_docker: 28 | @docker build --build-context build=./build -t oscbrdige:latest - < ./docker/publish/Dockerfile 29 | 30 | 31 | .PHONY: dev_start 32 | dev_start: _setup_buildx _dev_init ## Starts the development environment. 33 | # --no-cache 34 | @docker compose -f docker/docker-compose.yml build --build-arg UID=$$(id -u) --build-arg GID=$$(id -g) app-dev 35 | @docker compose -f docker/docker-compose.yml up app-dev 36 | 37 | .PHONY: dev_start_debug 38 | dev_start_debug: _setup_buildx _dev_init ## Starts the development container with a shell. 39 | @echo "To start the app, execute /mnt/docker/rundev.sh" 40 | @docker container rm app-dev || true 41 | @docker compose -f docker/docker-compose.yml run -u root --entrypoint "/usr/bin/bash -l" app-dev 42 | 43 | 44 | .PHONY: dev_shell 45 | dev_shell: ## Attaches a shell to the running development environment. (make dev_start needed for it) 46 | @docker compose -f docker/docker-compose.yml exec app-dev bash -l 47 | 48 | .PHONY: dev_root_shell 49 | dev_root_shell: ## Attaches a root shell to the running development environment. (make dev_start needed for it) 50 | @docker compose -f docker/docker-compose.yml exec -u root app-dev bash -l 51 | 52 | .PHONY: lint 53 | lint: _lint_prep _lint_exec ## Executes the linter in the dev env 54 | 55 | .PHONY: _lint_prep 56 | _lint_prep: _setup_buildx _dev_init 57 | @docker compose -f docker/docker-compose.yml build --build-arg UID=$$(id -u) --build-arg GID=$$(id -g) app-dev 58 | 59 | .PHONY: _lint_exec 60 | _lint_exec: _setup_buildx _dev_init 61 | @docker compose -f docker/docker-compose.yml run --entrypoint "bash -l /mnt/docker/lint.sh" app-dev 62 | 63 | .PHONY: _setup_buildx 64 | _setup_buildx: 65 | @docker buildx create --name app-building-node --platform linux/amd64 --use --bootstrap || true 66 | 67 | .PHONY: _dev_init 68 | _dev_init: 69 | @mkdir /tmp/app-tmp > /dev/null 2>&1 || true 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Table of contents

2 | 3 | 4 | 5 | * [Open Sound Control Bridge](#open-sound-control-bridge) 6 | * [Example uses](#example-uses) 7 | * [Install](#install) 8 | * [Docker](#docker) 9 | * [Overview](#overview) 10 | * [Configuration](#configuration) 11 | * [Example configuration](#example-configuration) 12 | * [Actions](#actions) 13 | * [Debouncing](#debouncing) 14 | * [Trigger chain](#trigger-chain) 15 | * [Conditions](#conditions) 16 | * [OSC_MATCH: Check if a single message exists](#oscmatch-check-if-a-single-message-exists) 17 | * [Trigger on change](#trigger-on-change) 18 | * [AND: Require all children condition to resolve to true](#and-require-all-children-condition-to-resolve-to-true) 19 | * [OR: Require at least one children to resolve to true](#or-require-at-least-one-children-to-resolve-to-true) 20 | * [NOT: Negate the single child's result.](#not-negate-the-single-childs-result) 21 | * [Sources](#sources) 22 | * [Digital Mixing Consoles](#digital-mixing-consoles) 23 | * [Dummy console](#dummy-console) 24 | * [OBS bridges](#obs-bridges) 25 | * [HTTP bridges](#http-bridges) 26 | * [Tickers](#tickers) 27 | * [Tasks](#tasks) 28 | * [HTTP request](#http-request) 29 | * [OBS Scene change](#obs-scene-change) 30 | * [OBS Vendor message](#obs-vendor-message) 31 | * [Delay](#delay) 32 | * [Run command](#run-command) 33 | * [Send OSC message](#send-osc-message) 34 | * [Development](#development) 35 | 36 | 37 | # Open Sound Control Bridge 38 | 39 | OSCBridge is a tool to help automate operations with audio/streaming gear. 40 | 41 | Input could come from various sources, such as: 42 | 43 | * Digital Audio Mixer Console state (such as Behringer X32 or other that supports OSC) 44 | * OBS Studio state 45 | * A HTTP Request 46 | * Time 47 | 48 | OSCBridge currently supports the following "tasks": 49 | 50 | * HTTP Request 51 | * Delay (just wait) 52 | * OBS Change preview scene 53 | * OBS Change program scene 54 | * OBS Send "vendor" message to any plugin that cares, e.g. to the amazingly 55 | excellent [Advanced Scene Switcher](https://github.com/WarmUpTill/SceneSwitcher). 56 | * Excecute a command 57 | * Send an OSC message 58 | 59 | ## Example uses 60 | 61 | Here is just a few idea: 62 | 63 | * When a microphone is unmuted, turn the PTZ camera to the speaker. 64 | * When the stage is unmuted, turn the PTZ camera to the stage. 65 | * When a special HTTP request arrives, mute/unmute something. 66 | * When a special HTTP request arrives, set the volume of a channel to the specified value. 67 | * At a specified time, unmute a microphone. 68 | * At a specified time, switch to an OBS Scene. 69 | * At a specified time, send an HTTP Request. 70 | * When something is unmuted, switch to a scene in OBS. 71 | * When a scene is activated in OBS unmute certain channels. 72 | * When a microphone is unmuted, then turn the camera but only if a ceratin OBS scene is active. 73 | * When ... send a command to Advanced Scene Switcher, to do 74 | a [zillion other things](https://github.com/WarmUpTill/SceneSwitcher/wiki) 75 | * When ... then make Advanced Scene Switcher do an http request to execute some other actions through the oscbridge. ( 76 | Btw A.S.S. can send OSC messages too.) 77 | 78 | I think now you got the point! 79 | 80 | # Install 81 | * Download the binary from the latest [release](https://github.com/KopiasCsaba/open_sound_control_bridge/releases) 82 | * Create a [config.yml](https://github.com/KopiasCsaba/open_sound_control_bridge#example-configuration) next to the binary. 83 | * Execute! 84 | 85 | ```bash 86 | $:oscbridge$ ls 87 | config.yml oscbridge-6acaf3b4-linux-amd64.bin 88 | 89 | $:oscbridge$ chmod +x oscbridge-6acaf3b4-linux-amd64.bin 90 | 91 | $:oscbridge$ ./oscbridge-6acaf3b4-linux-amd64.bin 92 | 2023-11-13 07:30:51 [ INFO] OPEN SOUND CONTROL BRIDGE is starting. 93 | 2023-11-13 07:30:51 [ INFO] Version: v1.0.0 Revision: 6acaf3b4 94 | 2023-11-13 07:30:51 [ INFO] Initializing OBS connections... 95 | 2023-11-13 07:30:51 [ INFO] Connecting to streaming_pc_obs... 96 | 2023-11-13 07:30:51 [ INFO] Initializing OBS bridges... 97 | 2023-11-13 07:30:51 [ INFO] Initializing Open Sound Control (mixer consoles, etc) connections... 98 | 99 | ... 100 | ``` 101 | 102 | You may override the config.yml location with the environment variable `APP_CONFIG_FILE`, e.g.: `APP_CONFIG_FILE=/a/b/c/d/osc.yml`. 103 | 104 | 105 | ## Docker 106 | A docker-hub version will be coming soon when my time permits. 107 | 108 | # Overview 109 | 110 | From a birds eye view, oscbridge provides a central "message store", to which "osc sources" can publish messages. 111 | Every time a new message arrives, each action is checked, if their trigger_chain conditions are resolving to true based 112 | on the current store. 113 | If every the trigger chain resolves to true, then the action's tasks are executed. 114 | 115 | So this is the control flow: 116 | [OSC SOURCES] -> [OSC MESSAGE STORE] -> [ACTION TRIGGER CHAIN] -> [ACTION TASK] 117 | 118 | # Configuration 119 | 120 | ## Example configuration 121 | 122 | Below is the simplest example to showcase how the system works. 123 |
124 | Click to see YAML 125 | 126 | ```yaml 127 | obs_connections: 128 | - name: "streaming_pc_obs" 129 | host: 192.168.1.75 130 | port: 4455 131 | password: "foobar" 132 | 133 | osc_sources: 134 | console_bridges: 135 | - name: "behringer_x32" 136 | enabled: false 137 | prefix: "" 138 | host: 192.168.2.99 139 | port: 10023 140 | osc_implementation: l 141 | init_command: 142 | address: /xinfo 143 | check_address: /ch/01/mix/on 144 | check_pattern: "^0|1$" 145 | subscriptions: 146 | - osc_command: 147 | address: /subscribe 148 | arguments: 149 | - type: string 150 | value: /ch/01/mix/on 151 | - type: int32 152 | value: 10 153 | repeat_millis: 8000 154 | 155 | dummy_connections: 156 | - name: "behringer_x32_dummy" 157 | enabled: true 158 | prefix: "" 159 | iteration_speed_secs: 1 160 | message_groups: 161 | - name: mic_1_on 162 | osc_commands: 163 | - address: /ch/01/mix/on 164 | comment: "headset mute (0: muted, 1: unmuted)" 165 | arguments: 166 | - type: int32 167 | value: 1 168 | - name: mic_1_off 169 | osc_commands: 170 | - address: /ch/01/mix/on 171 | comment: "headset mute (0: muted, 1: unmuted)" 172 | arguments: 173 | - type: int32 174 | value: 0 175 | 176 | actions: 177 | to_pulpit: 178 | trigger_chain: 179 | type: osc_match 180 | parameters: 181 | address: /ch/01/mix/on 182 | arguments: 183 | - index: 0 184 | type: "int32" 185 | value: "1" 186 | tasks: 187 | - type: http_request 188 | parameters: 189 | url: "http://127.0.0.1:8888/cgi-bin/ptzctrl.cgi?ptzcmd&poscall&0&__TURN_TO_PULPIT" 190 | method: "get" 191 | timeout_secs: 1 192 | - type: obs_scene_change 193 | parameters: 194 | scene: "PULPIT" 195 | scene_match_type: regexp 196 | target: "program" 197 | connection: "streaming_pc_obs" 198 | - type: obs_scene_change 199 | parameters: 200 | scene: "STAGE" 201 | scene_match_type: regexp 202 | target: "preview" 203 | connection: "streaming_pc_obs" 204 | 205 | to_stage: 206 | trigger_chain: 207 | type: osc_match 208 | parameters: 209 | address: /ch/01/mix/on 210 | arguments: 211 | - index: 0 212 | type: "int32" 213 | value: "0" 214 | tasks: 215 | - type: http_request 216 | parameters: 217 | url: "http://127.0.0.1:8888/cgi-bin/ptzctrl.cgi?ptzcmd&poscall&1&__TURN_TO_STAGE" 218 | method: "get" 219 | timeout_secs: 1 220 | - type: obs_scene_change 221 | parameters: 222 | scene: "STAGE" 223 | scene_match_type: regexp 224 | target: "program" 225 | connection: "streaming_pc_obs" 226 | - type: obs_scene_change 227 | parameters: 228 | scene: "PULPIT" 229 | scene_match_type: regexp 230 | target: "preview" 231 | connection: "streaming_pc_obs" 232 | ``` 233 | 234 |
235 | 236 | In this configuration there are two OSC sources: 237 | 238 | * Dummy (enabled) 239 | * A Behringer X32 digital console (disabled) 240 | 241 | The dummy source acts as if someone would press Ch1's mute button every second to toggle it. 242 | 243 | Then there are two actions defined, "to_pulpit" and "to_stage". 244 | Each has a single trigger, that matches /ch/01/mix/on to be 0 or 1. 245 | 246 | Then for each action, there are three tasks: 247 | 248 | * An HTTP request that would recall a PTZ Optics camera preset (0 and 1 respectively). 249 | * An obs_scene_change to change the program scene. 250 | * An obs_scene_change to change the preview scene. 251 | 252 | You can see the results on this gif: 253 | 254 | 255 | 256 | OBS is switching scenes based on the mute status, and at the bottom you can see the arriving requests. 257 | 258 | You can just switch from the dummy to the console one, and your mute button is then tied to OBS scenes and the camera. 259 | 260 | ## Actions 261 | 262 | Actions encapsulate a so called `trigger_chain` and a list of `tasks` together. 263 | 264 | This is how actions look like: 265 | 266 | ```yaml 267 | actions: 268 | change_to_pulpit: 269 | trigger_chain: 270 | # ... tree of conditions 271 | tasks: 272 | # ... 1 dimensional list of tasks to be executed in order, serially 273 | 274 | change_to_stage: 275 | trigger_chain: 276 | # ... tree of conditions 277 | tasks: 278 | # ... 1 dimensional list of tasks to be executed in order, serially 279 | 280 | start_live_stream: 281 | trigger_chain: 282 | # ... tree of conditions 283 | tasks: 284 | # ... 1 dimensional list of tasks to be executed in order, serially 285 | ``` 286 | 287 | Each action has it's own name, that is shown in the logs upon evaluation/execution. 288 | 289 | Whenever the internal store receives an update, OSCBridge checks each action's trigger_chain, the tree of conditions if 290 | they match the store or not. 291 | If the trigger_chain is evaluated to be true, then the tasks will be executed. 292 | 293 | ### Debouncing 294 | 295 | There is an option, that can be specified for each action, called `debounce_millis`, 296 | if provided then the logic changes a bit. Upon store change, if the trigger_chain resolves to true, 297 | then after the specified ammount of milliseconds the trigger_chain is re-evaluated. 298 | If it is still true, only then will the tasks be executed. 299 | 300 | For example: 301 | 302 | ```yaml 303 | actions: 304 | change_to_pulpit: 305 | trigger_chain: 306 | # ... tree of conditions 307 | tasks: 308 | # ... 1 dimensional list of tasks to be executed in order, serially 309 | debounce_millis: 500 310 | ``` 311 | 312 | This could protect against quick transients, e.g. an accidental unmute/mute. For example here, 313 | if the trigger chain is watching for ch1's unmute, then it will only execute the tasks if it is unmute for more than 314 | 0.5seconds. 315 | This can help avoid accidents, where you accidentally unmute something but then you immediately mute it back. 316 | 317 | ## Trigger chain 318 | 319 | The trigger chain is a tree of conditions. Some conditions can be nested, some of them are just leafs on a tree, without 320 | any children. 321 | 322 | You can build very complex conditions into here, e.g. (in pseudo code): 323 | 324 | ``` 325 | IF 326 | (mic1-is-muted AND mic2-is-unmuted) OR 327 | (ch10-is-unmuted AND 328 | ( 329 | ch11fader > 0.5 OR 330 | ch12fader > 0.5 331 | ) 332 | ) THEN 333 | ... 334 | 335 | ``` 336 | 337 | But the way to express these are a bit more complicated due to the YAML configuration we use. 338 | 339 | ### Conditions 340 | 341 | #### OSC_MATCH: Check if a single message exists 342 | 343 | The `osc_match` condition can nothave any children, and it is checking for a single message in the store. 344 | It can check based on address, address regexp and also based on arguments. 345 | 346 | Here is an example: 347 | 348 | ```yaml 349 | actions: 350 | change_to_pulpit: 351 | trigger_chain: 352 | - type: osc_match 353 | parameters: 354 | address: /ch/01/mix/on 355 | arguments: 356 | - index: 0 357 | type: "int32" 358 | value: "1" 359 | tasks: 360 | # ... 361 | ``` 362 | 363 | This is a single condition on an action's trigger_chain. 364 | This checks for a message with an exact address of "/ch/01/mix/on" and with a single first argument, that is int32 and 365 | the value is 1. 366 | 367 | If such a message exists in the store, the tasks will be executed. 368 | 369 | Parameters: 370 | 371 | | Parameter | Default value | Possible values | Description | Example values | 372 | |--------------------|----------------|-----------------|-------------------------------------------------------------------------------|----------------------------------| 373 | | address | none, required | | The value for matching a message's address. Can be a regexp, see next option. | /ch/01/mix/on, /ch/0[0-9]/mix/on | 374 | | address_match_type | `eq` | `eq`, `regexp` | Determines the way of address matching. | `regexp` | 375 | | trigger_on_change | `true` | `true`, `false` | See the [trigger on change](#trigger-on-change) paragraph. | `true` | 376 | | arguments | none, optional | | See the next table. | List of arguments | 377 | 378 | Arguments: 379 | 380 | | Parameter | Default value | Possible values | Description | Example values | 381 | |------------------|----------------|----------------------------------|---------------------------------------------------------------------------------|----------------| 382 | | index | none, required | `0` | The 0 based index for the argument. | `0`, `1`, `2` | 383 | | type | none, required | `string`, `int32`, `float32` | The type of the argument. | `string` | 384 | | value | none, required | | The value of the argument. | `1` | 385 | | value_match_type | `=` | `regexp`, `<=`,`<`,`>`,`>=`,`!=` | The comparison method. In case of regexp, the value can be a regexp expression. | `=` | 386 | 387 | ##### Trigger on change 388 | 389 | The `trigger_on_change` option is a special one. Whenever a new message arrives that changes the store, every 390 | trigger_chain is checked. 391 | 392 | Now, during the execution of the trigger_chain, it is being monitored what messages those conditions accessed. 393 | By default (when `trigger_on_change: true`) if the trigger chain did not access the NEWLY UPDATED message, so the one 394 | that just arrived, 395 | the tasks aren't going to be executed. This avoids unneccessary re-execution just because an unrelevant message updated 396 | the store. 397 | 398 | But this is also usable, to avoid re-execution in a case when a relevant message updated the store. 399 | 400 | Practically this option decouples a condition from being a trigger. The condition is still required to match in order to 401 | execute the tasks, but that single condition's change will not trigger execution. 402 | 403 | You want to set this to false, when you don't want to re-execute the action upon the toggling of one of the parameters 404 | your trigger_chain is watching for. This is an edge case, that comes handy sometimes. 405 | 406 | For example, let's say you have the following trigger_chain (in pseudo-ish code): 407 | 408 | ``` 409 | IF ( OBS-scene-name-contains-foobar AND 410 | OR (ch1-unmuted OR ch2-unmuted OR stage-is-muted) 411 | ) 412 | THEN 413 | ... 414 | ``` 415 | 416 | So you want to only execute the tasks, when certain things on the console match, but don't wanna re-execute just because 417 | of an OBS scene change. 418 | But you only want to execute the tasks, when certain things on the console match AND obs scene name contains foobar. 419 | 420 | Then you can mark the OBS-scene-name-contains condition with `trigger_on_change: false`. 421 | That will cause the tasks to be executed when the console state changes (and obs scene contains foobar), but will not 422 | trigger if only obs changes would otherwise match. 423 | E.g. you might switch from one scene to another that contains foobar in our pseudo example, but that would not 424 | re-execute the tasks. 425 | 426 | #### AND: Require all children condition to resolve to true 427 | 428 | `And` as it's name implies requires all children to resolve to true. 429 | 430 | The following example action requires both ch1 **AND** ch2 to be on. 431 | 432 | ```yaml 433 | actions: 434 | change_to_pulpit: 435 | trigger_chain: 436 | type: and 437 | children: 438 | - type: osc_match 439 | parameters: 440 | address: /ch/01/mix/on 441 | arguments: 442 | - index: 0 443 | type: "int32" 444 | value: "1" 445 | - type: osc_match 446 | parameters: 447 | address: /ch/02/mix/on 448 | arguments: 449 | - index: 0 450 | type: "int32" 451 | value: "1" 452 | tasks: 453 | # ... 454 | ``` 455 | 456 | Now you see how conditions can be nested. 457 | 458 | #### OR: Require at least one children to resolve to true 459 | 460 | `Or` as it's name implies requires that at least one of the childrens would resolve to true. 461 | 462 | The following example action executes the tasks if ch1 **OR** ch2 is be on. 463 | 464 | ```yaml 465 | actions: 466 | change_to_pulpit: 467 | trigger_chain: 468 | type: or 469 | children: 470 | - type: osc_match 471 | parameters: 472 | address: /ch/01/mix/on 473 | arguments: 474 | - index: 0 475 | type: "int32" 476 | value: "1" 477 | - type: osc_match 478 | parameters: 479 | address: /ch/02/mix/on 480 | arguments: 481 | - index: 0 482 | type: "int32" 483 | value: "1" 484 | tasks: 485 | # ... 486 | ``` 487 | 488 | #### NOT: Negate the single child's result. 489 | 490 | The `NOT` condition simply negates it's single child's result. 491 | 492 | Here is how you would achieve this pseudo code: 493 | 494 | ``` 495 | AND(ch1-unmuted; NOT(OR(ch10-unmuted,ch20-unmuted))) 496 | ``` 497 | 498 | In yaml: 499 | 500 | ```yaml 501 | actions: 502 | change_to_pulpit: 503 | trigger_chain: 504 | type: and 505 | children: 506 | - type: osc_match 507 | parameters: 508 | address: /ch/01/mix/on 509 | arguments: 510 | - index: 0 511 | type: "int32" 512 | value: "1" 513 | - type: not 514 | children: 515 | - type: or 516 | children: 517 | - type: osc_match 518 | parameters: 519 | address: /ch/10/mix/on 520 | arguments: 521 | - index: 0 522 | type: "int32" 523 | value: "1" 524 | - type: osc_match 525 | parameters: 526 | address: /ch/20/mix/on 527 | arguments: 528 | - index: 0 529 | type: "int32" 530 | value: "1" 531 | 532 | tasks: 533 | # ... 534 | ``` 535 | 536 | ## Sources 537 | 538 | Now that you know how to compose conditions, you need input sources, that would add messages to the internal store, 539 | against which you can match your trigger chains. 540 | 541 | ### Digital Mixing Consoles 542 | 543 | Many digital mixing consoles support a protocol 544 | called "[Open Sound Control](https://en.wikipedia.org/wiki/Open_Sound_Control)", 545 | this is a UDP based simple protocol. It is based on "Messages", where each message has an address, and 0 or more 546 | arguments, and each argument can be a string, a float, an int, etc. 547 | 548 | I have tested on Behringer X32, so most examples are based on this console. 549 | See pmalliot's excellent work [here](https://sites.google.com/site/patrickmaillot/x32) on 550 | X32's [OSC](https://drive.google.com/file/d/1Snbwx3m6us6L1qeP1_pD6s8hbJpIpD0a/view) implementation. 551 | 552 | In the case of X32, we need to regularly(8-10 sec) issue a /subscribe command with proper arguments, to show that we are 553 | interested in updates of a certain value from the console. Then the mixer is flooding us with the requested parameter. 554 | 555 | So below is a real world example for behringer x32 OSC connection: 556 | 557 |
558 | Click to see YAML 559 | 560 | ```yaml 561 | osc_sources: 562 | console_bridges: 563 | # The name of this mixer 564 | - name: "behringer_x32" 565 | 566 | # If enabled, OSCBRIDGE will try to connect, and restart if fails. 567 | enabled: true 568 | 569 | # Prefix determines the message address prefix as it will be stored to the store. 570 | # E.g. if you'd have multiple consoles, you could prefix them "/console1", "/console2", 571 | # and you could match for /console1/ch/01/mix/on for example. 572 | prefix: "" 573 | 574 | host: 192.168.2.99 575 | port: 10023 576 | 577 | # The driver to use. We only have "l" for now. 578 | osc_implementation: l 579 | 580 | # This command is sent right after the connection is opened. 581 | # It can be used for authentication, or anything that is required. 582 | # X32 does not require anything, but for this it returns it's own name. 583 | init_command: 584 | address: /xinfo 585 | # You could specify arguments also. 586 | # arguments: 587 | # - type: string 588 | # value: "foobar" 589 | 590 | # There is a regular query running, for checking if the connection is still alive. 591 | # Specify an address here, and a regexp that matches the returned value. 592 | # If there is no response, or the response doesn't match, the connection is counted as broken and the app restarts. 593 | check_address: /ch/01/mix/on 594 | check_pattern: "^0|1$" 595 | # Subscriptions are commands that are sent regularly (repeat_millis) that cause the mixer to update us with the lates values for the subscribed thing. 596 | # Research your own mixer for the exact syntax, but this is how you do it for X32. 597 | subscriptions: 598 | - osc_command: 599 | # This command subscribes for channel 1's mute status. 0 is muted, 1 is unmuted. 600 | address: /subscribe 601 | arguments: 602 | - type: string 603 | value: /ch/01/mix/on 604 | - type: int32 605 | value: 10 606 | repeat_millis: 8000 607 | ``` 608 | 609 |
610 | 611 | ### Dummy console 612 | 613 | The dummy console implementation is just what it's name implies. 614 | It has `message_groups`, and each `message_group` contains `messages`. 615 | The dummy console iterates infinitely through the groups, and executes the messages in them. 616 | Between each group it waits the configured ammount of time. 617 | 618 | The below example configures two groups, called "mic_1_on" and "mic_1_off". 619 | 620 | Therefore, it provides a way to test the logic even without a real connection to a mixer. 621 | You can have a dummy emitting the same messages the real console would, and you can freely enable/disable any source, 622 | so you can test, or you can switch to the real operation mode by enabling the console connection. 623 | 624 |
625 | Click to see YAML 626 | 627 | ```yaml 628 | osc_sources: 629 | dummy_connections: 630 | - name: "behringer_x32_dummy" 631 | # Use this source, or not. 632 | enabled: true 633 | 634 | # Prefix determines the message address prefix as it will be stored to the store. 635 | prefix: "" 636 | 637 | # How much delay should be between each group? 638 | iteration_speed_secs: 1 639 | 640 | # Message groups are set of messages being emitted at once. 641 | message_groups: 642 | - name: mic_1_on 643 | osc_commands: 644 | - address: /ch/01/mix/on 645 | comment: "headset mute (0: muted, 1: unmuted)" 646 | arguments: 647 | - type: int32 648 | value: 1 649 | 650 | - name: mic_1_off 651 | osc_commands: 652 | - address: /ch/01/mix/on 653 | comment: "headset mute (0: muted, 1: unmuted)" 654 | arguments: 655 | - type: int32 656 | value: 0 657 | 658 | ``` 659 | 660 |
661 | 662 | ### OBS bridges 663 | 664 | OSCBridge can be configured to connect to an OBS Studio instance via websocket, and it will subscribe to some events in 665 | OBS. 666 | 667 | These events are the following: 668 | 669 | * CurrentPreviewSceneChanged 670 | * Message: 671 | * Address: /obs/preview_scene 672 | * Argument[0]: string, value: NAME_OF_SCENE 673 | * CurrentProgramSceneChanged 674 | * Message: 675 | * Address: /obs/program_scene 676 | * Argument[0]: string, value: NAME_OF_SCENE 677 | * RecordStateChanged 678 | * Message: 679 | * Address: /obs/recording 680 | * Argument[0]: int32, value: 0 or 1 681 | * StreamStateChanged 682 | * Message: 683 | * Address: /obs/streaming 684 | * Argument[0]: int32, value: 0 or 1 685 | 686 | In order to configure an OBS Bridge, you'll also need to configure an OBS Connection. 687 | 688 |
689 | Click to see YAML 690 | 691 | ```yaml 692 | 693 | obs_connections: 694 | - name: "streampc_obs" 695 | host: 192.168.1.75 696 | port: 4455 697 | password: "foobar12345" 698 | 699 | osc_sources: 700 | obs_bridges: 701 | - name: "obsbridge1" 702 | # You may choose to disable it. 703 | enabled: true 704 | 705 | # Prefix determines the message address prefix as it will be stored to the store. 706 | prefix: "" 707 | 708 | # The name of the obs connection, see above. 709 | connection: "streampc_obs" 710 | 711 | ``` 712 | 713 |
714 | 715 | ### HTTP bridges 716 | 717 | HTTP Bridges in OSCBridge enables you to open a port on a network interface and start a HTTP server on them. 718 | The server can receive special HTTP GET requests, and converts them to OSC messages and stores them in the message 719 | store. 720 | Then you can write actions that check for that value, and may even execute tasks based on it. 721 | 722 | The message can be put away under some namespace by using the prefix option, but you could also use it to override an 723 | existing message. 724 | 725 | To insert an OSC Message like this: 726 | 727 | ``` 728 | Message(address: /foo/bar/baz, arguments: [Argument(string:hello), Argument(int32:1)]) 729 | ``` 730 | 731 | Execute a GET request like this: 732 | 733 | ```bash 734 | curl "127.0.0.1:7878/?address=/foo/bar/baz&args[]=string,hello&args[]=int32,1" 735 | ``` 736 | 737 |
738 | Click to see YAML 739 | 740 | ```yaml 741 | osc_sources: 742 | http_bridges: 743 | - name: "httpbridge1" 744 | # You may choose to disable it. 745 | enabled: true 746 | # Prefix determines the message address prefix as it will be stored to the store. 747 | prefix: "" 748 | port: 7878 749 | host: 0.0.0.0 750 | ``` 751 | 752 |
753 | 754 | ### Tickers 755 | 756 | You can enable "Tickers", that would regularly update the store with messages representing the current date/time. 757 | 758 | The ticker publishes several packages under "/time/" (if you don't specify a prefix), with names that might be weird for 759 | the first time, 760 | if you are not familiar with how golang's time formatting works. 761 | 762 | You may see the full reference [here](https://cs.opensource.google/go/go/+/refs/tags/go1.21.3:src/time/format.go;l=9). 763 | 764 | Currently these messages are being emitted in every iteration: 765 | 766 | ``` 767 | Message(address: /time/rfc3339, arguments: [Argument(string:2023-11-07T08:53:06Z)]) 768 | Message(address: /time/parts/2006, arguments: [Argument(string:2023)]) 769 | Message(address: /time/parts/06, arguments: [Argument(string:23)]) 770 | Message(address: /time/parts/Jan, arguments: [Argument(string:Nov)]) 771 | Message(address: /time/parts/January, arguments: [Argument(string:November)]) 772 | Message(address: /time/parts/01, arguments: [Argument(string:11)]) 773 | Message(address: /time/parts/1, arguments: [Argument(string:11)]) 774 | Message(address: /time/parts/Mon, arguments: [Argument(string:Tue)]) 775 | Message(address: /time/parts/Monday, arguments: [Argument(string:Tuesday)]) 776 | Message(address: /time/parts/2, arguments: [Argument(string:7)]) 777 | Message(address: /time/parts/_2, arguments: [Argument(string: 7)]) 778 | Message(address: /time/parts/02, arguments: [Argument(string:07)]) 779 | Message(address: /time/parts/__2, arguments: [Argument(string:311)]) 780 | Message(address: /time/parts/002, arguments: [Argument(string:311)]) 781 | Message(address: /time/parts/15, arguments: [Argument(string:08)]) 782 | Message(address: /time/parts/3, arguments: [Argument(string:8)]) 783 | Message(address: /time/parts/03, arguments: [Argument(string:08)]) 784 | Message(address: /time/parts/4, arguments: [Argument(string:53)]) 785 | Message(address: /time/parts/04, arguments: [Argument(string:53)]) 786 | Message(address: /time/parts/5, arguments: [Argument(string:6)]) 787 | Message(address: /time/parts/05, arguments: [Argument(string:06)]) 788 | Message(address: /time/parts/PM, arguments: [Argument(string:AM)]) 789 | ``` 790 | 791 | So if you want to match for hour:minute, then you want to match the values of /time/15 and /time/04 respectively in the 792 | trigger chain (to be explained later). 793 | 794 |
795 | Click to see YAML 796 | 797 | ```yaml 798 | osc_sources: 799 | tickers: 800 | - name: "ticker1" 801 | # You may choose to disable it. 802 | enabled: true 803 | 804 | # Prefix determines the message address prefix as it will be stored to the store. 805 | prefix: "" 806 | 807 | # How often updates should occur 808 | refresh_rate_millis: 1000 809 | ``` 810 | 811 |
812 | 813 | ## Tasks 814 | 815 | Now you have actions, trigger_chains and sources, the final piece is to have tasks that will be executed if the 816 | trigger_chain evaluates to true. 817 | 818 | ### HTTP request 819 | 820 | The `http_request` task executes a specific http request upon evaluation. 821 | 822 | Parameters: 823 | 824 | | Parameter | Default value | Possible values | Description | Example values | 825 | |--------------|----------------|-----------------|-------------------------------|----------------------------------------------------------| 826 | | url | none, required | | The URL for the request. | http://127.0.0.1/?foo=bar | 827 | | body | empty string | | The request body. | {"json":"or something else"} | 828 | | timeout_secs | 30 | | The timeout for the request. | 1 | 829 | | method | `GET` | `GET`, `POST` | The method for the request. | `POST` | 830 | | headers | empty | | A list of "Key: value" pairs. |
- "Content-Type: text/json"
- "X-Foo: bar"
| 831 | 832 | Example: 833 | 834 | ```yaml 835 | actions: 836 | to_pulpit: 837 | trigger_chain: 838 | # ... 839 | tasks: 840 | - type: http_request 841 | parameters: 842 | url: "http://127.0.0.1:8888/cgi-bin/ptzctrl.cgi?ptzcmd&poscall&0&__TURN_TO_PULPIT" 843 | method: "get" 844 | timeout_secs: 1 845 | headers: 846 | - "X-Foo: bar" 847 | - "X-Foo2: baz" 848 | body: "O HAI" 849 | ``` 850 | 851 | The request that will be made: 852 | 853 | ``` 854 | GET /cgi-bin/ptzctrl.cgi?ptzcmd&poscall&0&__TURN_TO_PULPIT HTTP/1.1 855 | Host: 127.0.0.1:8888 856 | User-Agent: Go-http-client/1.1 857 | Content-Length: 5 858 | X-Foo: bar 859 | X-Foo2: baz 860 | Accept-Encoding: gzip 861 | 862 | O HAI 863 | ``` 864 | 865 | ### OBS Scene change 866 | 867 | The `obs_scene_change` task changes the live or program scene on a remote OBS instance. 868 | 869 | Parameters: 870 | 871 | | Parameter | Default value | Possible values | Description | Example values | 872 | |------------------|----------------|----------------------|-----------------------------------------------------------|-------------------| 873 | | scene | none, required | | The name of the scene to which we need to switch. | `PULPIT`, `STAGE` | 874 | | connection | none, required | | The name of the obs connection that this task should use. | `streampc_obs` | 875 | | scene_match_type | `exact` | `exact`, `regexp` | How to match the scene name. | `regexp` | 876 | | target | none, required | `program`, `preview` | Which side of OBS should be switched. | `program` | 877 | 878 | Example: 879 | 880 | ```yaml 881 | obs_connections: 882 | - name: "streampc_obs" 883 | host: 192.168.1.75 884 | port: 4455 885 | password: "foobar12345" 886 | 887 | 888 | actions: 889 | to_pulpit: 890 | trigger_chain: 891 | # ... 892 | tasks: 893 | - type: obs_scene_change 894 | parameters: 895 | scene: "PULPIT.*" 896 | scene_match_type: regexp 897 | target: "program" 898 | connection: "streaming_pc_obs" 899 | 900 | - type: obs_scene_change 901 | parameters: 902 | scene: "STAGE" 903 | scene_match_type: exact 904 | target: "preview" 905 | connection: "streaming_pc_obs" 906 | ``` 907 | 908 | ### OBS Vendor message 909 | 910 | It is possible to send 911 | a [VendorEvent](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#vendorevent) to OBS 912 | via a websocket connection. 913 | Different plugins can listen for these events, one example is the 914 | marvelous [Advanced Scene Switcher](https://github.com/WarmUpTill/SceneSwitcher/), 915 | which [supports](https://github.com/WarmUpTill/SceneSwitcher/wiki/Websockets#websocket-condition) this. 916 | 917 | So given that you are listening in that plugin for "IF Websocket Message waas received: foobar_notice", 918 | you can execute macros remotely with OSCBridge: 919 | 920 | ```yaml 921 | obs_connections: 922 | - name: "streampc_obs" 923 | host: 192.168.1.75 924 | port: 4455 925 | password: "foobar12345" 926 | 927 | actions: 928 | to_pulpit: 929 | trigger_chain: 930 | # ... 931 | tasks: 932 | - type: obs_vendor_request 933 | parameters: 934 | connection: "streampc_obs" 935 | vendorName: "AdvancedSceneSwitcher" 936 | requestType: "AdvancedSceneSwitcherMessage" 937 | requestData: 938 | message: "foobar_notice" 939 | ``` 940 | 941 | Parameters: 942 | 943 | | Parameter | Default value | Description | Example values | 944 | |-------------|----------------|-----------------------------------------------------------|--------------------------------| 945 | | connection | none, required | The name of the obs connection that this task should use. | `streampc_obs` | 946 | | vendorName | none, required | | `AdvancedSceneSwitcher` | 947 | | requestType | none, required | | `AdvancedSceneSwitcherMessage` | 948 | | requestData | none, required | | `message: whatever` | 949 | 950 | ### Delay 951 | 952 | The `delay` simply delays the serial execution of the tasks, taking up as much time as you configure. 953 | 954 | Parameters: 955 | 956 | | Parameter | Default value | Description | Example values | 957 | |--------------|----------------|--------------------------------|-------------------------| 958 | | delay_millis | none, required | How much milliseconds to wait. | `1500` (for 1.5 second) | 959 | 960 | Example: 961 | 962 | ```yaml 963 | actions: 964 | to_pulpit: 965 | trigger_chain: 966 | # ... 967 | tasks: 968 | - type: delay 969 | parameters: 970 | delay_millis: 1500 971 | ``` 972 | 973 | ### Run command 974 | 975 | The `run_command` task simply executes the given command. 976 | 977 | | Parameter | Default value | Description | Example values | 978 | |-------------------|----------------|-------------------------------------------------------------------------------------|---------------------------------------------------------| 979 | | command | none, required | The path to the binary to execute. | /usr/bin/bash | 980 | | arguments | optional | The list of arguments. |
- "-l"
- "-c"
- "date > /tmp/date.txt"
| 981 | | run_in_background | false | Whether or not the serial execution of tasks should wait for the command to finish. | | 982 | | directory | optional | The execution folder for the command. | | 983 | 984 | You need to [follow](https://pkg.go.dev/os/exec#example-Command) the classical way of specifying a binary and it's 985 | arguments. 986 | So you can not use `date > /tmp/date.txt` as the command, you need to specify `/usr/bin/bash` as the command, and then 987 | the parameters. 988 | 989 | Example: 990 | 991 | ```yaml 992 | actions: 993 | to_pulpit: 994 | trigger_chain: 995 | # ... 996 | tasks: 997 | - type: run_command 998 | parameters: 999 | command: "/usr/bin/bash" 1000 | arguments: [ "-l","-c","date > /tmp/date.txt" ] 1001 | ``` 1002 | 1003 | ### Send OSC message 1004 | 1005 | The `send_osc_message` sends an open sound control message through the specified connection. 1006 | Currently only the `console_bridges` support sending a message. E.g. you can send a message back to your console. 1007 | 1008 | | Parameter | Default value | Description | Example values | 1009 | |------------|----------------|---------------------------------------------------------------------------|----------------------------------------| 1010 | | connection | none, required | The OSC connection to use (the `name` from one of your `console_bridges`) | `behringer_x32` | 1011 | | address | none, required | The address of the message. | `/ch/10/mix/on` | 1012 | | arguments | optional | The arguments of the message. |
- type: int32
- value: 0
| 1013 | 1014 | Example: 1015 | 1016 | (Unmute channel 10) 1017 | 1018 | ```yaml 1019 | actions: 1020 | to_pulpit: 1021 | trigger_chain: 1022 | # ... 1023 | tasks: 1024 | - type: send_osc_message 1025 | parameters: 1026 | connection: "behringer_x32" 1027 | address: "/ch/10/mix/on" 1028 | arguments: 1029 | - type: int32 1030 | value: 1 1031 | ``` 1032 | 1033 | # Development 1034 | 1035 | You'll need "make" and "docker" installed. 1036 | After cloning the repository, run "make" to see the available commands. 1037 | 1038 | Run `make dev_start` to start the development environment. 1039 | 1040 | It'll look for a config.yml in the source root. -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.2 2 | 3 | # Multi stage build file. 4 | # 5 | # buildbase (ubuntu) 6 | # buildbase -> build 7 | # buildbase -> development 8 | 9 | 10 | # ========================================================================================================= 11 | FROM --platform=$BUILDPLATFORM ubuntu:22.04 AS buildbase 12 | 13 | # https://vsupalov.com/buildkit-cache-mount-dockerfile/ 14 | RUN rm -f /etc/apt/apt.conf.d/docker-clean # Preventing the base-os to delete apt-cache. 15 | 16 | # Just to confirm buildx is working. It would fail around here otherwise. 17 | ARG TARGETPLATFORM 18 | ARG BUILDPLATFORM 19 | RUN (echo "Running on buildplatform: $BUILDPLATFORM, targetplatform: $TARGETPLATFORM" && arch) > /log 20 | 21 | 22 | ENV DEBIAN_FRONTEND="noninteractive" 23 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 24 | 25 | # Install dependencies for compiling & building 26 | # ============================================================================= 27 | RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ 28 | apt-get update && \ 29 | apt-get install -y --no-install-recommends git make build-essential curl wget nano tzdata ca-certificates 30 | 31 | 32 | 33 | # INSTALL GO 34 | # ============================================================================= 35 | 36 | RUN curl -sL https://go.dev/dl/go1.20.7.linux-amd64.tar.gz -o /tmp/go.tar.gz && tar -C /usr/local -xzf /tmp/go.tar.gz 37 | 38 | # Add GO to PATH 39 | RUN echo 'export PATH="${PATH}:/usr/local/go/bin"' >> /etc/profile 40 | 41 | 42 | WORKDIR /mnt 43 | 44 | 45 | 46 | # ========================================================================================================= 47 | FROM buildbase AS development 48 | 49 | WORKDIR /tmp 50 | 51 | 52 | # Install dependencies for the dev environment 53 | RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt apt-get update && \ 54 | apt-get install -y --no-install-recommends inotify-tools file mc 55 | 56 | WORKDIR /mnt 57 | 58 | # Add user with the same ID as the host (when specifies UID and GID). 59 | # This helps solving file permission issues between host/container. 60 | 61 | ARG UID=1000 62 | ARG GID=1000 63 | ARG USER=container 64 | 65 | RUN groupadd -g ${GID} ${USER} \ 66 | && useradd -u ${UID} -g ${GID} -d /home/${USER} -s /bin/bash -m ${USER} 67 | 68 | USER ${USER} 69 | RUN echo 'export PATH="${PATH}:${HOME}/go/bin"' >> ${HOME}/.bashrc 70 | 71 | RUN bash -l -c "go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" 72 | RUN bash -l -c "go install mvdan.cc/gofumpt@latest" 73 | RUN bash -l -c "go install github.com/volatiletech/sqlboiler/v4@latest" 74 | RUN bash -l -c "go install github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-psql@latest" 75 | 76 | ENTRYPOINT ["/bin/bash","-l","-c", "./docker/rundev.sh"] 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # DOC: This script builds the app itself, this is executed inside of the container 3 | 4 | # Jump to the src directory, with a relative cd to this script's location. 5 | cd "$(dirname "$0")/../src" || exit 6 | 7 | BUILD_DIR="/mnt/build/" 8 | export GOPATH="" 9 | 10 | echo -e "Downloading dependencies...\n" 11 | go mod download 12 | 13 | git config --global --add safe.directory /mnt 14 | 15 | REVISION=$(git rev-parse HEAD 2>/dev/null || echo 1) 16 | # build_application($1,$2,$3) executes go build with all the required parameters. 17 | # $1: The selected go operating system 18 | # $2: The selected go architecture 19 | build_application() { 20 | # set -x 21 | export CGO_ENABLED=0 22 | export GOOS=$1 23 | export GOARCH=$2 24 | export EXTENSION=$3 25 | 26 | echo "BUILDING for $GOOS/$GOARCH" 27 | 28 | rm "$BUILD_DIR/app-$GOOS-$GOARCH.bin" >/dev/null 2>&1 29 | 30 | go build -tags netgo \ 31 | -ldflags "-s -w -X main.Revision=$REVISION -X main.BuildTime=$(date +'%Y-%m-%d_%T') " \ 32 | -o "$BUILD_DIR/oscbridge-${REVISION:0:8}-$GOOS-$GOARCH.$EXTENSION" \ 33 | cmd/main/main.go 34 | 35 | 36 | echo -e "\n" 37 | } 38 | # BUILD 39 | # ------------------------------------------ 40 | 41 | # Normal compilation for linux/amd64 platform 42 | export CC=gcc 43 | build_application linux amd64 bin 44 | build_application windows amd64 exe 45 | 46 | echo "BUILT ARTIFACTS:" 47 | ls -lh "$BUILD_DIR" 48 | -------------------------------------------------------------------------------- /docker/devenv/devenv.env: -------------------------------------------------------------------------------- 1 | APP_CONFIG_FILE=config.yml -------------------------------------------------------------------------------- /docker/devenv/secrets.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KopiasCsaba/open_sound_control_bridge/ac6aaca7cb0356a79b0676d7624615d63a51afe2/docker/devenv/secrets.env.example -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | 5 | # This is the development container 6 | app-dev: 7 | hostname: app-dev 8 | container_name: app-dev 9 | image: app-dev 10 | platform: linux/amd64 11 | build: 12 | context: .. 13 | dockerfile: docker/Dockerfile 14 | target: development 15 | args: 16 | - "UID=${UID:-1000}" 17 | - "GID=${GID:-1000}" 18 | volumes: 19 | - type: bind 20 | source: .. 21 | target: /mnt 22 | - type: bind 23 | source: /tmp/app-tmp 24 | target: /tmp/app-tmp 25 | - type: volume 26 | source: go-modules-cache 27 | target: /home/container/go/pkg/mod 28 | ports: 29 | - "7878:7878" 30 | # - "4455:4455" 31 | # - "10023:10023/udp" 32 | network_mode: host 33 | environment: 34 | - APP_ENVFILES=/mnt/docker/devenv/devenv.env:/mnt/docker/devenv/secrets.env 35 | - APP_TMP=/tmp/app-tmp 36 | stop_grace_period: 10s # After CTRL-C 37 | 38 | volumes: 39 | go-modules-cache: 40 | pgdata: 41 | -------------------------------------------------------------------------------- /docker/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script performs LINT checks on the source. 3 | 4 | export PATH="${PATH}:${HOME}/go/bin" 5 | 6 | # Jump to the src directory, with a relative cd to this script's location. 7 | cd "$(dirname "$0")/../src" || exit 8 | 9 | 10 | echo "> Downloading dependencies ..." 11 | go mod download 12 | 13 | echo "> Running linters..." 14 | golangci-lint run --color always -------------------------------------------------------------------------------- /docker/publish/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 2 | 3 | RUN apk add curl bash tzdata ca-certificates 4 | 5 | ARG UID=1000 6 | ARG GID=1000 7 | ARG USER=app 8 | ARG GROUP=app 9 | 10 | RUN addgroup -S ${GROUP} && adduser -S ${USER} -G ${GROUP} 11 | 12 | 13 | ARG BIN_NAME=oscbridge-6acaf3b4-linux-amd64.bin 14 | COPY --from=build oscbridge-6acaf3b4-linux-amd64.bin /home/$USER/app 15 | 16 | 17 | # Switch to user 18 | USER ${UID}:${GID} 19 | 20 | WORKDIR /home/$USER 21 | ENTRYPOINT ["/bin/bash","-l","-c", "./app"] -------------------------------------------------------------------------------- /docker/rundev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # DOC: This script starts the app, and also restarts it upon any file change in src, enabling fast and efficient development. 3 | # The app is also restarted upon changes in the "docker/devenv/" folder. 4 | 5 | # Jump to the src directory, with a relative cd to this script's location. 6 | cd "$(dirname "$0")/../src" || exit 7 | 8 | export PATH="${PATH}:${HOME}/go/bin" 9 | git config --global --add safe.directory /mnt 10 | 11 | echo "> Downloading dependencies ..." 12 | go mod download 13 | 14 | 15 | 16 | export GOOS=linux 17 | export GOARCH=amd64 18 | 19 | while true; do 20 | echo "> Running gofumt..." 21 | gofumpt -w . & 22 | 23 | echo "> Running linters..." 24 | golangci-lint run --color always & 25 | 26 | echo "> Starting app..." 27 | # shellcheck disable=SC2086 28 | go run -ldflags "-s -w -X main.Revision='devel' -X main.BuildTime=$(date +'%Y-%m-%d_%T')" cmd/main/main.go & 29 | 30 | # Store PID 31 | PID=$! 32 | 33 | 34 | echo "Started app with PID: $PID. Restarting upon any file change." 35 | # Wait on app to start really before checking for changes 36 | go build -o ../build/oscbridge -tags netgo -ldflags "-s -w -X main.Revision='$(git rev-parse HEAD 2>/dev/null || echo 1)' -X main.BuildTime=$(date +'%Y-%m-%d_%T')" cmd/main/main.go & 37 | sleep 2 38 | # Wait on file changes 39 | inotifywait -e modify -e move -e create -e delete -e attrib -r "$(pwd)" "$(pwd)/../docker/devenv/" >/dev/null 2>&1 40 | 41 | echo "Restarting..." 42 | # Kill the app, repeat... 43 | kill -9 "$PID" > /dev/null 2>&1 44 | pkill -f go-build > /dev/null 2>&1 # For some reason descendant processes would stay alive 45 | 46 | done 47 | -------------------------------------------------------------------------------- /docs/assets/readme/example_config.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KopiasCsaba/open_sound_control_bridge/ac6aaca7cb0356a79b0676d7624615d63a51afe2/docs/assets/readme/example_config.gif -------------------------------------------------------------------------------- /docs/assets/readme/example_config.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KopiasCsaba/open_sound_control_bridge/ac6aaca7cb0356a79b0676d7624615d63a51afe2/docs/assets/readme/example_config.mkv -------------------------------------------------------------------------------- /src/.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | 4 | # Default: https://golangci-lint.run/usage/false-positives/#default-exclusions 5 | issues: 6 | exclude-rules: 7 | - path: cmd/.*/.*\.go 8 | text: (Revision|BuildTime) is a global variable 9 | # Reason: this is how we got the build time and revision injected at build time. Must be global. 10 | linters: 11 | - gochecknoglobals 12 | 13 | - path: adapters/config/for_usecases.go 14 | text: "G101: Potential hardcoded credentials" 15 | # Reason: false positive, no idea why it thinks that for that const. (configKeyDebugKeepWorkDirs) 16 | linters: 17 | - gosec 18 | 19 | - path: pkg/cliexecutor/executor.go 20 | text: "G204: Subprocess launched with a potential tainted input or cmd arguments" 21 | # Reason: the input value is checked. 22 | linters: 23 | - gosec 24 | 25 | - path: .*\.go 26 | text: "S1011: should replace loop with" 27 | # Reason: Using the ... can introduce performance issues with large arrays. 28 | linters: 29 | - gosimple 30 | 31 | - path: .*\.go 32 | text: "error returned from external package is unwrapped" 33 | linters: 34 | - wrapcheck 35 | 36 | - path: .*\.go 37 | text: "should be written without leading space as|parameter 'ctx' seems to be unused, consider removing or renaming|var-naming: don't use an underscore in package name|should not use underscores in package names|`ctx` is unused" 38 | 39 | - path: .*\.go 40 | text: "error returned from interface method should be wrapped.*IRepoTransaction" 41 | 42 | - path: .*\.go 43 | text: "unused-parameter: parameter" 44 | 45 | - path: cmd/test.*/.*\.go 46 | text: ".*" 47 | 48 | linters: 49 | disable-all: true 50 | enable: 51 | - bodyclose 52 | - dogsled 53 | - dupl 54 | - durationcheck 55 | - exhaustive 56 | - exportloopref 57 | - gofmt 58 | - gomoddirectives 59 | - goprintffuncname 60 | - govet 61 | - importas 62 | - ineffassign 63 | - makezero 64 | - misspell 65 | - nakedret 66 | - nilerr 67 | - noctx 68 | - nolintlint 69 | - prealloc 70 | - predeclared 71 | - revive 72 | - rowserrcheck 73 | - sqlclosecheck 74 | - staticcheck 75 | - stylecheck 76 | - tparallel 77 | - typecheck 78 | - unconvert 79 | - unparam 80 | - unused 81 | - wastedassign 82 | - cyclop 83 | - errcheck 84 | - errorlint 85 | - forbidigo 86 | - gochecknoglobals 87 | - gochecknoinits 88 | - gocognit 89 | - goconst 90 | - gocyclo 91 | - gosimple 92 | - paralleltest 93 | - thelper 94 | - goheader 95 | - gomodguard 96 | - forcetypeassert 97 | - gocritic 98 | - gosec 99 | - wrapcheck 100 | - whitespace 101 | 102 | # MAYBE ONCE UPON A TIME 103 | #- lll 104 | #- nlreturn 105 | #- gci 106 | #- gofumpt 107 | #- goimports 108 | 109 | # DON'T ENABLE: 110 | #- godot 111 | #- exhaustivestruct 112 | #- asciicheck 113 | #- funlen 114 | #- godox 115 | #- goerr113 116 | #- gomnd 117 | #- interfacer 118 | #- maligned 119 | #- nestif 120 | #- testpackage 121 | #- wsl 122 | 123 | # DEPRECATED 124 | #- ifshort 125 | #- structcheck 126 | #- deadcode 127 | #- varcheck 128 | #- scopelint 129 | -------------------------------------------------------------------------------- /src/adapters/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config loads, parses, verifies and enables the retrieval of the configuration. 2 | package config 3 | 4 | import "net.kopias.oscbridge/app/usecase/usecaseifs" 5 | 6 | var _ usecaseifs.IConfiguration = &MainConfig{} 7 | 8 | type ( 9 | // MainConfig represents the config YAML structure. 10 | MainConfig struct { 11 | OSCSources OSCSource `yaml:"osc_sources"` 12 | OBSConnections []OBSConnection `yaml:"obs_connections" ` 13 | App `yaml:"app"` 14 | Actions map[string]Action `yaml:"actions"` 15 | } 16 | 17 | // OSCSource is the tree for all the different sources where OSC Messages can be received. 18 | OSCSource struct { 19 | ConsoleBridges []ConsoleBridge `yaml:"console_bridges"` 20 | DummyConnections []DummyConnection `yaml:"dummy_connections"` 21 | OBSBridges []OBSBridge `yaml:"obs_bridges"` 22 | HTTPBridges []HTTPBridge `yaml:"http_bridges"` 23 | Tickers []Ticker `yaml:"tickers"` 24 | } 25 | 26 | // ConsoleBridge connects to an OSC source device, e.g. to a mixer console and receives messages, executes subscription commands. 27 | ConsoleBridge struct { 28 | Name string `yaml:"name"` 29 | Prefix string `yaml:"prefix"` 30 | Enabled bool `yaml:"enabled"` 31 | OSCImplementation string `yaml:"osc_implementation"` 32 | Port int64 `yaml:"port"` 33 | Host string `yaml:"host"` 34 | Subscriptions []ConsoleSubscription `yaml:"subscriptions"` 35 | InitCommand *OSCCommand `yaml:"init_command"` 36 | CheckAddress string `yaml:"check_address"` 37 | CheckPattern string `yaml:"check_pattern"` 38 | } 39 | 40 | // ConsoleSubscription contains messages to be repeated at certain intervals to subscribe events on a mixer console. 41 | ConsoleSubscription struct { 42 | OSCCommand OSCCommand `yaml:"osc_command"` 43 | RepeatMillis int64 `yaml:"repeat_millis"` 44 | } 45 | 46 | // DummyConnection generates pre-defined set of messages to trigger actions. Useful for testing without the actual mixer console. 47 | DummyConnection struct { 48 | Name string `yaml:"name"` 49 | Prefix string `yaml:"prefix"` 50 | Enabled bool `yaml:"enabled"` 51 | IterationSpeedSecs int64 `yaml:"iteration_speed_secs"` 52 | MessageGroups []DummyConsoleMessageGroup `yaml:"message_groups"` 53 | } 54 | 55 | // DummyConsoleMessageGroup is a set of messages to be emitted at the same time. 56 | DummyConsoleMessageGroup struct { 57 | Name string `yaml:"name"` 58 | Comment string `yaml:"comment"` 59 | OSCCommands []OSCCommand `yaml:"osc_commands"` 60 | } 61 | 62 | // An OBSBridge is an OSCSource, that uses an OBSConnection by its name to receive/poll status and convert it to OSCMessages. 63 | OBSBridge struct { 64 | Name string `yaml:"name"` 65 | Prefix string `yaml:"prefix"` 66 | Enabled bool `yaml:"enabled"` 67 | Connection string `yaml:"connection"` 68 | } 69 | 70 | // A HTTPBridge is an OSCSource, that listens on a port for requests and converts them to OSCMessages. 71 | HTTPBridge struct { 72 | Name string `yaml:"name"` 73 | Prefix string `yaml:"prefix"` 74 | Enabled bool `yaml:"enabled"` 75 | Port int64 `yaml:"port"` 76 | Host string `yaml:"host"` 77 | } 78 | 79 | // A Ticker is an OSCSource, that emits OSCMessages containing the time. 80 | Ticker struct { 81 | Name string `yaml:"name"` 82 | Prefix string `yaml:"prefix"` 83 | Enabled bool `yaml:"enabled"` 84 | RefreshRateMillis int64 `yaml:"refresh_rate_millis"` 85 | } 86 | 87 | // OSCCommand represent an OSC message 88 | OSCCommand struct { 89 | Address string `yaml:"address"` 90 | Comment string `yaml:"comment"` 91 | Arguments []OSCArgument `yaml:"arguments"` 92 | } 93 | 94 | // OSCArgument is a single argument for an OSC message. 95 | OSCArgument struct { 96 | Type string `yaml:"type"` 97 | Value string `yaml:"value"` 98 | } 99 | 100 | // OBSConnection represetnts a single connection to an OBS instance through websocket. 101 | OBSConnection struct { 102 | Name string `yaml:"name"` 103 | Port int64 `yaml:"port"` 104 | Host string `yaml:"host"` 105 | Password string `yaml:"password"` 106 | } 107 | 108 | // App contains general app settings. 109 | App struct { 110 | Debug Debug `yaml:"debug"` 111 | StorePersistPath string `yaml:"store_persist_path"` 112 | } 113 | 114 | Debug struct { 115 | DebugOSCConnection bool `yaml:"debug_osc_connection"` 116 | DebugOSCConditions bool `yaml:"debug_osc_conditions"` 117 | DebugTasks bool `yaml:"debug_tasks"` 118 | DebugOBSRemote bool `yaml:"debug_obs_remote"` 119 | } 120 | 121 | // Action contains a set of conditions that may trigger a set of tasks. E.g. If Channel 1 is muted, then do an HTTP request. 122 | Action struct { 123 | DebounceMillis int64 `yaml:"debounce_millis"` 124 | TriggerChain ActionConditionChecker `yaml:"trigger_chain"` 125 | Tasks []ActionTask `yaml:"tasks"` 126 | } 127 | 128 | ActionTask struct { 129 | Type string `yaml:"type"` 130 | Parameters map[string]interface{} `yaml:"parameters"` 131 | } 132 | 133 | ActionConditionChecker struct { 134 | Type string `yaml:"type"` 135 | Parameters map[string]interface{} `yaml:"parameters"` 136 | Children []ActionConditionChecker `yaml:"children"` 137 | } 138 | ) 139 | 140 | func (c *MainConfig) ShouldDebugOSCConditions() bool { 141 | return c.App.Debug.DebugOSCConditions 142 | } 143 | -------------------------------------------------------------------------------- /src/adapters/config/parse.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/ilyakaznacheev/cleanenv" 8 | ) 9 | 10 | // LoadConfig returns the app config. 11 | func LoadConfig() (*MainConfig, error) { 12 | cfg := &MainConfig{} 13 | configPath := "config.yml" 14 | configPathFromEnv := os.Getenv("APP_CONFIG_FILE") 15 | 16 | if configPathFromEnv != "" { 17 | configPath = configPathFromEnv 18 | } 19 | 20 | err := cleanenv.ReadConfig(configPath, cfg) 21 | if err != nil { 22 | return nil, fmt.Errorf("config error: %w (APP_CONFIG_FILE env variable: '%s', final config path: %s)", err, configPathFromEnv, configPath) 23 | } 24 | 25 | err = cleanenv.ReadEnv(cfg) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to readEnv: %w", err) 28 | } 29 | 30 | if err := validateConfig(cfg); err != nil { 31 | return nil, err 32 | } 33 | 34 | return cfg, nil 35 | } 36 | -------------------------------------------------------------------------------- /src/adapters/config/validate.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "net.kopias.oscbridge/app/pkg/slicetools" 8 | ) 9 | 10 | // nolint:cyclop,revive,nolintlint 11 | func validateConfig(cfg *MainConfig) error { 12 | err := validateAPPConfig(cfg) 13 | if err != nil { 14 | return err 15 | } 16 | return nil 17 | } 18 | 19 | func validateAPPConfig(cfg *MainConfig) error { 20 | oscImpls := []string{"l" /* "s" */} 21 | 22 | for _, cd := range cfg.OSCSources.ConsoleBridges { 23 | if slicetools.IndexOf(oscImpls, cd.OSCImplementation) == -1 { 24 | return fmt.Errorf("invalid osc implementation at %s: %s, valid values: %s", cd.Name, cd.OSCImplementation, strings.Join(oscImpls, ",")) 25 | } 26 | } 27 | 28 | // @TODO add checks for connection-name integrity 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /src/cmd/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "net.kopias.oscbridge/app/drivers/osc_connections/obs_bridge" 12 | 13 | "net.kopias.oscbridge/app/drivers/osc_conditions" 14 | 15 | "net.kopias.oscbridge/app/drivers/osc_connections/dummy_bridge" 16 | 17 | osc_ticker "net.kopias.oscbridge/app/drivers/osc_connections/ticker" 18 | 19 | "net.kopias.oscbridge/app/adapters/config" 20 | "net.kopias.oscbridge/app/drivers/actioncomposer" 21 | "net.kopias.oscbridge/app/drivers/messagestore" 22 | "net.kopias.oscbridge/app/drivers/obsremote" 23 | "net.kopias.oscbridge/app/drivers/osc_conditions/cond_and" 24 | "net.kopias.oscbridge/app/drivers/osc_conditions/cond_not" 25 | "net.kopias.oscbridge/app/drivers/osc_conditions/cond_or" 26 | "net.kopias.oscbridge/app/drivers/osc_conditions/cond_osc_msg_match" 27 | "net.kopias.oscbridge/app/drivers/osc_connections/console_bridge_l" 28 | "net.kopias.oscbridge/app/drivers/osc_connections/http_bridge" 29 | "net.kopias.oscbridge/app/drivers/osc_message" 30 | "net.kopias.oscbridge/app/drivers/tasks/delay" 31 | "net.kopias.oscbridge/app/drivers/tasks/httpreq" 32 | "net.kopias.oscbridge/app/drivers/tasks/obstasks" 33 | "net.kopias.oscbridge/app/drivers/tasks/run_command" 34 | "net.kopias.oscbridge/app/drivers/tasks/send_osc_message" 35 | "net.kopias.oscbridge/app/entities" 36 | "net.kopias.oscbridge/app/pkg/logger" 37 | "net.kopias.oscbridge/app/usecase" 38 | "net.kopias.oscbridge/app/usecase/usecaseifs" 39 | ) 40 | 41 | const Version = "v1.0.0" 42 | 43 | var ( 44 | 45 | // Revision is the git revision of the current executable, filled in with the build command. 46 | Revision string 47 | // BuildTime is the time of build of the current executable, filled in with the build command. 48 | BuildTime string 49 | ) 50 | 51 | func main() { 52 | for { 53 | ctx := context.Background() 54 | 55 | log := logger.New() 56 | log.Infof(ctx, "OPEN SOUND CONTROL BRIDGE is starting.") 57 | log.Infof(ctx, "Version: %s Revision: %.8s Built at: %s", Version, Revision, BuildTime) 58 | 59 | err := startApp(ctx, log) 60 | if err != nil { 61 | log.Err(ctx, fmt.Errorf("%w: the application will restart now", err)) 62 | } else { 63 | os.Exit(0) 64 | } 65 | // nolint:forbidigo 66 | fmt.Println("\n\n\n\n\n\n ") 67 | time.Sleep(2 * time.Second) 68 | } 69 | } 70 | 71 | // nolint:cyclop,gocognit,gocyclo 72 | func startApp(ctx context.Context, log *logger.Logger) error { 73 | log.AddPrefixerFunc(usecase.GetContextualLogPrefixer) 74 | 75 | // == Configuration 76 | cfg, err := config.LoadConfig() 77 | if err != nil { 78 | return fmt.Errorf("mainConfig error: %w", err) 79 | } 80 | 81 | // == OBS Connections 82 | log.Infof(ctx, "Initializing OBS connections...") 83 | obsConnections := map[string]*obsremote.OBSRemote{} 84 | defer stopObsConnections(ctx, obsConnections) 85 | 86 | for _, c := range cfg.OBSConnections { 87 | log.Infof(ctx, "\tConnecting to %s...", c.Name) 88 | obsRemoteCfg := obsremote.Config{ 89 | Host: c.Host, 90 | Port: c.Port, 91 | Password: c.Password, 92 | Debug: cfg.App.Debug.DebugOBSRemote, 93 | } 94 | obsRemote := obsremote.NewOBSRemote(log, obsRemoteCfg) 95 | 96 | if err = obsRemote.Start(ctx); err != nil { 97 | return err 98 | } 99 | obsConnections[c.Name] = obsRemote 100 | } 101 | 102 | oscConnections := []entities.OscConnectionDetails{} 103 | defer stopOscConnections(ctx, oscConnections) 104 | 105 | // OBS Brdiges 106 | log.Infof(ctx, "Initializing OBS bridges...") 107 | for _, c := range cfg.OSCSources.OBSBridges { 108 | if !c.Enabled { 109 | continue 110 | } 111 | 112 | var oscConn usecaseifs.IOSCConnection 113 | 114 | log.Infof(ctx, "\tStarting obs bridge %s...", c.Name) 115 | conn, ok := obsConnections[c.Connection] 116 | if !ok { 117 | return fmt.Errorf("failed to start obs bridge: there is no connection named '%s'", c.Connection) 118 | } 119 | obsCfg := obs_bridge.Config{ 120 | Debug: cfg.App.Debug.DebugOSCConnection, 121 | Connection: conn, 122 | } 123 | 124 | oscConn = obs_bridge.NewOBSBridge(log, obsCfg) 125 | if err = oscConn.Start(ctx); err != nil { 126 | return fmt.Errorf("failed to start obs bridge: %w", err) 127 | } 128 | 129 | oscConnections = append(oscConnections, *entities.NewOscConnectionDetails(c.Name, c.Prefix, oscConn)) 130 | } 131 | 132 | // == Console Bridges 133 | log.Infof(ctx, "Initializing Open Sound Control (mixer consoles, etc) connections...") 134 | for _, c := range cfg.OSCSources.ConsoleBridges { 135 | if !c.Enabled { 136 | continue 137 | } 138 | var oscConn usecaseifs.IOSCConnection 139 | 140 | log.Infof(ctx, "\tConnecting to %s...", c.Name) 141 | switch c.OSCImplementation { 142 | case "l": 143 | oscConnCfg := console_bridge_l.Config{ 144 | Debug: cfg.App.Debug.DebugOSCConnection, 145 | Subscriptions: c.Subscriptions, 146 | Port: c.Port, 147 | Host: c.Host, 148 | CheckAddress: c.CheckAddress, 149 | CheckPattern: c.CheckPattern, 150 | } 151 | oscConn = console_bridge_l.NewConnection(log, oscConnCfg) 152 | if err := oscConn.Start(ctx); err != nil { 153 | return fmt.Errorf("failed to start osc[l] connection: %w", err) 154 | } 155 | // case "s": 156 | // oscConnCfg := console_bridge_s.Config{ 157 | // Debug: cfg.App.Debug.DebugOSCConnection, 158 | // Subscriptions: c.Subscriptions, 159 | // Port: c.Port, 160 | // Host: c.Host, 161 | // CheckAddress: c.CheckAddress, 162 | // CheckPattern: c.CheckPattern, 163 | // } 164 | // oscConn = console_bridge_s.NewConnection(log, oscConnCfg) 165 | // if err := oscConn.Start(ctx); err != nil { 166 | // return fmt.Errorf("failed to start osc[s] connection: %w", err) 167 | // } 168 | default: 169 | return fmt.Errorf("unknown osc implementation: %s", c.OSCImplementation) 170 | } 171 | 172 | if c.InitCommand != nil { 173 | args := []usecaseifs.IOSCMessageArgument{} 174 | for _, a := range c.InitCommand.Arguments { 175 | args = append(args, osc_message.NewMessageArgument(a.Type, a.Value)) 176 | } 177 | 178 | if err := oscConn.SendMessage(ctx, osc_message.NewMessage(c.InitCommand.Address, args)); err != nil { 179 | return fmt.Errorf("failed to query mixer: %w", err) 180 | } 181 | } 182 | 183 | oscConnections = append(oscConnections, *entities.NewOscConnectionDetails(c.Name, c.Prefix, oscConn)) 184 | } 185 | 186 | // == Dummy Console Connections 187 | log.Infof(ctx, "Initializing Dummy Open Sound Control connections...") 188 | 189 | for _, c := range cfg.OSCSources.DummyConnections { 190 | if !c.Enabled { 191 | continue 192 | } 193 | 194 | var oscConn usecaseifs.IOSCConnection 195 | 196 | log.Infof(ctx, "\tConnecting to %s...", c.Name) 197 | 198 | oscConn = dummy_bridge.NewConnection(log, c, cfg.App.Debug.DebugOSCConnection) 199 | if err := oscConn.Start(ctx); err != nil { 200 | return fmt.Errorf("failed to start dummy osc connection: %w", err) 201 | } 202 | 203 | oscConnections = append(oscConnections, *entities.NewOscConnectionDetails(c.Name, c.Prefix, oscConn)) 204 | } 205 | 206 | // == Tickers 207 | log.Infof(ctx, "Initializing tickers...") 208 | for _, c := range cfg.OSCSources.Tickers { 209 | if !c.Enabled { 210 | continue 211 | } 212 | 213 | var oscConn usecaseifs.IOSCConnection 214 | 215 | log.Infof(ctx, "\tStarting ticker %s...", c.Name) 216 | tickerCfg := osc_ticker.Config{ 217 | Debug: cfg.App.Debug.DebugOSCConnection, 218 | RefreshRateMillis: c.RefreshRateMillis, 219 | } 220 | 221 | oscConn = osc_ticker.NewTicker(log, tickerCfg) 222 | if err := oscConn.Start(ctx); err != nil { 223 | return fmt.Errorf("failed to start ticker: %w", err) 224 | } 225 | 226 | oscConnections = append(oscConnections, *entities.NewOscConnectionDetails(c.Name, c.Prefix, oscConn)) 227 | } 228 | 229 | // == HTTP Bridges 230 | log.Infof(ctx, "Initializing http bridges...") 231 | for _, c := range cfg.OSCSources.HTTPBridges { 232 | if !c.Enabled { 233 | continue 234 | } 235 | 236 | var oscConn usecaseifs.IOSCConnection 237 | 238 | log.Infof(ctx, "\tStarting http bridge %s...", c.Name) 239 | hbCfg := http_bridge.Config{ 240 | Debug: cfg.App.Debug.DebugOSCConnection, 241 | Host: c.Host, 242 | Port: c.Port, 243 | } 244 | 245 | oscConn = http_bridge.NewHTTPBridge(log, hbCfg) 246 | if err := oscConn.Start(ctx); err != nil { 247 | return fmt.Errorf("failed to start http2osc bridge: %w", err) 248 | } 249 | 250 | oscConnections = append(oscConnections, *entities.NewOscConnectionDetails(c.Name, c.Prefix, oscConn)) 251 | } 252 | 253 | // == OSC Connection map 254 | oscConnectionMap := map[string]usecaseifs.IOSCConnection{} 255 | for _, c := range oscConnections { 256 | oscConnectionMap[c.Name] = c.Connection 257 | } 258 | 259 | // == Tasks 260 | log.Infof(ctx, "Initializing Tasks ...") 261 | 262 | registeredTasks := map[string]usecaseifs.ActionTaskFactory{ 263 | "obs_scene_change": obstasks.NewSceneChangerFactory(obsConnections, log, cfg.App.Debug.DebugTasks), 264 | "obs_vendor_request": obstasks.NewVendorRequestFactory(obsConnections, log, cfg.App.Debug.DebugTasks), 265 | "delay": delay.NewFactory(log, cfg.App.Debug.DebugTasks), 266 | "http_request": httpreq.NewFactory(log, cfg.App.Debug.DebugTasks), 267 | "send_osc_message": send_osc_message.NewFactory(log, cfg.App.Debug.DebugTasks, oscConnectionMap), 268 | "run_command": run_command.NewFactory(log, cfg.App.Debug.DebugTasks), 269 | } 270 | 271 | // == Conditions 272 | log.Infof(ctx, "Initializing Conditions ...") 273 | 274 | conditionTracker := osc_conditions.NewConditionTracker(log, cfg.App.Debug.DebugOSCConditions) 275 | 276 | registeredConditions := map[string]usecaseifs.ActionConditionFactory{ 277 | "and": cond_and.NewFactory(conditionTracker), 278 | "or": cond_or.NewFactory(conditionTracker), 279 | "not": cond_not.NewFactory(conditionTracker), 280 | "osc_match": cond_osc_msg_match.NewFactory(conditionTracker), 281 | } 282 | 283 | // == Composing actions 284 | actionComposer := actioncomposer.NewActionComposer(cfg.Actions, registeredConditions, registeredTasks) 285 | actions, err := actionComposer.GetActionList() 286 | if err != nil { 287 | return err 288 | } 289 | 290 | messageStore := messagestore.NewMessageStore() 291 | 292 | // == Compose use cases 293 | log.Infof(ctx, "Initializing Use cases...") 294 | ucs := usecase.New( 295 | log, 296 | cfg, 297 | oscConnections, 298 | messageStore, 299 | actions, 300 | cfg.StorePersistPath, 301 | ) 302 | 303 | if err := ucs.Start(ctx); err != nil { 304 | return err 305 | } 306 | defer ucs.Stop(ctx) 307 | 308 | // == CTRL-C trap 309 | interrupt, trapStop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGKILL, syscall.SIGTERM) 310 | defer trapStop() 311 | 312 | // == Serve & Watch for errors 313 | log.Info(ctx, "All services are up & running!") 314 | 315 | defer log.Infof(ctx, "Main thread quited.") 316 | 317 | select { 318 | case <-interrupt.Done(): 319 | log.Info(ctx, "Kill signal received.") 320 | return nil 321 | case err := <-oscConnNotify(ctx, oscConnections): 322 | return fmt.Errorf("OSC bridge encountered an issue: %w", err) 323 | 324 | case err := <-obsConnNotify(ctx, obsConnections): 325 | return fmt.Errorf("OBS remote encountered an issue: %w", err) 326 | 327 | case err := <-ucs.Notify(): 328 | return fmt.Errorf("USESCASES encountered an issue: %w", err) 329 | } 330 | } 331 | 332 | func stopObsConnections(ctx context.Context, connections map[string]*obsremote.OBSRemote) { 333 | for _, c := range connections { 334 | c.Stop(ctx) 335 | } 336 | } 337 | 338 | // obsConnNotify checks on all obs connections, and forwards the error if any of them emits an error. 339 | func obsConnNotify(ctx context.Context, connections map[string]*obsremote.OBSRemote) chan error { 340 | e := make(chan error, 1) 341 | 342 | for _, c := range connections { 343 | c := c 344 | go func() { 345 | e <- <-c.Notify() 346 | }() 347 | } 348 | 349 | return e 350 | } 351 | 352 | func stopOscConnections(ctx context.Context, connectionDetails []entities.OscConnectionDetails) { 353 | for _, cd := range connectionDetails { 354 | cd.Connection.Stop(ctx) 355 | } 356 | } 357 | 358 | func oscConnNotify(ctx context.Context, connectionDetails []entities.OscConnectionDetails) chan error { 359 | e := make(chan error, 1) 360 | 361 | for _, cd := range connectionDetails { 362 | cd := cd 363 | go func() { 364 | e <- <-cd.Connection.Notify() 365 | }() 366 | } 367 | 368 | return e 369 | } 370 | -------------------------------------------------------------------------------- /src/drivers/actioncomposer/actioncomposer.go: -------------------------------------------------------------------------------- 1 | package actioncomposer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "net.kopias.oscbridge/app/adapters/config" 7 | "net.kopias.oscbridge/app/entities" 8 | "net.kopias.oscbridge/app/usecase/usecaseifs" 9 | ) 10 | 11 | // ActionComposer is responsible for composing actions from the YAML structure into an instance-structure. 12 | type ActionComposer struct { 13 | // actions holds the name->configured action structure pairs 14 | actions map[string]config.Action 15 | 16 | // conditions holds name->factory pairs for the conditions. 17 | conditions map[string]usecaseifs.ActionConditionFactory 18 | 19 | // tasks holds name->factory pairs for the tasks. 20 | tasks map[string]usecaseifs.ActionTaskFactory 21 | } 22 | 23 | func NewActionComposer( 24 | actions map[string]config.Action, 25 | conditions map[string]usecaseifs.ActionConditionFactory, 26 | tasks map[string]usecaseifs.ActionTaskFactory, 27 | ) *ActionComposer { 28 | return &ActionComposer{ 29 | actions: actions, 30 | conditions: conditions, 31 | tasks: tasks, 32 | } 33 | } 34 | 35 | // GetActionList processes the input actions and returns a list of IActions. 36 | func (a *ActionComposer) GetActionList() ([]usecaseifs.IAction, error) { 37 | actionList := []usecaseifs.IAction{} 38 | childIndex := 0 39 | 40 | for actionName, cfgAction := range a.actions { 41 | // Convert the conditions for this action. 42 | condition, err := a.convertCondition(cfgAction.TriggerChain, actionName, childIndex) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | // Validate the condition parameters. 48 | if err := condition.Validate(); err != nil { 49 | return nil, fmt.Errorf("failed to validate %s's triggers: %w", actionName, err) 50 | } 51 | 52 | // Convert the tasks for this action. 53 | tasks, err := a.convertTasks(actionName, cfgAction.Tasks) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to load tasks: %w", err) 56 | } 57 | 58 | actionList = append(actionList, entities.NewAction(actionName, condition, tasks, cfgAction.DebounceMillis)) 59 | childIndex++ 60 | } 61 | return actionList, nil 62 | } 63 | 64 | // convertCondition instantiates and configures a single condition 65 | // [path] tracks the hierarchy of the conditions, used for logging. 66 | // [index] reveals the child-index of the current node. 67 | func (a *ActionComposer) convertCondition(chain config.ActionConditionChecker, path string, index int) (usecaseifs.IActionCondition, error) { 68 | factory, ok := a.conditions[chain.Type] 69 | if !ok { 70 | return nil, fmt.Errorf("there are no condition implementation for type '%s'", chain.Type) 71 | } 72 | 73 | currentPath := fmt.Sprintf("%s/%s:%d", path, chain.Type, index) 74 | cond := factory(currentPath) 75 | cond.SetParameters(chain.Parameters) 76 | 77 | for childIndex, child := range chain.Children { 78 | children, err := a.convertCondition(child, currentPath, childIndex) 79 | if err != nil { 80 | return nil, err 81 | } 82 | cond.AddChild(children) 83 | } 84 | return cond, nil 85 | } 86 | 87 | // convertTasks instantiates and configures the tasks. 88 | func (a *ActionComposer) convertTasks(actionName string, tasks []config.ActionTask) ([]usecaseifs.IActionTask, error) { 89 | result := []usecaseifs.IActionTask{} 90 | 91 | for i, task := range tasks { 92 | taskFactory, ok := a.tasks[task.Type] 93 | if !ok { 94 | return nil, fmt.Errorf("no such task type registered: %s", task.Type) 95 | } 96 | 97 | newTask := taskFactory() 98 | newTask.SetParameters(task.Parameters) 99 | if err := newTask.Validate(); err != nil { 100 | return nil, fmt.Errorf("failed to validate %s action's [%d-%s] task: %w", actionName, i, task.Type, err) 101 | } 102 | result = append(result, newTask) 103 | } 104 | return result, nil 105 | } 106 | -------------------------------------------------------------------------------- /src/drivers/messagestore/record.go: -------------------------------------------------------------------------------- 1 | package messagestore 2 | 3 | import ( 4 | "time" 5 | 6 | "net.kopias.oscbridge/app/usecase/usecaseifs" 7 | ) 8 | 9 | var _ usecaseifs.IMessageStoreRecord = &Record{} 10 | 11 | type Record struct { 12 | message usecaseifs.IOSCMessage 13 | arrivedAt time.Time 14 | } 15 | 16 | func NewMessageStoreRecord(message usecaseifs.IOSCMessage, arrivedAt time.Time) *Record { 17 | return &Record{message: message, arrivedAt: arrivedAt} 18 | } 19 | 20 | func (m *Record) GetMessage() usecaseifs.IOSCMessage { 21 | return m.message 22 | } 23 | 24 | func (m *Record) GetArrivedAt() time.Time { 25 | return m.arrivedAt 26 | } 27 | -------------------------------------------------------------------------------- /src/drivers/messagestore/store.go: -------------------------------------------------------------------------------- 1 | // Package messagestore implements the core of the application, the store that accepts OSC messages, 2 | // and on which the conditions run. 3 | package messagestore 4 | 5 | import ( 6 | "regexp" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "net.kopias.oscbridge/app/pkg/slicetools" 12 | 13 | "net.kopias.oscbridge/app/usecase/usecaseifs" 14 | ) 15 | 16 | var _ usecaseifs.IMessageStore = &MessageStore{} 17 | 18 | type MessageStore struct { 19 | m *sync.RWMutex 20 | store map[string]usecaseifs.IMessageStoreRecord 21 | watchedRecord *usecaseifs.IOSCMessage 22 | watchedRecordAccesses int64 23 | } 24 | 25 | // WatchRecordAccess registers a message, and from the point of the call, the store will cound how many times that address has been accessed. 26 | // See [MessageStore.GetWatchedRecordAccesses]. 27 | func (e *MessageStore) WatchRecordAccess(msg *usecaseifs.IOSCMessage) { 28 | e.watchedRecord = msg 29 | e.watchedRecordAccesses = 0 30 | } 31 | 32 | // GetWatchedRecordAccesses returns the number of accesses that the watched record received. 33 | // See [MessageStore.GetWatchedRecordAccesses] 34 | func (e *MessageStore) GetWatchedRecordAccesses() int64 { 35 | return e.watchedRecordAccesses 36 | } 37 | 38 | // checkWatchedRecordAccess determines if the list of about-to-return messages [msg] contains the watched message. 39 | // [trackAccess] can override watching altogether. 40 | func (e *MessageStore) checkWatchedRecordAccess(msgs []usecaseifs.IOSCMessage, trackAccess bool) { 41 | if !trackAccess { 42 | return 43 | } 44 | 45 | if e.watchedRecord == nil { 46 | return 47 | } 48 | 49 | for _, m := range msgs { 50 | if m.Equal(*e.watchedRecord) { 51 | e.watchedRecordAccesses++ 52 | } 53 | } 54 | } 55 | 56 | // Clone clones this message store 57 | func (e *MessageStore) Clone() usecaseifs.IMessageStore { 58 | newStore := NewMessageStore() 59 | newStore.store = e.GetAll() 60 | 61 | return newStore 62 | } 63 | 64 | // GetAll returns every record. It does not do record watching. 65 | func (e *MessageStore) GetAll() map[string]usecaseifs.IMessageStoreRecord { 66 | newData := map[string]usecaseifs.IMessageStoreRecord{} 67 | 68 | e.m.RLock() 69 | for key, value := range e.store { 70 | newData[key] = value 71 | } 72 | // we clone ourselves, hopefully will always work 73 | // newData := deepcopy.Anything(e.store).(map[string]usecaseifs.IMessageStoreRecord) 74 | e.m.RUnlock() 75 | 76 | return newData 77 | } 78 | 79 | // GetRecord returns a message with the exact [address]. 80 | // [trackAccess] determines if this access is tracked or not. See WatchRecordAccess. 81 | func (e *MessageStore) GetRecord(address string, trackAccess bool) (usecaseifs.IMessageStoreRecord, bool) { 82 | e.m.RLock() 83 | record, ok := e.store[address] 84 | if ok { 85 | e.checkWatchedRecordAccess([]usecaseifs.IOSCMessage{record.GetMessage()}, trackAccess) 86 | } 87 | e.m.RUnlock() 88 | 89 | return record, ok 90 | } 91 | 92 | // GetOneRecordByRegexp returns a single record whose address is matching the [re] expression. 93 | // [trackAccess] determines if this access is tracked or not. See WatchRecordAccess. 94 | func (e *MessageStore) GetOneRecordByRegexp(re string, trackAccess bool) (usecaseifs.IMessageStoreRecord, error) { 95 | result, err := e.getRecordsByRegexpFinder(re, true, trackAccess) 96 | 97 | if len(result) == 0 { 98 | return nil, err 99 | } 100 | 101 | e.checkWatchedRecordAccess([]usecaseifs.IOSCMessage{result[0].GetMessage()}, trackAccess) 102 | return result[0], err 103 | } 104 | 105 | // GetRecordsByRegexp returns a list of records whose address is matching the [re] expression. 106 | // [trackAccess] determines if this access is tracked or not. See WatchRecordAccess. 107 | func (e *MessageStore) GetRecordsByRegexp(re string, trackAccess bool) ([]usecaseifs.IMessageStoreRecord, error) { 108 | return e.getRecordsByRegexpFinder(re, false, trackAccess) 109 | } 110 | 111 | // getRecordsByRegexpFinder does the grunt work for GetRecordsByRegexp and GetOneRecordByRegexp. 112 | func (e *MessageStore) getRecordsByRegexpFinder(re string, firstOnly bool, trackAccess bool) ([]usecaseifs.IMessageStoreRecord, error) { 113 | compiledRegex, err := regexp.Compile(re) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | result := []usecaseifs.IMessageStoreRecord{} 119 | 120 | e.m.RLock() 121 | for address, record := range e.store { 122 | if compiledRegex.MatchString(address) { 123 | result = append(result, record) 124 | if firstOnly { 125 | break 126 | } 127 | } 128 | } 129 | e.checkWatchedRecordAccess(slicetools.Map(result, func(t usecaseifs.IMessageStoreRecord) usecaseifs.IOSCMessage { 130 | return t.GetMessage() 131 | }), trackAccess) 132 | 133 | e.m.RUnlock() 134 | return result, nil 135 | } 136 | 137 | // GetRecordsByPrefix returns a list of records whose address starts with [prefix]. 138 | // [trackAccess] determines if this access is tracked or not. See WatchRecordAccess. 139 | func (e *MessageStore) GetRecordsByPrefix(prefix string, trackAccess bool) []usecaseifs.IMessageStoreRecord { 140 | result := []usecaseifs.IMessageStoreRecord{} 141 | 142 | e.m.RLock() 143 | for address, record := range e.store { 144 | if strings.HasPrefix(address, prefix) { 145 | result = append(result, record) 146 | } 147 | } 148 | e.checkWatchedRecordAccess(slicetools.Map(result, func(t usecaseifs.IMessageStoreRecord) usecaseifs.IOSCMessage { 149 | return t.GetMessage() 150 | }), trackAccess) 151 | e.m.RUnlock() 152 | return result 153 | } 154 | 155 | // SetRecord updates the store. Returns the fact if the store changed or not. 156 | func (e *MessageStore) SetRecord(record usecaseifs.IOSCMessage) bool { 157 | changed := false 158 | e.m.Lock() 159 | 160 | oldRecord, ok := e.store[record.GetAddress()] 161 | 162 | if ok && !oldRecord.GetMessage().Equal(record) || !ok { 163 | e.store[record.GetAddress()] = NewMessageStoreRecord(record, time.Now()) 164 | changed = true 165 | } 166 | 167 | e.m.Unlock() 168 | return changed 169 | } 170 | 171 | func NewMessageStore() *MessageStore { 172 | return &MessageStore{ 173 | m: &sync.RWMutex{}, 174 | store: make(map[string]usecaseifs.IMessageStoreRecord), 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/drivers/obsremote/api.go: -------------------------------------------------------------------------------- 1 | package obsremote 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/andreykaipov/goobs/api/requests/general" 8 | 9 | "github.com/andreykaipov/goobs/api/requests/ui" 10 | 11 | "github.com/andreykaipov/goobs/api/requests/record" 12 | "github.com/andreykaipov/goobs/api/requests/stream" 13 | 14 | "github.com/andreykaipov/goobs/api/requests/scenes" 15 | 16 | "github.com/andreykaipov/goobs/api/typedefs" 17 | "net.kopias.oscbridge/app/pkg/slicetools" 18 | ) 19 | 20 | func (or *OBSRemote) ListScenes(ctx context.Context) ([]string, error) { 21 | if or.client == nil { 22 | return nil, fmt.Errorf("not connected") 23 | } 24 | 25 | or.m.Lock() 26 | defer or.m.Unlock() 27 | 28 | list, err := or.client.Scenes.GetSceneList() 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to list scenes: %w", err) 31 | } 32 | sceneNames := slicetools.Map(list.Scenes, func(t *typedefs.Scene) string { 33 | return t.SceneName 34 | }) 35 | 36 | return sceneNames, nil 37 | } 38 | 39 | func (or *OBSRemote) SwitchPreviewScene(ctx context.Context, sceneName string) error { 40 | if or.client == nil { 41 | return fmt.Errorf("not connected") 42 | } 43 | 44 | or.m.Lock() 45 | defer or.m.Unlock() 46 | 47 | params := &scenes.SetCurrentPreviewSceneParams{ 48 | SceneName: sceneName, 49 | } 50 | 51 | _, err := or.client.Scenes.SetCurrentPreviewScene(params) 52 | if err != nil { 53 | return fmt.Errorf("failed to switch scene: %w", err) 54 | } 55 | return nil 56 | } 57 | 58 | func (or *OBSRemote) SwitchProgramScene(ctx context.Context, sceneName string) error { 59 | if or.client == nil { 60 | return fmt.Errorf("not connected") 61 | } 62 | 63 | or.m.Lock() 64 | defer or.m.Unlock() 65 | 66 | params := &scenes.SetCurrentProgramSceneParams{ 67 | SceneName: sceneName, 68 | } 69 | _, err := or.client.Scenes.SetCurrentProgramScene(params) 70 | if err != nil { 71 | return fmt.Errorf("failed to switch scene: %w", err) 72 | } 73 | return nil 74 | } 75 | 76 | func (or *OBSRemote) GetCurrentProgramScene(ctx context.Context) (string, error) { 77 | if or.client == nil { 78 | return "", fmt.Errorf("not connected") 79 | } 80 | 81 | or.m.Lock() 82 | defer or.m.Unlock() 83 | 84 | sme, err := or.client.Ui.GetStudioModeEnabled(&ui.GetStudioModeEnabledParams{}) 85 | if err != nil { 86 | return "", fmt.Errorf("failed to retrieve studio mode state: %w", err) 87 | } 88 | if !sme.StudioModeEnabled { 89 | return "", nil 90 | } 91 | 92 | params := &scenes.GetCurrentProgramSceneParams{} 93 | r, err := or.client.Scenes.GetCurrentProgramScene(params) 94 | if err != nil { 95 | return "", fmt.Errorf("failed to retrieve current program scene: %w", err) 96 | } 97 | return r.CurrentProgramSceneName, nil 98 | } 99 | 100 | func (or *OBSRemote) GetCurrentPreviewScene(ctx context.Context) (string, error) { 101 | if or.client == nil { 102 | return "", fmt.Errorf("not connected") 103 | } 104 | or.m.Lock() 105 | defer or.m.Unlock() 106 | 107 | params := &scenes.GetCurrentPreviewSceneParams{} 108 | r, err := or.client.Scenes.GetCurrentPreviewScene(params) 109 | if err != nil { 110 | return "", fmt.Errorf("failed to retrieve current preview scene: %w", err) 111 | } 112 | return r.CurrentPreviewSceneName, nil 113 | } 114 | 115 | func (or *OBSRemote) IsStreaming(ctx context.Context) (bool, error) { 116 | if or.client == nil { 117 | return false, fmt.Errorf("not connected") 118 | } 119 | 120 | or.m.Lock() 121 | defer or.m.Unlock() 122 | 123 | params := &stream.GetStreamStatusParams{} 124 | r, err := or.client.Stream.GetStreamStatus(params) 125 | if err != nil { 126 | return false, fmt.Errorf("failed to retrieve stream status: %w", err) 127 | } 128 | return r.OutputActive, nil 129 | } 130 | 131 | func (or *OBSRemote) IsRecording(ctx context.Context) (bool, error) { 132 | if or.client == nil { 133 | return false, fmt.Errorf("not connected") 134 | } 135 | 136 | or.m.Lock() 137 | defer or.m.Unlock() 138 | 139 | params := &record.GetRecordStatusParams{} 140 | r, err := or.client.Record.GetRecordStatus(params) 141 | if err != nil { 142 | return false, fmt.Errorf("failed to retrieve recording status: %w", err) 143 | } 144 | 145 | return r.OutputActive, nil 146 | } 147 | 148 | func (or *OBSRemote) VendorRequest(ctx context.Context, vendorName string, requestType string, requestData interface{}) (responseData interface{}, err error) { 149 | if or.client == nil { 150 | return nil, fmt.Errorf("not connected") 151 | } 152 | 153 | or.m.Lock() 154 | defer or.m.Unlock() 155 | 156 | params := &general.CallVendorRequestParams{ 157 | RequestData: requestData, 158 | RequestType: requestType, 159 | VendorName: vendorName, 160 | } 161 | r, err := or.client.General.CallVendorRequest(params) 162 | if err != nil { 163 | return nil, fmt.Errorf("failed to send vendor request: %w", err) 164 | } 165 | 166 | return r.ResponseData, nil 167 | } 168 | -------------------------------------------------------------------------------- /src/drivers/obsremote/logger.go: -------------------------------------------------------------------------------- 1 | package obsremote 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/andreykaipov/goobs/api" 8 | "net.kopias.oscbridge/app/usecase/usecaseifs" 9 | ) 10 | 11 | var _ api.Logger = &obsRemoteLogger{} 12 | 13 | type obsRemoteLogger struct { 14 | logger usecaseifs.ILogger 15 | ctx context.Context 16 | debug bool 17 | } 18 | 19 | func newOBSRemoteLogger(ctx context.Context, logger usecaseifs.ILogger, debug bool) *obsRemoteLogger { 20 | return &obsRemoteLogger{ctx: ctx, logger: logger, debug: debug} 21 | } 22 | 23 | func (orem *obsRemoteLogger) Printf(s string, i ...interface{}) { 24 | if !orem.debug { 25 | if strings.HasPrefix(s, "[DEBUG]") || strings.HasPrefix(s, "[INFO]") { 26 | return 27 | } 28 | } 29 | orem.logger.Infof(orem.ctx, s, i...) 30 | } 31 | -------------------------------------------------------------------------------- /src/drivers/obsremote/obsremote.go: -------------------------------------------------------------------------------- 1 | package obsremote 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/andreykaipov/goobs" 10 | "net.kopias.oscbridge/app/usecase/usecaseifs" 11 | ) 12 | 13 | var _ usecaseifs.IOBSRemote = &OBSRemote{} 14 | 15 | type Config struct { 16 | Host string 17 | Port int64 18 | Password string 19 | Debug bool 20 | } 21 | 22 | // OBSRemote uses goobs to connect to an OBS instance based on the config, and to provide the IOBSRemote interface. 23 | type OBSRemote struct { 24 | client *goobs.Client 25 | logger usecaseifs.ILogger 26 | cfg Config 27 | 28 | notify chan error 29 | quit chan interface{} 30 | 31 | // Locking is introduced because goobs is not trhead safe apparently, 32 | // produces mismatched id errors if handled from multiple threads. 33 | m *sync.Mutex 34 | } 35 | 36 | func NewOBSRemote(logger usecaseifs.ILogger, cfg Config) *OBSRemote { 37 | return &OBSRemote{ 38 | logger: logger, 39 | cfg: cfg, 40 | notify: make(chan error, 1), 41 | quit: make(chan interface{}, 1), 42 | m: &sync.Mutex{}, 43 | } 44 | } 45 | 46 | func (or *OBSRemote) Start(ctx context.Context) error { 47 | var err error 48 | or.m.Lock() 49 | 50 | opts := []goobs.Option{} 51 | 52 | if or.cfg.Password != "" { 53 | opts = append(opts, goobs.WithPassword(or.cfg.Password)) 54 | } 55 | 56 | opts = append(opts, goobs.WithLogger(newOBSRemoteLogger(ctx, or.logger, or.cfg.Debug))) 57 | 58 | or.client, err = goobs.New(fmt.Sprintf("%s:%d", or.cfg.Host, or.cfg.Port), opts...) 59 | 60 | or.m.Unlock() 61 | 62 | if err != nil { 63 | return fmt.Errorf("failed to connect to OBS: %w", err) 64 | } 65 | 66 | if err := or.checkConnection(ctx); err != nil { 67 | return err 68 | } 69 | go or.watchdog(ctx) 70 | return nil 71 | } 72 | 73 | func (or *OBSRemote) Stop(ctx context.Context) { 74 | or.m.Lock() 75 | defer or.m.Unlock() 76 | 77 | if or.quit == nil { 78 | return 79 | } 80 | close(or.quit) 81 | or.quit = nil 82 | if or.client == nil { 83 | or.logger.Err(ctx, fmt.Errorf("not connected")) 84 | } 85 | 86 | if err := or.client.Disconnect(); err != nil { 87 | or.logger.Err(ctx, err) 88 | } 89 | } 90 | 91 | func (or *OBSRemote) checkConnection(ctx context.Context) error { 92 | or.m.Lock() 93 | defer or.m.Unlock() 94 | 95 | _, err := or.client.General.GetVersion() 96 | if err != nil { 97 | return err 98 | } 99 | return nil 100 | } 101 | 102 | func (or *OBSRemote) watchdog(ctx context.Context) { 103 | for { 104 | if or.cfg.Debug { 105 | or.logger.Infof(ctx, "OBS remote checking connection...") 106 | } 107 | err := or.checkConnection(ctx) 108 | if err != nil { 109 | or.Stop(ctx) 110 | or.notify <- fmt.Errorf("obsRemote connection is broken: %w", err) 111 | } 112 | 113 | select { 114 | case <-or.quit: 115 | return 116 | case <-time.After(5 * time.Second): 117 | } 118 | } 119 | } 120 | 121 | func (or *OBSRemote) Notify() chan error { 122 | return or.notify 123 | } 124 | 125 | func (or *OBSRemote) GetIncomingEvents() (chan interface{}, error) { 126 | if or.client == nil { 127 | return nil, fmt.Errorf("not connected") 128 | } 129 | 130 | return or.client.IncomingEvents, nil 131 | } 132 | -------------------------------------------------------------------------------- /src/drivers/osc_conditions/cond_and/and.go: -------------------------------------------------------------------------------- 1 | package cond_and 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "net.kopias.oscbridge/app/drivers/osc_conditions" 8 | 9 | "net.kopias.oscbridge/app/usecase/usecaseifs" 10 | ) 11 | 12 | var _ usecaseifs.IActionCondition = &AndCondition{} 13 | 14 | // AndCondition makes sure all it's children return true for matching the current store. 15 | type AndCondition struct { 16 | path string 17 | children []usecaseifs.IActionCondition 18 | conditionTracker *osc_conditions.ConditionTracker 19 | } 20 | 21 | func NewFactory(conditionTracker *osc_conditions.ConditionTracker) usecaseifs.ActionConditionFactory { 22 | return func(path string) usecaseifs.IActionCondition { 23 | return &AndCondition{path: path, conditionTracker: conditionTracker} 24 | } 25 | } 26 | 27 | func (a *AndCondition) GetType() string { 28 | return "AND" 29 | } 30 | 31 | func (a *AndCondition) Evaluate(ctx context.Context, store usecaseifs.IMessageStore) (bool, error) { 32 | for i, child := range a.children { 33 | a.conditionTracker.Log(ctx, a.path, "Checking on child %d...", i) 34 | matched, err := child.Evaluate(ctx, store) 35 | if err != nil { 36 | return a.conditionTracker.R(ctx, false, a.path, "Child %d evaluation failed: %s", i, err.Error()), err 37 | } 38 | 39 | if !matched { 40 | return a.conditionTracker.R(ctx, false, a.path, "Child %d returned false", i), nil 41 | } 42 | } 43 | 44 | return a.conditionTracker.R(ctx, true, a.path, "all child returned true"), nil 45 | } 46 | 47 | func (a *AndCondition) SetParameters(_ map[string]interface{}) { 48 | // noop 49 | } 50 | 51 | func (a *AndCondition) AddChild(condition usecaseifs.IActionCondition) { 52 | a.children = append(a.children, condition) 53 | } 54 | 55 | func (a *AndCondition) Validate() error { 56 | if len(a.children) == 0 { 57 | return fmt.Errorf("%s: this node has no children", a.GetType()) 58 | } 59 | 60 | for _, child := range a.children { 61 | if err := child.Validate(); err != nil { 62 | return fmt.Errorf("AND failed to validate it's children: %w", err) 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /src/drivers/osc_conditions/cond_not/not.go: -------------------------------------------------------------------------------- 1 | package cond_not 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "net.kopias.oscbridge/app/drivers/osc_conditions" 8 | 9 | "net.kopias.oscbridge/app/usecase/usecaseifs" 10 | ) 11 | 12 | var _ usecaseifs.IActionCondition = &NotCondition{} 13 | 14 | // NotCondition simply negates its SINGLE children's result for Evaluate. 15 | type NotCondition struct { 16 | path string 17 | children []usecaseifs.IActionCondition 18 | conditionTracker *osc_conditions.ConditionTracker 19 | } 20 | 21 | func NewFactory(conditionTracker *osc_conditions.ConditionTracker) usecaseifs.ActionConditionFactory { 22 | return func(path string) usecaseifs.IActionCondition { 23 | return &NotCondition{path: path, conditionTracker: conditionTracker} 24 | } 25 | } 26 | 27 | func (a *NotCondition) GetType() string { 28 | return "NOT" 29 | } 30 | 31 | func (a *NotCondition) Evaluate(ctx context.Context, store usecaseifs.IMessageStore) (bool, error) { 32 | matched, err := a.children[0].Evaluate(ctx, store) 33 | if err != nil { 34 | return a.conditionTracker.R(ctx, false, a.path, "Child evaluation failed: %s", err.Error()), err 35 | } 36 | 37 | return a.conditionTracker.R(ctx, !matched, a.path, "Child returned %t", matched), nil 38 | } 39 | 40 | func (a *NotCondition) SetParameters(_ map[string]interface{}) { 41 | // noop 42 | } 43 | 44 | func (a *NotCondition) AddChild(condition usecaseifs.IActionCondition) { 45 | a.children = append(a.children, condition) 46 | } 47 | 48 | func (a *NotCondition) Validate() error { 49 | if len(a.children) == 0 { 50 | return fmt.Errorf("%s: this node has no children", a.GetType()) 51 | } 52 | if len(a.children) > 1 { 53 | return fmt.Errorf("%s: this node has more than one children", a.GetType()) 54 | } 55 | 56 | for _, child := range a.children { 57 | if err := child.Validate(); err != nil { 58 | return fmt.Errorf("OR failed to validate it's children: %w", err) 59 | } 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /src/drivers/osc_conditions/cond_or/or.go: -------------------------------------------------------------------------------- 1 | package cond_or 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "net.kopias.oscbridge/app/drivers/osc_conditions" 8 | 9 | "net.kopias.oscbridge/app/usecase/usecaseifs" 10 | ) 11 | 12 | var _ usecaseifs.IActionCondition = &OrCondition{} 13 | 14 | type OrCondition struct { 15 | path string 16 | children []usecaseifs.IActionCondition 17 | conditionTracker *osc_conditions.ConditionTracker 18 | } 19 | 20 | func NewFactory(conditionTracker *osc_conditions.ConditionTracker) usecaseifs.ActionConditionFactory { 21 | return func(path string) usecaseifs.IActionCondition { 22 | return &OrCondition{path: path, conditionTracker: conditionTracker} 23 | } 24 | } 25 | 26 | func (a *OrCondition) GetType() string { 27 | return "OR" 28 | } 29 | 30 | func (a *OrCondition) Evaluate(ctx context.Context, store usecaseifs.IMessageStore) (bool, error) { 31 | for i, child := range a.children { 32 | a.conditionTracker.Log(ctx, a.path, "Checking on child %d...", i) 33 | matched, err := child.Evaluate(ctx, store) 34 | if err != nil { 35 | return a.conditionTracker.R(ctx, false, a.path, "Child %d evaluation failed: %s", i, err.Error()), err 36 | } 37 | if matched { 38 | return a.conditionTracker.R(ctx, true, a.path, "Child %d returned true", i), nil 39 | } 40 | } 41 | return a.conditionTracker.R(ctx, false, a.path, "no child returned true"), nil 42 | } 43 | 44 | func (a *OrCondition) SetParameters(_ map[string]interface{}) { 45 | // noop 46 | } 47 | 48 | func (a *OrCondition) AddChild(condition usecaseifs.IActionCondition) { 49 | a.children = append(a.children, condition) 50 | } 51 | 52 | func (a *OrCondition) Validate() error { 53 | if len(a.children) == 0 { 54 | return fmt.Errorf("%s: this node has no children", a.GetType()) 55 | } 56 | 57 | for _, child := range a.children { 58 | if err := child.Validate(); err != nil { 59 | return fmt.Errorf("OR failed to validate it's children: %w", err) 60 | } 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /src/drivers/osc_conditions/cond_osc_msg_match/osc_message_match.go: -------------------------------------------------------------------------------- 1 | package cond_osc_msg_match 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "net.kopias.oscbridge/app/drivers/osc_conditions" 10 | 11 | "net.kopias.oscbridge/app/drivers/paramsanitizer" 12 | 13 | "net.kopias.oscbridge/app/usecase/usecaseifs" 14 | ) 15 | 16 | var _ usecaseifs.IActionCondition = &OSCCondition{} 17 | 18 | const ( 19 | AddressKey = "address" 20 | AddressMatchTypeKey = "address_match_type" 21 | ArgumentsKey = "arguments" 22 | TriggerOnChangeKey = "trigger_on_change" 23 | 24 | AddressMatchTypeEq = "eq" 25 | AddressMatchTypeRegexp = "regexp" 26 | 27 | ArgIndexKey = "index" 28 | ArgTypeKey = "type" 29 | ArgValueKey = "value" 30 | ArgValueMatchTypeKey = "value_match_type" 31 | 32 | ValueMatchTypeRegexp = "regexp" 33 | ValueMatchTypeEq = "=" 34 | ValueMatchTypeLTE = "<=" 35 | ValueMatchTypeGTE = ">=" 36 | ValueMatchTypeLT = "<" 37 | ValueMatchTypeGT = ">" 38 | ValueMatchTypeNOT = "!=" 39 | // @TODO ADD MOD 40 | ) 41 | 42 | type argumentCondition struct { 43 | index int 44 | variableType string 45 | variableValue string 46 | variableValueRegexp *regexp.Regexp 47 | variableValueMatchType string 48 | } 49 | 50 | func (ac argumentCondition) String() string { 51 | return fmt.Sprintf("ArgumentCondition(type: %s, value: %s, matchType: %s)", ac.variableType, ac.variableValue, ac.variableValueMatchType) 52 | } 53 | 54 | // OSCCondition matches an entire OSC Message by address and arguments if applicable. 55 | type OSCCondition struct { 56 | path string 57 | children []usecaseifs.IActionCondition 58 | 59 | configError error 60 | 61 | addressPattern string 62 | 63 | addressMatchType string 64 | argumentPatterns []argumentCondition 65 | triggerOnChange bool 66 | conditionTracker *osc_conditions.ConditionTracker 67 | } 68 | 69 | func NewFactory(conditionTracker *osc_conditions.ConditionTracker) usecaseifs.ActionConditionFactory { 70 | return func(path string) usecaseifs.IActionCondition { 71 | return &OSCCondition{path: path, conditionTracker: conditionTracker} 72 | } 73 | } 74 | 75 | func (a *OSCCondition) SetParameters(m map[string]interface{}) { 76 | sanitized, err := paramsanitizer.SanitizeParams(m, []paramsanitizer.ParameterDefinition{ 77 | { 78 | Name: AddressKey, 79 | Optional: false, 80 | DefaultValue: nil, 81 | Type: []string{"string"}, 82 | }, { 83 | Name: AddressMatchTypeKey, 84 | Optional: true, 85 | DefaultValue: AddressMatchTypeEq, 86 | ValuePattern: fmt.Sprintf("^%s|%s$", AddressMatchTypeEq, AddressMatchTypeRegexp), 87 | Type: []string{"string"}, 88 | }, { 89 | Name: TriggerOnChangeKey, 90 | Optional: true, 91 | DefaultValue: true, 92 | Type: []string{"bool"}, 93 | }, { 94 | Name: ArgumentsKey, 95 | Optional: true, 96 | Type: []string{"[]interface {}"}, 97 | }, 98 | }) 99 | if err != nil { 100 | a.configError = fmt.Errorf("%s failed to verify parameters: %w", a.path, err) 101 | return 102 | } 103 | 104 | // nolint:forcetypeassert 105 | a.addressPattern = sanitized[AddressKey].(string) 106 | 107 | // nolint:forcetypeassert 108 | a.addressMatchType = sanitized[AddressMatchTypeKey].(string) 109 | 110 | // nolint:forcetypeassert 111 | a.triggerOnChange = sanitized[TriggerOnChangeKey].(bool) 112 | 113 | if sanitized[AddressMatchTypeKey] == AddressMatchTypeRegexp { 114 | _, err = regexp.Compile(sanitized[AddressKey].(string)) 115 | if err != nil { 116 | a.configError = fmt.Errorf("%s is not a valid regexp: %w", AddressKey, err) 117 | return 118 | } 119 | } 120 | 121 | args, ok := sanitized[ArgumentsKey] 122 | if !ok { 123 | a.configError = fmt.Errorf("key %s was not found", ArgumentsKey) 124 | return 125 | } 126 | // nolint:forcetypeassert 127 | argsSlice := args.([]interface{}) 128 | 129 | for i, argParams := range argsSlice { 130 | err = a.setArgumentParameters(argParams) 131 | if err != nil { 132 | a.configError = fmt.Errorf("%s failed to verify parameters: Agrgument[%d]: %w", a.path, i, err) 133 | return 134 | } 135 | } 136 | } 137 | 138 | func (a *OSCCondition) setArgumentParameters(m interface{}) error { 139 | mCasted, ok := m.(map[string]interface{}) 140 | if !ok { 141 | return fmt.Errorf("failed to cast supplied arguments") 142 | } 143 | sanitized, err := paramsanitizer.SanitizeParams(mCasted, []paramsanitizer.ParameterDefinition{ 144 | { 145 | Name: ArgIndexKey, 146 | Optional: false, 147 | Type: []string{"int"}, 148 | }, 149 | { 150 | Name: ArgTypeKey, 151 | Optional: false, 152 | ValuePattern: "^string|int|float$", // @TODO this doesn't work... int32 is accepted. 153 | Type: []string{"string"}, 154 | }, 155 | { 156 | Name: ArgValueKey, 157 | Optional: false, 158 | Type: []string{"string"}, 159 | }, 160 | { 161 | Name: ArgValueMatchTypeKey, 162 | Optional: true, 163 | DefaultValue: ValueMatchTypeEq, 164 | ValuePattern: fmt.Sprintf("^%s$", strings.Join([]string{ 165 | ValueMatchTypeEq, 166 | ValueMatchTypeRegexp, 167 | ValueMatchTypeLTE, 168 | ValueMatchTypeGTE, 169 | ValueMatchTypeLT, 170 | ValueMatchTypeGT, 171 | ValueMatchTypeNOT, 172 | }, "|")), 173 | Type: []string{"string"}, 174 | }, 175 | }) 176 | if err != nil { 177 | return err 178 | } 179 | 180 | newArgCondition := argumentCondition{} 181 | 182 | // nolint:forcetypeassert 183 | newArgCondition.index = sanitized[ArgIndexKey].(int) 184 | // nolint:forcetypeassert 185 | newArgCondition.variableType = sanitized[ArgTypeKey].(string) 186 | // nolint:forcetypeassert 187 | newArgCondition.variableValue = sanitized[ArgValueKey].(string) 188 | // nolint:forcetypeassert 189 | newArgCondition.variableValueMatchType = sanitized[ArgValueMatchTypeKey].(string) 190 | 191 | if newArgCondition.variableValueMatchType == ValueMatchTypeRegexp { 192 | newArgCondition.variableValueRegexp, err = regexp.Compile(newArgCondition.variableValue) 193 | if err != nil { 194 | return fmt.Errorf("failed to compile value regexp: %s: %w", newArgCondition.variableValue, err) 195 | } 196 | } 197 | 198 | a.argumentPatterns = append(a.argumentPatterns, newArgCondition) 199 | return nil 200 | } 201 | 202 | func (a *OSCCondition) GetType() string { 203 | return "MATCH" 204 | } 205 | 206 | func (a *OSCCondition) Evaluate(ctx context.Context, store usecaseifs.IMessageStore) (bool, error) { 207 | var err error 208 | var record usecaseifs.IMessageStoreRecord 209 | var found bool 210 | 211 | if a.addressMatchType == AddressMatchTypeRegexp { 212 | record, err = store.GetOneRecordByRegexp(a.addressPattern, a.triggerOnChange) 213 | if err != nil { 214 | err = fmt.Errorf("failed to get record by regexp: %w", err) 215 | return a.conditionTracker.R(ctx, false, a.path, err.Error()), err 216 | } 217 | if record == nil { 218 | return a.conditionTracker.R(ctx, false, a.path, "record not found by regexp on address: %s", a.addressPattern), nil 219 | } 220 | } else { 221 | record, found = store.GetRecord(a.addressPattern, a.triggerOnChange) 222 | if !found { 223 | return a.conditionTracker.R(ctx, false, a.path, "record not found by exact match: %s", a.addressPattern), nil 224 | } 225 | } 226 | 227 | var matched bool 228 | for i, ap := range a.argumentPatterns { 229 | matched, err = a.matchArguments(record, ap) 230 | if err != nil { 231 | err = fmt.Errorf("failed to match argument[%d]: %w", i, err) 232 | return a.conditionTracker.R(ctx, false, a.path, err.Error()), err 233 | } 234 | if !matched { 235 | return a.conditionTracker.R(ctx, false, a.path, "argument %d did not match '%s'", i, ap.String()), nil 236 | } 237 | } 238 | return a.conditionTracker.R(ctx, true, a.path, "all checks passed"), nil 239 | } 240 | 241 | // nolint: unparam,cyclop 242 | func (a *OSCCondition) matchArguments(record usecaseifs.IMessageStoreRecord, ac argumentCondition) (bool, error) { 243 | // If it has no argument with the specified index 244 | if len(record.GetMessage().GetArguments())-1 < ac.index { 245 | return false, nil 246 | } 247 | arg := record.GetMessage().GetArguments()[ac.index] 248 | 249 | if arg.GetType() != ac.variableType { 250 | return false, nil 251 | } 252 | 253 | switch ac.variableValueMatchType { 254 | case ValueMatchTypeEq: 255 | if arg.GetValue() != ac.variableValue { 256 | return false, nil 257 | } 258 | case ValueMatchTypeRegexp: 259 | if !ac.variableValueRegexp.MatchString(arg.GetValue()) { 260 | return false, nil 261 | } 262 | case ValueMatchTypeLTE: 263 | if arg.GetValue() > ac.variableValue { 264 | return false, nil 265 | } 266 | case ValueMatchTypeGTE: 267 | if arg.GetValue() < ac.variableValue { 268 | return false, nil 269 | } 270 | case ValueMatchTypeLT: 271 | if arg.GetValue() >= ac.variableValue { 272 | return false, nil 273 | } 274 | case ValueMatchTypeGT: 275 | if arg.GetValue() <= ac.variableValue { 276 | return false, nil 277 | } 278 | case ValueMatchTypeNOT: 279 | if arg.GetValue() == ac.variableValue { 280 | return false, nil 281 | } 282 | } 283 | 284 | return true, nil 285 | } 286 | 287 | func (a *OSCCondition) AddChild(condition usecaseifs.IActionCondition) { 288 | a.children = append(a.children, condition) 289 | } 290 | 291 | func (a *OSCCondition) Validate() error { 292 | if len(a.children) != 0 { 293 | return fmt.Errorf("this node can not have children") 294 | } 295 | if a.configError != nil { 296 | return a.configError 297 | } 298 | 299 | return nil 300 | } 301 | -------------------------------------------------------------------------------- /src/drivers/osc_conditions/utils.go: -------------------------------------------------------------------------------- 1 | package osc_conditions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "net.kopias.oscbridge/app/usecase/usecaseifs" 9 | ) 10 | 11 | // ConditionTracker is a utility for tracking condition calls and debug why something matched or not. 12 | type ConditionTracker struct { 13 | log usecaseifs.ILogger 14 | enabled bool 15 | } 16 | 17 | func NewConditionTracker(log usecaseifs.ILogger, enabled bool) *ConditionTracker { 18 | return &ConditionTracker{log: log, enabled: enabled} 19 | } 20 | 21 | func (ct *ConditionTracker) Log(ctx context.Context, prefix string, message string, args ...interface{}) { 22 | if ct.enabled { 23 | ct.log.Debugf(ctx, prefix+" "+message, args...) 24 | } 25 | } 26 | 27 | func (ct *ConditionTracker) R(ctx context.Context, ret bool, prefix string, message string, args ...interface{}) bool { 28 | ct.Log(ctx, prefix, "returned %s because %s.", strings.ToUpper(fmt.Sprintf("%t", ret)), fmt.Sprintf(message, args...)) 29 | return ret 30 | } 31 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/console_bridge_l/console_bridge_l.go: -------------------------------------------------------------------------------- 1 | package console_bridge_l 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "time" 8 | 9 | "net.kopias.oscbridge/app/drivers/osc_message" 10 | 11 | "net.kopias.oscbridge/app/pkg/chantools" 12 | 13 | "net.kopias.oscbridge/app/adapters/config" 14 | 15 | "github.com/loffa/gosc" 16 | "net.kopias.oscbridge/app/usecase/usecaseifs" 17 | ) 18 | 19 | // This implementation uses loffa/gosc, which is lacking disconnect options. 20 | 21 | var _ usecaseifs.IOSCConnection = &Connection{} 22 | 23 | type Config struct { 24 | Debug bool 25 | Subscriptions []config.ConsoleSubscription 26 | Port int64 27 | Host string 28 | CheckAddress string 29 | CheckPattern string 30 | } 31 | 32 | type Connection struct { 33 | cfg Config 34 | client *gosc.Client 35 | messages chan usecaseifs.IOSCMessage 36 | log usecaseifs.ILogger 37 | 38 | // Signals that the client is stopped. 39 | quit chan any 40 | 41 | // A channel that shows when the client exited with an error. 42 | notify chan error 43 | } 44 | 45 | func NewConnection(log usecaseifs.ILogger, cfg Config) usecaseifs.IOSCConnection { 46 | return &Connection{ 47 | log: log, 48 | cfg: cfg, 49 | quit: make(chan any), 50 | messages: make(chan usecaseifs.IOSCMessage), 51 | notify: make(chan error, 1), 52 | } 53 | } 54 | 55 | func (c *Connection) Start(ctx context.Context) error { 56 | // Set up the client. 57 | client, err := gosc.NewClient(fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port)) 58 | if err != nil { 59 | return fmt.Errorf("failed to resolve udp addr: %w", err) 60 | } 61 | 62 | c.client = client 63 | 64 | err = c.client.ReceiveMessageFunc(".*", func(oscMessage *gosc.Message) { 65 | msg, err2 := MessageFromOSCMessage(*oscMessage) 66 | if err2 != nil { 67 | c.log.Err(ctx, err2) 68 | } 69 | if c.cfg.Debug { 70 | c.log.Infof(ctx, "Received message: %v", msg) 71 | } 72 | c.messages <- msg 73 | }) 74 | 75 | if err != nil { 76 | return fmt.Errorf("failed to register dispatcher: %w", err) 77 | } 78 | 79 | go c.watchdog(ctx) 80 | go c.manageSubscriptions(ctx) 81 | 82 | return nil 83 | } 84 | 85 | func (c *Connection) manageSubscriptions(ctx context.Context) { 86 | // Initial execution & ticker setup for repeating subscriptions as they time out. 87 | tickers := []*time.Ticker{} 88 | for i, sub := range c.cfg.Subscriptions { 89 | c.executeSub(ctx, i) 90 | t := time.NewTicker(time.Duration(sub.RepeatMillis) * time.Millisecond) 91 | tickers = append(tickers, t) 92 | } 93 | 94 | // Stop the tickers when returning... 95 | defer func() { 96 | for _, ticker := range tickers { 97 | ticker.Stop() 98 | } 99 | }() 100 | 101 | // Check on all the tickers and refresh subscriptions... 102 | for { 103 | for i, ticker := range tickers { 104 | select { 105 | case <-ticker.C: 106 | c.executeSub(ctx, i) 107 | case <-c.quit: 108 | return 109 | default: 110 | } 111 | } 112 | 113 | // Wait a bit and restart 114 | <-time.After(100 * time.Millisecond) 115 | } 116 | } 117 | 118 | // executeSub sends the configured OSC message to signal to the console that we are interested in certain updates. 119 | func (c *Connection) executeSub(ctx context.Context, i int) { 120 | sub := c.cfg.Subscriptions[i] 121 | if c.cfg.Debug { 122 | c.log.Infof(ctx, "Subscribing to: %v (%s)", sub, sub.OSCCommand.Comment) 123 | } 124 | 125 | msg := &gosc.Message{ 126 | Address: sub.OSCCommand.Address, 127 | Arguments: []any{}, 128 | } 129 | 130 | for _, a := range sub.OSCCommand.Arguments { 131 | var oscArgument usecaseifs.IOSCMessageArgument = osc_message.NewMessageArgument(a.Type, a.Value) 132 | 133 | oscMsgArg, err := OSCArgumentFromMessageArgument(oscArgument) 134 | if err != nil { 135 | c.log.Err(ctx, err) 136 | return 137 | } 138 | msg.Arguments = append(msg.Arguments, oscMsgArg) 139 | } 140 | 141 | err := c.client.SendMessage(msg) 142 | if err != nil { 143 | c.log.Err(ctx, fmt.Errorf("failed to subscribe/check subscription[%d] %v: %w", i, sub.OSCCommand, err)) 144 | } 145 | } 146 | 147 | func (c *Connection) watchdog(ctx context.Context) { 148 | for { 149 | if c.cfg.Debug { 150 | c.log.Infof(ctx, "OSC conn checking connection...") 151 | 152 | // The checkConnection should finish in 10seconds, or we emit an error signalling that this connection is dead. 153 | stop := make(chan any, 1) 154 | 155 | go func() { 156 | select { 157 | case <-time.After(10 * time.Second): 158 | c.notify <- fmt.Errorf("timeout without check connection response") 159 | case <-stop: 160 | return 161 | } 162 | }() 163 | 164 | err := c.checkConnection(ctx) 165 | stop <- true 166 | if err != nil { 167 | c.notify <- fmt.Errorf("osc connection is broken: %w", err) 168 | c.Stop(ctx) 169 | } 170 | 171 | // Wait on stop to finish, or retry... 172 | select { 173 | case <-c.quit: 174 | return 175 | case <-time.After(5 * time.Second): 176 | } 177 | } 178 | } 179 | } 180 | 181 | // CheckConnection sends a check OSC Message for which some response is expected. 182 | // The resulting response's first argument will be matched against a pattern. 183 | func (c *Connection) checkConnection(ctx context.Context) error { 184 | msg, err := OSCMessageFromMessage(osc_message.NewMessage(c.cfg.CheckAddress, nil)) 185 | if err != nil { 186 | return fmt.Errorf("failed to convert check message: %w", err) 187 | } 188 | resp, err := c.client.SendAndReceiveMessage(msg) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | if len(resp.Arguments) == 0 { 194 | return fmt.Errorf("the received message has no arguments to check") 195 | } 196 | 197 | p, err := regexp.Compile(c.cfg.CheckPattern) 198 | if err != nil { 199 | return fmt.Errorf("failed to compile check pattern: %w", err) 200 | } 201 | 202 | if !p.MatchString(fmt.Sprintf("%v", resp.Arguments[0])) { 203 | return fmt.Errorf("failed to match '%s' against '%v'", c.cfg.CheckPattern, resp.Arguments[0]) 204 | } 205 | 206 | return nil 207 | } 208 | 209 | // Notify returns the notification channel that can be used to listen for the client's exit 210 | func (c *Connection) Notify() <-chan error { 211 | return c.notify 212 | } 213 | 214 | func (c *Connection) Stop(ctx context.Context) { 215 | if chantools.ChanIsOpenReader(c.quit) { 216 | close(c.quit) 217 | } 218 | } 219 | 220 | func (c *Connection) GetEventChan(ctx context.Context) <-chan usecaseifs.IOSCMessage { 221 | return c.messages 222 | } 223 | 224 | func (c *Connection) SendMessage(ctx context.Context, msg usecaseifs.IOSCMessage) error { 225 | if c.cfg.Debug { 226 | c.log.Infof(ctx, "Sending message %v", msg) 227 | } 228 | pkg, err := OSCMessageFromMessage(msg) 229 | if err != nil { 230 | return err 231 | } 232 | return c.client.SendMessage(pkg) 233 | } 234 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/console_bridge_l/message.go: -------------------------------------------------------------------------------- 1 | package console_bridge_l 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/loffa/gosc" 8 | "net.kopias.oscbridge/app/drivers/osc_message" 9 | 10 | "net.kopias.oscbridge/app/usecase/usecaseifs" 11 | ) 12 | 13 | // MessageFromOSCMessage converts the internal gosc Message to an IOSCMessage. 14 | func MessageFromOSCMessage(oscMsg gosc.Message) (usecaseifs.IOSCMessage, error) { 15 | arguments := []usecaseifs.IOSCMessageArgument{} 16 | 17 | for i, oscArg := range oscMsg.Arguments { 18 | arg, err := MessageArgumentFromOSCArgument(oscArg) 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to convert arg %d: %w", i, err) 21 | } 22 | arguments = append(arguments, arg) 23 | } 24 | 25 | return osc_message.NewMessage(oscMsg.Address, arguments), nil 26 | } 27 | 28 | // OSCMessageFromMessage converts an IOSCMessage into the internal gosc Message. 29 | func OSCMessageFromMessage(msg usecaseifs.IOSCMessage) (*gosc.Message, error) { 30 | oscMessage := gosc.Message{} 31 | oscMessage.Address = msg.GetAddress() 32 | 33 | arguments := []any{} 34 | 35 | for _, msgArg := range msg.GetArguments() { 36 | oscArg, err := OSCArgumentFromMessageArgument(msgArg) 37 | if err != nil { 38 | return nil, err 39 | } 40 | arguments = append(arguments, oscArg) 41 | } 42 | 43 | oscMessage.Arguments = arguments 44 | return &oscMessage, nil 45 | } 46 | 47 | // MessageArgumentFromOSCArgument converts the internal gosc Message to an IOSCMessageArgument. 48 | func MessageArgumentFromOSCArgument(arg any) (usecaseifs.IOSCMessageArgument, error) { 49 | msgType := fmt.Sprintf("%T", arg) 50 | var msgValue string 51 | switch t := arg.(type) { 52 | case int32: 53 | msgValue = fmt.Sprintf("%d", t) 54 | case float32: 55 | msgValue = fmt.Sprintf("%f", t) 56 | case string: 57 | msgValue = t 58 | default: 59 | return nil, fmt.Errorf("response type %T is not supported", arg) 60 | } 61 | 62 | return osc_message.NewMessageArgument(msgType, msgValue), nil 63 | } 64 | 65 | // OSCArgumentFromMessageArgument converts an IOSCMessageArgument into the internal gosc MessageArgument (any). 66 | func OSCArgumentFromMessageArgument(arg usecaseifs.IOSCMessageArgument) (any, error) { 67 | var v any 68 | switch arg.GetType() { 69 | case "int32": 70 | i64, err := strconv.ParseInt(arg.GetValue(), 10, 32) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to convert message argument to int32, it should be %s but seems to be %T", arg.GetType(), arg.GetValue()) 73 | } 74 | v = int32(i64) 75 | case "float32": 76 | f64, err := strconv.ParseFloat(arg.GetValue(), 32) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to convert message argument to int32, it should be %s but seems to be %T", arg.GetType(), arg.GetValue()) 79 | } 80 | v = float32(f64) 81 | case "string": 82 | v = arg.GetValue() 83 | default: 84 | return nil, fmt.Errorf("argument type %T is not supported", arg) 85 | } 86 | return v, nil 87 | } 88 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/dummy_bridge/dummy_bridge.go: -------------------------------------------------------------------------------- 1 | package dummy_bridge 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "net.kopias.oscbridge/app/drivers/osc_message" 8 | 9 | "net.kopias.oscbridge/app/adapters/config" 10 | 11 | "net.kopias.oscbridge/app/usecase/usecaseifs" 12 | ) 13 | 14 | var _ usecaseifs.IOSCConnection = &Connection{} 15 | 16 | // Connection acts as a dummy mixer console, that emits pre-configured recurring messages in groups, imitating changing scenarios, enabling testing of actions. 17 | type Connection struct { 18 | messages chan usecaseifs.IOSCMessage 19 | log usecaseifs.ILogger 20 | mockSettings config.DummyConnection 21 | 22 | // A channel that shows when the client exited with an error. 23 | notify chan error 24 | quit chan interface{} 25 | debug bool 26 | } 27 | 28 | func NewConnection(log usecaseifs.ILogger, mockSettings config.DummyConnection, debug bool) *Connection { 29 | return &Connection{ 30 | log: log, 31 | mockSettings: mockSettings, 32 | debug: debug, 33 | notify: make(chan error, 1), 34 | quit: make(chan interface{}, 1), 35 | messages: make(chan usecaseifs.IOSCMessage, 1), 36 | } 37 | } 38 | 39 | func (c *Connection) Start(ctx context.Context) error { 40 | if len(c.mockSettings.MessageGroups) == 0 { 41 | c.log.Warn(ctx, "No message_groups were specified!") 42 | return nil 43 | } 44 | go c.clientDispatch(ctx) 45 | 46 | return nil 47 | } 48 | 49 | func (c *Connection) clientDispatch(ctx context.Context) { 50 | changeTicker := time.NewTicker(time.Duration(c.mockSettings.IterationSpeedSecs) * time.Second) 51 | defer changeTicker.Stop() 52 | round := 0 53 | 54 | time.Sleep(1 * time.Second) 55 | 56 | for { 57 | index := round % len(c.mockSettings.MessageGroups) 58 | currentGroup := c.mockSettings.MessageGroups[index] 59 | c.log.Infof(ctx, "Simulating console state: %s %s", currentGroup.Name, currentGroup.Comment) 60 | 61 | for _, command := range currentGroup.OSCCommands { 62 | args := []usecaseifs.IOSCMessageArgument{} 63 | for _, a := range command.Arguments { 64 | args = append(args, osc_message.NewMessageArgument(a.Type, a.Value)) 65 | } 66 | msg := osc_message.NewMessage(command.Address, args) 67 | c.messages <- msg 68 | } 69 | 70 | round++ 71 | 72 | select { 73 | case <-c.quit: 74 | c.log.Info(ctx, "Dummy implementation quiting...") 75 | return 76 | case <-changeTicker.C: 77 | } 78 | } 79 | } 80 | 81 | // Notify returns the notification channel that can be used to listen for the client's exit 82 | func (c *Connection) Notify() <-chan error { 83 | return c.notify 84 | } 85 | 86 | func (c *Connection) Stop(ctx context.Context) { 87 | c.quit <- true 88 | } 89 | 90 | func (c *Connection) GetEventChan(ctx context.Context) <-chan usecaseifs.IOSCMessage { 91 | return c.messages 92 | } 93 | 94 | func (c *Connection) SendMessage(ctx context.Context, msg usecaseifs.IOSCMessage) error { 95 | c.log.Infof(ctx, "Sending message: %v", msg) 96 | c.messages <- msg 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/dummy_bridge/message.go: -------------------------------------------------------------------------------- 1 | package dummy_bridge 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/scgolang/osc" 8 | "net.kopias.oscbridge/app/drivers/osc_message" 9 | "net.kopias.oscbridge/app/usecase/usecaseifs" 10 | ) 11 | 12 | const ( 13 | ArgTypeInt = "int" 14 | ArgTypeFloat = "float" 15 | ArgTypeBool = "bool" 16 | ArgTypeString = "string" 17 | ) 18 | 19 | // MessageFromOSCMessage converts the internal osc Message to an IOSCMessage. 20 | func MessageFromOSCMessage(oscMsg osc.Message) (usecaseifs.IOSCMessage, error) { 21 | arguments := []usecaseifs.IOSCMessageArgument{} 22 | 23 | for i, oscArg := range oscMsg.Arguments { 24 | arg, err := MessageArgumentFromOSCArgument(oscArg) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to convert arg %d: %w", i, err) 27 | } 28 | arguments = append(arguments, arg) 29 | } 30 | 31 | return osc_message.NewMessage(oscMsg.Address, arguments), nil 32 | } 33 | 34 | // OSCMessageFromMessage converts an IOSCMessage into the internal osc Message. 35 | func OSCMessageFromMessage(msg usecaseifs.IOSCMessage) (*osc.Message, error) { 36 | oscMessage := osc.Message{} 37 | oscMessage.Address = msg.GetAddress() 38 | 39 | arguments := []osc.Argument{} 40 | 41 | for _, msgArg := range msg.GetArguments() { 42 | oscArg, err := OSCArgumentFromMessageArgument(msgArg) 43 | if err != nil { 44 | return nil, err 45 | } 46 | arguments = append(arguments, oscArg) 47 | } 48 | 49 | oscMessage.Arguments = arguments 50 | return &oscMessage, nil 51 | } 52 | 53 | // MessageArgumentFromOSCArgument converts the internal osc Message to an IOSCMessageArgument. 54 | func MessageArgumentFromOSCArgument(arg osc.Argument) (usecaseifs.IOSCMessageArgument, error) { 55 | var msgType string 56 | var msgValue string 57 | var err error 58 | switch arg.Typetag() { 59 | case osc.TypetagInt: 60 | msgType = ArgTypeInt 61 | intValue, err := arg.ReadInt32() 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to read Int32 argument: %w", err) 64 | } 65 | msgValue = string(intValue) 66 | 67 | case osc.TypetagFloat: 68 | msgType = ArgTypeFloat 69 | floatValue, err := arg.ReadFloat32() 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to read float32 argument: %w", err) 72 | } 73 | msgValue = fmt.Sprintf("%f", floatValue) 74 | 75 | case osc.TypetagTrue: 76 | msgType = ArgTypeBool 77 | msgValue = "true" 78 | 79 | case osc.TypetagFalse: 80 | msgType = ArgTypeBool 81 | msgValue = "false" 82 | 83 | case osc.TypetagString: 84 | msgType = ArgTypeString 85 | msgValue, err = arg.ReadString() 86 | if err != nil { 87 | return nil, fmt.Errorf("failed to read string argument: %w", err) 88 | } 89 | // case osc.TypetagBlob: 90 | default: 91 | return nil, fmt.Errorf("unsupported type: %q", string(arg.Typetag())) 92 | } 93 | return osc_message.NewMessageArgument(msgType, msgValue), nil 94 | } 95 | 96 | // OSCArgumentFromMessageArgument converts an IOSCMessageArgument into the internal osc MessageArgument (any). 97 | func OSCArgumentFromMessageArgument(arg usecaseifs.IOSCMessageArgument) (osc.Argument, error) { 98 | switch arg.GetType() { 99 | case ArgTypeInt: 100 | intVal, err := strconv.ParseInt(arg.GetValue(), 10, 32) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to convert string to int32: %w", err) 103 | } 104 | return osc.Int(intVal), nil 105 | 106 | case ArgTypeFloat: 107 | floatVal, err := strconv.ParseFloat(arg.GetValue(), 32) 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to convert string to float32: %w", err) 110 | } 111 | return osc.Float(floatVal), nil 112 | 113 | case ArgTypeBool: 114 | if arg.GetValue() == "true" { 115 | return osc.Bool(true), nil 116 | } 117 | return osc.Bool(false), nil 118 | 119 | case ArgTypeString: 120 | return osc.String(arg.GetValue()), nil 121 | 122 | // case "blob": 123 | // osc.TypetagBlob: 124 | 125 | default: 126 | return nil, fmt.Errorf("unsupported type: %s", arg.GetType()) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/http_bridge/http_bridge.go: -------------------------------------------------------------------------------- 1 | package http_bridge 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "net.kopias.oscbridge/app/drivers/osc_message" 13 | 14 | "net.kopias.oscbridge/app/pkg/chantools" 15 | 16 | "net.kopias.oscbridge/app/usecase/usecaseifs" 17 | ) 18 | 19 | // This implementation uses loffa/gosc , which is lacking disconnect options. 20 | 21 | var _ usecaseifs.IOSCConnection = &HTTPBridge{} 22 | 23 | type Config struct { 24 | Debug bool 25 | Host string 26 | Port int64 27 | } 28 | 29 | // HTTPBridge starts an HTTP server receiving GET requests and converts them to osc messages. 30 | type HTTPBridge struct { 31 | log usecaseifs.ILogger 32 | messages chan usecaseifs.IOSCMessage 33 | // A channel that shows when the client exited with an error. 34 | quit chan any 35 | cfg Config 36 | notify chan error 37 | mux *http.ServeMux 38 | srv *http.Server 39 | } 40 | 41 | func NewHTTPBridge(log usecaseifs.ILogger, cfg Config) usecaseifs.IOSCConnection { 42 | return &HTTPBridge{ 43 | log: log, 44 | cfg: cfg, 45 | quit: make(chan any), 46 | messages: make(chan usecaseifs.IOSCMessage, 1), 47 | notify: make(chan error, 1), 48 | } 49 | } 50 | 51 | func (c *HTTPBridge) Start(ctx context.Context) error { 52 | c.mux = http.NewServeMux() 53 | c.mux.HandleFunc("/", c.getRoot) 54 | 55 | go c.run(ctx) 56 | return nil 57 | } 58 | 59 | func (c *HTTPBridge) run(ctx context.Context) { 60 | c.log.Infof(ctx, "Starting server at port %s:%d", c.cfg.Host, c.cfg.Port) 61 | c.srv = &http.Server{ 62 | Addr: fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port), 63 | Handler: c.mux, 64 | ReadTimeout: 5 * time.Second, 65 | WriteTimeout: 10 * time.Second, 66 | BaseContext: func(listener net.Listener) context.Context { 67 | return ctx 68 | }, 69 | } 70 | 71 | if err := c.srv.ListenAndServe(); err != nil { 72 | c.notify <- err 73 | c.Stop(ctx) 74 | } 75 | } 76 | 77 | // getRoot is the handler for '/', converts GET requests to OSC messages. 78 | // The 'address' query parameter will be the address. 79 | // The 'args[]' array will become a list of arguments. 80 | // 81 | // Each value must be in the format: 'type,value' 82 | func (c *HTTPBridge) getRoot(w http.ResponseWriter, r *http.Request) { 83 | // Parse the query parameters 84 | queryParams := r.URL.Query() 85 | 86 | // Get the value of the 'address' parameter 87 | address := queryParams.Get("address") 88 | 89 | if address == "" { 90 | err := fmt.Errorf("invalid address: '%s'", address) 91 | c.badRequest(r.Context(), w, err) 92 | return 93 | } 94 | // Get the values of the 'args' parameter as an array 95 | args := queryParams["args[]"] 96 | 97 | // Process the values 98 | // response := fmt.Sprintf("Address: %s\nArgs: %s", address, strings.Join(args, ", ")) 99 | 100 | oscMsgArgs := []usecaseifs.IOSCMessageArgument{} 101 | for i, arg := range args { 102 | before, after, found := strings.Cut(arg, ",") 103 | if !found { 104 | err := fmt.Errorf("invalid request: call argument[%d] does not contain a comma", i) 105 | c.badRequest(r.Context(), w, err) 106 | return 107 | } 108 | oscMsgArgs = append(oscMsgArgs, osc_message.NewMessageArgument(before, after)) 109 | } 110 | oscMsg := osc_message.NewMessage(address, oscMsgArgs) 111 | 112 | if _, err := io.WriteString(w, "OK"); err != nil { 113 | c.log.Err(r.Context(), fmt.Errorf("failed to respond to request: %w", err)) 114 | } 115 | c.messages <- oscMsg 116 | } 117 | 118 | func (c *HTTPBridge) badRequest(ctx context.Context, w http.ResponseWriter, err error) { 119 | w.Header().Set("Content-Type", "text/plain") 120 | w.WriteHeader(http.StatusBadRequest) 121 | if _, err := io.WriteString(w, err.Error()); err != nil { 122 | c.log.Err(ctx, fmt.Errorf("failed to respond to request: %w", err)) 123 | } 124 | } 125 | 126 | // Notify returns the notification channel that can be used to listen for the client's exit 127 | func (c *HTTPBridge) Notify() <-chan error { 128 | return c.notify 129 | } 130 | 131 | func (c *HTTPBridge) Stop(ctx context.Context) { 132 | _ = c.srv.Shutdown(context.Background()) 133 | 134 | if chantools.ChanIsOpenReader(c.quit) { 135 | close(c.quit) 136 | } 137 | } 138 | 139 | func (c *HTTPBridge) GetEventChan(ctx context.Context) <-chan usecaseifs.IOSCMessage { 140 | return c.messages 141 | } 142 | 143 | func (c *HTTPBridge) SendMessage(ctx context.Context, msg usecaseifs.IOSCMessage) error { 144 | return fmt.Errorf("HTTPBridges does not support sending messages") 145 | } 146 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/obs_bridge/obs_bridge.go: -------------------------------------------------------------------------------- 1 | package obs_bridge 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/andreykaipov/goobs/api/events" 8 | "net.kopias.oscbridge/app/drivers/osc_message" 9 | 10 | "net.kopias.oscbridge/app/drivers/obsremote" 11 | "net.kopias.oscbridge/app/pkg/chantools" 12 | "net.kopias.oscbridge/app/usecase/usecaseifs" 13 | ) 14 | 15 | var _ usecaseifs.IOSCConnection = &OBSBridge{} 16 | 17 | type Config struct { 18 | Debug bool 19 | Connection *obsremote.OBSRemote 20 | } 21 | 22 | // OBSBridge uses a named OBS Connection, subscribes for certain events from OBS and emits an OSC Message when an update is received. 23 | type OBSBridge struct { 24 | log usecaseifs.ILogger 25 | messages chan usecaseifs.IOSCMessage 26 | // A channel that shows when the client exited with an error. 27 | quit chan any 28 | cfg Config 29 | notify chan error 30 | } 31 | 32 | func NewOBSBridge(log usecaseifs.ILogger, cfg Config) usecaseifs.IOSCConnection { 33 | return &OBSBridge{ 34 | log: log, 35 | cfg: cfg, 36 | quit: make(chan any), 37 | messages: make(chan usecaseifs.IOSCMessage, 10), 38 | notify: make(chan error, 1), 39 | } 40 | } 41 | 42 | func (c *OBSBridge) Start(ctx context.Context) error { 43 | if err := c.initialize(ctx); err != nil { 44 | return fmt.Errorf("failed to initialize: %w", err) 45 | } 46 | 47 | go c.listen(ctx) 48 | return nil 49 | } 50 | 51 | // Notify returns the notification channel that can be used to listen for the client's exit 52 | func (c *OBSBridge) Notify() <-chan error { 53 | return c.notify 54 | } 55 | 56 | func (c *OBSBridge) Stop(ctx context.Context) { 57 | if chantools.ChanIsOpenReader(c.quit) { 58 | close(c.quit) 59 | } 60 | } 61 | 62 | func (c *OBSBridge) GetEventChan(ctx context.Context) <-chan usecaseifs.IOSCMessage { 63 | return c.messages 64 | } 65 | 66 | func (c *OBSBridge) SendMessage(ctx context.Context, msg usecaseifs.IOSCMessage) error { 67 | return fmt.Errorf("OBSBridge does not support sending messages") 68 | } 69 | 70 | // initialize retrieves initial values for the subscribed events. 71 | func (c *OBSBridge) initialize(ctx context.Context) error { 72 | programScene, err := c.cfg.Connection.GetCurrentProgramScene(ctx) 73 | if err != nil { 74 | return err 75 | } 76 | c.handleObsEvent(ctx, &events.CurrentProgramSceneChanged{SceneName: programScene}) 77 | 78 | previewScene, err := c.cfg.Connection.GetCurrentPreviewScene(ctx) 79 | if err != nil { 80 | return err 81 | } 82 | c.handleObsEvent(ctx, &events.CurrentPreviewSceneChanged{SceneName: previewScene}) 83 | 84 | isStreaming, err := c.cfg.Connection.IsStreaming(ctx) 85 | if err != nil { 86 | return err 87 | } 88 | c.handleObsEvent(ctx, &events.StreamStateChanged{OutputActive: isStreaming}) 89 | 90 | isRecording, err := c.cfg.Connection.IsRecording(ctx) 91 | if err != nil { 92 | return err 93 | } 94 | c.handleObsEvent(ctx, &events.RecordStateChanged{OutputActive: isRecording}) 95 | 96 | return nil 97 | } 98 | 99 | // listen watches for incoming messages from OBS. 100 | func (c *OBSBridge) listen(ctx context.Context) { 101 | eventChan, err := c.cfg.Connection.GetIncomingEvents() 102 | if err != nil { 103 | c.notify <- fmt.Errorf("failed to open incoming events channel for obs: %w", err) 104 | } 105 | for { 106 | select { 107 | case e := <-eventChan: 108 | c.handleObsEvent(ctx, e) 109 | case <-c.quit: 110 | return 111 | } 112 | } 113 | } 114 | 115 | // handleObsEvent filters the incoming events and converts the appropriate ones to OSC Messages. 116 | func (c *OBSBridge) handleObsEvent(ctx context.Context, event interface{}) { 117 | // c.log.Debugf(ctx, "INCOMING OBS EVENT: %#v", event) 118 | 119 | switch t := event.(type) { 120 | case *events.CurrentPreviewSceneChanged: 121 | args := []usecaseifs.IOSCMessageArgument{osc_message.NewMessageArgument("string", t.SceneName)} 122 | c.messages <- osc_message.NewMessage("/obs/preview_scene", args) 123 | 124 | case *events.CurrentProgramSceneChanged: 125 | args := []usecaseifs.IOSCMessageArgument{osc_message.NewMessageArgument("string", t.SceneName)} 126 | c.messages <- osc_message.NewMessage("/obs/program_scene", args) 127 | 128 | case *events.RecordStateChanged: 129 | active := "0" 130 | if t.OutputActive { 131 | active = "1" 132 | } 133 | args := []usecaseifs.IOSCMessageArgument{osc_message.NewMessageArgument("int32", active)} 134 | c.messages <- osc_message.NewMessage("/obs/recording", args) 135 | 136 | case *events.StreamStateChanged: 137 | active := "0" 138 | if t.OutputActive { 139 | active = "1" 140 | } 141 | args := []usecaseifs.IOSCMessageArgument{osc_message.NewMessageArgument("int32", active)} 142 | c.messages <- osc_message.NewMessage("/obs/streaming", args) 143 | 144 | default: 145 | // c.log.Debugf(ctx, "UNHANDLED INCOMING OBS EVENT: %#v", t) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/drivers/osc_connections/ticker/ticker.go: -------------------------------------------------------------------------------- 1 | package osc_ticker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "net.kopias.oscbridge/app/drivers/osc_message" 10 | 11 | "net.kopias.oscbridge/app/pkg/chantools" 12 | 13 | "net.kopias.oscbridge/app/usecase/usecaseifs" 14 | ) 15 | 16 | var _ usecaseifs.IOSCConnection = &Ticker{} 17 | 18 | type Config struct { 19 | Debug bool 20 | RefreshRateMillis int64 21 | } 22 | 23 | // Ticker just emits OSC Messages about the time. 24 | type Ticker struct { 25 | log usecaseifs.ILogger 26 | messages chan usecaseifs.IOSCMessage 27 | // A channel that shows when the client exited with an error. 28 | quit chan any 29 | cfg Config 30 | notify chan error 31 | } 32 | 33 | func NewTicker(log usecaseifs.ILogger, cfg Config) usecaseifs.IOSCConnection { 34 | return &Ticker{ 35 | log: log, 36 | cfg: cfg, 37 | quit: make(chan any), 38 | messages: make(chan usecaseifs.IOSCMessage, 1), 39 | notify: make(chan error, 1), 40 | } 41 | } 42 | 43 | func (c *Ticker) Start(ctx context.Context) error { 44 | go c.run(ctx) 45 | return nil 46 | } 47 | 48 | func (c *Ticker) run(ctx context.Context) { 49 | t := time.NewTicker(time.Duration(c.cfg.RefreshRateMillis) * time.Millisecond) 50 | defer t.Stop() 51 | 52 | for { 53 | select { 54 | case <-t.C: 55 | c.log.Debugf(ctx, "Updating time...") 56 | c.updateTime(ctx) 57 | case <-c.quit: 58 | return 59 | } 60 | } 61 | } 62 | 63 | // Notify returns the notification channel that can be used to listen for the client's exit 64 | func (c *Ticker) Notify() <-chan error { 65 | return c.notify 66 | } 67 | 68 | func (c *Ticker) Stop(ctx context.Context) { 69 | if chantools.ChanIsOpenReader(c.quit) { 70 | close(c.quit) 71 | } 72 | } 73 | 74 | func (c *Ticker) GetEventChan(ctx context.Context) <-chan usecaseifs.IOSCMessage { 75 | return c.messages 76 | } 77 | 78 | func (c *Ticker) SendMessage(ctx context.Context, msg usecaseifs.IOSCMessage) error { 79 | return fmt.Errorf("ticker does not support sending messages") 80 | } 81 | 82 | func (c *Ticker) updateTime(ctx context.Context) { 83 | c.log.Debugf(ctx, "Updating time...") 84 | rfcDateTime := time.Now().Format(time.RFC3339) 85 | 86 | c.messages <- osc_message.NewMessage("/time/rfc3339", []usecaseifs.IOSCMessageArgument{osc_message.NewMessageArgument("string", rfcDateTime)}) 87 | 88 | parts := strings.Split("2006,06,Jan,January,01,1,Mon,Monday,2,_2,02,__2,002,15,3,03,4,04,5,05,PM", ",") 89 | for _, part := range parts { 90 | formattedPart := time.Now().Format(part) 91 | c.messages <- osc_message.NewMessage("/time/parts/"+part, []usecaseifs.IOSCMessageArgument{osc_message.NewMessageArgument("string", formattedPart)}) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/drivers/osc_message/message.go: -------------------------------------------------------------------------------- 1 | package osc_message 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "net.kopias.oscbridge/app/usecase/usecaseifs" 8 | ) 9 | 10 | var _ usecaseifs.IOSCMessage = &Message{} 11 | 12 | // Message is the internal implementation of IOSCMessage 13 | type Message struct { 14 | address string 15 | arguments []usecaseifs.IOSCMessageArgument 16 | } 17 | 18 | func NewMessage(address string, arguments []usecaseifs.IOSCMessageArgument) *Message { 19 | return &Message{address: address, arguments: arguments} 20 | } 21 | 22 | func (m *Message) GetAddress() string { 23 | return m.address 24 | } 25 | 26 | func (m *Message) GetArguments() []usecaseifs.IOSCMessageArgument { 27 | return m.arguments 28 | } 29 | 30 | func (m *Message) Equal(msg usecaseifs.IOSCMessage) bool { 31 | if m.GetAddress() != msg.GetAddress() { 32 | return false 33 | } 34 | 35 | if len(m.arguments) != len(msg.GetArguments()) { 36 | return false 37 | } 38 | 39 | for i, a := range m.arguments { 40 | if a.GetType() != msg.GetArguments()[i].GetType() { 41 | return false 42 | } 43 | if a.GetValue() != msg.GetArguments()[i].GetValue() { 44 | return false 45 | } 46 | } 47 | 48 | return true 49 | } 50 | 51 | func (m *Message) String() string { 52 | argStrings := []string{} 53 | for _, arg := range m.arguments { 54 | argStrings = append(argStrings, arg.String()) 55 | } 56 | return fmt.Sprintf("Message(address: %s, arguments: [%s])", m.address, strings.Join(argStrings, ", ")) 57 | } 58 | -------------------------------------------------------------------------------- /src/drivers/osc_message/message_argument.go: -------------------------------------------------------------------------------- 1 | package osc_message 2 | 3 | import ( 4 | "fmt" 5 | 6 | "net.kopias.oscbridge/app/usecase/usecaseifs" 7 | ) 8 | 9 | var _ usecaseifs.IOSCMessageArgument = &MessageArgument{} 10 | 11 | type MessageArgument struct { 12 | msgType string 13 | msgValue string 14 | } 15 | 16 | func NewMessageArgument(msgType string, msgValue string) *MessageArgument { 17 | return &MessageArgument{msgType: msgType, msgValue: msgValue} 18 | } 19 | 20 | func (m *MessageArgument) GetType() string { 21 | return m.msgType 22 | } 23 | 24 | func (m *MessageArgument) GetValue() string { 25 | return m.msgValue 26 | } 27 | 28 | func (m *MessageArgument) String() string { 29 | return fmt.Sprintf("Argument(%s:%s)", m.msgType, m.msgValue) 30 | } 31 | -------------------------------------------------------------------------------- /src/drivers/paramsanitizer/paramsanitizer.go: -------------------------------------------------------------------------------- 1 | package paramsanitizer 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "net.kopias.oscbridge/app/pkg/slicetools" 9 | ) 10 | 11 | type ParameterDefinition struct { 12 | // Name is the name of the parameter. 13 | Name string 14 | 15 | // Optional determines if specifying this variable is required or not. 16 | Optional bool 17 | 18 | // DefaultValue is in effect, when the parameter is optional. 19 | DefaultValue interface{} 20 | 21 | // ValuePattern if specified determines a regexp that must match the given value. 22 | ValuePattern string 23 | 24 | // Type is a list of golang types, in the form as sprintf would say it for %T. 25 | Type []string 26 | } 27 | 28 | // SanitizeParams verifies the incoming parameters and enforces specific rules on them. 29 | func SanitizeParams(parameters map[string]interface{}, keysAndTypes []ParameterDefinition) (map[string]interface{}, error) { 30 | sanitized := map[string]interface{}{} 31 | 32 | for _, pd := range keysAndTypes { 33 | value, found := parameters[pd.Name] 34 | 35 | if !found && !pd.Optional { 36 | return nil, fmt.Errorf("parameter '%s' is not specified", pd.Name) 37 | } 38 | 39 | if found { 40 | valueType := fmt.Sprintf("%T", value) 41 | 42 | if slicetools.IndexOf(pd.Type, valueType) == -1 { 43 | return nil, fmt.Errorf("parameter '%s' is of a wrong type (%s). Allowed types: %s", pd.Name, valueType, strings.Join(pd.Type, ", ")) 44 | } 45 | 46 | if pd.ValuePattern != "" { 47 | valueString, ok := value.(string) 48 | if !ok { 49 | return nil, fmt.Errorf("failed to cast ValuePattern to string") 50 | } 51 | if !regexp.MustCompile(pd.ValuePattern).MatchString(valueString) { 52 | return nil, fmt.Errorf("parameter '%s' value does not match: %s", pd.Name, pd.ValuePattern) 53 | } 54 | } 55 | sanitized[pd.Name] = value 56 | } else { 57 | sanitized[pd.Name] = pd.DefaultValue 58 | } 59 | } 60 | 61 | return sanitized, nil 62 | } 63 | -------------------------------------------------------------------------------- /src/drivers/tasks/delay/delay.go: -------------------------------------------------------------------------------- 1 | package delay 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "net.kopias.oscbridge/app/pkg/maptools" 9 | 10 | "net.kopias.oscbridge/app/usecase/usecaseifs" 11 | ) 12 | 13 | var _ usecaseifs.IActionTask = &Delay{} 14 | 15 | // Delay simply pauses the execution of the next tasks by the specified amount of time. 16 | type Delay struct { 17 | parameters map[string]interface{} 18 | log usecaseifs.ILogger 19 | debug bool 20 | } 21 | 22 | const ( 23 | ParamDelayMillis = "delay_millis" 24 | ) 25 | 26 | func NewFactory(log usecaseifs.ILogger, debug bool) usecaseifs.ActionTaskFactory { 27 | return func() usecaseifs.IActionTask { return &Delay{log: log, debug: debug} } 28 | } 29 | 30 | func (o *Delay) Validate() error { 31 | _, err := maptools.GetIntValue(o.parameters, ParamDelayMillis) 32 | if err != nil { 33 | return fmt.Errorf("unable to validate delay_millis: %w", err) 34 | } 35 | return nil 36 | } 37 | 38 | func (o *Delay) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 39 | o.log.Infof(ctx, "\tExecuting task: delay") 40 | delayMillis, err := maptools.GetIntValue(o.parameters, ParamDelayMillis) 41 | if err != nil { 42 | return fmt.Errorf("unable to validate delay_millis: %w", err) 43 | } 44 | if o.debug { 45 | o.log.Debugf(ctx, "Waiting %d milliseconds.", delayMillis) 46 | } 47 | 48 | // All that code for a bit of sleep... 49 | time.Sleep(time.Millisecond * time.Duration(delayMillis)) 50 | if o.debug { 51 | o.log.Debugf(ctx, "Waiting %d milliseconds is over.", delayMillis) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (o *Delay) SetParameters(m map[string]interface{}) { 58 | o.parameters = m 59 | } 60 | -------------------------------------------------------------------------------- /src/drivers/tasks/httpreq/http_request.go: -------------------------------------------------------------------------------- 1 | package httpreq 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "net.kopias.oscbridge/app/drivers/paramsanitizer" 12 | 13 | "net.kopias.oscbridge/app/usecase/usecaseifs" 14 | ) 15 | 16 | var _ usecaseifs.IActionTask = &HTTPRequest{} 17 | 18 | // HTTPRequest makes a HTTP request as configured. 19 | type HTTPRequest struct { 20 | log usecaseifs.ILogger 21 | debug bool 22 | configError error 23 | url string 24 | body string 25 | method string 26 | headers []string 27 | timeoutSecs int 28 | } 29 | 30 | const ( 31 | ParamURLKey = "url" 32 | ParamBodyKey = "body" 33 | ParamMethodKey = "method" 34 | ParamMethodPost = "POST" 35 | ParamMethodGet = "GET" 36 | ParamHeadersKey = "headers" 37 | ParamTimeoutSecsKey = "timeout_secs" 38 | ) 39 | 40 | func NewFactory(log usecaseifs.ILogger, debug bool) usecaseifs.ActionTaskFactory { 41 | return func() usecaseifs.IActionTask { return &HTTPRequest{log: log, debug: debug} } 42 | } 43 | 44 | func (o *HTTPRequest) SetParameters(m map[string]interface{}) { 45 | sanitized, err := paramsanitizer.SanitizeParams(m, []paramsanitizer.ParameterDefinition{ 46 | { 47 | Name: ParamURLKey, 48 | Optional: false, 49 | Type: []string{"string"}, 50 | }, { 51 | Name: ParamBodyKey, 52 | Optional: true, 53 | DefaultValue: "", 54 | Type: []string{"string"}, 55 | }, { 56 | Name: ParamTimeoutSecsKey, 57 | Optional: true, 58 | DefaultValue: 30, 59 | Type: []string{"int"}, 60 | }, { 61 | Name: ParamMethodKey, 62 | Optional: true, 63 | DefaultValue: ParamMethodGet, 64 | ValuePattern: fmt.Sprintf("(?i)^%s|%s$", ParamMethodPost, ParamMethodGet), 65 | Type: []string{"string"}, 66 | }, { 67 | Name: ParamHeadersKey, 68 | Optional: true, 69 | DefaultValue: []interface{}{}, 70 | Type: []string{"[]interface {}"}, 71 | }, 72 | }) 73 | if err != nil { 74 | o.configError = fmt.Errorf("failed to verify parameters: %w", err) 75 | return 76 | } 77 | 78 | // nolint:forcetypeassert 79 | o.url = sanitized[ParamURLKey].(string) 80 | 81 | // nolint:forcetypeassert 82 | o.timeoutSecs = sanitized[ParamTimeoutSecsKey].(int) 83 | 84 | // nolint:forcetypeassert 85 | o.body = sanitized[ParamBodyKey].(string) 86 | 87 | // nolint:forcetypeassert 88 | o.method = sanitized[ParamMethodKey].(string) 89 | 90 | // nolint:forcetypeassert 91 | headerSlice := sanitized[ParamHeadersKey].([]interface{}) 92 | 93 | o.headers = []string{} 94 | for i, v := range headerSlice { 95 | vString, ok := v.(string) 96 | if !ok { 97 | o.configError = fmt.Errorf("failed to convert header[%d] to string (from %T)", i, v) 98 | return 99 | } 100 | o.headers = append(o.headers, vString) 101 | } 102 | } 103 | 104 | func (o *HTTPRequest) Validate() error { 105 | return o.configError 106 | } 107 | 108 | func (o *HTTPRequest) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 109 | o.log.Infof(ctx, "\tExecuting task: HTTP request") 110 | 111 | // Configure request options 112 | headersToSend := map[string]string{} 113 | var parts []string 114 | for i, header := range o.headers { 115 | parts = strings.SplitN(header, ":", 2) 116 | if len(parts) != 2 { 117 | return fmt.Errorf("failed to parse header[%d]: '%s' invalid header definition", i, header) 118 | } 119 | headersToSend[parts[0]] = parts[1] 120 | } 121 | 122 | reqCtx, cncl := context.WithTimeout(ctx, time.Second*time.Duration(o.timeoutSecs)) 123 | defer cncl() 124 | 125 | // Create a request 126 | req, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(o.method), o.url, bytes.NewBufferString(o.body)) 127 | if err != nil { 128 | return fmt.Errorf("error creating request: %w", err) 129 | } 130 | 131 | // Add custom headers 132 | for key, value := range headersToSend { 133 | req.Header.Set(key, value) 134 | } 135 | 136 | if o.debug { 137 | o.log.Debugf(ctx, "Initiating HTTP %s request on %s", o.method, o.url) 138 | } 139 | 140 | // Perform the request 141 | client := &http.Client{} 142 | resp, err := client.Do(req) 143 | if err != nil { 144 | return fmt.Errorf("error sending request: %w", err) 145 | } 146 | defer resp.Body.Close() 147 | 148 | // Read and print the response 149 | respBody := new(bytes.Buffer) 150 | _, err = respBody.ReadFrom(resp.Body) 151 | if err != nil { 152 | return fmt.Errorf("error reading response: %w", err) 153 | } 154 | 155 | if o.debug { 156 | o.log.Infof(ctx, "Response Status: %s", resp.Status) 157 | o.log.Infof(ctx, "Response Body: %s", respBody.String()) 158 | } 159 | return nil 160 | } 161 | -------------------------------------------------------------------------------- /src/drivers/tasks/obstasks/obs.go: -------------------------------------------------------------------------------- 1 | package obstasks 2 | 3 | import ( 4 | "net.kopias.oscbridge/app/drivers/obsremote" 5 | "net.kopias.oscbridge/app/usecase/usecaseifs" 6 | ) 7 | 8 | const ParamConnectionKey = "connection" 9 | 10 | func NewSceneChangerFactory(obsConnections map[string]*obsremote.OBSRemote, log usecaseifs.ILogger, debug bool) usecaseifs.ActionTaskFactory { 11 | return func() usecaseifs.IActionTask { return NewSceneChanger(obsConnections, log, debug) } 12 | } 13 | 14 | func NewVendorRequestFactory(obsConnections map[string]*obsremote.OBSRemote, log usecaseifs.ILogger, debug bool) usecaseifs.ActionTaskFactory { 15 | return func() usecaseifs.IActionTask { return NewVendorRequest(obsConnections, log, debug) } 16 | } 17 | -------------------------------------------------------------------------------- /src/drivers/tasks/obstasks/scene_changer.go: -------------------------------------------------------------------------------- 1 | package obstasks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "net.kopias.oscbridge/app/drivers/obsremote" 10 | 11 | "net.kopias.oscbridge/app/drivers/paramsanitizer" 12 | 13 | "net.kopias.oscbridge/app/usecase/usecaseifs" 14 | ) 15 | 16 | var _ usecaseifs.IActionTask = &SceneChanger{} 17 | 18 | const ( 19 | ParamSceneTargetKey = "target" 20 | ParamSceneTargetProgram = "program" 21 | ParamSceneTargetPreview = "preview" 22 | ParamSceneKey = "scene" 23 | ParamSceneMatchTypeKey = "scene_match_type" 24 | ParamSceneMatchExact = "exact" 25 | ParamSceneMatchRegexp = "regexp" 26 | ) 27 | 28 | // SceneChanger allows for changing preview/program scenes. 29 | type SceneChanger struct { 30 | obsConnections map[string]*obsremote.OBSRemote 31 | 32 | cachedSceneList []string 33 | 34 | sceneName string 35 | scenePattern *string 36 | debug bool 37 | log usecaseifs.ILogger 38 | configError error 39 | sceneTarget string 40 | connectionName string 41 | } 42 | 43 | func NewSceneChanger(obsConnections map[string]*obsremote.OBSRemote, log usecaseifs.ILogger, debug bool) usecaseifs.IActionTask { 44 | return &SceneChanger{obsConnections: obsConnections, log: log, debug: debug, cachedSceneList: []string{}} 45 | } 46 | 47 | func (o *SceneChanger) Validate() error { 48 | return o.configError 49 | } 50 | 51 | func (o *SceneChanger) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 52 | o.log.Infof(ctx, "\tExecuting task: obs scene change") 53 | 54 | var err error 55 | sceneName := o.sceneName 56 | 57 | if len(o.cachedSceneList) == 0 { 58 | o.cachedSceneList, err = o.obsConnections[o.connectionName].ListScenes(ctx) 59 | if err != nil { 60 | return fmt.Errorf("failed to list scenes: %w", err) 61 | } 62 | } 63 | 64 | if o.scenePattern != nil { 65 | sceneName, err = o.getSceneNameByRegexp(*o.scenePattern) 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | if o.sceneTarget == ParamSceneTargetPreview { 72 | err := o.obsConnections[o.connectionName].SwitchPreviewScene(ctx, sceneName) 73 | if err != nil { 74 | return fmt.Errorf("failed to change preview scene to %s: %w", sceneName, err) 75 | } 76 | } 77 | 78 | if o.sceneTarget == ParamSceneTargetProgram { 79 | err := o.obsConnections[o.connectionName].SwitchProgramScene(ctx, sceneName) 80 | if err != nil { 81 | return fmt.Errorf("failed to change program scene to %s: %w", sceneName, err) 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (o *SceneChanger) SetParameters(m map[string]interface{}) { 89 | sanitized, err := paramsanitizer.SanitizeParams(m, []paramsanitizer.ParameterDefinition{ 90 | { 91 | Name: ParamSceneKey, 92 | Optional: false, 93 | Type: []string{"string"}, 94 | }, { 95 | Name: ParamConnectionKey, 96 | Optional: false, 97 | Type: []string{"string"}, 98 | }, { 99 | Name: ParamSceneMatchTypeKey, 100 | Optional: true, 101 | DefaultValue: ParamSceneMatchExact, 102 | ValuePattern: fmt.Sprintf("^%s|%s$", ParamSceneMatchExact, ParamSceneMatchRegexp), 103 | Type: []string{"string"}, 104 | }, { 105 | Name: ParamSceneTargetKey, 106 | Optional: false, 107 | ValuePattern: fmt.Sprintf("^%s|%s$", ParamSceneTargetProgram, ParamSceneTargetPreview), 108 | Type: []string{"string"}, 109 | }, 110 | }) 111 | if err != nil { 112 | o.configError = fmt.Errorf("failed to verify parameters: %w", err) 113 | return 114 | } 115 | 116 | // nolint:forcetypeassert 117 | o.sceneName = sanitized[ParamSceneKey].(string) 118 | 119 | // nolint:forcetypeassert 120 | o.connectionName = sanitized[ParamConnectionKey].(string) 121 | 122 | // nolint:forcetypeassert 123 | o.sceneTarget = sanitized[ParamSceneTargetKey].(string) 124 | 125 | if sanitized[ParamSceneMatchTypeKey] == ParamSceneMatchRegexp { 126 | // nolint:forcetypeassert 127 | scp := sanitized[ParamSceneKey].(string) 128 | _, err = regexp.Compile(scp) 129 | if err != nil { 130 | o.configError = fmt.Errorf("%s is not a valid regexp: %w", ParamSceneMatchTypeKey, err) 131 | return 132 | } 133 | o.scenePattern = &scp 134 | } 135 | } 136 | 137 | func (o *SceneChanger) getSceneNameByRegexp(p string) (string, error) { 138 | r, err := regexp.Compile(p) 139 | if err != nil { 140 | return "", err 141 | } 142 | for _, scene := range o.cachedSceneList { 143 | if r.MatchString(scene) { 144 | return scene, nil 145 | } 146 | } 147 | 148 | return "", fmt.Errorf("no scene matched '%s' (current scenes: %s)", p, strings.Join(o.cachedSceneList, ", ")) 149 | } 150 | -------------------------------------------------------------------------------- /src/drivers/tasks/obstasks/vendor_request.go: -------------------------------------------------------------------------------- 1 | package obstasks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "net.kopias.oscbridge/app/drivers/obsremote" 8 | 9 | "net.kopias.oscbridge/app/drivers/paramsanitizer" 10 | 11 | "net.kopias.oscbridge/app/usecase/usecaseifs" 12 | ) 13 | 14 | var _ usecaseifs.IActionTask = &VendorRequest{} 15 | 16 | const ( 17 | ParamVendorName = "vendorName" 18 | ParamRequestType = "requestType" 19 | ParamRequestData = "requestData" 20 | ) 21 | 22 | // VendorRequest allows sending arbitrary "vendor" messages. 23 | type VendorRequest struct { 24 | obsConnections map[string]*obsremote.OBSRemote 25 | 26 | debug bool 27 | log usecaseifs.ILogger 28 | configError error 29 | vendorName string 30 | requestType string 31 | requestData map[string]interface{} 32 | connectionName string 33 | } 34 | 35 | func NewVendorRequest(obsConnections map[string]*obsremote.OBSRemote, log usecaseifs.ILogger, debug bool) usecaseifs.IActionTask { 36 | return &VendorRequest{obsConnections: obsConnections, log: log, debug: debug} 37 | } 38 | 39 | func (o *VendorRequest) Validate() error { 40 | return o.configError 41 | } 42 | 43 | func (o *VendorRequest) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 44 | o.log.Infof(ctx, "\tExecuting task: OBS vendor request") 45 | 46 | if _, err := o.obsConnections[o.connectionName].VendorRequest(ctx, o.vendorName, o.requestType, o.requestData); err != nil { 47 | return fmt.Errorf("failed to execute vendor request: %w", err) 48 | } 49 | return nil 50 | } 51 | 52 | func (o *VendorRequest) SetParameters(m map[string]interface{}) { 53 | sanitized, err := paramsanitizer.SanitizeParams(m, []paramsanitizer.ParameterDefinition{ 54 | { 55 | Name: ParamVendorName, 56 | Optional: false, 57 | Type: []string{"string"}, 58 | }, { 59 | Name: ParamRequestType, 60 | Optional: false, 61 | Type: []string{"string"}, 62 | }, { 63 | Name: ParamRequestData, 64 | Optional: false, 65 | Type: []string{"map[string]interface {}"}, 66 | }, { 67 | Name: ParamConnectionKey, 68 | Optional: false, 69 | Type: []string{"string"}, 70 | }, 71 | }) 72 | if err != nil { 73 | o.configError = fmt.Errorf("failed to verify parameters: %w", err) 74 | return 75 | } 76 | 77 | // nolint:forcetypeassert 78 | o.vendorName = sanitized[ParamVendorName].(string) 79 | 80 | // nolint:forcetypeassert 81 | o.requestType = sanitized[ParamRequestType].(string) 82 | 83 | // nolint:forcetypeassert 84 | o.requestData = sanitized[ParamRequestData].(map[string]interface{}) 85 | 86 | // nolint:forcetypeassert 87 | o.connectionName = sanitized[ParamConnectionKey].(string) 88 | } 89 | -------------------------------------------------------------------------------- /src/drivers/tasks/run_command/run_command.go: -------------------------------------------------------------------------------- 1 | package run_command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | 9 | "net.kopias.oscbridge/app/drivers/paramsanitizer" 10 | 11 | "net.kopias.oscbridge/app/usecase/usecaseifs" 12 | ) 13 | 14 | var _ usecaseifs.IActionTask = &RunCommandTask{} 15 | 16 | // RunCommandTask executes the given command as-is. 17 | type RunCommandTask struct { 18 | log usecaseifs.ILogger 19 | debug bool 20 | configError error 21 | command string 22 | arguments []string 23 | runInBackground bool 24 | directory string 25 | } 26 | 27 | const ( 28 | ParamCommand = "command" 29 | ParamArguments = "arguments" 30 | ParamBackground = "run_in_background" 31 | ParamDirectory = "directory" 32 | ) 33 | 34 | func NewFactory(log usecaseifs.ILogger, debug bool) usecaseifs.ActionTaskFactory { 35 | return func() usecaseifs.IActionTask { 36 | return &RunCommandTask{log: log, debug: debug, arguments: []string{}} 37 | } 38 | } 39 | 40 | func (o *RunCommandTask) SetParameters(m map[string]interface{}) { 41 | sanitized, err := paramsanitizer.SanitizeParams(m, []paramsanitizer.ParameterDefinition{ 42 | { 43 | Name: ParamCommand, 44 | Optional: false, 45 | Type: []string{"string"}, 46 | }, { 47 | Name: ParamArguments, 48 | Optional: true, 49 | DefaultValue: []interface{}{}, 50 | Type: []string{"[]interface {}"}, 51 | }, { 52 | Name: ParamBackground, 53 | Optional: true, 54 | DefaultValue: false, 55 | Type: []string{"bool"}, 56 | }, { 57 | Name: ParamDirectory, 58 | Optional: true, 59 | DefaultValue: "", 60 | Type: []string{"string"}, 61 | }, 62 | }) 63 | if err != nil { 64 | o.configError = fmt.Errorf("failed to verify parameters: %w", err) 65 | return 66 | } 67 | 68 | // nolint:forcetypeassert 69 | o.command = sanitized[ParamCommand].(string) 70 | // nolint:forcetypeassert 71 | o.runInBackground = sanitized[ParamBackground].(bool) 72 | // nolint:forcetypeassert 73 | o.directory = sanitized[ParamDirectory].(string) 74 | 75 | args, ok := sanitized[ParamArguments] 76 | if !ok { 77 | o.configError = fmt.Errorf("key %s was not found", ParamArguments) 78 | return 79 | } 80 | // nolint:forcetypeassert 81 | argsSlice := args.([]interface{}) 82 | 83 | for i, argument := range argsSlice { 84 | argumentString, ok := argument.(string) 85 | if !ok { 86 | o.configError = fmt.Errorf("failed to convert argument %d to string", i) 87 | return 88 | } 89 | o.arguments = append(o.arguments, argumentString) 90 | } 91 | } 92 | 93 | func (o *RunCommandTask) Validate() error { 94 | return o.configError 95 | } 96 | 97 | func (o *RunCommandTask) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 98 | o.log.Infof(ctx, "\tExecuting task: Run command") 99 | 100 | if o.runInBackground { 101 | go o.execute(ctx) 102 | } else { 103 | o.execute(ctx) 104 | } 105 | return nil 106 | } 107 | 108 | func (o *RunCommandTask) execute(ctx context.Context) { 109 | // nolint: gosec 110 | cmd := exec.Command(o.command, o.arguments...) 111 | if o.directory != "" { 112 | cmd.Dir = o.directory 113 | } 114 | err := cmd.Run() 115 | if err != nil { 116 | o.log.Err(ctx, fmt.Errorf("failed to execute %s %s: %w", o.command, strings.Join(o.arguments, " "), err)) 117 | } 118 | o.log.Infof(ctx, "Command exit code: %d", cmd.ProcessState.ExitCode()) 119 | } 120 | -------------------------------------------------------------------------------- /src/drivers/tasks/send_osc_message/send_osc_message.go: -------------------------------------------------------------------------------- 1 | package send_osc_message 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "net.kopias.oscbridge/app/drivers/osc_message" 8 | 9 | "net.kopias.oscbridge/app/drivers/paramsanitizer" 10 | 11 | "net.kopias.oscbridge/app/usecase/usecaseifs" 12 | ) 13 | 14 | var _ usecaseifs.IActionTask = &SendOscMessageTask{} 15 | 16 | // SendOscMessageTask sends an OSC Message to the named connection. 17 | type SendOscMessageTask struct { 18 | log usecaseifs.ILogger 19 | debug bool 20 | configError error 21 | connections map[string]usecaseifs.IOSCConnection 22 | connection string 23 | address string 24 | arguments []argument 25 | } 26 | 27 | type argument struct { 28 | variableType string 29 | variableValue string 30 | } 31 | 32 | const ( 33 | ParamConnectionKey = "connection" 34 | ParamAddress = "address" 35 | ParamArguments = "arguments" 36 | ParamArgumentType = "type" 37 | ParamArgumentValue = "value" 38 | ) 39 | 40 | func NewFactory(log usecaseifs.ILogger, debug bool, connections map[string]usecaseifs.IOSCConnection) usecaseifs.ActionTaskFactory { 41 | return func() usecaseifs.IActionTask { 42 | return &SendOscMessageTask{log: log, debug: debug, connections: connections} 43 | } 44 | } 45 | 46 | func (o *SendOscMessageTask) SetParameters(m map[string]interface{}) { 47 | sanitized, err := paramsanitizer.SanitizeParams(m, []paramsanitizer.ParameterDefinition{ 48 | { 49 | Name: ParamConnectionKey, 50 | Optional: false, 51 | Type: []string{"string"}, 52 | }, { 53 | Name: ParamAddress, 54 | Optional: false, 55 | Type: []string{"string"}, 56 | }, { 57 | Name: ParamArguments, 58 | Optional: true, 59 | Type: []string{"[]interface {}"}, 60 | }, 61 | }) 62 | if err != nil { 63 | o.configError = fmt.Errorf("failed to verify parameters: %w", err) 64 | return 65 | } 66 | 67 | // nolint:forcetypeassert 68 | o.connection = sanitized[ParamConnectionKey].(string) 69 | 70 | // nolint:forcetypeassert 71 | o.address = sanitized[ParamAddress].(string) 72 | 73 | args, ok := sanitized[ParamArguments] 74 | if !ok { 75 | o.configError = fmt.Errorf("key %s was not found", ParamArguments) 76 | return 77 | } 78 | // nolint:forcetypeassert 79 | argsSlice := args.([]interface{}) 80 | 81 | for i, argParams := range argsSlice { 82 | err = o.setArgumentParameters(argParams) 83 | if err != nil { 84 | o.configError = fmt.Errorf("osc_message task failed to verify parameters: Agrgument[%d]: %w", i, err) 85 | return 86 | } 87 | } 88 | } 89 | 90 | func (o *SendOscMessageTask) setArgumentParameters(m interface{}) error { 91 | mCasted, ok := m.(map[string]interface{}) 92 | if !ok { 93 | return fmt.Errorf("failed to cast supplied arguments") 94 | } 95 | sanitized, err := paramsanitizer.SanitizeParams(mCasted, []paramsanitizer.ParameterDefinition{ 96 | { 97 | Name: ParamArgumentType, 98 | Optional: false, 99 | Type: []string{"string"}, 100 | }, 101 | { 102 | Name: ParamArgumentValue, 103 | Optional: false, 104 | Type: []string{"string"}, 105 | }, 106 | }) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | newArg := argument{} 112 | 113 | // nolint:forcetypeassert 114 | newArg.variableType = sanitized[ParamArgumentType].(string) 115 | // nolint:forcetypeassert 116 | newArg.variableValue = sanitized[ParamArgumentValue].(string) 117 | 118 | o.arguments = append(o.arguments, newArg) 119 | return nil 120 | } 121 | 122 | func (o *SendOscMessageTask) Validate() error { 123 | return o.configError 124 | } 125 | 126 | func (o *SendOscMessageTask) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 127 | o.log.Infof(ctx, "\tExecuting task: OSC Message send") 128 | 129 | conn, ok := o.connections[o.connection] 130 | if !ok { 131 | return fmt.Errorf("there is no osc connection named '%s'", o.connection) 132 | } 133 | 134 | args := []usecaseifs.IOSCMessageArgument{} 135 | 136 | for _, a := range o.arguments { 137 | arg := osc_message.NewMessageArgument(a.variableType, a.variableValue) 138 | args = append(args, arg) 139 | } 140 | msg := osc_message.NewMessage(o.address, args) 141 | 142 | err := conn.SendMessage(ctx, msg) 143 | if err != nil { 144 | return fmt.Errorf("failed to send message: %s: %w", msg.String(), err) 145 | } 146 | 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /src/entities/action.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "net.kopias.oscbridge/app/usecase/usecaseifs" 9 | ) 10 | 11 | var _ usecaseifs.IAction = &Action{} 12 | 13 | // Action represents a living, composed set of instances of triggers and tasks 14 | type Action struct { 15 | name string 16 | // triggerChain is a tree of conditions 17 | triggerChain usecaseifs.IActionCondition 18 | 19 | // tasks is a list of tasks that must be executed serially. 20 | tasks []usecaseifs.IActionTask 21 | 22 | // debounceMillis causes repeated evaluation with this delay to see if the condition is still true. 23 | debounceMillis int64 24 | } 25 | 26 | func (a *Action) GetDebounceMillis() int64 { 27 | return a.debounceMillis 28 | } 29 | 30 | func NewAction(name string, triggerChain usecaseifs.IActionCondition, tasks []usecaseifs.IActionTask, debounceMillis int64) *Action { 31 | return &Action{ 32 | name: name, 33 | triggerChain: triggerChain, 34 | tasks: tasks, 35 | debounceMillis: debounceMillis, 36 | } 37 | } 38 | 39 | func (a *Action) Evaluate(ctx context.Context, store usecaseifs.IMessageStore) (bool, error) { 40 | matched, err := a.triggerChain.Evaluate(ctx, store) 41 | if err != nil { 42 | return false, fmt.Errorf("failed to evaluate trigger chain: %w", err) 43 | } 44 | return matched, nil 45 | } 46 | 47 | func (a *Action) Execute(ctx context.Context, store usecaseifs.IMessageStore) error { 48 | errs := []any{} 49 | for i, task := range a.tasks { 50 | if err := task.Execute(ctx, store); err != nil { 51 | errs = append(errs, fmt.Errorf("failed to execute task %d: %w", i, err)) 52 | } 53 | } 54 | 55 | if len(errs) > 0 { 56 | return fmt.Errorf("failed to exectue task(s) "+strings.Repeat(": %w", len(errs)), errs...) 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func (a *Action) GetName() string { 63 | return a.name 64 | } 65 | -------------------------------------------------------------------------------- /src/entities/osc_connection_details.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import "net.kopias.oscbridge/app/usecase/usecaseifs" 4 | 5 | // OscConnectionDetails wraps a connection and adds metadata. 6 | type OscConnectionDetails struct { 7 | // Name is the name of this connection. 8 | Name string 9 | 10 | // Prefix determines the address prefix for this connection. 11 | Prefix string 12 | 13 | Connection usecaseifs.IOSCConnection 14 | } 15 | 16 | func NewOscConnectionDetails(name string, prefix string, connection usecaseifs.IOSCConnection) *OscConnectionDetails { 17 | return &OscConnectionDetails{Name: name, Prefix: prefix, Connection: connection} 18 | } 19 | -------------------------------------------------------------------------------- /src/entities/prefixed_osc_message.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "net.kopias.oscbridge/app/usecase/usecaseifs" 8 | ) 9 | 10 | var _ usecaseifs.IOSCMessage = &PrefixedOSCMessage{} 11 | 12 | // PrefixedOSCMessage is a wrapper to add a prefix for the address of a message. 13 | type PrefixedOSCMessage struct { 14 | Prefix string 15 | Message usecaseifs.IOSCMessage 16 | } 17 | 18 | func NewPrefixedOSCMessage(prefix string, message usecaseifs.IOSCMessage) *PrefixedOSCMessage { 19 | return &PrefixedOSCMessage{Prefix: prefix, Message: message} 20 | } 21 | 22 | func (p PrefixedOSCMessage) Equal(msg usecaseifs.IOSCMessage) bool { 23 | return p.Message.Equal(msg) 24 | } 25 | 26 | func (p PrefixedOSCMessage) GetAddress() string { 27 | if p.Prefix == "" { 28 | return p.Message.GetAddress() 29 | } 30 | return fmt.Sprintf("%s/%s", p.Prefix, p.Message.GetAddress()) 31 | } 32 | 33 | func (p PrefixedOSCMessage) GetArguments() []usecaseifs.IOSCMessageArgument { 34 | return p.Message.GetArguments() 35 | } 36 | 37 | func (p PrefixedOSCMessage) String() string { 38 | argStrings := []string{} 39 | for _, arg := range p.GetArguments() { 40 | argStrings = append(argStrings, arg.String()) 41 | } 42 | return fmt.Sprintf("Message(address: %s, arguments: [%s])", p.GetAddress(), strings.Join(argStrings, ", ")) 43 | } 44 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module net.kopias.oscbridge/app 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/andreykaipov/goobs v0.12.1 7 | github.com/google/uuid v1.3.1 8 | github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 9 | github.com/ilyakaznacheev/cleanenv v1.5.0 10 | github.com/loffa/gosc v0.0.0-20230901113444-a138fef9ff88 11 | github.com/pkg/errors v0.9.1 12 | github.com/scgolang/osc v0.11.1 13 | ) 14 | 15 | require ( 16 | github.com/BurntSushi/toml v1.3.2 // indirect 17 | github.com/buger/jsonparser v1.1.1 // indirect 18 | github.com/gorilla/websocket v1.5.0 // indirect 19 | github.com/hashicorp/logutils v1.0.0 // indirect 20 | github.com/imdario/go-ulid v0.0.0-20180116185620-aeb52bf96595 // indirect 21 | github.com/joho/godotenv v1.5.1 // indirect 22 | github.com/kr/pretty v0.3.1 // indirect 23 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 24 | github.com/stretchr/testify v1.8.1 // indirect 25 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 2 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 3 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 4 | github.com/andreykaipov/goobs v0.12.1 h1:KfVHfuvGFHg1MDi1muaKCwRxek3O4tzKcqBZmLDF8EA= 5 | github.com/andreykaipov/goobs v0.12.1/go.mod h1:9lCSWI7uZScJx05Hc0KnRtIItGjU8VpGV5dhONiiUgg= 6 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 7 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 13 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 14 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 15 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 16 | github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= 17 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 18 | github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 h1:fqwINudmUrvGCuw+e3tedZ2UJ0hklSw6t8UPomctKyQ= 19 | github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5/go.mod h1:lqMjoCs0y0GoRRujSPZRBaGb4c5ER6TfkFKSClxkMbY= 20 | github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= 21 | github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= 22 | github.com/imdario/go-ulid v0.0.0-20180116185620-aeb52bf96595 h1:8MKHx/6AMMFGslqvr37RF7zktr3eJmY1z2FKdq3Zo/o= 23 | github.com/imdario/go-ulid v0.0.0-20180116185620-aeb52bf96595/go.mod h1:ugPCasYVpR6Cf8xlF0vkZdVKntj7zTgo9pLR4Si7Boo= 24 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 25 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 26 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 27 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 28 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 29 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 30 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 31 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 32 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 33 | github.com/loffa/gosc v0.0.0-20230901113444-a138fef9ff88 h1:pmUkdeN8nImTLNPE1xAPgcOnEaud7uT9/+xzNspFrDo= 34 | github.com/loffa/gosc v0.0.0-20230901113444-a138fef9ff88/go.mod h1:R14JH1s5slLW1QZ8MRdqbmXH3izVG5o3fkm49JUzPFg= 35 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 36 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 37 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 38 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 39 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 43 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 44 | github.com/scgolang/osc v0.11.1 h1:o2+nXrQrlyEAoFcgZ2zk6p5iI6ht+NgiSKaGQBpvWbU= 45 | github.com/scgolang/osc v0.11.1/go.mod h1:fu5QITvJ5w2pzKXJBmyVTF89ZycPN4bS4cOHJErpR2A= 46 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 47 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 48 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 49 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 50 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 51 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 52 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 55 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 56 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 58 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 59 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 60 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 61 | -------------------------------------------------------------------------------- /src/pkg/chantools/chantools.go: -------------------------------------------------------------------------------- 1 | package chantools 2 | 3 | func ChanIsOpenReader[T comparable](c <-chan T) bool { 4 | select { 5 | case _, ok := <-c: 6 | if !ok { 7 | return false 8 | } 9 | return true 10 | 11 | default: 12 | return true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/pkg/filetools/filetools.go: -------------------------------------------------------------------------------- 1 | package filetools 2 | 3 | import ( 4 | "fmt" 5 | "hash" 6 | "io" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | func FileExists(filePath string) bool { 16 | _, err := os.Stat(filePath) 17 | return !errors.Is(err, os.ErrNotExist) 18 | } 19 | 20 | func MkdirIfNotExist(name string, perm os.FileMode) error { 21 | err := os.Mkdir(name, perm) 22 | if err != nil { 23 | if !os.IsExist(err) { 24 | return fmt.Errorf("unable to create directory: '%s': %w", name, err) 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | func MkdirAllIfNotExist(name string, perm os.FileMode) error { 31 | err := os.MkdirAll(name, perm) 32 | if err != nil { 33 | if !os.IsExist(err) { 34 | return fmt.Errorf("unable to create directory: '%s': %w", name, err) 35 | } 36 | } 37 | return nil 38 | } 39 | 40 | func CopyFile(sourcePath string, destinationPath string) error { 41 | fInput, err := os.Open(sourcePath) 42 | if err != nil { 43 | return fmt.Errorf("failed to open %s to copy to %s: %w", sourcePath, destinationPath, err) 44 | } 45 | defer func(fInput *os.File) { 46 | _ = fInput.Close() 47 | }(fInput) 48 | 49 | fOutput, err := os.Create(destinationPath) 50 | if err != nil { 51 | return fmt.Errorf("failed to create %s to copy from %s: %w", destinationPath, sourcePath, err) 52 | } 53 | 54 | defer func(fOutput *os.File) { 55 | _ = fOutput.Close() 56 | }(fOutput) 57 | 58 | _, err = io.Copy(fOutput, fInput) 59 | if err != nil { 60 | return fmt.Errorf("failed to copy %s to %s: %w", sourcePath, destinationPath, err) 61 | } 62 | return nil 63 | } 64 | 65 | func FileNameWithoutExtension(fn string) string { 66 | return strings.TrimSuffix(fn, filepath.Ext(fn)) 67 | } 68 | 69 | func FileBaseNameWithoutExtension(fn string) string { 70 | return strings.TrimSuffix(path.Base(fn), filepath.Ext(fn)) 71 | } 72 | 73 | // CalculateHashOfFile sha256.New() 74 | func CalculateHashOfFile(file string, hashAlgorithm hash.Hash) (string, error) { 75 | fileHandle, err := os.Open(file) 76 | if err != nil { 77 | return "", fmt.Errorf("failed to open %s: %w", file, err) 78 | } 79 | defer fileHandle.Close() 80 | 81 | if _, err := io.Copy(hashAlgorithm, fileHandle); err != nil { 82 | return "", fmt.Errorf("failed to ophash file %s: %w", file, err) 83 | } 84 | sum := hashAlgorithm.Sum(nil) 85 | 86 | return fmt.Sprintf("%x", sum), nil 87 | } 88 | -------------------------------------------------------------------------------- /src/pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Logger struct { 12 | prefixers []GetPrefixesFunc 13 | } 14 | 15 | func New() *Logger { 16 | return &Logger{prefixers: []GetPrefixesFunc{}} 17 | } 18 | 19 | func (l *Logger) Debugf(ctx context.Context, message string, args ...interface{}) { 20 | l.msg(ctx, "debug", fmt.Sprintf(message, args...)) 21 | } 22 | 23 | func (l *Logger) Debug(ctx context.Context, message string) { 24 | l.msg(ctx, "debug", message) 25 | } 26 | 27 | func (l *Logger) Infof(ctx context.Context, message string, args ...interface{}) { 28 | l.msg(ctx, "info", fmt.Sprintf(message, args...)) 29 | } 30 | 31 | func (l *Logger) Info(ctx context.Context, message string) { 32 | l.msg(ctx, "info", message) 33 | } 34 | 35 | func (l *Logger) Warnf(ctx context.Context, message string, args ...interface{}) { 36 | l.msg(ctx, "warn", fmt.Sprintf(message, args...)) 37 | } 38 | 39 | func (l *Logger) Warn(ctx context.Context, message string) { 40 | l.msg(ctx, "warn", message) 41 | } 42 | 43 | func (l *Logger) Errorf(ctx context.Context, message string, args ...interface{}) { 44 | l.msg(ctx, "error", fmt.Sprintf(message, args...)) 45 | } 46 | 47 | func (l *Logger) Error(ctx context.Context, message string) { 48 | l.msg(ctx, "error", message) 49 | } 50 | 51 | func (l *Logger) Messsage(ctx context.Context, level string, message string) { 52 | l.msg(ctx, level, message) 53 | } 54 | 55 | func (l *Logger) Err(ctx context.Context, err error) { 56 | l.msg(ctx, "error", err.Error()) 57 | } 58 | 59 | func (l *Logger) Fatalf(ctx context.Context, err error, args ...interface{}) { 60 | l.msg(ctx, "fatal", fmt.Sprintf(err.Error(), args...)) 61 | os.Exit(1) 62 | } 63 | 64 | func (l *Logger) Fatal(ctx context.Context, err error) { 65 | l.msg(ctx, "fatal", err.Error()) 66 | os.Exit(1) 67 | } 68 | 69 | func (l *Logger) msg(ctx context.Context, level string, fullText string) { 70 | ctxPrefixString := l.GetPrefixForContext(ctx) 71 | 72 | prefixString := fmt.Sprintf("%s [%+5s]%s ", time.Now().UTC().Format("2006-01-02 15:04:05"), strings.ToUpper(level), ctxPrefixString) 73 | 74 | fullText = prefixString + strings.ReplaceAll(fullText, "\n", "\n"+prefixString) 75 | 76 | //nolint:forbidigo 77 | fmt.Println(fullText) 78 | } 79 | -------------------------------------------------------------------------------- /src/pkg/logger/prefixer.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "net.kopias.oscbridge/app/pkg/slicetools" 9 | ) 10 | 11 | type LogPrefixer interface { 12 | GetContextualLogPrefixer(ctx context.Context) []string 13 | } 14 | 15 | // GetPrefixesFunc is a function that returns an array of strings in regard to a context. 16 | // For example a http context might contain a request id. The returned array of strings will be used as a prefix in the log lines. 17 | type GetPrefixesFunc func(context.Context) []string 18 | 19 | // AddPrefixerFunc adds a PrefixerFunc which will be called upon printing log messages. 20 | // The PrefixerFunc-s can access the context, and extract any value from it and return it as a log prefix. 21 | func (l *Logger) AddPrefixerFunc(prefixer GetPrefixesFunc) { 22 | l.prefixers = append(l.prefixers, prefixer) 23 | } 24 | 25 | // AddPrefixer adds a special Prefixer which will be called upon printing log messages. 26 | // It is a shorthand for AddPrefixerFunc to extract simple string keys from the context. 27 | func (l *Logger) AddPrefixer(key interface{}) { 28 | l.AddPrefixerFunc(func(ctx context.Context) []string { 29 | p := ctx.Value(key) 30 | if p != nil { 31 | pString, ok := p.(string) 32 | if ok { 33 | return []string{pString} 34 | } 35 | } 36 | return nil 37 | }) 38 | } 39 | 40 | // GetPrefixForContext returns all the prefixes merged, that can be extracted from the contexts with the prefixers. 41 | func (l *Logger) GetPrefixForContext(ctx context.Context) string { 42 | prefixString := "" 43 | prefixes := []string{} 44 | if ctx != nil { 45 | for i := range l.prefixers { 46 | prefixerResults := l.prefixers[i](ctx) 47 | prefixerResults = slicetools.Filter(prefixerResults, func(s string) bool { 48 | return s != "" 49 | }) 50 | 51 | if len(prefixerResults) > 0 { 52 | prefixes = append(prefixes, prefixerResults...) 53 | } 54 | } 55 | 56 | if len(prefixes) > 0 { 57 | prefixString = fmt.Sprintf(" %s", strings.Join(prefixes, "/")) 58 | } 59 | } 60 | return prefixString 61 | } 62 | -------------------------------------------------------------------------------- /src/pkg/maptools/maptools.go: -------------------------------------------------------------------------------- 1 | package maptools 2 | 3 | import "fmt" 4 | 5 | func GetKeys[T comparable](param map[T]any) []T { 6 | result := []T{} 7 | 8 | for k := range param { 9 | result = append(result, k) 10 | } 11 | return result 12 | } 13 | 14 | func GetStringValue[T comparable](m map[T]any, key T) (string, error) { 15 | value, ok := m[key] 16 | if !ok { 17 | return "", fmt.Errorf("map does not contain key '%v'", key) 18 | } 19 | vCasted, ok := value.(string) 20 | if !ok { 21 | return "", fmt.Errorf("the key's ('%v') value can not be casted to string", key) 22 | } 23 | 24 | return vCasted, nil 25 | } 26 | 27 | func GetStringSliceValue[T comparable](m map[T]any, key T) ([]string, error) { 28 | value, ok := m[key] 29 | if !ok { 30 | return nil, fmt.Errorf("map does not contain key '%v'", key) 31 | } 32 | vCasted, ok := value.([]string) 33 | if !ok { 34 | return nil, fmt.Errorf("the key's ('%v') value can not be casted to string", key) 35 | } 36 | 37 | return vCasted, nil 38 | } 39 | 40 | func GetInt64Value[T comparable](m map[T]any, key T) (int64, error) { 41 | value, ok := m[key] 42 | if !ok { 43 | return 0, fmt.Errorf("map does not contain key '%v'", key) 44 | } 45 | vCasted, ok := value.(int64) 46 | if !ok { 47 | return 0, fmt.Errorf("the key's ('%v') value can not be casted to int64", key) 48 | } 49 | 50 | return vCasted, nil 51 | } 52 | 53 | func GetIntValue[T comparable](m map[T]any, key T) (int, error) { 54 | value, ok := m[key] 55 | if !ok { 56 | return 0, fmt.Errorf("map does not contain key '%v'", key) 57 | } 58 | vCasted, ok := value.(int) 59 | if !ok { 60 | return 0, fmt.Errorf("the key's ('%v') value can not be casted to int", key) 61 | } 62 | 63 | return vCasted, nil 64 | } 65 | 66 | func GetBoolValue[T comparable](m map[T]any, key T) (bool, error) { 67 | value, ok := m[key] 68 | if !ok { 69 | return false, fmt.Errorf("map does not contain key '%v'", key) 70 | } 71 | vCasted, ok := value.(bool) 72 | if !ok { 73 | return false, fmt.Errorf("the key's ('%v') value can not be casted to bool", key) 74 | } 75 | 76 | return vCasted, nil 77 | } 78 | -------------------------------------------------------------------------------- /src/pkg/shelltools/shelltools.go: -------------------------------------------------------------------------------- 1 | package shelltools 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "net.kopias.oscbridge/app/pkg/slicetools" 8 | ) 9 | 10 | // EscapeShellArg is based on the same function in php 11 | // https://github.com/php/php-src/blob/master/ext/standard/exec.c#L388 12 | func EscapeShellArg(arg string) (string, error) { 13 | if len(arg) > 4096-2-1 { 14 | return "", fmt.Errorf("argument exceeds the allowed length of 4096 bytes. (%s)", arg) 15 | } 16 | result := strings.Builder{} 17 | result.WriteString("'") 18 | argValidUtf8 := []rune(strings.ToValidUTF8(arg, "")) 19 | 20 | //nolint:staticcheck,gosimple 21 | for _, r := range argValidUtf8 { 22 | switch r { 23 | case []rune("'")[0]: 24 | result.WriteRune(r) 25 | result.WriteString("\\") 26 | result.WriteRune(r) 27 | result.WriteRune(r) 28 | default: 29 | result.WriteRune(r) 30 | } 31 | } 32 | result.WriteString("'") 33 | 34 | if result.Len() > 4096-1 { 35 | return "", fmt.Errorf("escaped argument exceeds the allowed length of 4096 bytes (%s)", arg) 36 | } 37 | return result.String(), nil 38 | } 39 | 40 | func EscapeShellArgImplode(args []string) (string, error) { 41 | var err error 42 | var escaped string 43 | 44 | result := strings.Join( 45 | slicetools.Map(args, func(arg string) string { 46 | if err != nil { 47 | return "" 48 | } 49 | escaped, err = EscapeShellArg(arg) 50 | return escaped 51 | }), 52 | " ") 53 | 54 | if err != nil { 55 | return "", err 56 | } 57 | return result, nil 58 | } 59 | -------------------------------------------------------------------------------- /src/pkg/slicetools/slicetools.go: -------------------------------------------------------------------------------- 1 | package slicetools 2 | 3 | func Map[T any, U any](slice []T, cb func(T) U) []U { 4 | r := make([]U, len(slice)) 5 | for i, v := range slice { 6 | r[i] = cb(v) 7 | } 8 | return r 9 | } 10 | 11 | func FindOne[T any](slice []T, cb func(T) bool) *T { 12 | for _, v := range slice { 13 | if cb(v) { 14 | return &v 15 | } 16 | } 17 | return nil 18 | } 19 | 20 | func Filter[T any](slice []T, cb func(T) bool) []T { 21 | r := []T{} 22 | for _, v := range slice { 23 | if cb(v) { 24 | r = append(r, v) 25 | } 26 | } 27 | return r 28 | } 29 | 30 | func FilterOnType[T any, Q any](slice []T, cb func(T) (Q, bool)) []Q { 31 | r := []Q{} 32 | var now Q 33 | var ok bool 34 | for _, v := range slice { 35 | if now, ok = cb(v); ok { 36 | r = append(r, now) 37 | } 38 | } 39 | return r 40 | } 41 | 42 | func Reduce[T, U any](slice []T, init U, cb func(U, T) U) U { 43 | r := init 44 | for _, v := range slice { 45 | r = cb(r, v) 46 | } 47 | return r 48 | } 49 | 50 | func IndexOf[T comparable](slice []T, needle T) int { 51 | for index, v := range slice { 52 | if v == needle { 53 | return index 54 | } 55 | } 56 | return -1 57 | } 58 | 59 | func Contains[T comparable](slice []T, element T) bool { 60 | return IndexOf(slice, element) != -1 61 | } 62 | 63 | func ContainsTheSameElements[T comparable](a []T, b []T) bool { 64 | if len(a) != len(b) { 65 | return false 66 | } 67 | 68 | outer: 69 | for ai := range a { 70 | for bi := range b { 71 | if a[ai] == b[bi] { 72 | continue outer 73 | } 74 | } 75 | return false 76 | } 77 | 78 | return true 79 | } 80 | 81 | func Diff[T comparable](a []T, b []T) []T { 82 | diff := []T{} 83 | var found bool 84 | for _, aElement := range a { 85 | found = false 86 | for _, bElement := range b { 87 | if aElement == bElement { 88 | found = true 89 | break 90 | } 91 | } 92 | if !found { 93 | diff = append(diff, aElement) 94 | } 95 | } 96 | 97 | return diff 98 | } 99 | 100 | func SmallestItem[T int | int64 | int32 | float32 | float64](slice []T) (int, *T) { 101 | if len(slice) == 0 { 102 | return -1, nil 103 | } 104 | 105 | smallest := slice[0] 106 | index := 0 107 | for i, element := range slice { 108 | if element < smallest { 109 | smallest = element 110 | index = i 111 | } 112 | } 113 | 114 | return index, &smallest 115 | } 116 | 117 | func LargestItem[T int | int64 | int32 | float32 | float64](slice []T) (int, *T) { 118 | if len(slice) == 0 { 119 | return -1, nil 120 | } 121 | 122 | smallest := slice[0] 123 | index := 0 124 | for i, element := range slice { 125 | if element > smallest { 126 | smallest = element 127 | index = i 128 | } 129 | } 130 | 131 | return index, &smallest 132 | } 133 | -------------------------------------------------------------------------------- /src/pkg/stringtools/stringtools.go: -------------------------------------------------------------------------------- 1 | package stringtools 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // GenerateUID returns a compressed uid like string in the length of [length]. 12 | func GenerateUID(length int) string { 13 | if length > 32 { 14 | length = 32 15 | } 16 | return strings.ReplaceAll(uuid.New().String(), "-", "")[0:length] 17 | } 18 | 19 | // JSONPrettyPrint pretty-prints the given object in JSON format. 20 | func JSONPrettyPrint(data interface{}) (string, error) { 21 | val, err := json.MarshalIndent(data, "", " ") 22 | if err != nil { 23 | return "", fmt.Errorf("unable to marshal json: %w", err) 24 | } 25 | return string(val), nil 26 | } 27 | -------------------------------------------------------------------------------- /src/usecase/context.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "net.kopias.oscbridge/app/pkg/stringtools" 8 | ) 9 | 10 | type contextKey string 11 | 12 | func getTaskExecutionSessionContext(ctx context.Context) context.Context { 13 | return context.WithValue(ctx, contextKey("task_exec_session"), stringtools.GenerateUID(6)) 14 | } 15 | 16 | func GetContextualLogPrefixer(ctx context.Context) []string { 17 | result := []string{} 18 | 19 | if value := ctx.Value(contextKey("task_exec_session")); value != nil { 20 | result = append(result, fmt.Sprintf("T:%s", value)) 21 | } 22 | 23 | return result 24 | } 25 | -------------------------------------------------------------------------------- /src/usecase/osc_listener.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "net.kopias.oscbridge/app/entities" 8 | 9 | "net.kopias.oscbridge/app/usecase/usecaseifs" 10 | ) 11 | 12 | var _ iusecase = &oscListener{} 13 | 14 | // oscListener is watching for new incoming messages from all the different connections. 15 | type oscListener struct { 16 | ucs *UseCases 17 | log usecaseifs.ILogger 18 | cfg usecaseifs.IConfiguration 19 | 20 | oscConnections []entities.OscConnectionDetails 21 | quit chan interface{} 22 | } 23 | 24 | func newOscListener(log usecaseifs.ILogger, cfg usecaseifs.IConfiguration, oscConnections []entities.OscConnectionDetails) *oscListener { 25 | return &oscListener{ 26 | log: log, 27 | cfg: cfg, 28 | oscConnections: oscConnections, 29 | quit: make(chan interface{}, 1), 30 | } 31 | } 32 | 33 | func (e *oscListener) setUseCases(ucs *UseCases) { 34 | e.ucs = ucs 35 | } 36 | 37 | func (e *oscListener) Start(ctx context.Context) error { 38 | go e.listeningLoop(ctx) 39 | return nil 40 | } 41 | 42 | func (e *oscListener) Stop(ctx context.Context) error { 43 | e.quit <- true 44 | return nil 45 | } 46 | 47 | func (e *oscListener) listeningLoop(ctx context.Context) { 48 | for { 49 | // 50 | for _, cd := range e.oscConnections { 51 | // fmt.Println("CHECKING ON ", cd.Name) 52 | select { 53 | case msg := <-cd.Connection.GetEventChan(ctx): 54 | // e.log.Infof(ctx, "Incoming message from: %s: %s", cd.Name, msg.String()) 55 | 56 | prefixedMessage := entities.NewPrefixedOSCMessage(cd.Prefix, msg) 57 | 58 | e.ucs.oscMessageStore.updateRecord(ctx, prefixedMessage) 59 | 60 | case <-e.quit: 61 | return 62 | 63 | default: 64 | } 65 | 66 | time.Sleep(10 * time.Millisecond) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/usecase/osc_message_store_manager.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "net.kopias.oscbridge/app/drivers/osc_message" 11 | 12 | "net.kopias.oscbridge/app/pkg/filetools" 13 | 14 | "net.kopias.oscbridge/app/usecase/usecaseifs" 15 | ) 16 | 17 | var _ iusecase = &oscMessageStoreManager{} 18 | 19 | type serializedMessage struct { 20 | Address string 21 | Arguments []serializedArgument 22 | } 23 | type serializedArgument struct { 24 | Type string 25 | Value string 26 | } 27 | 28 | // oscMessageStoreManager is resposnible for managing store updates and state. 29 | type oscMessageStoreManager struct { 30 | ucs *UseCases 31 | log usecaseifs.ILogger 32 | cfg usecaseifs.IConfiguration 33 | 34 | store usecaseifs.IMessageStore 35 | storeVersion int 36 | actions []usecaseifs.IAction 37 | storePersistPath string 38 | notify chan error 39 | quit chan interface{} 40 | } 41 | 42 | func newOscMessageStoreManager( 43 | log usecaseifs.ILogger, 44 | cfg usecaseifs.IConfiguration, 45 | store usecaseifs.IMessageStore, 46 | actions []usecaseifs.IAction, 47 | storePersistPath string, 48 | ) *oscMessageStoreManager { 49 | return &oscMessageStoreManager{ 50 | log: log, 51 | cfg: cfg, 52 | actions: actions, 53 | store: store, 54 | storeVersion: 0, 55 | storePersistPath: storePersistPath, 56 | notify: make(chan error, 1), 57 | quit: make(chan interface{}, 1), 58 | } 59 | } 60 | 61 | func (e *oscMessageStoreManager) Start(ctx context.Context) error { 62 | if e.storePersistPath != "" { 63 | if err := e.loadJSONDump(ctx); err != nil { 64 | return fmt.Errorf("failed to load persistence file: %w", err) 65 | } 66 | } 67 | 68 | go e.jsonSync(ctx) 69 | return nil 70 | } 71 | 72 | // jsonSync dumps the store into a json file every sync. 73 | func (e *oscMessageStoreManager) jsonSync(ctx context.Context) { 74 | if e.storePersistPath == "" { 75 | return 76 | } 77 | 78 | lastStoreVersion := e.storeVersion 79 | for { 80 | select { 81 | case <-e.quit: 82 | e.log.Info(ctx, "Message store JSON syncing quiting...") 83 | return 84 | default: 85 | } 86 | 87 | if lastStoreVersion != e.storeVersion { 88 | e.dumpStoreToJSON(ctx) 89 | lastStoreVersion = e.storeVersion 90 | } 91 | time.Sleep(1 * time.Second) 92 | } 93 | } 94 | 95 | func (e *oscMessageStoreManager) setUseCases(ucs *UseCases) { 96 | e.ucs = ucs 97 | } 98 | 99 | func (e *oscMessageStoreManager) updateRecord(ctx context.Context, msg usecaseifs.IOSCMessage) { 100 | if e.store.SetRecord(msg) { 101 | e.storeVersion++ 102 | 103 | e.log.Infof(ctx, "Store updated with: %v", msg) 104 | go e.evaluateActions(ctx, msg) 105 | } 106 | } 107 | 108 | func (e *oscMessageStoreManager) evaluateActions(ctx context.Context, latestUpdatedMessage usecaseifs.IOSCMessage) { 109 | ctx = getTaskExecutionSessionContext(ctx) 110 | currentStore := e.store.Clone() 111 | currentStore.WatchRecordAccess(&latestUpdatedMessage) 112 | 113 | if e.cfg.ShouldDebugOSCConditions() { 114 | e.log.Info(ctx, "Evaluating actions because of a change in the osc message store.") 115 | } 116 | for _, action := range e.actions { 117 | e.evaluateAction(ctx, action, currentStore) 118 | } 119 | 120 | e.log.Info(ctx, "finished.") 121 | } 122 | 123 | func (e *oscMessageStoreManager) evaluateAction(ctx context.Context, action usecaseifs.IAction, currentStore usecaseifs.IMessageStore) { 124 | if e.cfg.ShouldDebugOSCConditions() { 125 | e.log.Infof(ctx, "Evaluating action: %s", action.GetName()) 126 | } 127 | matched, err := action.Evaluate(ctx, currentStore) 128 | if err != nil { 129 | e.log.Err(ctx, fmt.Errorf("error during evaluation of %s: %w", action.GetName(), err)) 130 | } 131 | 132 | if !matched { 133 | return 134 | } 135 | 136 | if currentStore.GetWatchedRecordAccesses() == 0 { 137 | e.log.Infof(ctx, "Although the triggers matched, none of them selected the newly changed record, therefore skipping execution.") 138 | return 139 | } 140 | 141 | if action.GetDebounceMillis() != 0 { 142 | time.Sleep(time.Duration(action.GetDebounceMillis()) * time.Millisecond) 143 | matched, err = action.Evaluate(ctx, currentStore) 144 | if err != nil { 145 | e.log.Err(ctx, fmt.Errorf("error during evaluation of %s: %w", action.GetName(), err)) 146 | } 147 | 148 | if e.cfg.ShouldDebugOSCConditions() { 149 | e.log.Infof(ctx, "After debouncing for %d ms, the result is: %t", action.GetDebounceMillis(), matched) 150 | } 151 | 152 | if !matched { 153 | return 154 | } 155 | } 156 | 157 | e.log.Infof(ctx, "Executing action: %s", action.GetName()) 158 | if err := action.Execute(ctx, currentStore); err != nil { 159 | e.log.Err(ctx, err) 160 | } 161 | } 162 | 163 | // Notify returns the notification channel that can be used to listen for the client's exit 164 | func (e *oscMessageStoreManager) Notify() <-chan error { 165 | return e.notify 166 | } 167 | 168 | func (e *oscMessageStoreManager) Stop(ctx context.Context) { 169 | e.quit <- true 170 | } 171 | 172 | func (e *oscMessageStoreManager) dumpStoreToJSON(ctx context.Context) { 173 | serialized := []serializedMessage{} 174 | for _, record := range e.store.GetAll() { 175 | if record.GetMessage() == nil { 176 | continue 177 | } 178 | 179 | msg := serializedMessage{ 180 | Address: record.GetMessage().GetAddress(), 181 | Arguments: []serializedArgument{}, 182 | } 183 | 184 | for _, argument := range record.GetMessage().GetArguments() { 185 | msg.Arguments = append(msg.Arguments, serializedArgument{ 186 | Type: argument.GetType(), 187 | Value: argument.GetValue(), 188 | }) 189 | } 190 | serialized = append(serialized, msg) 191 | } 192 | 193 | jsonData, err := json.MarshalIndent(serialized, "", " ") 194 | if err != nil { 195 | e.log.Err(ctx, fmt.Errorf("failed to marshal store: %w", err)) 196 | return 197 | } 198 | // Create or open the file for writing 199 | file, err := os.Create(e.storePersistPath) 200 | if err != nil { 201 | e.log.Err(ctx, fmt.Errorf("failed to create json dump to %s: %w", e.storePersistPath, err)) 202 | return 203 | } 204 | defer file.Close() // Close the file when we're done 205 | 206 | // Write the JSON data to the file 207 | _, err = file.Write(jsonData) 208 | if err != nil { 209 | e.log.Err(ctx, fmt.Errorf("failed to write json dump to %s: %w", e.storePersistPath, err)) 210 | return 211 | } 212 | } 213 | 214 | func (e *oscMessageStoreManager) loadJSONDump(ctx context.Context) error { 215 | unserialized := []serializedMessage{} 216 | 217 | if !filetools.FileExists(e.storePersistPath) { 218 | e.log.Infof(ctx, "Not loading persistence file %s: it does not exist", e.storePersistPath) 219 | return nil 220 | } 221 | content, err := os.ReadFile(e.storePersistPath) 222 | if err != nil { 223 | return fmt.Errorf("error when opening file: %s %w", e.storePersistPath, err) 224 | } 225 | 226 | if err = json.Unmarshal(content, &unserialized); err != nil { 227 | return fmt.Errorf("error when parsing file: %s %w", e.storePersistPath, err) 228 | } 229 | 230 | for _, msg := range unserialized { 231 | args := []usecaseifs.IOSCMessageArgument{} 232 | for _, argument := range msg.Arguments { 233 | args = append(args, osc_message.NewMessageArgument(argument.Type, argument.Value)) 234 | } 235 | e.store.SetRecord(osc_message.NewMessage(msg.Address, args)) 236 | } 237 | e.log.Infof(ctx, "loaded persistence file %s.", e.storePersistPath) 238 | return nil 239 | } 240 | -------------------------------------------------------------------------------- /src/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | 6 | "net.kopias.oscbridge/app/entities" 7 | 8 | "net.kopias.oscbridge/app/usecase/usecaseifs" 9 | ) 10 | 11 | // Private interfaces 12 | type ( 13 | iusecase interface { 14 | setUseCases(cases *UseCases) 15 | } 16 | ) 17 | 18 | var _ usecaseifs.IUseCases = UseCases{} 19 | 20 | // UseCases are the root to all the usecase groups in the system. 21 | type UseCases struct { 22 | oscMessageStore *oscMessageStoreManager 23 | oscListener *oscListener 24 | 25 | notify chan error 26 | quit chan interface{} 27 | log usecaseifs.ILogger 28 | } 29 | 30 | func New( 31 | log usecaseifs.ILogger, 32 | cfg usecaseifs.IConfiguration, 33 | oscConnections []entities.OscConnectionDetails, 34 | store usecaseifs.IMessageStore, 35 | actions []usecaseifs.IAction, 36 | storePersistPath string, 37 | ) *UseCases { 38 | ucs := &UseCases{ 39 | oscMessageStore: newOscMessageStoreManager(log, cfg, store, actions, storePersistPath), 40 | oscListener: newOscListener(log, cfg, oscConnections), 41 | 42 | notify: make(chan error, 1), 43 | quit: make(chan interface{}, 1), 44 | log: log, 45 | } 46 | 47 | // The trick here is, to inject the object itself back to every member, then cross-calling is possible within the usecases. 48 | ucs.oscMessageStore.setUseCases(ucs) 49 | ucs.oscListener.setUseCases(ucs) 50 | 51 | return ucs 52 | } 53 | 54 | func (u UseCases) Start(ctx context.Context) error { 55 | if err := u.oscMessageStore.Start(ctx); err != nil { 56 | return err 57 | } 58 | 59 | return u.oscListener.Start(ctx) 60 | } 61 | 62 | func (u UseCases) Stop(ctx context.Context) { 63 | u.oscMessageStore.Stop(ctx) 64 | 65 | if err := u.oscListener.Stop(ctx); err != nil { 66 | u.log.Err(ctx, err) 67 | } 68 | 69 | u.quit <- true 70 | } 71 | 72 | func (u UseCases) Notify() <-chan error { 73 | return u.oscMessageStore.Notify() 74 | } 75 | -------------------------------------------------------------------------------- /src/usecase/usecaseifs/depends.go: -------------------------------------------------------------------------------- 1 | package usecaseifs 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Adapters are plugins converting to and from the usecases, they are usually found in the "adapters" folder, 9 | // implementing the following interfaces. 10 | type ( 11 | // IConfiguration determines the used configuration values & methods by the use-cases. 12 | IConfiguration interface { 13 | ShouldDebugOSCConditions() bool 14 | } 15 | 16 | // ILogger specifies an interface for general logging. 17 | ILogger interface { 18 | Debugf(ctx context.Context, message string, args ...interface{}) 19 | Debug(ctx context.Context, message string) 20 | Infof(ctx context.Context, message string, args ...interface{}) 21 | Info(ctx context.Context, message string) 22 | Warnf(ctx context.Context, message string, args ...interface{}) 23 | Warn(ctx context.Context, message string) 24 | Fatalf(ctx context.Context, message error, args ...interface{}) 25 | Fatal(ctx context.Context, message error) 26 | Errorf(ctx context.Context, message string, args ...interface{}) 27 | Error(ctx context.Context, message string) 28 | Err(ctx context.Context, err error) 29 | Messsage(ctx context.Context, level string, message string) 30 | } 31 | 32 | IOSCConnection interface { 33 | Start(context.Context) error 34 | Stop(context.Context) 35 | Notify() <-chan error 36 | GetEventChan(ctx context.Context) <-chan IOSCMessage 37 | SendMessage(ctx context.Context, msg IOSCMessage) error 38 | } 39 | 40 | IOSCMessage interface { 41 | Equal(msg IOSCMessage) bool 42 | GetAddress() string 43 | GetArguments() []IOSCMessageArgument 44 | String() string 45 | } 46 | 47 | IOSCMessageArgument interface { 48 | GetType() string 49 | GetValue() string 50 | String() string 51 | } 52 | 53 | IEventPublisher interface { 54 | PublishEvent(ctx context.Context, e IOSCMessage) error 55 | } 56 | ) 57 | 58 | type ( 59 | IOBSRemote interface { 60 | ListScenes(ctx context.Context) ([]string, error) 61 | SwitchPreviewScene(ctx context.Context, sceneName string) error 62 | SwitchProgramScene(ctx context.Context, sceneName string) error 63 | GetCurrentProgramScene(ctx context.Context) (string, error) 64 | GetCurrentPreviewScene(ctx context.Context) (string, error) 65 | IsStreaming(ctx context.Context) (bool, error) 66 | IsRecording(ctx context.Context) (bool, error) 67 | VendorRequest(ctx context.Context, vendorName string, requestType string, requestData interface{}) (responseData interface{}, err error) 68 | } 69 | ) 70 | 71 | type ( 72 | IMessageStore interface { 73 | Clone() IMessageStore 74 | GetAll() map[string]IMessageStoreRecord 75 | GetRecord(key string, trackAccess bool) (IMessageStoreRecord, bool) 76 | GetOneRecordByRegexp(re string, trackAccess bool) (IMessageStoreRecord, error) 77 | GetRecordsByRegexp(re string, trackAccess bool) ([]IMessageStoreRecord, error) 78 | GetRecordsByPrefix(prefix string, trackAccess bool) []IMessageStoreRecord 79 | SetRecord(msg IOSCMessage) (updated bool) 80 | WatchRecordAccess(msg *IOSCMessage) 81 | GetWatchedRecordAccesses() int64 82 | } 83 | 84 | IMessageStoreRecord interface { 85 | GetMessage() IOSCMessage 86 | GetArrivedAt() time.Time 87 | } 88 | 89 | ActionTaskFactory func() IActionTask 90 | 91 | IActionTask interface { 92 | Execute(ctx context.Context, store IMessageStore) error 93 | SetParameters(map[string]interface{}) 94 | Validate() error 95 | } 96 | 97 | IAction interface { 98 | GetName() string 99 | Evaluate(ctx context.Context, store IMessageStore) (bool, error) 100 | Execute(ctx context.Context, store IMessageStore) error 101 | GetDebounceMillis() int64 102 | } 103 | 104 | ActionConditionFactory func(path string) IActionCondition 105 | 106 | IActionCondition interface { 107 | // @TODO notsure if these are right here 108 | SetParameters(map[string]interface{}) 109 | AddChild(condition IActionCondition) 110 | 111 | Evaluate(ctx context.Context, store IMessageStore) (bool, error) 112 | Validate() error 113 | } 114 | ) 115 | -------------------------------------------------------------------------------- /src/usecase/usecaseifs/provides.go: -------------------------------------------------------------------------------- 1 | package usecaseifs 2 | 3 | import "context" 4 | 5 | // Use cases 6 | type ( 7 | // IUseCases is the public facing api of the application's business logic. 8 | IUseCases interface { 9 | Start(context.Context) error 10 | Stop(context.Context) 11 | Notify() <-chan error 12 | } 13 | 14 | IOSCMessageStore interface{} 15 | ) 16 | --------------------------------------------------------------------------------