diff options
author | cbdev <cb@cbcdn.com> | 2021-06-30 03:03:48 +0200 |
---|---|---|
committer | cbdev <cb@cbcdn.com> | 2021-06-30 03:03:48 +0200 |
commit | bc3d80e9e6c038c87a64432586670c663a23e53d (patch) | |
tree | 5a28b0004a7f3492455316f34bb2c783e670f944 | |
parent | 8a0a413f1dd5593189dd6b651babcff9b2495451 (diff) | |
parent | f16f7db86662fcdbf45b6373257c90c824b0b4b0 (diff) | |
download | midimonster-bc3d80e9e6c038c87a64432586670c663a23e53d.tar.gz midimonster-bc3d80e9e6c038c87a64432586670c663a23e53d.tar.bz2 midimonster-bc3d80e9e6c038c87a64432586670c663a23e53d.zip |
Merge branch 'master' into debian/master
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | .travis-ci.sh | 114 | ||||
-rw-r--r-- | .travis.yml | 175 | ||||
-rw-r--r-- | DEVELOPMENT.md | 57 | ||||
-rw-r--r-- | Makefile | 18 | ||||
-rw-r--r-- | README.md | 29 | ||||
-rw-r--r-- | assets/MIDIMonster.svg (renamed from MIDIMonster.svg) | 0 | ||||
-rw-r--r-- | assets/TODO (renamed from TODO) | 9 | ||||
-rw-r--r-- | assets/ci-config | 73 | ||||
-rwxr-xr-x | assets/ci.sh | 313 | ||||
-rw-r--r-- | assets/midimonster.1 (renamed from midimonster.1) | 0 | ||||
-rw-r--r-- | assets/midimonster.ico (renamed from midimonster.ico) | bin | 321510 -> 321510 bytes | |||
-rw-r--r-- | assets/midimonster.rc (renamed from midimonster.rc) | 0 | ||||
-rw-r--r-- | backends/Makefile | 55 | ||||
-rw-r--r-- | backends/artnet.c | 143 | ||||
-rw-r--r-- | backends/artnet.h | 2 | ||||
-rw-r--r-- | backends/artnet.md | 8 | ||||
-rw-r--r-- | backends/evdev.c | 37 | ||||
-rw-r--r-- | backends/evdev.md | 3 | ||||
-rw-r--r-- | backends/jack.c | 174 | ||||
-rw-r--r-- | backends/jack.h | 22 | ||||
-rw-r--r-- | backends/jack.md | 17 | ||||
-rw-r--r-- | backends/libmmbackend.c | 8 | ||||
-rw-r--r-- | backends/lua.c | 50 | ||||
-rw-r--r-- | backends/lua.md | 5 | ||||
-rw-r--r-- | backends/maweb.c | 114 | ||||
-rw-r--r-- | backends/maweb.h | 2 | ||||
-rw-r--r-- | backends/maweb.md | 2 | ||||
-rw-r--r-- | backends/midi.c | 220 | ||||
-rw-r--r-- | backends/midi.h | 14 | ||||
-rw-r--r-- | backends/midi.md | 25 | ||||
-rw-r--r-- | backends/mqtt.c | 1006 | ||||
-rw-r--r-- | backends/mqtt.h | 87 | ||||
-rw-r--r-- | backends/mqtt.md | 85 | ||||
-rw-r--r-- | backends/openpixelcontrol.md | 4 | ||||
-rw-r--r-- | backends/osc.c | 3 | ||||
-rw-r--r-- | backends/osc.md | 2 | ||||
-rw-r--r-- | backends/python.c | 36 | ||||
-rw-r--r-- | backends/python.md | 5 | ||||
-rw-r--r-- | backends/rtpmidi.c | 214 | ||||
-rw-r--r-- | backends/rtpmidi.h | 23 | ||||
-rw-r--r-- | backends/rtpmidi.md | 17 | ||||
-rw-r--r-- | backends/sacn.c | 91 | ||||
-rw-r--r-- | backends/sacn.h | 1 | ||||
-rw-r--r-- | backends/sacn.md | 5 | ||||
-rw-r--r-- | backends/visca.c | 466 | ||||
-rw-r--r-- | backends/visca.h | 94 | ||||
-rw-r--r-- | backends/visca.md | 70 | ||||
-rw-r--r-- | backends/wininput.c | 766 | ||||
-rw-r--r-- | backends/wininput.h | 54 | ||||
-rw-r--r-- | backends/wininput.md | 135 | ||||
-rw-r--r-- | backends/winmidi.c | 218 | ||||
-rw-r--r-- | backends/winmidi.h | 22 | ||||
-rw-r--r-- | backends/winmidi.md | 25 | ||||
-rw-r--r-- | configs/launchctl-sacn.cfg | 3 | ||||
-rw-r--r-- | configs/midi-gamepad.cfg | 25 | ||||
-rw-r--r-- | configs/pyexample.py | 8 | ||||
-rw-r--r-- | configs/returnone.lua | 24 | ||||
-rw-r--r-- | configs/scripting-example.cfg | 22 | ||||
-rw-r--r-- | configs/trackpad.lua | 59 | ||||
-rw-r--r-- | configs/visca.cfg | 34 | ||||
-rw-r--r-- | core/backend.c (renamed from backend.c) | 61 | ||||
-rw-r--r-- | core/backend.h (renamed from backend.h) | 1 | ||||
-rw-r--r-- | core/config.c (renamed from config.c) | 117 | ||||
-rw-r--r-- | core/config.h (renamed from config.h) | 9 | ||||
-rw-r--r-- | core/plugin.c (renamed from plugin.c) | 0 | ||||
-rw-r--r-- | core/plugin.h (renamed from plugin.h) | 0 | ||||
-rwxr-xr-x | installer.sh | 88 | ||||
-rw-r--r-- | midimonster.c | 19 | ||||
-rw-r--r-- | midimonster.h | 29 |
70 files changed, 4921 insertions, 698 deletions
@@ -5,3 +5,5 @@ libmmapi.a *.o *.so *.dll +__pycache__ +.vscode/
\ No newline at end of file diff --git a/.travis-ci.sh b/.travis-ci.sh deleted file mode 100644 index 8f6a5ca..0000000 --- a/.travis-ci.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash - -if [ "$TASK" = "spellcheck" ]; then - result=0 - # Create list of files to be spellchecked - spellcheck_files=$(find . -type f | grep -v ".git/") - - # Run spellintian to find spelling errors - sl_results=$(xargs spellintian 2>&1 <<< "$spellcheck_files") - - sl_errors=$(wc -l <<< "$sl_results") - sl_errors_dups=$((grep "\(duplicate word\)" | wc -l) <<< "$sl_results") - sl_errors_nodups=$((grep -v "\(duplicate word\)" | wc -l) <<< "$sl_results") - - if [ "$sl_errors" -ne 0 ]; then - printf "Spellintian found %s errors (%s spelling, %s duplicate words):\n\n" "$sl_errors" "$sl_errors_nodups" "$sl_errors_dups" - printf "%s\n\n" "$sl_results" - result=1 - else - printf "Spellintian reports no errors\n" - fi - - # Run codespell to find some more - cs_results=$(xargs codespell --quiet 2 <<< "$spellcheck_files" 2>&1) - cs_errors=$(wc -l <<< "$cs_results") - if [ "$cs_errors" -ne 0 ]; then - printf "Codespell found %s errors:\n\n" "$cs_errors" - printf "%s\n\n" "$cs_results" - result=1 - else - printf "Codespell reports no errors\n" - fi - exit "$result" -elif [ "$TASK" = "codesmell" ]; then - result=0 - - if [ -z "$(which lizard)" ]; then - printf "Installing lizard...\n" - pip3 install lizard - fi - - # Run shellcheck for all shell scripts - printf "Running shellcheck...\n" - shell_files="$(find . -type f -iname \*.sh)" - xargs shellcheck -Cnever -s bash <<< "$shell_files" - if [ "$?" -ne "0" ]; then - result=1 - fi - - # Run cloc for some stats - printf "Code statistics:\n\n" - cloc ./ - - # Run lizard for the project - printf "Running lizard for code complexity analysis\n" - lizard ./ - if [ "$?" -ne "0" ]; then - result=1 - fi - - exit "$result" -elif [ "$TASK" = "sanitize" ]; then - # Run sanitized compile - travis_fold start "make_sanitize" - if ! make sanitize; then - exit "$?" - fi - travis_fold end "make_sanitize" -elif [ "$TASK" = "windows" ]; then - travis_fold start "make_windows" - if ! make windows; then - exit "$?" - fi - make -C backends lua.dll - travis_fold end "make_windows" - if [ "$(git describe)" == "$(git describe --abbrev=0)" ]; then - travis_fold start "deploy_windows" - mkdir ./deployment - mkdir ./deployment/backends - mkdir ./deployment/docs - cp ./midimonster.exe ./deployment/ - cp ./backends/*.dll ./deployment/backends/ - cp ./backends/*.dll.disabled ./deployment/backends/ - cp ./monster.cfg ./deployment/monster.cfg - cp ./backends/*.md ./deployment/docs/ - cp -r ./configs ./deployment/ - cd ./deployment - zip -r "./midimonster-$(git describe)-windows.zip" "./" - find . ! -iname '*.zip' -delete - travis_fold end "deploy_windows" - fi -else - # Otherwise compile as normal - travis_fold start "make" - if ! make full; then - exit "$?" - fi - travis_fold end "make" - if [ "$(git describe)" == "$(git describe --abbrev=0)" ]; then - travis_fold start "deploy_unix" - mkdir ./deployment - mkdir ./deployment/backends - mkdir ./deployment/docs - cp ./midimonster ./deployment/ - cp ./backends/*.so ./deployment/backends/ - cp ./monster.cfg ./deployment/monster.cfg - cp ./backends/*.md ./deployment/docs/ - cp -r ./configs ./deployment/ - cd ./deployment - tar czf "midimonster-$(git describe)-$TRAVIS_OS_NAME.tgz" ./ - find . ! -iname '*.tgz' -delete - travis_fold end "deploy_unix" - fi -fi diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4e14339..0000000 --- a/.travis.yml +++ /dev/null @@ -1,175 +0,0 @@ -language: c -group: edge -os: linux -dist: bionic - -before_script: - - export -f travis_fold - -script: - - "bash .travis-ci.sh" - -addons: - apt: - packages: &base_build - - ccache - packages: &core_build - # This is all the bits we need to enable all options - - *base_build - - libasound2-dev - - libevdev-dev - - libola-dev - - libjack-jackd2-dev - - liblua5.3-dev - - python3-dev - - libssl-dev - - lintian - packages: &core_build_gpp_latest - - *core_build - - gcc-8 - - g++-8 - packages: &core_build_clang_latest - - *core_build - - clang-6.0 - packages: &core_build_windows - - *core_build - - mingw-w64 - packages: &linters - - python3 - - python3-pip - - lintian - - codespell - - shellcheck - - cloc - -jobs: - fast_finish: true - include: - - os: linux - dist: bionic - compiler: clang - env: TASK='compile' - addons: - apt: - packages: - - *core_build_clang_latest - - os: linux - dist: bionic - compiler: gcc - env: TASK='compile' - addons: - apt: - packages: - - *core_build_gpp_latest - - os: linux - dist: bionic - compiler: mingw32-gcc - env: - - TASK='windows' - - CC='x86_64-w64-mingw32-gcc' - addons: - apt: - packages: - - *core_build_windows - - os: linux - dist: bionic - compiler: clang - env: TASK='sanitize' - addons: - apt: - packages: - - *core_build_clang_latest - - os: osx - osx_image: xcode10.2 - compiler: clang - env: - - TASK='compile' - - os: osx - osx_image: xcode10.2 - compiler: gcc - env: - - TASK='compile' - - os: osx - osx_image: xcode10.2 - compiler: clang - env: - - TASK='sanitize' - - os: linux - dist: bionic - env: TASK='codesmell' - addons: - apt: - packages: - - *linters - - os: linux - dist: bionic - env: TASK='spellcheck' - addons: - apt: - packages: - - *linters - allow_failures: - - os: linux - dist: bionic - env: TASK='codesmell' - - os: linux - dist: bionic - env: TASK='spellcheck' - -env: - global: - # No colours in terminal (to reduce log file size) - - TERM=dumb - # Parallel make build - - MAKEFLAGS="-j 2" - -cache: - apt: true - directories: - - $HOME/.ccache # ccache cache - -before_cache: - - ccache -s # see how many hits ccache got - -before_install: -# Travis clones with --branch, which omits tags. Since we use them for the version string at build time, fetch them - - git pull --tags - - printf "This is %s on %s\n" "$(git describe)" "$TRAVIS_OS_NAME" - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update; fi -# 'brew install' sometimes returns non-zero for some arcane reason. Executing 'true' resets the exit code and allows Travis to continue building... -# Travis seems to have Python 2.7 installed by default, which for some reason prevents pkg-config from reading python3.pc - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew install ccache ola lua openssl jack python3; brew link --overwrite python; true; fi -# OpenSSL is not a proper install due to some Apple bull, so provide additional locations via the environment... -# Additionally, newer versions of this "recipe" seem to use the name 'openssl@1.1' instead of plain 'openssl' and there seems to be -# no way to programmatically get the link and include paths. Genius! Hardcoding the new version for the time being... - - export CFLAGS="$CFLAGS -I/usr/local/opt/openssl@1.1/include" - - export LDFLAGS="$LDFLAGS -L/usr/local/opt/openssl@1.1/lib" - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then PATH=/usr/local/opt/ccache/libexec:$PATH; fi # Use ccache on Mac too - - if [ "$TRAVIS_OS_NAME" == "linux" -a \( "$TASK" = "compile" -o "$TASK" = "sanitize" \) -a "$CC" = "gcc" ]; then export CC="ccache gcc-8"; export CXX="ccache g++-8"; fi -#Use the latest clang if we're compiling with clang - - if [ "$TRAVIS_OS_NAME" == "linux" -a "$CC" = "clang" ]; then export CC="clang-6.0"; export CXX="clang-6.0"; fi -#Report the compiler versions - - $CC --version -#OS X uses something other than $CXX variable - - if [ "$TRAVIS_OS_NAME" == "linux" -a \( "$TASK" = "compile" -o "$TASK" = "sanitize" \) ]; then $CXX --version; fi -# Download libraries to link with on Windows - - if [ "$TASK" == "windows" ]; then wget "https://downloads.sourceforge.net/project/luabinaries/5.3.5/Windows%20Libraries/Dynamic/lua-5.3.5_Win64_dllw6_lib.zip" -O lua53.zip; unzip lua53.zip lua53.dll; fi - -notifications: - irc: - channels: - - "irc.hackint.org#midimonster" - on_success: change # default: always - on_failure: always # default: always - nick: mm_ci - use_notice: true - -deploy: - provider: releases - file_glob: true - token: $GITHUB_TOKEN - file: ./deployment/* - skip_cleanup: true - draft: true - on: - tags: true diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 79005a9..a2ec6a2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,8 +1,7 @@ # MIDIMonster development guide This document serves as a reference for contributors interested in the low-level implementation -of the MIDIMonster. It is currently a work in progress and will be extended as problems come -up and need solving ;) +of the MIDIMonster. It will be extended as problems come up and need solving ;) ## Basics @@ -19,6 +18,7 @@ in spirit rather than by the letter. * Commit messages should be in the imperative voice ("When applied, this commit will: "). * The working language for this repository is english. * External dependencies are only acceptable when necessary and available from package repositories. + * Note that external dependencies make OS portability complicated ### Code style @@ -43,8 +43,55 @@ in spirit rather than by the letter. * Avoid `atoi()`/`itoa()`, use `strto[u]l[l]()` and `snprintf()` * Avoid unsafe functions without explicit bounds parameters (eg. `strcat()`). -# Build pipeline +## Repository layout -# Architecture +* Keep the root directory as clean as possible + * Files that are not related directly to the MIDIMonster implementation go into the `assets/` directory +* Prefer vendor-neutral names for configuration files where necessary -# Debugging +## Build pipeline + +* The primary build pipeline is `make` + +## Architecture + +* Strive to make backends platform-portable + * If that is not possible, try to keep the backend configuration compatible to other backends implementing the same protocol +* If there is significant potential for sharing functionality between backends, consider implementing it in `libmmbackend` +* Place a premium on keeping the MIDIMonster a lightweight tool in terms of installed dependencies and core functionality + * If possible, prefer a local implementation to one which requires additional (dynamic) dependencies + +## Language & Communication + +* All visible communication (ie. error messages, debug messages) should be complete, correct english sentences +* Strive for each output to have a concrete benefit or information to the reader + * Corollary: If nothing happens, don't send output + * Debug messages are somewhat exempt from this guideline +* For error messages, give enough context to reasonably allow the user to either track down the problem or report a meaningful issue + +# Packaging + +Packaging the MIDIMonster for release in distributions is an important task. It facilitates easy access to +the MIDIMonster functionality to a wide audience. This section is not strictly relevant for development, but touches +on some of the same principles. + +As the MIDIMonster is a tool designed for interfacing between several different protocols, applications and +other tools, to use "all" functionality of the MIDIMonster would imply installing additional software the user +might not actually need. This runs counter to our goal of staying a lightweight tool for translation and control. + +The recommended way to package the MIDIMonster for binary distribution would be to split the build artifacts into +multiple packages, separating out the heavier dependencies into separately installable units. If that is not an option, +marking external dependencies of backends as `optional` or `recommended` should be preferred to having them required +to be installed. + +Some backends have been marked optional in the repository and are only built when using `make full`. + +The recommended grouping into packaging units is as follows (without regard to platform compatibility, which +may further impact the grouping): + +* Package `midimonster`: Core, Backends `evdev`, `artnet`, `osc`, `loopback`, `sacn`, `maweb`, `openpixelcontrol`, `rtpmidi`, `visca`, `mqtt` + * External dependencies: `libevdev`, `openssl` +* Package `midimonster-programming`: Backends `lua`, `python` + * External dependencies: `liblua`, `python3` +* Package `midimonster-media`: `midi`, `jack`, `ola` + * External dependencies: `libasound2`, `libjack-jackd2`, `libola` @@ -1,5 +1,5 @@ .PHONY: all clean run sanitize backends windows full backends-full install -OBJS = config.o backend.o plugin.o +OBJS = core/config.o core/backend.o core/plugin.o PREFIX ?= /usr PLUGIN_INSTALL = $(PREFIX)/lib/midimonster @@ -13,12 +13,16 @@ CFLAGS ?= -g -Wall -Wpedantic # Hide all non-API symbols for export CFLAGS += -fvisibility=hidden +# Subdirectory objects need the include path +RCCFLAGS += -I./ +core/%: CFLAGS += -I./ + midimonster: LDLIBS = -ldl # Replace version string with current git-describe if possible ifneq "$(GITVERSION)" "" midimonster: CFLAGS += -DMIDIMONSTER_VERSION=\"$(GITVERSION)\" midimonster.exe: CFLAGS += -DMIDIMONSTER_VERSION=\"$(GITVERSION)\" -resource.o: RCCFLAGS += -DMIDIMONSTER_VERSION=\\\"$(GITVERSION)\\\" +assets/resource.o: RCCFLAGS += -DMIDIMONSTER_VERSION=\\\"$(GITVERSION)\\\" endif # Work around strange linker passing convention differences in Linux and OSX @@ -55,10 +59,10 @@ backends-full: midimonster: midimonster.c portability.h $(OBJS) $(CC) $(CFLAGS) $(LDFLAGS) $< $(OBJS) $(LDLIBS) -o $@ -resource.o: midimonster.rc midimonster.ico +assets/resource.o: assets/midimonster.rc assets/midimonster.ico $(RCC) $(RCCFLAGS) $< -o $@ --output-format=coff -midimonster.ico: MIDIMonster.svg +assets/midimonster.ico: assets/MIDIMonster.svg convert -density 384 $< -define icon:auto-resize $@ midimonster.exe: export CC = x86_64-w64-mingw32-gcc @@ -66,14 +70,14 @@ midimonster.exe: RCC ?= x86_64-w64-mingw32-windres midimonster.exe: CFLAGS += -Wno-format midimonster.exe: LDLIBS = -lws2_32 midimonster.exe: LDFLAGS += -Wl,--out-implib,libmmapi.a -midimonster.exe: midimonster.c portability.h $(OBJS) resource.o - $(CC) $(CFLAGS) $(LDFLAGS) $< $(OBJS) resource.o $(LDLIBS) -o $@ +midimonster.exe: midimonster.c portability.h $(OBJS) assets/resource.o + $(CC) $(CFLAGS) $(LDFLAGS) $< $(OBJS) assets/resource.o $(LDLIBS) -o $@ clean: $(RM) midimonster $(RM) midimonster.exe $(RM) libmmapi.a - $(RM) resource.o + $(RM) assets/resource.o $(RM) $(OBJS) $(MAKE) -C backends clean @@ -1,8 +1,8 @@ # The MIDIMonster -<img align="right" src="/MIDIMonster.svg?raw=true&sanitize=true" alt="MIDIMonster Logo" width="20%"> +<img align="right" src="/assets/MIDIMonster.svg?raw=true&sanitize=true" alt="MIDIMonster Logo" width="20%"> -[![Build Status](https://travis-ci.com/cbdevnet/midimonster.svg?branch=master)](https://travis-ci.com/cbdevnet/midimonster) [![Coverity Scan Build Status](https://scan.coverity.com/projects/15168/badge.svg)](https://scan.coverity.com/projects/15168) +[![CI Pipeline Status](https://ci.spacecdn.de/buildStatus/icon?job=midimonster%2Fmaster)](https://ci.spacecdn.de/blue/organizations/jenkins/midimonster/activity) [![IRC Channel](https://static.midimonster.net/hackint-badge.svg)](https://webirc.hackint.org/#irc://irc.hackint.org/#midimonster) Named for its scary math, the MIDIMonster is a universal control and translation @@ -16,12 +16,14 @@ Currently, the MIDIMonster supports the following protocols: | ArtNet | Linux, Windows, OSX | Version 4 | [`artnet`](backends/artnet.md) | | Streaming ACN (sACN / E1.31) | Linux, Windows, OSX | | [`sacn`](backends/sacn.md) | | OpenSoundControl (OSC) | Linux, Windows, OSX | | [`osc`](backends/osc.md) | +| MQTT | Linux, Windows, OSX | Protocol versions 5 and 3.1.1 | [`mqtt`](backends/mqtt.md) | | RTP-MIDI | Linux, Windows, OSX | AppleMIDI sessions supported | [`rtpmidi`](backends/rtpmidi.md) | | OpenPixelControl | Linux, Windows, OSX | 8 Bit & 16 Bit modes | [`openpixelcontrol`](backends/openpixelcontrol.md) | -| evdev input devices | Linux | Virtual output supported | [`evdev`](backends/evdev.md) | +| Input devices (Mouse, Keyboard, etc)| Linux, Windows | | [`evdev`](backends/evdev.md), [`wininput`](backends/wininput.md) | | Open Lighting Architecture | Linux, OSX | | [`ola`](backends/ola.md) | | MA Lighting Web Remote | Linux, Windows, OSX | GrandMA2 and dot2 (incl. OnPC) | [`maweb`](backends/maweb.md) | | JACK/LV2 Control Voltage (CV) | Linux, OSX | | [`jack`](backends/jack.md) | +| VISCA | Linux, Windows, OSX | PTZ Camera control over TCP/UDP | [`visca`](backends/visca.md) | | Lua Scripting | Linux, Windows, OSX | | [`lua`](backends/lua.md) | | Python Scripting | Linux, OSX | | [`python`](backends/python.md) | | Loopback | Linux, Windows, OSX | | [`loopback`](backends/loopback.md) | @@ -122,18 +124,25 @@ The last line is a shorter way to create a bi-directional mapping. ### Multi-channel mapping -To make mapping large contiguous sets of channels easier, channel names may contain -expressions of the form `{<start>..<end>}`, with *start* and *end* being positive integers -delimiting a range of channels. Multiple such expressions may be used in one channel -specification, with the rightmost expression being incremented (or decremented) first for -evaluation. +To make mapping large contiguous sets of channels easier, channel names may contain certain +types of expressions specifying multiple channels at once. + +Expressions of the form `{<start>..<end>}`, with *start* and *end* being positive integers, +expand to a range of channels, with the expression replaced by the incrementing or decrementing +value. + +Expressions of the form `{value1,value2,value3}` (with any number of values separated by commas) +are replaced with each of the specified values in sequence. + +Multiple such expressions may be used in one channel specification, with the rightmost expression +being evaluated first. Both sides of a multi-channel assignment need to have the same number of channels, or one side must have exactly one channel. Example multi-channel mapping: ``` -instance-a.channel{1..10} > instance-b.{10..1} +instance-a.channel{1..5} > instance-b.{a,b,c,d,e} ``` ## Backend documentation @@ -152,10 +161,12 @@ special information. These documentation files are located in the `backends/` di * [`loopback` backend documentation](backends/loopback.md) * [`ola` backend documentation](backends/ola.md) * [`osc` backend documentation](backends/osc.md) +* [`mqtt` backend documentation](backends/mqtt.md) * [`openpixelcontrol` backend documentation](backends/openpixelcontrol.md) * [`lua` backend documentation](backends/lua.md) * [`python` backend documentation](backends/python.md) * [`maweb` backend documentation](backends/maweb.md) +* [`wininput` backend documentation](backends/wininput.md) ## Installation diff --git a/MIDIMonster.svg b/assets/MIDIMonster.svg index 7e411dc..7e411dc 100644 --- a/MIDIMonster.svg +++ b/assets/MIDIMonster.svg @@ -4,3 +4,12 @@ udp backends may ignore MTU make event collectors threadsafe to stop marshalling data... collect & check backend API version move all connection establishment to _start to be able to hot-stop/start all backends +event deduplication in core? +move all typenames to _t + +per-channel filters + * invert + * edge detection + +channel discovery / enumeration +note exit condition/reconnection details for backends diff --git a/assets/ci-config b/assets/ci-config new file mode 100644 index 0000000..ac94661 --- /dev/null +++ b/assets/ci-config @@ -0,0 +1,73 @@ +#!/usr/bin/env groovy + +/* + * This Jenkinsfile is intended to run on https://ci.spacecdn.de and may fail anywhere else. + * It makes assumptions about plugins being installed, labels mapping to nodes that can build what is needed, etc. + */ + +def buildTypes = ['linux', 'windows'] +def builds = [:] + +//if(env.TAG_NAME) { +// buildTypes.add("debian") +//} + +buildTypes.each{ + builds["$it"] = { + node() { + skipDefaultCheckout() + stage('Checkout') { + checkout scm + } + + stage("$it Build"){ + sh label: "Build", script: "./assets/ci.sh --target=build-$it --deploy" + } + + stage('Stash artifacts') { + stash includes: "deployment/$it/*", name: "$it", allowEmpty: 'false' + } + } + } +} + +def deploy = { + node(){ + skipDefaultCheckout() + stage('Deploy') { + buildTypes.each{ + unstash "$it" + } + archiveArtifacts artifacts: 'deployment/*/*', onlyIfSuccessful: true, fingerprint: true + } + } +} + +builds.Test = { + node() { + skipDefaultCheckout() + stage('Checkout') { + checkout scm + } + stage('Test') { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + sh label: "Check Spelling", script: './assets/ci.sh --target=check-spelling' + } + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + sh label: "Check Codespelling", script: './assets/ci.sh --target=check-codespelling' + } + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + sh label: "Analyze Complexity", script: './assets/ci.sh --target=analyze-complexity' + } + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + sh label: "Analyze Shellscripts", script: './assets/ci.sh --target=analyze-shellscript' + } + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + sh label: "Code Statistics", script: './assets/ci.sh --target=stats' + } + } + } +} + +parallel builds +deploy.call() diff --git a/assets/ci.sh b/assets/ci.sh new file mode 100755 index 0000000..94b8bed --- /dev/null +++ b/assets/ci.sh @@ -0,0 +1,313 @@ +#!/bin/bash +# shellcheck disable=SC2001,SC2181 + +################################################ SETUP ################################################ +dep_build_core=( + libasound2-dev + libevdev-dev + liblua5.3-dev + libola-dev + libjack-jackd2-dev + python3-dev + libssl-dev + build-essential + pkg-config + git +) + +dep_build_win=( + mingw-w64 +) + +dep_build_debian=( + git-buildpackage + debhelper +) + +exitcode="0" + +############################################## FUNCTIONS ############################################## + +ARGS(){ + for i in "$@"; do + case "$i" in + --target=*|-t=*) + TARGETS="${i#*=}" + ;; + --deploy) + deploy="1" + ;; + --deps) + install_deps="1" + ;; + -v|--verbose) + verbose="1" + ;; + -af|--allow-failure) + allow_failure="1" + ;; + -h|--help|*) + print_help + exit "0" + ;; + esac + shift + done + [[ -z $TARGETS ]] && print_help && printf "\nNo target specified!\n" && exit "1" # If no target(s) are specified exit. +} + +print_help() { + printf "Usage: %s [OPTIONS]\n\n" "$0" + printf -- "-t=<argument>, <argument>\t--target=<argument>, <argument>\n\n" + printf -- "--deploy\tPackage release/nightly versions to the ./deployment/\$target directory.\n" + printf -- "--deps\t\tCheck and install all dependencies needed for the specified target without the need to manualy run the dependency install targets/s.\n" + printf -- "-af, --allow-failure\tAlways exit with code 0.\n" + printf -- "-v, --verbose\tEnables detailed log output.\n\n" + printf "Valid test targets are: \t\"check-spelling\" - \"1\", \"check-codespelling\" - \"2\", \"analyze-complexity\" - \"3\", \"analyze-shellscript\" - \"4\", \"stats\" - \"5\".\n" + printf "Valid build targets are: \t\"build-linux\" - \"10\", \"build-windows\" - \"11\", \"build-debian\" - \"12\".\n" + printf "Valid dependency install targets are: \t\"deps-linux\", \"deps-windows\", \"deps-debian\", \"deps-osx\" \"deps-tests\", \"deps-all\".\n\n" +} + +install_dependencies(){ + start_apt update -y -qq > /dev/null || error_handler "There was an error doing apt update." + for dependency in "$@"; do + if [ "$(dpkg-query -W -f='${Status}' "$dependency" 2>/dev/null | grep -c "ok installed")" -eq 0 ]; then + deps+=("$dependency") # Add not installed dependency to the "to be installed array". + else + [[ -n $verbose ]] && printf "%s already installed!\n" "$dependency" # If the dependency is already installed print it. + fi + done + +if [ ! "${#deps[@]}" -ge "1" ]; then # If nothing needs to get installed don't start apt. + [[ -n $verbose ]] && echo "All dependencies are fulfilled." # Dependency array empty! Not running apt! +else + [[ -z $verbose ]] && echo "Starting dependency installation." + [[ -n $verbose ]] && echo "Then following dependencies are going to be installed:" # Dependency array contains items. Running apt. + [[ -n $verbose ]] && echo "${deps[@]}" | sed 's/ /, /g' + start_apt install -y -qq --no-install-suggests --no-install-recommends "${deps[@]}" > /dev/null || error_handler "There was an error doing dependency installation!" +fi + [[ -n $verbose ]] && printf "\n" +} + +start_apt(){ + i="0" + if command -v fuser &> /dev/null; then + while fuser /var/lib/dpkg/lock >/dev/null 2>&1 ; do + [ "$i" -eq "0" ] && printf "\nWaiting for other software managers to finish" + [ "$i" -le "16" ] && printf "." # Print a max of 16 dots if waiting. + ((i=i+1)) + sleep "1s" + done + [ "$i" -ge "1" ] && printf "ready!\n" + fi + DEBIAN_FRONTEND=noninteractive apt-get "$@" +} + +# Build targets and corresponding deployment. + +build-linux(){ + [[ -n $install_deps ]] && install_dependencies "${dep_build_core[@]}" + make full +} + +build-linux-deploy(){ + #printf "\nLinux Deployment started..\n" + mkdir -p ./deployment/linux/backends + mkdir -p ./deployment/linux/docs + cp ./midimonster ./deployment/linux/ + cp ./backends/*.so ./deployment/linux/backends/ + cp ./monster.cfg ./deployment/linux/monster.cfg + cp ./backends/*.md ./deployment/linux/docs/ + cp -r ./configs ./deployment/linux/ + cd ./deployment/linux || error_handler "Error doing cd to ./deployment" + filename="midimonster-$(git describe)-$OS.tgz" + touch "$filename" && tar --exclude=*.tgz -czf "$filename" "./" + find . ! -iname "*.zip" ! -iname "*.tgz" -delete +} + +build-windows(){ + [[ -n $install_deps ]] && install_dependencies "${dep_build_core[@]}" "${dep_build_win[@]}" + make windows +} + +build-windows-deploy(){ + #printf "\nWindows Deployment started..\n" + mkdir -p ./deployment/windows/backends + mkdir -p ./deployment/windows/docs + strip midimonster.exe backends/*.dll # Strip the Windows binaries as they become huge quickly. + cp ./midimonster.exe ./deployment/windows/ + cp ./backends/*.dll ./deployment/windows/backends/ + cp ./backends/*.dll.disabled ./deployment/windows/backends/ + cp ./monster.cfg ./deployment/windows/monster.cfg + cp ./backends/*.md ./deployment/windows/docs/ + cp -r ./configs ./deployment/windows/ + cd ./deployment/windows || error_handler "Error doing cd to ./deployment/windows" + zip -r "./midimonster-$(git describe)-windows.zip" "./" + find . ! -iname "*.zip" ! -iname "*.tgz" -delete +} + +build-debian(){ + [[ -n $install_deps ]] && install_dependencies "${dep_build_core[@]}" "${dep_build_debian[@]}" + git checkout debian/master + gbp buildpackage +} + +build-debian-deploy(){ + #printf "\nDebian Package Deployment started..\n" + mkdir -p ./deployment/debian/ + cp ./*.deb ./deployment/debian/ +} + +# Tests + +ckeck-spelling(){ # Check spelling. + [[ -n $install_deps ]] && install_dependencies "lintian" + spellcheck_files=$(find . -type f | grep -v ".git/") # Create list of files to be spellchecked. + sl_results=$(xargs spellintian 2>&1 <<< "$spellcheck_files") # Run spellintian to find spelling errors + sl_errors=$(wc -l <<< "$sl_results") + sl_errors_dups=$( (grep -c "\(duplicate word\)") <<< "$sl_results") + sl_errors_nodups=$( (grep -cv "\(duplicate word\)") <<< "$sl_results") + + if [ "$sl_errors" -gt "1" ]; then + printf "Spellintian found %s errors (%s spelling, %s duplicate words):\n\n" "$sl_errors" "$sl_errors_nodups" "$sl_errors_dups" + printf "%s\n\n" "$sl_results" + exitcode=1 + else + printf "Spellintian reports no errors\n" + fi +} + +check-codespelling(){ # Check code for common misspellings. + [[ -n $install_deps ]] && install_dependencies "codespell" + spellcheck_files=$(find . -type f | grep -v ".git/") # Create list of files to be spellchecked. + cs_results=$(xargs codespell --quiet 2 <<< "$spellcheck_files" 2>&1) + cs_errors=$(wc -l <<< "$cs_results") + if [ "$cs_errors" -gt "1" ]; then + printf "Codespell found %s errors:\n\n" "$cs_errors" + printf "%s\n\n" "$cs_results" + exitcode=1 + else + printf "Codespell reports no errors\n" + fi +} + +analyze-complexity(){ # code complexity analyser. + [[ -n $install_deps ]] && install_dependencies "python3" "python3-pip" + if [ -z "$(which ~/.local/bin/lizard)" ]; then + printf "Installing lizard...\n" + pip3 install lizard >/dev/null + fi + printf "Running lizard for code complexity analysis\n" + ~/.local/bin/lizard ./ + if [ "$?" -ne "0" ]; then + exitcode=1 + fi +} + +analyze-shellscript(){ # Shellscript analysis tool. + [[ -n $install_deps ]] && install_dependencies "shellcheck" + printf "Running shellcheck:\n" + shell_files="$(find . -type f -iname \*.sh)" + xargs shellcheck -Cnever -s bash <<< "$shell_files" + if [ "$?" -ne "0" ]; then + exitcode=1 + fi +} + +stats(){ # Code statistics. + [[ -n $install_deps ]] && install_dependencies "cloc" + printf "Code statistics:\n" + cloc ./ +} + +target_queue(){ + printf "\n" + IFS=',|.' read -ra Queue <<< "$TARGETS" + for i in "${Queue[@]}"; do + case "$i" in + check-spelling|1) + ckeck-spelling + ;; + check-codespelling|2) + check-codespelling + ;; + analyze-complexity|3) + analyze-complexity + ;; + analyze-shellscript|4) + analyze-shellscript + ;; + stats|5) + stats + ;; + build-linux|10) + OS="linux" + build-linux + [[ -n $deploy ]] && build-linux-deploy # Deploy build artifacts if the deploy flag is set. + ;; + build-windows|build-win|11) + build-windows + [[ -n $deploy ]] && build-windows-deploy # Deploy build artifacts if the deploy flag is set. + ;; + build-debian|build-deb|12) + build-debian + [[ -n $deploy ]] && build-debian-deploy # Deploy build artifacts if the deploy flag is set. + ;; + build-osx|13) + OS="osx" + printf "\nNot implemented yet!\n" + #build-linux + #[[ -n $deploy ]] && build-linux-deploy # Deploy build artifacts if the deploy flag is set. + ;; + deps-linux) + # Target to install all needed dependencies for linux builds. + install_dependencies "${dep_build_core[@]}" + ;; + deps-windows|deps-win) + # Target to install all needed dependencies for windows builds. + install_dependencies "${dep_build_core[@]}" "${dep_build_win[@]}" + ;; + deps-debian|deps-deb) + # Target to install all needed dependencies for debian packaging. + install_dependencies "${dep_build_core[@]}" "${dep_build_debian[@]}" + ;; + deps-osx) + # Target to install all needed dependencies for osx. + printf "\nNot implemented yet!\n" + ;; + deps-tests) + install_dependencies "lintian" "codespell" "python3" "python3-pip" "shellcheck" "cloc" + # Install lizard if not found. + if [ -z "$(which ~/.local/bin/lizard)" ]; then + pip3 install lizard >/dev/null + fi + ;; + deps-all) + # Target to install all needed dependencies for this ci script. + install_dependencies "${dep_build_core[@]}" "${dep_build_win[@]}" "${dep_build_debian[@]}" "lintian" "codespell" "python3" "python3-pip" "shellcheck" "cloc" + ;; + *) + printf "Target '%s' not valid!\n" "$i" + ;; + esac + printf "\n" + done +} + +error_handler(){ + [[ -n $1 ]] && printf "\n%s\n" "$1" + printf "\nAborting" + for i in {1..3}; do sleep 0.3s && printf "." && sleep 0.2s; done + printf "\n" + exit "1" +} + +################################################ Main ################################################# +trap error_handler SIGINT SIGTERM + +ARGS "$@" # Parse arguments. +target_queue # Start requestet targets. + +# Allow failure handler. +[[ -z $allow_failure ]] && exit "$exitcode" +exit "0"
\ No newline at end of file diff --git a/midimonster.1 b/assets/midimonster.1 index 44c414e..44c414e 100644 --- a/midimonster.1 +++ b/assets/midimonster.1 diff --git a/midimonster.ico b/assets/midimonster.ico Binary files differindex 9391160..9391160 100644 --- a/midimonster.ico +++ b/assets/midimonster.ico diff --git a/midimonster.rc b/assets/midimonster.rc index 45a88aa..45a88aa 100644 --- a/midimonster.rc +++ b/assets/midimonster.rc diff --git a/backends/Makefile b/backends/Makefile index 700c9b3..be870d6 100644 --- a/backends/Makefile +++ b/backends/Makefile @@ -1,16 +1,26 @@ .PHONY: all clean full +# Backends that can only be built on Linux LINUX_BACKENDS = midi.so evdev.so -WINDOWS_BACKENDS = artnet.dll osc.dll loopback.dll sacn.dll maweb.dll winmidi.dll openpixelcontrol.dll rtpmidi.dll -BACKENDS = artnet.so osc.so loopback.so sacn.so lua.so maweb.so jack.so openpixelcontrol.so python.so rtpmidi.so +# Backends that can only be built on Windows (mostly due to the .DLL extension) +WINDOWS_BACKENDS = artnet.dll osc.dll loopback.dll sacn.dll maweb.dll winmidi.dll openpixelcontrol.dll rtpmidi.dll wininput.dll visca.dll mqtt.dll +# Backends that can be built on any platform that can load .SO libraries +BACKENDS = artnet.so osc.so loopback.so sacn.so lua.so maweb.so jack.so openpixelcontrol.so python.so rtpmidi.so visca.so mqtt.so +# Backends that require huge dependencies to be installed OPTIONAL_BACKENDS = ola.so +# Backends that need to be built manually (but still should be included in the clean target) +MANUAL_BACKENDS = lua.dll + +# The backend library, providing platform-independent abstractions for common things BACKEND_LIB = libmmbackend.o +# Evaluate which system we are on SYSTEM := $(shell uname -s) # Generate debug symbols unless overridden CFLAGS ?= -g CPPFLAGS ?= -g +# All backends are shared libraries CFLAGS += -fPIC -I../ -Wall -Wpedantic CPPFLAGS += -fPIC -I../ LDFLAGS += -shared @@ -24,6 +34,7 @@ ifeq ($(SYSTEM),Darwin) LDFLAGS += -undefined dynamic_lookup endif +# Most of these next few backends just pull in the backend lib, some set additional flags artnet.so: ADDITIONAL_OBJS += $(BACKEND_LIB) artnet.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) artnet.dll: LDLIBS += -lws2_32 @@ -36,12 +47,20 @@ sacn.so: ADDITIONAL_OBJS += $(BACKEND_LIB) sacn.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) sacn.dll: LDLIBS += -lws2_32 +visca.so: ADDITIONAL_OBJS += $(BACKEND_LIB) +visca.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) +visca.dll: LDLIBS += -lws2_32 + +mqtt.so: ADDITIONAL_OBJS += $(BACKEND_LIB) +mqtt.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) +mqtt.dll: LDLIBS += -lws2_32 + openpixelcontrol.so: ADDITIONAL_OBJS += $(BACKEND_LIB) openpixelcontrol.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) openpixelcontrol.dll: LDLIBS += -lws2_32 maweb.so: ADDITIONAL_OBJS += $(BACKEND_LIB) -maweb.so: LDLIBS = -lssl +maweb.so: LDLIBS = $(shell pkg-config --libs openssl || echo "-DBUILD_ERROR=\"Missing pkg-config data for openssl\"") maweb.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) maweb.dll: LDLIBS += -lws2_32 maweb.dll: CFLAGS += -DMAWEB_NO_LIBSSL @@ -53,6 +72,8 @@ rtpmidi.dll: LDLIBS += -lws2_32 -liphlpapi winmidi.dll: ADDITIONAL_OBJS += $(BACKEND_LIB) winmidi.dll: LDLIBS += -lwinmm -lws2_32 +wininput.dll: LDLIBS += -lwinmm + jack.so: LDLIBS = -ljack -lpthread midi.so: LDLIBS = -lasound evdev.so: CFLAGS += $(shell pkg-config --cflags libevdev || echo "-DBUILD_ERROR=\"Missing pkg-config data for libevdev\"") @@ -62,28 +83,38 @@ ola.so: CPPFLAGS += -Wno-write-strings # The pkg-config name for liblua5.3 is subject to discussion. I prefer 'lua5.3' (which works on Debian and OSX), # but Arch requires 'lua53' which works on Debian, too, but breaks on OSX. -lua.so: CFLAGS += $(shell pkg-config --cflags lua53 || pkg-config --cflags lua5.3 || echo "-DBUILD_ERROR=\"Missing pkg-config data for lua53\"") -lua.so: LDLIBS += $(shell pkg-config --libs lua53 || pkg-config --libs lua5.3 || echo "-DBUILD_ERROR=\"Missing pkg-config data for lua53\"") -lua.dll: CFLAGS += $(shell pkg-config --cflags lua53 || pkg-config --cflags lua5.3 || echo "-DBUILD_ERROR=\"Missing pkg-config data for lua53\"") +lua.so: CFLAGS += $(shell pkg-config --cflags lua53 || pkg-config --cflags lua5.3 || pkg-config --cflags lua || echo "-DBUILD_ERROR=\"Missing pkg-config data for lua53\"") +lua.so: LDLIBS += $(shell pkg-config --libs lua53 || pkg-config --libs lua5.3 || pkg-config --libs lua || echo "-DBUILD_ERROR=\"Missing pkg-config data for lua53\"") +lua.dll: CFLAGS += $(shell pkg-config --cflags lua53 || pkg-config --cflags lua5.3 || pkg-config --cflags lua || echo "-DBUILD_ERROR=\"Missing pkg-config data for lua53\"") lua.dll: LDLIBS += -L../ -llua53 -python.so: CFLAGS += $(shell pkg-config --cflags python3 || pkg-config --cflags python || echo "-DBUILD_ERROR=\"Missing pkg-config data for python3\"") -python.so: CFLAGS += $(shell pkg-config --libs python3 || pkg-config --libs python || echo "-DBUILD_ERROR=\"Missing pkg-config data for python3\"") +# Python seems to ship their own little python3-config tool instead of properly maintaining their pkg-config files. +# This one also spams a good deal of unwanted flags into CFLAGS, so we use only --includes. On the other hand, the --libs +# info from this one seems to include the actual interpreter library only on some systems, which makes it worse than useless. +python.so: CFLAGS += $(shell python3-config --includes || pkg-config --cflags python3 || pkg-config --cflags python || echo "-DBUILD_ERROR=\"Missing pkg-config data for python3\"") +python.so: LDLIBS += $(shell pkg-config --libs python3-embed || python3-config --libs || pkg-config --libs python3 || echo "-DBUILD_ERROR=\"Missing pkg-config data for python3\"") +python.so: LDFLAGS += $(shell python3-config --ldflags || echo "-DBUILD_ERROR=\"Missing pkg-config data for python3\"") +python.dll: CFLAGS += $(shell python3-config --includes || echo "-DBUILD_ERROR=\"Missing pkg-config data for python3\"") +python.dll: LDLIBS += -L../ -lpython3 +# Generic rules on how to build .SO/.DLL's from C and CPP sources %.so :: %.c %.h $(BACKEND_LIB) - $(CC) $(CFLAGS) $(LDLIBS) $< $(ADDITIONAL_OBJS) -o $@ $(LDFLAGS) + $(CC) $(CFLAGS) $< $(ADDITIONAL_OBJS) -o $@ $(LDFLAGS) $(LDLIBS) %.dll :: %.c %.h $(BACKEND_LIB) $(CC) $(CFLAGS) $< $(ADDITIONAL_OBJS) -o $@ $(LDFLAGS) $(LDLIBS) %.so :: %.cpp %.h - $(CXX) $(CPPFLAGS) $(LDLIBS) $< $(ADDITIONAL_OBJS) -o $@ $(LDFLAGS) + $(CXX) $(CPPFLAGS) $< $(ADDITIONAL_OBJS) -o $@ $(LDFLAGS) $(LDLIBS) +# This is the actual first named target, and thus the default all: $(BACKEND_LIB) $(BACKENDS) +# Build an import lib for the windows build if it's not already there ../libmmapi.a: $(MAKE) -C ../ midimonster.exe +# Override a bunch of stuff for the windows target and it's DLL dependencies %.dll: export CC = x86_64-w64-mingw32-gcc %.dll: LDLIBS += -lmmapi %.dll: LDFLAGS += -L../ @@ -92,7 +123,9 @@ windows: CFLAGS += -Wno-format -Wno-pointer-sign windows: export CC = x86_64-w64-mingw32-gcc windows: ../libmmapi.a $(BACKEND_LIB) $(WINDOWS_BACKENDS) +# Optional target including the backends that require large dependencies full: $(BACKEND_LIB) $(BACKENDS) $(OPTIONAL_BACKENDS) +# Clean up all generated files clean: - $(RM) $(BACKEND_LIB) $(BACKENDS) $(OPTIONAL_BACKENDS) $(WINDOWS_BACKENDS) + $(RM) $(BACKEND_LIB) $(BACKENDS) $(OPTIONAL_BACKENDS) $(WINDOWS_BACKENDS) $(MANUAL_BACKENDS) diff --git a/backends/artnet.c b/backends/artnet.c index e07ea52..dae9ba3 100644 --- a/backends/artnet.c +++ b/backends/artnet.c @@ -9,14 +9,19 @@ #define MAX_FDS 255 -static uint32_t next_frame = 0; -static uint8_t default_net = 0; -static size_t artnet_fds = 0; -static artnet_descriptor* artnet_fd = NULL; +static struct { + uint32_t next_frame; + uint8_t default_net; + size_t fds; + artnet_descriptor* fd; + uint8_t detect; +} global_cfg = { + 0 +}; static int artnet_listener(char* host, char* port){ int fd; - if(artnet_fds >= MAX_FDS){ + if(global_cfg.fds >= MAX_FDS){ LOG("Backend descriptor limit reached"); return -1; } @@ -27,18 +32,19 @@ static int artnet_listener(char* host, char* port){ } //store fd - artnet_fd = realloc(artnet_fd, (artnet_fds + 1) * sizeof(artnet_descriptor)); - if(!artnet_fd){ + global_cfg.fd = realloc(global_cfg.fd, (global_cfg.fds + 1) * sizeof(artnet_descriptor)); + if(!global_cfg.fd){ close(fd); + global_cfg.fds = 0; LOG("Failed to allocate memory"); return -1; } - LOGPF("Interface %" PRIsize_t " bound to %s port %s", artnet_fds, host, port); - artnet_fd[artnet_fds].fd = fd; - artnet_fd[artnet_fds].output_instances = 0; - artnet_fd[artnet_fds].output_instance = NULL; - artnet_fds++; + LOGPF("Interface %" PRIsize_t " bound to %s port %s", global_cfg.fds, host, port); + global_cfg.fd[global_cfg.fds].fd = fd; + global_cfg.fd[global_cfg.fds].output_instances = 0; + global_cfg.fd[global_cfg.fds].output_instance = NULL; + global_cfg.fds++; return 0; } @@ -70,8 +76,8 @@ MM_PLUGIN_API int init(){ } static uint32_t artnet_interval(){ - if(next_frame){ - return next_frame; + if(global_cfg.next_frame){ + return global_cfg.next_frame; } return ARTNET_KEEPALIVE_INTERVAL; } @@ -80,7 +86,7 @@ static int artnet_configure(char* option, char* value){ char* host = NULL, *port = NULL, *fd_opts = NULL; if(!strcmp(option, "net")){ //configure default net - default_net = strtoul(value, NULL, 0); + global_cfg.default_net = strtoul(value, NULL, 0); return 0; } else if(!strcmp(option, "bind")){ @@ -97,6 +103,16 @@ static int artnet_configure(char* option, char* value){ } return 0; } + else if(!strcmp(option, "detect")){ + global_cfg.detect = 0; + if(!strcmp(value, "on")){ + global_cfg.detect = 1; + } + else if(!strcmp(value, "verbose")){ + global_cfg.detect = 2; + } + return 0; + } LOGPF("Unknown backend option %s", option); return 1; @@ -111,7 +127,7 @@ static int artnet_instance(instance* inst){ return 1; } - data->net = default_net; + data->net = global_cfg.default_net; for(u = 0; u < sizeof(data->data.channel) / sizeof(channel); u++){ data->data.channel[u].ident = u; data->data.channel[u].instance = inst; @@ -136,7 +152,7 @@ static int artnet_configure_instance(instance* inst, char* option, char* value){ else if(!strcmp(option, "iface") || !strcmp(option, "interface")){ data->fd_index = strtoul(value, NULL, 0); - if(data->fd_index >= artnet_fds){ + if(data->fd_index >= global_cfg.fds){ LOGPF("Invalid interface configured for instance %s", inst->name); return 1; } @@ -152,6 +168,10 @@ static int artnet_configure_instance(instance* inst, char* option, char* value){ return mmbackend_parse_sockaddr(host, port ? port : ARTNET_PORT, &data->dest_addr, &data->dest_len); } + else if(!strcmp(option, "realtime")){ + data->realtime = strtoul(value, NULL, 10); + return 0; + } LOGPF("Unknown instance option %s for instance %s", option, inst->name); return 1; @@ -223,7 +243,7 @@ static int artnet_transmit(instance* inst, artnet_output_universe* output){ }; memcpy(frame.data, data->data.out, 512); - if(sendto(artnet_fd[data->fd_index].fd, (uint8_t*) &frame, sizeof(frame), 0, (struct sockaddr*) &data->dest_addr, data->dest_len) < 0){ + if(sendto(global_cfg.fd[data->fd_index].fd, (uint8_t*) &frame, sizeof(frame), 0, (struct sockaddr*) &data->dest_addr, data->dest_len) < 0){ #ifdef _WIN32 if(WSAGetLastError() != WSAEWOULDBLOCK){ #else @@ -234,8 +254,8 @@ static int artnet_transmit(instance* inst, artnet_output_universe* output){ } //reschedule frame output output->mark = 1; - if(!next_frame || next_frame > ARTNET_SYNTHESIZE_MARGIN){ - next_frame = ARTNET_SYNTHESIZE_MARGIN; + if(!global_cfg.next_frame || global_cfg.next_frame > ARTNET_SYNTHESIZE_MARGIN){ + global_cfg.next_frame = ARTNET_SYNTHESIZE_MARGIN; } return 0; } @@ -278,23 +298,26 @@ static int artnet_set(instance* inst, size_t num, channel** c, channel_value* v) } if(mark){ - //find last frame time - for(u = 0; u < artnet_fd[data->fd_index].output_instances; u++){ - if(artnet_fd[data->fd_index].output_instance[u].label == inst->ident){ + //find output control data for the instance + for(u = 0; u < global_cfg.fd[data->fd_index].output_instances; u++){ + if(global_cfg.fd[data->fd_index].output_instance[u].label == inst->ident){ break; } } - frame_delta = mm_timestamp() - artnet_fd[data->fd_index].output_instance[u].last_frame; - //check output rate limit, request next frame - if(frame_delta < ARTNET_FRAME_TIMEOUT){ - artnet_fd[data->fd_index].output_instance[u].mark = 1; - if(!next_frame || next_frame > (ARTNET_FRAME_TIMEOUT - frame_delta)){ - next_frame = (ARTNET_FRAME_TIMEOUT - frame_delta); + if(!data->realtime){ + frame_delta = mm_timestamp() - global_cfg.fd[data->fd_index].output_instance[u].last_frame; + + //check output rate limit, request next frame + if(frame_delta < ARTNET_FRAME_TIMEOUT){ + global_cfg.fd[data->fd_index].output_instance[u].mark = 1; + if(!global_cfg.next_frame || global_cfg.next_frame > (ARTNET_FRAME_TIMEOUT - frame_delta)){ + global_cfg.next_frame = (ARTNET_FRAME_TIMEOUT - frame_delta); + } + return 0; } - return 0; } - return artnet_transmit(inst, artnet_fd[data->fd_index].output_instance + u); + return artnet_transmit(inst, global_cfg.fd[data->fd_index].output_instance + u); } return 0; @@ -307,6 +330,11 @@ static inline int artnet_process_frame(instance* inst, artnet_pkt* frame){ channel_value val; artnet_instance_data* data = (artnet_instance_data*) inst->impl; + if(!data->last_input && global_cfg.detect){ + LOGPF("Valid data on instance %s (Net %d Universe %d): %d channels", inst->name, data->net, data->uni, be16toh(frame->length)); + } + data->last_input = mm_timestamp(); + if(be16toh(frame->length) > 512){ LOGPF("Invalid frame channel count: %d", be16toh(frame->length)); return 1; @@ -366,23 +394,23 @@ static int artnet_handle(size_t num, managed_fd* fds){ artnet_pkt* frame = (artnet_pkt*) recv_buf; //transmit keepalive & synthesized frames - next_frame = 0; - for(u = 0; u < artnet_fds; u++){ - for(c = 0; c < artnet_fd[u].output_instances; c++){ - synthesize_delta = timestamp - artnet_fd[u].output_instance[c].last_frame; - if((artnet_fd[u].output_instance[c].mark + global_cfg.next_frame = 0; + for(u = 0; u < global_cfg.fds; u++){ + for(c = 0; c < global_cfg.fd[u].output_instances; c++){ + synthesize_delta = timestamp - global_cfg.fd[u].output_instance[c].last_frame; + if((global_cfg.fd[u].output_instance[c].mark && synthesize_delta >= ARTNET_FRAME_TIMEOUT + ARTNET_SYNTHESIZE_MARGIN) //synthesize next frame || synthesize_delta >= ARTNET_KEEPALIVE_INTERVAL){ //keepalive timeout - inst = mm_instance_find(BACKEND_NAME, artnet_fd[u].output_instance[c].label); + inst = mm_instance_find(BACKEND_NAME, global_cfg.fd[u].output_instance[c].label); if(inst){ - artnet_transmit(inst, artnet_fd[u].output_instance + c); + artnet_transmit(inst, global_cfg.fd[u].output_instance + c); } } //update next_frame - if(artnet_fd[u].output_instance[c].mark - && (!next_frame || next_frame > ARTNET_FRAME_TIMEOUT + ARTNET_SYNTHESIZE_MARGIN - synthesize_delta)){ - next_frame = ARTNET_FRAME_TIMEOUT + ARTNET_SYNTHESIZE_MARGIN - synthesize_delta; + if(global_cfg.fd[u].output_instance[c].mark + && (!global_cfg.next_frame || global_cfg.next_frame > ARTNET_FRAME_TIMEOUT + ARTNET_SYNTHESIZE_MARGIN - synthesize_delta)){ + global_cfg.next_frame = ARTNET_FRAME_TIMEOUT + ARTNET_SYNTHESIZE_MARGIN - synthesize_delta; } } } @@ -400,6 +428,9 @@ static int artnet_handle(size_t num, managed_fd* fds){ if(inst && artnet_process_frame(inst, frame)){ LOG("Failed to process frame"); } + else if(!inst && global_cfg.detect > 1){ + LOGPF("Received data for unconfigured universe %d (net %d) on descriptor %" PRIsize_t, frame->universe, frame->net, (((uint64_t) fds[u].impl) & 0xFF)); + } } } } while(bytes_read > 0); @@ -429,7 +460,7 @@ static int artnet_start(size_t n, instance** inst){ .label = 0 }; - if(!artnet_fds){ + if(!global_cfg.fds){ LOG("Failed to start backend: no descriptors bound"); return 1; } @@ -452,23 +483,23 @@ static int artnet_start(size_t n, instance** inst){ //if enabled for output, add to keepalive tracking if(data->dest_len){ - artnet_fd[data->fd_index].output_instance = realloc(artnet_fd[data->fd_index].output_instance, (artnet_fd[data->fd_index].output_instances + 1) * sizeof(artnet_output_universe)); + global_cfg.fd[data->fd_index].output_instance = realloc(global_cfg.fd[data->fd_index].output_instance, (global_cfg.fd[data->fd_index].output_instances + 1) * sizeof(artnet_output_universe)); - if(!artnet_fd[data->fd_index].output_instance){ + if(!global_cfg.fd[data->fd_index].output_instance){ LOG("Failed to allocate memory"); goto bail; } - artnet_fd[data->fd_index].output_instance[artnet_fd[data->fd_index].output_instances].label = id.label; - artnet_fd[data->fd_index].output_instance[artnet_fd[data->fd_index].output_instances].last_frame = 0; - artnet_fd[data->fd_index].output_instance[artnet_fd[data->fd_index].output_instances].mark = 0; + global_cfg.fd[data->fd_index].output_instance[global_cfg.fd[data->fd_index].output_instances].label = id.label; + global_cfg.fd[data->fd_index].output_instance[global_cfg.fd[data->fd_index].output_instances].last_frame = 0; + global_cfg.fd[data->fd_index].output_instance[global_cfg.fd[data->fd_index].output_instances].mark = 0; - artnet_fd[data->fd_index].output_instances++; + global_cfg.fd[data->fd_index].output_instances++; } } - LOGPF("Registering %" PRIsize_t " descriptors to core", artnet_fds); - for(u = 0; u < artnet_fds; u++){ - if(mm_manage_fd(artnet_fd[u].fd, BACKEND_NAME, 1, (void*) u)){ + LOGPF("Registering %" PRIsize_t " descriptors to core", global_cfg.fds); + for(u = 0; u < global_cfg.fds; u++){ + if(mm_manage_fd(global_cfg.fd[u].fd, BACKEND_NAME, 1, (void*) u)){ goto bail; } } @@ -485,11 +516,13 @@ static int artnet_shutdown(size_t n, instance** inst){ free(inst[p]->impl); } - for(p = 0; p < artnet_fds; p++){ - close(artnet_fd[p].fd); - free(artnet_fd[p].output_instance); + for(p = 0; p < global_cfg.fds; p++){ + close(global_cfg.fd[p].fd); + free(global_cfg.fd[p].output_instance); } - free(artnet_fd); + free(global_cfg.fd); + global_cfg.fd = NULL; + global_cfg.fds = 0; LOG("Backend shut down"); return 0; diff --git a/backends/artnet.h b/backends/artnet.h index a517aa0..b42646d 100644 --- a/backends/artnet.h +++ b/backends/artnet.h @@ -47,6 +47,8 @@ typedef struct /*_artnet_instance_model*/ { socklen_t dest_len; artnet_universe data; size_t fd_index; + uint64_t last_input; + uint8_t realtime; } artnet_instance_data; typedef union /*_artnet_instance_id*/ { diff --git a/backends/artnet.md b/backends/artnet.md index 383203d..f035ad7 100644 --- a/backends/artnet.md +++ b/backends/artnet.md @@ -9,8 +9,9 @@ Art-Net™ Designed by and Copyright Artistic Licence Holdings Ltd. | Option | Example value | Default value | Description | |---------------|-----------------------|-----------------------|-----------------------| -| `bind` | `127.0.0.1 6454` | none | Binds a network address to listen for data. This option may be set multiple times, with each interface being assigned an index starting from 0 to be used with the `interface` instance configuration option. At least one interface is required for transmission. | +| `bind` | `127.0.0.1 6454` | none | Binds a network address to listen for data. This option may be set multiple times, with each interface being assigned an index starting from 0 to be used with the `interface` instance configuration option. At least one interface is required for transmission. | | `net` | `0` | `0` | The default net to use | +| `detect` | `on`, `verbose` | `off` | Output additional information on received data packets to help with configuring complex scenarios | #### Instance configuration @@ -20,6 +21,7 @@ Art-Net™ Designed by and Copyright Artistic Licence Holdings Ltd. | `universe` | `0` | `0` | Universe identifier | | `destination` | `10.2.2.2` | none | Destination address for sent ArtNet frames. Setting this enables the universe for output | | `interface` | `1` | `0` | The bound address to use for data input/output | +| `realtime` | `1` | `0` | Disable the recommended rate-limiting (approx. 44 packets per second) for this instance | #### Channel specification @@ -38,3 +40,7 @@ net1.1+2 > net2.5+123 A normal channel that is part of a wide channel can not be mapped individually. #### Known bugs / problems + +When using this backend for output with a fast event source, some events may appear to be lost due to the packet output rate limiting +mandated by the [ArtNet specification](https://artisticlicence.com/WebSiteMaster/User%20Guides/art-net.pdf) (Section `Refresh rate`). +This limit can be disabled on a per-instance basis using the `realtime` instance option. diff --git a/backends/evdev.c b/backends/evdev.c index 8a14200..8f7c4f9 100644 --- a/backends/evdev.c +++ b/backends/evdev.c @@ -113,12 +113,14 @@ static int evdev_attach(instance* inst, evdev_instance_data* data, char* node){ static char* evdev_find(char* name){ int fd = -1; struct dirent* file = NULL; - char file_path[PATH_MAX * 2]; + char file_path[PATH_MAX * 2], *result = calloc(PATH_MAX * 2, sizeof(char)); DIR* nodes = opendir(INPUT_NODES); - char device_name[UINPUT_MAX_NAME_SIZE], *result = NULL; + char device_name[UINPUT_MAX_NAME_SIZE]; + size_t min_distance = -1, distance = 0; if(!nodes){ LOGPF("Failed to query input device nodes in %s: %s", INPUT_NODES, strerror(errno)); + free(result); return NULL; } @@ -141,20 +143,23 @@ static char* evdev_find(char* name){ close(fd); if(!strncmp(device_name, name, strlen(name))){ - LOGPF("Matched name %s for %s: %s", device_name, name, file_path); - break; + distance = strlen(device_name) - strlen(name); + LOGPF("Matched name %s as candidate (distance %" PRIsize_t ") for %s: %s", device_name, distance, name, file_path); + if(distance < min_distance){ + strncpy(result, file_path, (PATH_MAX * 2) - 1); + min_distance = distance; + } } } } - if(file){ - result = calloc(strlen(file_path) + 1, sizeof(char)); - if(result){ - strncpy(result, file_path, strlen(file_path)); - } - } - closedir(nodes); + + if(!result[0]){ + free(result); + return NULL; + } + LOGPF("Using %s for input name %s", result, name); return result; } @@ -206,6 +211,7 @@ static int evdev_configure_instance(instance* inst, char* option, char* value) { else if(data->relative_axis[data->relative_axes].max == 0){ LOGPF("Relative axis configuration for %s.%s has invalid range", inst->name, option + 8); } + //this does not crash on single-integer `value`s because strtoll sets `next_token` to the terminator data->relative_axis[data->relative_axes].current = strtoul(next_token, NULL, 0); if(data->relative_axis[data->relative_axes].code < 0){ LOGPF("Failed to configure relative axis extents for %s.%s", inst->name, option + 8); @@ -366,7 +372,9 @@ static int evdev_handle(size_t num, managed_fd* fds){ data = (evdev_instance_data*) inst->impl; - for(read_status = libevdev_next_event(data->input_ev, read_flags, &ev); read_status >= 0; read_status = libevdev_next_event(data->input_ev, read_flags, &ev)){ + for(read_status = libevdev_next_event(data->input_ev, read_flags, &ev); + read_status == LIBEVDEV_READ_STATUS_SUCCESS || read_status == LIBEVDEV_READ_STATUS_SYNC; + read_status = libevdev_next_event(data->input_ev, read_flags, &ev)){ read_flags = LIBEVDEV_READ_FLAG_NORMAL; if(read_status == LIBEVDEV_READ_STATUS_SYNC){ read_flags = LIBEVDEV_READ_FLAG_SYNC; @@ -382,6 +390,11 @@ static int evdev_handle(size_t num, managed_fd* fds){ return 1; } } + + if(read_status != -EAGAIN){ + LOGPF("Failed to handle events: %s\n", strerror(-read_status)); + return 1; + } } return 0; diff --git a/backends/evdev.md b/backends/evdev.md index d57201d..e7ba3cc 100644 --- a/backends/evdev.md +++ b/backends/evdev.md @@ -16,7 +16,7 @@ This functionality may require elevated privileges (such as special group member | Option | Example value | Default value | Description | |---------------|-----------------------|---------------|-------------------------------------------------------| | `device` | `/dev/input/event1` | none | `evdev` device to use as input device | -| `input` | `Xbox Wireless` | none | Presentation name of evdev device to use as input (prefix-matched) | +| `input` | `Xbox Wireless` | none | Presentation name of evdev device to use as input (most-specific prefix matched), can be used instead of the `device` option | | `output` | `My Input Device` | none | Output device presentation name. Setting this option enables the instance for output | | `exclusive` | `1` | `0` | Prevent other processes from using the device | | `id` | `0x1 0x2 0x3` | none | Set output device bus identification (Vendor, Product and Version), optional | @@ -49,7 +49,6 @@ If relative axes are used without specifying their extents, the channel will gen of `0`, `0.5` and `1` for any input less than, equal to and greater than `0`, respectively. As for output, only the values `-1`, `0` and `1` are generated for the same interval. - #### Channel specification A channel is specified by its event type and event code, separated by `.`. For a complete list of event types and codes diff --git a/backends/jack.c b/backends/jack.c index c84ed0f..fe74a80 100644 --- a/backends/jack.c +++ b/backends/jack.c @@ -18,8 +18,6 @@ #endif #endif -//FIXME pitchbend range is somewhat oob - static struct /*_mmjack_backend_cfg*/ { unsigned verbosity; volatile sig_atomic_t jack_shutdown; @@ -80,13 +78,98 @@ static int mmjack_midiqueue_append(mmjack_port* port, mmjack_channel_ident ident return 0; } +static void mmjack_process_midiout(void* buffer, size_t sample_offset, uint8_t type, uint8_t channel, uint8_t control, uint16_t value){ + jack_midi_data_t* event_data = jack_midi_event_reserve(buffer, sample_offset, (type == midi_aftertouch || type == midi_program) ? 2 : 3); + + if(!event_data){ + LOG("Failed to reserve MIDI stream data"); + return; + } + + //build midi event + event_data[0] = channel | type; + event_data[1] = control & 0x7F; + event_data[2] = value & 0x7F; + + if(type == midi_pitchbend){ + event_data[1] = value & 0x7F; + event_data[2] = (value >> 7) & 0x7F; + } + else if(type == midi_aftertouch || type == midi_program){ + event_data[1] = value & 0x7F; + event_data[2] = 0; + } +} + +//this state machine was copied more-or-less verbatim from the alsa midi implementation - fixes there will need to be integrated +static void mmjack_handle_epn(mmjack_port* port, uint8_t chan, uint16_t control, uint16_t value){ + mmjack_channel_ident ident = { + .label = 0 + }; + + //switching between nrpn and rpn clears all valid bits + if(((port->epn_status[chan] & EPN_NRPN) && (control == 101 || control == 100)) + || (!(port->epn_status[chan] & EPN_NRPN) && (control == 99 || control == 98))){ + port->epn_status[chan] &= ~(EPN_NRPN | EPN_PARAMETER_LO | EPN_PARAMETER_HI); + } + + //setting an address always invalidates the value valid bits + if(control >= 98 && control <= 101){ + port->epn_status[chan] &= ~EPN_VALUE_HI; + } + + //parameter hi + if(control == 101 || control == 99){ + port->epn_control[chan] &= 0x7F; + port->epn_control[chan] |= value << 7; + port->epn_status[chan] |= EPN_PARAMETER_HI | ((control == 99) ? EPN_NRPN : 0); + if(control == 101 && value == 127){ + port->epn_status[chan] &= ~EPN_PARAMETER_HI; + } + } + + //parameter lo + if(control == 100 || control == 98){ + port->epn_control[chan] &= ~0x7F; + port->epn_control[chan] |= value & 0x7F; + port->epn_status[chan] |= EPN_PARAMETER_LO | ((control == 98) ? EPN_NRPN : 0); + if(control == 100 && value == 127){ + port->epn_status[chan] &= ~EPN_PARAMETER_LO; + } + } + + //value hi, clears low, mark as update candidate + if(control == 6 + //check if parameter is set before accepting value update + && ((port->epn_status[chan] & (EPN_PARAMETER_HI | EPN_PARAMETER_LO)) == (EPN_PARAMETER_HI | EPN_PARAMETER_LO))){ + port->epn_value[chan] = value << 7; + port->epn_status[chan] |= EPN_VALUE_HI; + } + + //value lo, flush the value + if(control == 38 + && port->epn_status[chan] & EPN_VALUE_HI){ + port->epn_value[chan] &= ~0x7F; + port->epn_value[chan] |= value & 0x7F; + port->epn_status[chan] &= ~EPN_VALUE_HI; + + //find the updated channel + ident.fields.sub_type = port->epn_status[chan] & EPN_NRPN ? midi_nrpn : midi_rpn; + ident.fields.sub_channel = chan; + ident.fields.sub_control = port->epn_control[chan]; + + //ident.fields.port set on output in mmjack_handle_midi + mmjack_midiqueue_append(port, ident, port->epn_value[chan]); + } +} + static int mmjack_process_midi(instance* inst, mmjack_port* port, size_t nframes, size_t* mark){ + mmjack_instance_data* data = (mmjack_instance_data*) inst->impl; void* buffer = jack_port_get_buffer(port->port, nframes); jack_nframes_t event_count = jack_midi_get_event_count(buffer); jack_midi_event_t event; - jack_midi_data_t* event_data; mmjack_channel_ident ident; - size_t u; + size_t u, frame; uint16_t value; if(port->input){ @@ -109,10 +192,19 @@ static int mmjack_process_midi(instance* inst, mmjack_port* port, size_t nframes ident.fields.sub_control = 0; value = event.buffer[1] | (event.buffer[2] << 7); } - else if(ident.fields.sub_type == midi_aftertouch){ + else if(ident.fields.sub_type == midi_aftertouch || ident.fields.sub_type == midi_program){ ident.fields.sub_control = 0; value = event.buffer[1]; } + + //forward the EPN CCs to the EPN state machine + if(ident.fields.sub_type == midi_cc + && ((ident.fields.sub_control <= 101 && ident.fields.sub_control >= 98) + || ident.fields.sub_control == 6 + || ident.fields.sub_control == 38)){ + mmjack_handle_epn(port, ident.fields.sub_channel, ident.fields.sub_control, value); + } + //append midi data mmjack_midiqueue_append(port, ident, value); } @@ -124,30 +216,33 @@ static int mmjack_process_midi(instance* inst, mmjack_port* port, size_t nframes //clear buffer jack_midi_clear_buffer(buffer); + frame = 0; for(u = 0; u < port->queue_len; u++){ - //build midi event ident.label = port->queue[u].ident.label; - event_data = jack_midi_event_reserve(buffer, u, (ident.fields.sub_type == midi_aftertouch) ? 2 : 3); - if(!event_data){ - LOG("Failed to reserve MIDI stream data"); - return 1; - } - event_data[0] = ident.fields.sub_channel | ident.fields.sub_type; - if(ident.fields.sub_type == midi_pitchbend){ - event_data[1] = port->queue[u].raw & 0x7F; - event_data[2] = (port->queue[u].raw >> 7) & 0x7F; - } - else if(ident.fields.sub_type == midi_aftertouch){ - event_data[1] = port->queue[u].raw & 0x7F; + + if(ident.fields.sub_type == midi_rpn + || ident.fields.sub_type == midi_nrpn){ + //transmit parameter number + mmjack_process_midiout(buffer, frame++, midi_cc, ident.fields.sub_channel, (ident.fields.sub_type == midi_rpn) ? 101 : 99, (ident.fields.sub_control >> 7) & 0x7F); + mmjack_process_midiout(buffer, frame++, midi_cc, ident.fields.sub_channel, (ident.fields.sub_type == midi_rpn) ? 100 : 98, ident.fields.sub_control & 0x7F); + + //transmit parameter value + mmjack_process_midiout(buffer, frame++, midi_cc, ident.fields.sub_channel, 6, (port->queue[u].raw >> 7) & 0x7F); + mmjack_process_midiout(buffer, frame++, midi_cc, ident.fields.sub_channel, 38, port->queue[u].raw & 0x7F); + + if(!data->midi_epn_tx_short){ + //clear active parameter + mmjack_process_midiout(buffer, frame++, midi_cc, ident.fields.sub_channel, 101, 127); + mmjack_process_midiout(buffer, frame++, midi_cc, ident.fields.sub_channel, 100, 127); + } } else{ - event_data[1] = ident.fields.sub_control; - event_data[2] = port->queue[u].raw & 0x7F; + mmjack_process_midiout(buffer, frame++, ident.fields.sub_type, ident.fields.sub_channel, ident.fields.sub_control, port->queue[u].raw); } } - if(port->queue_len){ - DBGPF("Wrote %" PRIsize_t " MIDI events to port %s", port->queue_len, port->name); + if(frame){ + DBGPF("Wrote %" PRIsize_t " MIDI events to port %s", frame, port->name); } port->queue_len = 0; } @@ -305,6 +400,13 @@ static int mmjack_configure_instance(instance* inst, char* option, char* value){ data->server_name = strdup(value); return 0; } + else if(!strcmp(option, "epn-tx")){ + data->midi_epn_tx_short = 0; + if(!strcmp(value, "short")){ + data->midi_epn_tx_short = 1; + } + return 0; + } //register new port, first check for unique name for(p = 0; p < data->ports; p++){ @@ -385,12 +487,23 @@ static int mmjack_parse_midispec(mmjack_channel_ident* ident, char* spec){ ident->fields.sub_type = midi_pressure; next_token += 8; } + else if(!strncmp(next_token, "rpn", 3)){ + ident->fields.sub_type = midi_rpn; + next_token += 3; + } + else if(!strncmp(next_token, "nrpn", 4)){ + ident->fields.sub_type = midi_nrpn; + next_token += 4; + } else if(!strncmp(next_token, "pitch", 5)){ ident->fields.sub_type = midi_pitchbend; } else if(!strncmp(next_token, "aftertouch", 10)){ ident->fields.sub_type = midi_aftertouch; } + else if(!strncmp(next_token, "program", 7)){ + ident->fields.sub_type = midi_program; + } else{ LOGPF("Unknown MIDI control type in spec %s", spec); return 1; @@ -399,7 +512,9 @@ static int mmjack_parse_midispec(mmjack_channel_ident* ident, char* spec){ ident->fields.sub_control = strtoul(next_token, NULL, 10); if(ident->fields.sub_type == midi_none - || ident->fields.sub_control > 127){ + || (ident->fields.sub_type != midi_nrpn + && ident->fields.sub_type != midi_rpn + && ident->fields.sub_control > 127)){ LOGPF("Invalid MIDI spec %s", spec); return 1; } @@ -467,9 +582,12 @@ static int mmjack_set(instance* inst, size_t num, channel** c, channel_value* v) break; case port_midi: value = v[u].normalised * 127.0; - if(ident.fields.sub_type == midi_pitchbend){ - value = ((uint16_t)(v[u].normalised * 16384.0)); + if(ident.fields.sub_type == midi_pitchbend + || ident.fields.sub_type == midi_nrpn + || ident.fields.sub_type == midi_rpn){ + value = ((uint16_t)(v[u].normalised * 16383.0)); } + if(mmjack_midiqueue_append(data->port + ident.fields.port, ident, value)){ pthread_mutex_unlock(&data->port[ident.fields.port].lock); return 1; @@ -494,8 +612,10 @@ static void mmjack_handle_midi(instance* inst, size_t index, mmjack_port* port){ port->queue[u].ident.fields.port = index; chan = mm_channel(inst, port->queue[u].ident.label, 0); if(chan){ - if(port->queue[u].ident.fields.sub_type == midi_pitchbend){ - val.normalised = ((double)port->queue[u].raw) / 16384.0; + if(port->queue[u].ident.fields.sub_type == midi_pitchbend + || port->queue[u].ident.fields.sub_type == midi_rpn + || port->queue[u].ident.fields.sub_type == midi_nrpn){ + val.normalised = ((double)port->queue[u].raw) / 16383.0; } else{ val.normalised = ((double)port->queue[u].raw) / 127.0; diff --git a/backends/jack.h b/backends/jack.h index 03ce052..42905f1 100644 --- a/backends/jack.h +++ b/backends/jack.h @@ -16,22 +16,29 @@ static int mmjack_shutdown(size_t n, instance** inst); #define JACK_DEFAULT_SERVER_NAME "default" #define JACK_MIDIQUEUE_CHUNK 10 +#define EPN_NRPN 8 +#define EPN_PARAMETER_HI 4 +#define EPN_PARAMETER_LO 2 +#define EPN_VALUE_HI 1 + enum /*mmjack_midi_channel_type*/ { midi_none = 0, midi_note = 0x90, - midi_cc = 0xB0, midi_pressure = 0xA0, + midi_cc = 0xB0, + midi_program = 0xC0, midi_aftertouch = 0xD0, - midi_pitchbend = 0xE0 + midi_pitchbend = 0xE0, + midi_rpn = 0xF1, + midi_nrpn = 0xF2 }; typedef union { struct { uint32_t port; - uint8_t pad; uint8_t sub_type; uint8_t sub_channel; - uint8_t sub_control; + uint16_t sub_control; } fields; uint64_t label; } mmjack_channel_ident; @@ -58,10 +65,15 @@ typedef struct /*_mmjack_port_data*/ { double min; uint8_t mark; double last; + size_t queue_len; size_t queue_alloc; mmjack_midiqueue* queue; + uint16_t epn_control[16]; + uint16_t epn_value[16]; + uint8_t epn_status[16]; + pthread_mutex_t lock; } mmjack_port; @@ -70,6 +82,8 @@ typedef struct /*_jack_instance_data*/ { char* client_name; int fd; + uint8_t midi_epn_tx_short; + jack_client_t* client; size_t ports; mmjack_port* port; diff --git a/backends/jack.md b/backends/jack.md index b6ff5a9..c67f060 100644 --- a/backends/jack.md +++ b/backends/jack.md @@ -16,6 +16,7 @@ transport of control data via either JACK midi ports or control voltage (CV) inp |---------------|-----------------------|-----------------------|-----------------------| | `name` | `Controller` | `MIDIMonster` | Client name for the JACK connection | | `server` | `jackserver` | `default` | JACK server identifier to connect to | +| `epn-tx` | `short` | `full` | Configure whether to clear the active parameter number after transmitting a MIDI `nrpn` or `rpn` parameter. | Channels (corresponding to JACK ports) need to be configured with their type and, if applicable, value limits. To configure a port, specify it in the instance configuration using the following syntax: @@ -55,6 +56,9 @@ MIDI ports provide subchannels for the various MIDI controls available. Each MID corresponding pressure controls for each note, 128 control change (CC) controls (numbered likewise), one channel wide "aftertouch" control and one channel-wide pitchbend control. +Every MIDI channel also provides `rpn` and `nrpn` controls, which are implemented on top of the MIDI protocol, using +the CC controls 101/100/99/98/38/6. Both control types have 14-bit IDs and 14-bit values. + A MIDI port subchannel is specified using the syntax `channel<channel>.<type><index>`. The shorthand `ch` may be used instead of the word `channel` (Note that `channel` here refers to the MIDI channel number). @@ -65,13 +69,18 @@ The following values are recognized for `type`: * `pressure` - Note pressure/aftertouch messages * `aftertouch` - Channel-wide aftertouch messages * `pitch` - Channel pitchbend messages +* `program` - Channel program change messages +* `rpn` - Registered parameter numbers (14-bit extension) +* `nrpn` - Non-registered parameter numbers (14-bit extension) -The `pitch` and `aftertouch` events are channel-wide, thus they can be specified as `channel<channel>.<type>`. +The `pitch`, `aftertouch` and `program` messages/events are channel-wide, thus they can be specified as `channel<channel>.<type>`. Example mappings: ``` jack1.cv_in > jack1.midi_out.ch0.note3 jack1.midi_in.ch0.pitch > jack1.cv_out +jack2.midi_in.ch0.nrpn900 > jack1.midi_out.ch1.rpn1 +jack1.midi_in.ch15.note1 > jack1.midi_out.ch4.program ``` The MIDI subchannel syntax is intentionally kept compatible to the different MIDI backends also supported @@ -79,6 +88,12 @@ by the MIDIMonster #### Known bugs / problems +MIDI extended parameter numbers (EPNs, the `rpn` and `nrpn` control types) will also generate events on the controls (CC 101 through +98, 38 and 6) that are used as the lower layer transport. When using EPNs, mapping those controls is probably not useful. + +EPN control types support only the full 14-bit transfer encoding, not the shorter variant transmitting only the 7 +high-order bits. This may be changed if there is sufficient interest in the functionality. + While JACK has rudimentary capabilities for transporting OSC messages, configuring and parsing such channels with this backend would take a great amount of dedicated syntax & code. CV ports can provide fine-grained single control channels as an alternative to MIDI. This feature may be implemented at some point in the future. diff --git a/backends/libmmbackend.c b/backends/libmmbackend.c index bad048c..18611e1 100644 --- a/backends/libmmbackend.c +++ b/backends/libmmbackend.c @@ -3,6 +3,10 @@ #define LOGPF(format, ...) fprintf(stderr, "libmmbe\t" format "\n", __VA_ARGS__) #define LOG(message) fprintf(stderr, "libmmbe\t%s\n", (message)) +#ifndef _WIN32 + #define closesocket close +#endif + int mmbackend_strdup(char** dest, char* src){ if(*dest){ free(*dest); @@ -186,14 +190,14 @@ int mmbackend_socket(char* host, char* port, int socktype, uint8_t listener, uin if(listener){ status = bind(fd, addr_it->ai_addr, addr_it->ai_addrlen); if(status < 0){ - close(fd); + closesocket(fd); continue; } } else{ status = connect(fd, addr_it->ai_addr, addr_it->ai_addrlen); if(status < 0){ - close(fd); + closesocket(fd); continue; } } diff --git a/backends/lua.c b/backends/lua.c index 98ce369..0a638f7 100644 --- a/backends/lua.c +++ b/backends/lua.c @@ -137,6 +137,8 @@ static int lua_update_timerfd(){ } static void lua_thread_resume(size_t current_thread){ + int thread_status = 0; + //push coroutine reference lua_pushstring(thread[current_thread].thread, LUA_REGISTRY_CURRENT_THREAD); lua_pushnumber(thread[current_thread].thread, current_thread); @@ -144,9 +146,23 @@ static void lua_thread_resume(size_t current_thread){ //call thread main DBGPF("Resuming thread %" PRIsize_t " on %s", current_thread, thread[current_thread].instance->name); - if(lua_resume(thread[current_thread].thread, NULL, 0) != LUA_YIELD){ - DBGPF("Thread %" PRIsize_t " on %s terminated", current_thread, thread[current_thread].instance->name); + //the lua_resume API has changed with lua5.4 + #if LUA_VERSION_NUM > 503 + int results = 0; + thread_status = lua_resume(thread[current_thread].thread, NULL, 0, &results); + #else + thread_status = lua_resume(thread[current_thread].thread, NULL, 0); + #endif + + if(thread_status == LUA_YIELD){ + DBGPF("Thread %" PRIsize_t " on %s yielded execution", current_thread, thread[current_thread].instance->name); + } + else{ thread[current_thread].timeout = 0; + LOGPF("Thread %" PRIsize_t " on %s terminated", current_thread, thread[current_thread].instance->name); + if(thread_status){ + LOGPF("Last error message: %s", lua_tostring(thread[current_thread].thread, -1)); + } } //remove coroutine reference @@ -166,6 +182,30 @@ static instance* lua_fetch_instance(lua_State* interpreter){ return inst; } +static int lua_callback_channels(lua_State* interpreter){ + size_t u; + instance* inst = lua_fetch_instance(interpreter); + lua_instance_data* data = (lua_instance_data*) inst->impl; + + if(!last_timestamp){ + LOG("The channels() API will not return usable results before the configuration has been read completely"); + } + + //create a table for the return array + lua_createtable(interpreter, data->channels, 0); + + for(u = 0; u < data->channels; u++){ + //push the key + lua_pushnumber(interpreter, u + 1); + //push the value + lua_pushstring(interpreter, data->channel[u].name); + //settable pops key and value, leaving the table + lua_settable(interpreter, -3); + } + + return 1; +} + static int lua_callback_thread(lua_State* interpreter){ instance* inst = lua_fetch_instance(interpreter); size_t u = threads; @@ -467,6 +507,7 @@ static int lua_instance(instance* inst){ lua_register(data->interpreter, "thread", lua_callback_thread); lua_register(data->interpreter, "sleep", lua_callback_sleep); lua_register(data->interpreter, "cleanup_handler", lua_callback_cleanup_handler); + lua_register(data->interpreter, "channels", lua_callback_channels); //store instance pointer to the lua state lua_pushstring(data->interpreter, LUA_REGISTRY_KEY); @@ -604,6 +645,7 @@ static int lua_resolve_symbol(lua_State* interpreter, char* symbol){ || !strcmp(symbol, "input_channel") || !strcmp(symbol, "timestamp") || !strcmp(symbol, "cleanup_handler") + || !strcmp(symbol, "channels") || !strcmp(symbol, "interval")){ return LUA_NOREF; } @@ -622,6 +664,10 @@ static int lua_start(size_t n, instance** inst){ int default_handler; channel_value v; + #ifdef LUA_VERSION_NUM + DBGPF("Lua backend built with %s (%d)", LUA_VERSION, LUA_VERSION_NUM); + #endif + //resolve channels to their handler functions for(u = 0; u < n; u++){ data = (lua_instance_data*) inst[u]->impl; diff --git a/backends/lua.md b/backends/lua.md index b2f40e0..026c945 100644 --- a/backends/lua.md +++ b/backends/lua.md @@ -6,8 +6,8 @@ and manipulate events using the Lua scripting language. Every instance has its own interpreter state which can be loaded with custom scripts. To process incoming channel events, the MIDIMonster calls corresponding Lua functions (if they exist) -with the value (as a Lua `number` type) as parameter. Alternatively, a designated default channel handler -which will receive events for all incoming channels may be supplied in the configuration. +with the normalized event value (as a Lua `number` type) as parameter. Alternatively, a designated +default channel handler which will receive events for all incoming channels may be set in the configuration. The backend can also call Lua functions repeatedly using a timer, allowing users to implement time-based functionality (such as evaluating a fixed mathematical function or outputting periodic updates). @@ -25,6 +25,7 @@ The following functions are provided within the Lua interpreter for interaction | `timestamp()` | `print(timestamp())` | Returns the core timestamp for this iteration with millisecond resolution. This is not a performance timer, but intended for timeouting, etc | | `thread(function)` | `thread(run_show)` | Run a function as a Lua thread (see below) | | `sleep(number)` | `sleep(100)` | Suspend current thread for time specified in milliseconds | +| `channels()` | `chans = channels()` | Fetch an array of all currently known channels on the instance. Note that this function only works properly after the configuration has been read completely, i.e. any time after startup | While a channel handler executes, calling `input_value` for that channel returns the previous value. The stored value is updated once the handler returns. diff --git a/backends/maweb.c b/backends/maweb.c index 97d4cea..8b878b0 100644 --- a/backends/maweb.c +++ b/backends/maweb.c @@ -1,4 +1,5 @@ #define BACKEND_NAME "maweb" +//#define DEBUG #include <string.h> #include <unistd.h> @@ -15,14 +16,11 @@ #define WS_FLAG_FIN 0x80 #define WS_FLAG_MASK 0x80 -/* - * TODO handle peer close/unregister/reopen and fallback connections - */ +static void maweb_disconnect(instance* inst); static uint64_t last_keepalive = 0; -static uint64_t update_interval = 50; +static uint64_t update_interval = 0; static uint64_t last_update = 0; -static uint64_t updates_inflight = 0; static uint64_t quiet_mode = 0; static maweb_command_key cmdline_keys[] = { @@ -136,7 +134,10 @@ static int channel_comparator(const void* raw_a, const void* raw_b){ } static uint32_t maweb_interval(){ - return update_interval - (last_update % update_interval); + if(update_interval){ + return update_interval - (last_update % update_interval); + } + return 0; } static int maweb_configure(char* option, char* value){ @@ -248,7 +249,7 @@ static int maweb_instance(instance* inst){ static channel* maweb_channel(instance* inst, char* spec, uint8_t flags){ maweb_instance_data* data = (maweb_instance_data*) inst->impl; maweb_channel_data chan = { - 0 + .in = -1 //this hack allows the initial data request to push events even for zero'ed channels }; char* next_token = NULL; channel* channel_ref = NULL; @@ -352,8 +353,7 @@ static int maweb_send_frame(instance* inst, maweb_operation op, uint8_t* payload if(mmbackend_send(data->fd, frame_header, header_bytes) || mmbackend_send(data->fd, payload, len)){ LOGPF("Failed to send on instance %s, assuming connection failure", inst->name); - data->state = ws_closed; - data->login = 0; + maweb_disconnect(inst); return 1; } @@ -423,6 +423,7 @@ static int maweb_process_playback(instance* inst, int64_t page, maweb_channel_ty } static int maweb_process_playbacks(instance* inst, int64_t page, char* payload, size_t payload_length){ + maweb_instance_data* data = (maweb_instance_data*) inst->impl; size_t base_offset = json_obj_offset(payload, "itemGroups"), group_offset, subgroup_offset, item_offset; uint64_t group = 0, subgroup, item, metatype; @@ -466,8 +467,9 @@ static int maweb_process_playbacks(instance* inst, int64_t page, char* payload, } group++; } - updates_inflight--; - DBGPF("Playback message processing done, %" PRIu64 " updates inflight", updates_inflight); + + data->updates_inflight--; + DBGPF("Playback message processing done, %" PRIu64 " updates inflight on %s", data->updates_inflight, inst->name); return 0; } @@ -479,9 +481,9 @@ static int maweb_request_playbacks(instance* inst){ char item_indices[1024] = "[300,400,500]", item_counts[1024] = "[16,16,16]", item_types[1024] = "[3,3,3]"; size_t page_index = 0, view = 3, channel = 0, offsets[3], channel_offset, channels; - if(updates_inflight){ + if(data->updates_inflight){ if(quiet_mode < 1){ - LOGPF("Skipping update request, %" PRIu64 " updates still inflight - consider raising the interval time", updates_inflight); + LOGPF("Skipping update request on %s, %" PRIu64 " updates still inflight - consider raising the interval time", inst->name, data->updates_inflight); } return 0; } @@ -572,15 +574,16 @@ static int maweb_request_playbacks(instance* inst){ data->session); maweb_send_frame(inst, ws_text, (uint8_t*) xmit_buffer, strlen(xmit_buffer)); DBGPF("Poll request: %s", xmit_buffer); - updates_inflight++; + data->updates_inflight++; } - DBGPF("Poll request handling done, %" PRIu64 " updates requested", updates_inflight); + DBGPF("Poll request handling done, %" PRIu64 " updates requested on %s", data->updates_inflight, inst->name); return rv; } static int maweb_handle_message(instance* inst, char* payload, size_t payload_length){ char xmit_buffer[MAWEB_XMIT_CHUNK]; + int64_t session = 0; char* field; maweb_instance_data* data = (maweb_instance_data*) inst->impl; @@ -591,31 +594,50 @@ static int maweb_handle_message(instance* inst, char* payload, size_t payload_le if(json_obj_bool(payload, "result", 0)){ LOG("Login successful"); data->login = 1; + + //initially request playbacks + if(!update_interval){ + maweb_request_playbacks(inst); + } } else{ - LOG("Login failed"); data->login = 0; + + if(data->hosts > 1){ + LOGPF("Console login failed on %s, will try again with the next host", inst->name); + maweb_disconnect(inst); + } + else{ + LOGPF("Console login failed on %s", inst->name); + } + return 0; } } if(!strncmp(field, "playbacks", 9)){ if(maweb_process_playbacks(inst, json_obj_int(payload, "iPage", 0), payload, payload_length)){ LOG("Failed to handle/request input data"); } + + //request playbacks again if configured + if(!update_interval && data->login && !data->updates_inflight){ + maweb_request_playbacks(inst); + } return 0; } } DBGPF("Incoming message (%" PRIsize_t "): %s", payload_length, payload); if(json_obj(payload, "session") == JSON_NUMBER){ - data->session = json_obj_int(payload, "session", data->session); - if(data->session < 0){ - LOG("Login failed"); - data->login = 0; - return 0; + session = json_obj_int(payload, "session", data->session); + if(session < 0){ + LOG("Invalid web remote session identifier received, closing connection"); + maweb_disconnect(inst); + return 0; } - if(quiet_mode < 2){ - LOGPF("Session id is now %" PRId64, data->session); + if(data->session != session){ + LOGPF("Web remote session ID changed from %" PRId64 " to %" PRId64 "", data->session, session); } + data->session = session; } if(json_obj_bool(payload, "forceLogin", 0)){ @@ -642,6 +664,30 @@ static int maweb_handle_message(instance* inst, char* payload, size_t payload_le return 0; } +static void maweb_disconnect(instance* inst){ + maweb_instance_data* data = (maweb_instance_data*) inst->impl; + char xmit_buffer[MAWEB_XMIT_CHUNK]; + + if(data->fd){ + //close the session if one is active + if(data->session > 0){ + snprintf(xmit_buffer, sizeof(xmit_buffer), "{\"requestType\":\"close\",\"session\":%" PRIu64 "}", data->session); + maweb_send_frame(inst, ws_text, (uint8_t*) xmit_buffer, strlen(xmit_buffer)); + } + + mm_manage_fd(data->fd, BACKEND_NAME, 0, NULL); + close(data->fd); + } + + data->fd = -1; + data->state = ws_closed; + data->login = 0; + data->session = -1; + data->peer_type = peer_unidentified; + data->offset = 0; + data->updates_inflight = 0; +} + static int maweb_connect(instance* inst){ int rv = 1; maweb_instance_data* data = (maweb_instance_data*) inst->impl; @@ -650,14 +696,8 @@ static int maweb_connect(instance* inst){ goto bail; } - //unregister old fd from core - if(data->fd >= 0){ - mm_manage_fd(data->fd, BACKEND_NAME, 0, NULL); - close(data->fd); - data->fd = -1; - } - data->state = ws_closed; - data->login = 0; + //close old connection and reset state + maweb_disconnect(inst); LOGPF("Connecting to host %" PRIsize_t " of %" PRIsize_t " on %s", data->next_host + 1, data->hosts, inst->name); @@ -1047,7 +1087,7 @@ static int maweb_handle(size_t num, managed_fd* fds){ last_keepalive = mm_timestamp(); } - if(last_update && mm_timestamp() - last_update >= update_interval){ + if(update_interval && last_update && mm_timestamp() - last_update >= update_interval){ rv |= maweb_poll(); last_update = mm_timestamp(); } @@ -1071,7 +1111,7 @@ static int maweb_start(size_t n, instance** inst){ //re-set channel identifiers for(p = 0; p < data->channels; p++){ - data->channel[p].chan->ident = p; + mm_channel_update(data->channel[p].chan, p); } //try to connect to any available host @@ -1114,14 +1154,10 @@ static int maweb_shutdown(size_t n, instance** inst){ free(data->pass); data->pass = NULL; - close(data->fd); - data->fd = -1; - + maweb_disconnect(inst[u]); free(data->buffer); data->buffer = NULL; - - data->offset = data->allocated = 0; - data->state = ws_closed; + data->allocated = 0; free(data->channel); data->channel = NULL; diff --git a/backends/maweb.h b/backends/maweb.h index 85ca09d..8efe6a8 100644 --- a/backends/maweb.h +++ b/backends/maweb.h @@ -100,4 +100,6 @@ typedef struct /*_maweb_instance_data*/ { size_t offset; size_t allocated; uint8_t* buffer; + + uint64_t updates_inflight; } maweb_instance_data; diff --git a/backends/maweb.md b/backends/maweb.md index 1547919..6ac2cd1 100644 --- a/backends/maweb.md +++ b/backends/maweb.md @@ -18,7 +18,7 @@ Web Remote. Set a web remote password using the option below the activation sett | Option | Example value | Default value | Description | |---------------|-----------------------|-----------------------|---------------------------------------------------------------| -| `interval` | `100` | `50` | Query interval for input data polling (in msec). | +| `interval` | `100` | `0` | Query interval for input data polling (in msec). If set to 0 (the default), data is queried again when the previous data request has received an answer. | | `quiet` | `1` | `0` | Turn off some warning messages, for use by experts. | #### Instance configuration diff --git a/backends/midi.c b/backends/midi.c index 1f0f2d5..4bf846a 100644 --- a/backends/midi.c +++ b/backends/midi.c @@ -13,7 +13,10 @@ enum /*_midi_channel_type*/ { cc, pressure, aftertouch, - pitchbend + pitchbend, + program, + rpn, + nrpn }; static struct { @@ -81,7 +84,7 @@ static int midi_configure_instance(instance* inst, char* option, char* value){ midi_instance_data* data = (midi_instance_data*) inst->impl; //FIXME maybe allow connecting more than one device - if(!strcmp(option, "read")){ + if(!strcmp(option, "read") || !strcmp(option, "source")){ //connect input device if(data->read){ LOGPF("Instance %s was already connected to an input device", inst->name); @@ -90,7 +93,7 @@ static int midi_configure_instance(instance* inst, char* option, char* value){ data->read = strdup(value); return 0; } - else if(!strcmp(option, "write")){ + else if(!strcmp(option, "write") || !strcmp(option, "target")){ //connect output device if(data->write){ LOGPF("Instance %s was already connected to an output device", inst->name); @@ -99,8 +102,15 @@ static int midi_configure_instance(instance* inst, char* option, char* value){ data->write = strdup(value); return 0; } + else if(!strcmp(option, "epn-tx")){ + data->epn_tx_short = 0; + if(!strcmp(value, "short")){ + data->epn_tx_short = 1; + } + return 0; + } - LOGPF("Unknown instance option %s", option); + LOGPF("Unknown instance configuration option %s on instance %s", option, inst->name); return 1; } @@ -147,9 +157,20 @@ static channel* midi_channel(instance* inst, char* spec, uint8_t flags){ ident.fields.type = pressure; channel += 8; } + else if(!strncmp(channel, "rpn", 3)){ + ident.fields.type = rpn; + channel += 3; + } + else if(!strncmp(channel, "nrpn", 4)){ + ident.fields.type = nrpn; + channel += 4; + } else if(!strncmp(channel, "pitch", 5)){ ident.fields.type = pitchbend; } + else if(!strncmp(channel, "program", 7)){ + ident.fields.type = program; + } else if(!strncmp(channel, "aftertouch", 10)){ ident.fields.type = aftertouch; } @@ -167,9 +188,40 @@ static channel* midi_channel(instance* inst, char* spec, uint8_t flags){ return NULL; } +static void midi_tx(int port, uint8_t type, uint8_t channel, uint8_t control, uint16_t value){ + snd_seq_event_t ev; + + snd_seq_ev_clear(&ev); + snd_seq_ev_set_source(&ev, port); + snd_seq_ev_set_subs(&ev); + snd_seq_ev_set_direct(&ev); + + switch(type){ + case note: + snd_seq_ev_set_noteon(&ev, channel, control, value); + break; + case cc: + snd_seq_ev_set_controller(&ev, channel, control, value); + break; + case pressure: + snd_seq_ev_set_keypress(&ev, channel, control, value); + break; + case pitchbend: + snd_seq_ev_set_pitchbend(&ev, channel, value); + break; + case aftertouch: + snd_seq_ev_set_chanpress(&ev, channel, value); + break; + case program: + snd_seq_ev_set_pgmchange(&ev, channel, value); + break; + } + + snd_seq_event_output(sequencer, &ev); +} + static int midi_set(instance* inst, size_t num, channel** c, channel_value* v){ size_t u; - snd_seq_event_t ev; midi_instance_data* data = (midi_instance_data*) inst->impl; midi_channel_ident ident = { .label = 0 @@ -178,30 +230,29 @@ static int midi_set(instance* inst, size_t num, channel** c, channel_value* v){ for(u = 0; u < num; u++){ ident.label = c[u]->ident; - snd_seq_ev_clear(&ev); - snd_seq_ev_set_source(&ev, data->port); - snd_seq_ev_set_subs(&ev); - snd_seq_ev_set_direct(&ev); - switch(ident.fields.type){ - case note: - snd_seq_ev_set_noteon(&ev, ident.fields.channel, ident.fields.control, v[u].normalised * 127.0); - break; - case cc: - snd_seq_ev_set_controller(&ev, ident.fields.channel, ident.fields.control, v[u].normalised * 127.0); - break; - case pressure: - snd_seq_ev_set_keypress(&ev, ident.fields.channel, ident.fields.control, v[u].normalised * 127.0); + case rpn: + case nrpn: + //transmit parameter number + midi_tx(data->port, cc, ident.fields.channel, (ident.fields.type == rpn) ? 101 : 99, (ident.fields.control >> 7) & 0x7F); + midi_tx(data->port, cc, ident.fields.channel, (ident.fields.type == rpn) ? 100 : 98, ident.fields.control & 0x7F); + //transmit parameter value + midi_tx(data->port, cc, ident.fields.channel, 6, (((uint16_t) (v[u].normalised * 16383.0)) >> 7) & 0x7F); + midi_tx(data->port, cc, ident.fields.channel, 38, ((uint16_t) (v[u].normalised * 16383.0)) & 0x7F); + + if(!data->epn_tx_short){ + //clear active parameter + midi_tx(data->port, cc, ident.fields.channel, 101, 127); + midi_tx(data->port, cc, ident.fields.channel, 100, 127); + } break; case pitchbend: - snd_seq_ev_set_pitchbend(&ev, ident.fields.channel, (v[u].normalised * 16383.0) - 8192); - break; - case aftertouch: - snd_seq_ev_set_chanpress(&ev, ident.fields.channel, v[u].normalised * 127.0); + //TODO check whether this actually works that well + midi_tx(data->port, ident.fields.type, ident.fields.channel, ident.fields.control, (v[u].normalised * 16383.0) - 8192); break; + default: + midi_tx(data->port, ident.fields.type, ident.fields.channel, ident.fields.control, v[u].normalised * 127.0); } - - snd_seq_event_output(sequencer, &ev); } snd_seq_drain_output(sequencer); @@ -216,21 +267,108 @@ static char* midi_type_name(uint8_t type){ return "note"; case cc: return "cc"; + case rpn: + return "rpn"; + case nrpn: + return "nrpn"; case pressure: return "pressure"; case aftertouch: return "aftertouch"; case pitchbend: return "pitch"; + case program: + return "program"; } return "unknown"; } +//this state machine is used more-or-less verbatim in the winmidi, rtpmidi and jack backends - fixes need to be applied there, too +static void midi_handle_epn(instance* inst, uint8_t chan, uint16_t control, uint16_t value){ + midi_instance_data* data = (midi_instance_data*) inst->impl; + midi_channel_ident ident = { + .label = 0 + }; + channel* changed = NULL; + channel_value val; + //check for 3-byte update TODO + + //switching between nrpn and rpn clears all valid bits + if(((data->epn_status[chan] & EPN_NRPN) && (control == 101 || control == 100)) + || (!(data->epn_status[chan] & EPN_NRPN) && (control == 99 || control == 98))){ + data->epn_status[chan] &= ~(EPN_NRPN | EPN_PARAMETER_LO | EPN_PARAMETER_HI); + } + + //setting an address always invalidates the value valid bits + if(control >= 98 && control <= 101){ + data->epn_status[chan] &= ~(EPN_VALUE_HI /*| EPN_VALUE_LO*/); + } + + //parameter hi + if(control == 101 || control == 99){ + data->epn_control[chan] &= 0x7F; + data->epn_control[chan] |= value << 7; + data->epn_status[chan] |= EPN_PARAMETER_HI | ((control == 99) ? EPN_NRPN : 0); + if(control == 101 && value == 127){ + data->epn_status[chan] &= ~EPN_PARAMETER_HI; + } + } + + //parameter lo + if(control == 100 || control == 98){ + data->epn_control[chan] &= ~0x7F; + data->epn_control[chan] |= value & 0x7F; + data->epn_status[chan] |= EPN_PARAMETER_LO | ((control == 98) ? EPN_NRPN : 0); + if(control == 100 && value == 127){ + data->epn_status[chan] &= ~EPN_PARAMETER_LO; + } + } + + //value hi, clears low, mark as update candidate + if(control == 6 + //check if parameter is set before accepting value update + && ((data->epn_status[chan] & (EPN_PARAMETER_HI | EPN_PARAMETER_LO)) == (EPN_PARAMETER_HI | EPN_PARAMETER_LO))){ + data->epn_value[chan] = value << 7; + data->epn_status[chan] |= EPN_VALUE_HI; + } + + //FIXME is the update order for the value bits fixed? + //FIXME can there be standalone updates on CC 38? + + //value lo, flush the value + if(control == 38 + && data->epn_status[chan] & EPN_VALUE_HI){ + data->epn_value[chan] &= ~0x7F; + data->epn_value[chan] |= value & 0x7F; + //FIXME not clearing the valid bit would allow for fast low-order updates + data->epn_status[chan] &= ~EPN_VALUE_HI; + + if(midi_config.detect){ + LOGPF("Incoming EPN data on channel %s.ch%d.%s%d", inst->name, chan, data->epn_status[chan] & EPN_NRPN ? "nrpn" : "rpn", data->epn_control[chan]); + } + + //find the updated channel + ident.fields.type = data->epn_status[chan] & EPN_NRPN ? nrpn : rpn; + ident.fields.channel = chan; + ident.fields.control = data->epn_control[chan]; + val.normalised = (double) data->epn_value[chan] / 16383.0; + + //push the new value + changed = mm_channel(inst, ident.label, 0); + if(changed){ + mm_channel_event(changed, val); + } + } +} + static int midi_handle(size_t num, managed_fd* fds){ snd_seq_event_t* ev = NULL; instance* inst = NULL; + midi_instance_data* data = NULL; + channel* changed = NULL; channel_value val; + char* event_type = NULL; midi_channel_ident ident = { .label = 0 @@ -248,6 +386,14 @@ static int midi_handle(size_t num, managed_fd* fds){ ident.fields.control = ev->data.note.note; val.normalised = (double) ev->data.note.velocity / 127.0; + //scan for the instance before parsing incoming data, instance state is required for the EPN state machine + inst = mm_instance_find(BACKEND_NAME, ev->dest.port); + if(!inst){ + LOG("Delivered event did not match any instance"); + continue; + } + data = (midi_instance_data*) inst->impl; + switch(ev->type){ case SND_SEQ_EVENT_NOTEON: case SND_SEQ_EVENT_NOTEOFF: @@ -263,18 +409,35 @@ static int midi_handle(size_t num, managed_fd* fds){ case SND_SEQ_EVENT_CHANPRESS: ident.fields.type = aftertouch; ident.fields.channel = ev->data.control.channel; + ident.fields.control = 0; val.normalised = (double) ev->data.control.value / 127.0; break; case SND_SEQ_EVENT_PITCHBEND: ident.fields.type = pitchbend; + ident.fields.control = 0; ident.fields.channel = ev->data.control.channel; val.normalised = ((double) ev->data.control.value + 8192) / 16383.0; break; + case SND_SEQ_EVENT_PGMCHANGE: + ident.fields.type = program; + ident.fields.control = 0; + ident.fields.channel = ev->data.control.channel; + val.normalised = (double) ev->data.control.value / 127.0; + break; case SND_SEQ_EVENT_CONTROLLER: ident.fields.type = cc; ident.fields.channel = ev->data.control.channel; ident.fields.control = ev->data.control.param; val.normalised = (double) ev->data.control.value / 127.0; + + //check for EPN CCs and update the state machine + if((ident.fields.control <= 101 && ident.fields.control >= 98) + || ident.fields.control == 6 + || ident.fields.control == 38 + //if the high-order value bits are set, forward any control to the state machine for the short update form + || data->epn_status[ident.fields.channel] & EPN_VALUE_HI){ + midi_handle_epn(inst, ident.fields.channel, ident.fields.control, ev->data.control.value); + } break; default: LOG("Ignored event of unsupported type"); @@ -282,13 +445,6 @@ static int midi_handle(size_t num, managed_fd* fds){ } event_type = midi_type_name(ident.fields.type); - inst = mm_instance_find(BACKEND_NAME, ev->dest.port); - if(!inst){ - //FIXME might want to return failure - LOG("Delivered event did not match any instance"); - continue; - } - changed = mm_channel(inst, ident.label, 0); if(changed){ if(mm_channel_event(changed, val)){ @@ -298,7 +454,7 @@ static int midi_handle(size_t num, managed_fd* fds){ } if(midi_config.detect && event_type){ - if(ident.fields.type == pitchbend || ident.fields.type == aftertouch){ + if(ident.fields.type == pitchbend || ident.fields.type == aftertouch || ident.fields.type == program){ LOGPF("Incoming data on channel %s.ch%d.%s", inst->name, ident.fields.channel, event_type); } else{ diff --git a/backends/midi.h b/backends/midi.h index dcee010..e2d6543 100644 --- a/backends/midi.h +++ b/backends/midi.h @@ -10,18 +10,28 @@ static int midi_handle(size_t num, managed_fd* fds); static int midi_start(size_t n, instance** inst); static int midi_shutdown(size_t n, instance** inst); +#define EPN_NRPN 8 +#define EPN_PARAMETER_HI 4 +#define EPN_PARAMETER_LO 2 +#define EPN_VALUE_HI 1 + typedef struct /*_midi_instance_data*/ { int port; char* read; char* write; + + uint8_t epn_tx_short; + uint16_t epn_control[16]; + uint16_t epn_value[16]; + uint8_t epn_status[16]; } midi_instance_data; typedef union { struct { - uint8_t pad[5]; + uint8_t pad[4]; uint8_t type; uint8_t channel; - uint8_t control; + uint16_t control; } fields; uint64_t label; } midi_channel_ident; diff --git a/backends/midi.md b/backends/midi.md index d3d6e33..6280205 100644 --- a/backends/midi.md +++ b/backends/midi.md @@ -11,10 +11,11 @@ The MIDI backend provides read-write access to the MIDI protocol via virtual por #### Instance configuration -| Option | Example value | Default value | Description | -|---------------|-----------------------|-----------------------|-----------------------| -| `read` | `20:0` | none | MIDI device to connect for input | -| `write` | `DeviceName` | none | MIDI device to connect for output | +| Option | Example value | Default value | Description | +|-----------------------|-----------------------|-----------------------|-----------------------| +| `read` / `source` | `20:0` | none | MIDI device to connect for input | +| `write` / `target` | `DeviceName` | none | MIDI device to connect for output | +| `epn-tx` | `short` | `full` | Configures whether to clear the active parameter number after transmitting an `nrpn` or `rpn` parameter | MIDI device names may either be `client:port` portnames or prefixes of MIDI device names. Run `aconnect -i` to list input ports and `aconnect -o` to list output ports. @@ -30,25 +31,39 @@ The MIDI backend supports mapping different MIDI events to MIDIMonster channels. * `pressure` - Note pressure/aftertouch messages * `aftertouch` - Channel-wide aftertouch messages * `pitch` - Channel pitchbend messages +* `program` - Channel program change messages +* `rpn` - Registered parameter numbers (14-bit extension) +* `nrpn` - Non-registered parameter numbers (14-bit extension) A MIDIMonster channel is specified using the syntax `channel<channel>.<type><index>`. The shorthand `ch` may be used instead of the word `channel` (Note that `channel` here refers to the MIDI channel number). -The `pitch` and `aftertouch` events are channel-wide, thus they can be specified as `channel<channel>.<type>`. +The `pitch`, `aftertouch` and `program` messages/events are channel-wide, thus they can be specified as `channel<channel>.<type>`. MIDI channels range from `0` to `15`. Each MIDI channel consists of 128 notes (numbered `0` through `127`), which additionally each have a pressure control, 128 CC's (numbered likewise), a channel pressure control (also called 'channel aftertouch') and a pitch control which may all be mapped to individual MIDIMonster channels. +Every MIDI channel also provides `rpn` and `nrpn` controls, which are implemented on top of the MIDI protocol, using +the CC controls 101/100/99/98/38/6. Both control types have 14-bit IDs and 14-bit values. + Example mappings: ``` midi1.ch0.note9 > midi2.channel1.cc4 midi1.channel15.pressure1 > midi1.channel0.note0 midi1.ch1.aftertouch > midi2.ch2.cc0 midi1.ch0.pitch > midi2.ch1.pitch +midi1.ch0.nrpn900 > midi2.ch0.rpn1 +midi2.ch15.note1 > midi1.ch2.program ``` #### Known bugs / problems +Extended parameter numbers (EPNs, the `rpn` and `nrpn` control types) will also generate events on the controls (CC 101 through +98, 38 and 6) that are used as the lower layer transport. When using EPNs, mapping those controls is probably not useful. + +EPN control types support only the full 14-bit transfer encoding, not the shorter variant transmitting only the 7 +high-order bits. This may be changed if there is sufficient interest in the functionality. + To access MIDI data, the user running MIDIMonster needs read & write access to the ALSA sequencer. This can usually be done by adding this user to the `audio` system group. diff --git a/backends/mqtt.c b/backends/mqtt.c new file mode 100644 index 0000000..da4bf38 --- /dev/null +++ b/backends/mqtt.c @@ -0,0 +1,1006 @@ +#define BACKEND_NAME "mqtt" +//#define DEBUG + +#include <string.h> +#include <time.h> +#include <math.h> + +#include "libmmbackend.h" +#include "mqtt.h" + +static uint64_t last_maintenance = 0; +/* according to spec 2.2.2.2 */ +static struct { + uint8_t property; + uint8_t storage; +} property_lengths[] = { + {0x01, STORAGE_U8}, + {0x02, STORAGE_U32}, + {0x03, STORAGE_PREFIXED}, + {0x08, STORAGE_PREFIXED}, + {0x09, STORAGE_PREFIXED}, + {0x0B, STORAGE_VARINT}, + {0x11, STORAGE_U32}, + + {0x12, STORAGE_PREFIXED}, + {0x13, STORAGE_U16}, + {0x15, STORAGE_PREFIXED}, + {0x16, STORAGE_PREFIXED}, + {0x17, STORAGE_U8}, + {0x18, STORAGE_U32}, + {0x19, STORAGE_U8}, + {0x1A, STORAGE_PREFIXED}, + {0x1C, STORAGE_PREFIXED}, + {0x1F, STORAGE_PREFIXED}, + {0x21, STORAGE_U16}, + {0x22, STORAGE_U16}, + {0x23, STORAGE_U16}, + {0x24, STORAGE_U8}, + {0x25, STORAGE_U8}, + {0x26, STORAGE_PREFIXPAIR}, + {0x27, STORAGE_U32}, + {0x28, STORAGE_U8}, + {0x29, STORAGE_U8}, + {0x2A, STORAGE_U8} +}; + +/* + * TODO + * * proper RETAIN handling + * * TLS + * * JSON subchannels + */ + +MM_PLUGIN_API int init(){ + backend mqtt = { + .name = BACKEND_NAME, + .conf = mqtt_configure, + .create = mqtt_instance, + .conf_instance = mqtt_configure_instance, + .channel = mqtt_channel, + .handle = mqtt_set, + .process = mqtt_handle, + .start = mqtt_start, + .shutdown = mqtt_shutdown + }; + + //register backend + if(mm_backend_register(mqtt)){ + LOG("Failed to register backend"); + return 1; + } + return 0; +} + +static int mqtt_parse_hostspec(instance* inst, char* hostspec){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + char* host = strchr(hostspec, '@'), *password = NULL, *port = NULL; + + //mqtt[s]://[username][:password]@host.domain[:port] + if(!strncmp(hostspec, "mqtt://", 7)){ + hostspec += 7; + } + else if(!strncmp(hostspec, "mqtts://", 8)){ + data->tls = 1; + hostspec += 8; + } + + if(host){ + //parse credentials, separate out host spec + *host = 0; + host++; + + password = strchr(hostspec, ':'); + if(password){ + //password supplied, store + *password = 0; + password++; + mmbackend_strdup(&(data->password), password); + } + + //store username + mmbackend_strdup(&(data->user), hostspec); + } + else{ + host = hostspec; + } + + //parse port if supplied + port = strchr(host, ':'); + if(port){ + *port = 0; + port++; + mmbackend_strdup(&(data->port), port); + } + + mmbackend_strdup(&(data->host), host); + return 0; +} + +static int mqtt_generate_instanceid(instance* inst){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + char clientid[24] = ""; + + snprintf(clientid, sizeof(clientid), "MIDIMonster-%d-%s", (uint32_t) time(NULL), inst->name); + return mmbackend_strdup(&(data->client_id), clientid); +} + +static size_t mqtt_pop_varint(uint8_t* buffer, size_t len, uint32_t* result){ + size_t value = 0, offset = 0; + do { + if(offset >= len){ + return 0; + } + + value |= (buffer[offset] & 0x7F) << (7 * offset); + offset++; + } while(buffer[offset - 1] & 0x80); + + if(result){ + *result = value; + } + return offset; +} + +static size_t mqtt_pop_property(uint8_t* buffer, size_t bytes){ + size_t length = 0, u; + + if(bytes){ + for(u = 0; u < sizeof(property_lengths)/sizeof(property_lengths[0]); u++){ + if(property_lengths[u].property == buffer[0]){ + switch(property_lengths[u].storage){ + case STORAGE_U8: + return 2; + case STORAGE_U16: + return 3; + case STORAGE_U32: + return 5; + case STORAGE_VARINT: + return mqtt_pop_varint(buffer + 1, bytes - 1, NULL) + 1; + case STORAGE_PREFIXED: + if(bytes >= 3){ + return ((buffer[1] << 8) | buffer[2]) + 1; + } + //best-effort guess + return 3; + case STORAGE_PREFIXPAIR: + if(bytes >= 3){ + length = ((buffer[1] << 8) | buffer[2]); + if(bytes >= length + 5){ + return (1 + 2 + length + 2 + ((buffer[length + 3] << 8) | buffer[length + 4])); + } + return length + 3; + } + //best-effort guess + return 5; + } + } + } + } + + LOGPF("Storage class for property %02X was unknown", buffer[0]); + return 1; +} + +static size_t mqtt_push_varint(size_t value, size_t maxlen, uint8_t* buffer){ + //implementation conforming to spec 1.5.5 + size_t offset = 0; + do { + buffer[offset] = value % 128; + value = value / 128; + if(value){ + buffer[offset] |= 0x80; + } + offset++; + } while(value); + return offset; +} + +static size_t mqtt_push_binary(uint8_t* buffer, size_t buffer_length, uint8_t* content, size_t length){ + if(buffer_length < length + 2 || length > 65535){ + LOG("Failed to push length-prefixed data blob, buffer size exceeded"); + return 0; + } + + buffer[0] = (length >> 8) & 0xFF; + buffer[1] = length & 0xFF; + + memcpy(buffer + 2, content, length); + return length + 2; +} + +static size_t mqtt_push_utf8(uint8_t* buffer, size_t buffer_length, char* content){ + //FIXME might want to validate the string for valid UTF-8 + return mqtt_push_binary(buffer, buffer_length, (uint8_t*) content, strlen(content)); +} + +static size_t mqtt_pop_utf8(uint8_t* buffer, size_t buffer_length, char** data){ + size_t length = 0; + *data = NULL; + + if(buffer_length < 2){ + return 0; + } + + length = (buffer[0] << 8) | buffer[1]; + if(buffer_length >= length + 2){ + *data = (char*) buffer + 2; + } + return length; +} + +static void mqtt_disconnect(instance* inst){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + size_t u; + + data->last_control = 0; + + //reset aliases as they can not be reused across sessions + data->server_max_alias = 0; + data->current_alias = 1; + for(u = 0; u < data->nchannels; u++){ + data->channel[u].topic_alias_sent = 0; + data->channel[u].topic_alias_rcvd = 0; + } + + //unmanage the fd + mm_manage_fd(data->fd, BACKEND_NAME, 0, NULL); + + close(data->fd); + data->fd = -1; +} + +static int mqtt_transmit(instance* inst, uint8_t type, size_t vh_length, uint8_t* vh, size_t payload_length, uint8_t* payload){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + uint8_t fixed_header[5]; + size_t offset = 0; + + //how in the world is it a _fixed_ header if it contains a variable length integer? eh... + fixed_header[offset++] = type; + offset += mqtt_push_varint(vh_length + payload_length, sizeof(fixed_header) - offset, fixed_header + offset); + + if(mmbackend_send(data->fd, fixed_header, offset) + || (vh && vh_length && mmbackend_send(data->fd, vh, vh_length)) + || (payload && payload_length && mmbackend_send(data->fd, payload, payload_length))){ + LOGPF("Failed to transmit control message for %s, assuming connection failure", inst->name); + mqtt_disconnect(inst); + return 1; + } + + data->last_control = mm_timestamp(); + return 0; +} + +static int mqtt_configure(char* option, char* value){ + LOG("This backend does not take global configuration"); + return 1; +} + +static int mqtt_reconnect(instance* inst){ + uint8_t variable_header[MQTT_BUFFER_LENGTH] = {0x00, 0x04, 'M', 'Q', 'T', 'T', MQTT_VERSION_DEFAULT, 0x00 /*flags*/, ((MQTT_KEEPALIVE * 2) >> 8) & 0xFF, (MQTT_KEEPALIVE * 2) & 0xFF}; + uint8_t payload[MQTT_BUFFER_LENGTH]; + size_t vh_offset = 10, payload_offset = 0; + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + + if(!data->host){ + LOGPF("No host specified for instance %s", inst->name); + return 2; + } + + if(data->fd >= 0){ + mqtt_disconnect(inst); + } + + LOGPF("Connecting instance %s to host %s port %s (TLS: %s, Authentication: %s, Protocol: %s)", + inst->name, data->host, + data->port ? data->port : (data->tls ? MQTT_TLS_PORT : MQTT_PORT), + data->tls ? "yes " : "no", + (data->user || data->password) ? "yes" : "no", + (data->mqtt_version == 0x05) ? "v5" : "v3.1.1"); + + data->fd = mmbackend_socket(data->host, + data->port ? data->port : (data->tls ? MQTT_TLS_PORT : MQTT_PORT), + SOCK_STREAM, 0, 0, 1); + + if(data->fd < 0){ + //retry later + return 1; + } + + //prepare CONNECT message header + variable_header[6] = data->mqtt_version; + variable_header[7] = 0x02 /*clean start*/ | (data->user ? 0x80 : 0x00) | (data->user ? 0x40 : 0x00); + + if(data->mqtt_version == 0x05){ //mqtt v5 has additional options + //push number of option bytes (as a varint, no less) before actually pushing the option data. + //obviously someone thought saving 3 whole bytes in exchange for not being able to sequentially creating the package was smart.. + variable_header[vh_offset++] = 8; + //push maximum packet size option + variable_header[vh_offset++] = 0x27; + variable_header[vh_offset++] = (MQTT_BUFFER_LENGTH >> 24) & 0xFF; + variable_header[vh_offset++] = (MQTT_BUFFER_LENGTH >> 16) & 0xFF; + variable_header[vh_offset++] = (MQTT_BUFFER_LENGTH >> 8) & 0xFF; + variable_header[vh_offset++] = (MQTT_BUFFER_LENGTH) & 0xFF; + //push topic alias maximum option + variable_header[vh_offset++] = 0x22; + variable_header[vh_offset++] = 0xFF; + variable_header[vh_offset++] = 0xFF; + } + + //prepare CONNECT payload + //push client id + payload_offset += mqtt_push_utf8(payload + payload_offset, sizeof(payload) - payload_offset, data->client_id); + if(data->user){ + payload_offset += mqtt_push_utf8(payload + payload_offset, sizeof(payload) - payload_offset, data->user); + } + if(data->password){ + payload_offset += mqtt_push_utf8(payload + payload_offset, sizeof(payload) - payload_offset, data->password); + } + + mqtt_transmit(inst, MSG_CONNECT, vh_offset, variable_header, payload_offset, payload); + + //register the fd + if(mm_manage_fd(data->fd, BACKEND_NAME, 1, (void*) inst)){ + LOG("Failed to register FD"); + return 2; + } + + return 0; +} + +static int mqtt_configure_channel(instance* inst, char* option, char* value){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + char* next_token = NULL; + channel* configure = NULL; + uint8_t mark = 0; + mqtt_channel_value config = { + 0 + }; + + if(!strncmp(value, "range ", 6)){ + //we support min > max for range configurations + value += 6; + + config.min = strtod(value, &next_token); + if(value == next_token){ + LOGPF("Failed to parse range preconfiguration for topic %s.%s", inst->name, option); + return 1; + } + + config.max = strtod(next_token, &value); + if(value == next_token){ + LOGPF("Failed to parse range preconfiguration for topic %s.%s", inst->name, option); + return 1; + } + } + else if(!strncmp(value, "discrete ", 9)){ + value += 9; + + for(; *value && isspace(*value); value++){ + } + if(value[0] == '!'){ + mark = 1; + value++; + } + config.min = clamp(strtod(value, &next_token), 1.0, 0.0); + value = next_token; + + for(; *value && isspace(*value); value++){ + } + if(value[0] == '!'){ + mark = 2; + value++; + } + + config.max = clamp(strtod(value, &next_token), 1.0, 0.0); + value = next_token; + if(config.max < config.min){ + LOGPF("Discrete topic configuration for %s.%s has invalid limit ordering", inst->name, option); + return 1; + } + + for(; *value && isspace(*value); value++){ + } + + config.discrete = strdup(value); + config.normal = mark ? ((mark == 1) ? config.min : config.max) : (config.min + (config.max - config.min) / 2); + } + else{ + LOGPF("Unknown instance configuration option or invalid preconfiguration %s on instance %s", option, inst->name); + return 1; + } + + configure = mqtt_channel(inst, option, 0); + if(!configure + //if configuring scale, no other config is possible + || (!config.discrete && data->channel[configure->ident].values) + //if configuring discrete, the previous one can't be a a scale + || (config.discrete && data->channel[configure->ident].values && !data->channel[configure->ident].value[0].discrete)){ + LOGPF("Failed to configure topic %s.%s", inst->name, option); + free(config.discrete); + return 1; + } + + data->channel[configure->ident].value = realloc(data->channel[configure->ident].value, (data->channel[configure->ident].values + 1) * sizeof(mqtt_channel_value)); + if(!data->channel[configure->ident].value){ + LOG("Failed to allocate memory"); + return 1; + } + + DBGPF("Configuring value on %s.%s: min %f max %f normal %f discrete %s", inst->name, option, config.min, config.max, config.normal, config.discrete ? config.discrete : "-"); + data->channel[configure->ident].value[data->channel[configure->ident].values] = config; + data->channel[configure->ident].values++; + DBGPF("Value configuration for %s.%s now at %" PRIsize_t " entries", inst->name, option, data->channel[configure->ident].values); + return 0; +} + +static int mqtt_configure_instance(instance* inst, char* option, char* value){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + + if(!strcmp(option, "user")){ + mmbackend_strdup(&(data->user), value); + return 0; + } + else if(!strcmp(option, "password")){ + mmbackend_strdup(&(data->password), value); + return 0; + } + else if(!strcmp(option, "host")){ + if(mqtt_parse_hostspec(inst, value)){ + return 1; + } + return 0; + } + else if(!strcmp(option, "clientid")){ + if(strlen(value)){ + mmbackend_strdup(&(data->client_id), value); + return 0; + } + else{ + return mqtt_generate_instanceid(inst); + } + } + else if(!strcmp(option, "protocol")){ + data->mqtt_version = MQTT_VERSION_DEFAULT; + if(!strcmp(value, "3.1.1")){ + data->mqtt_version = 4; + } + return 0; + } + + //try to register as channel preconfig + return mqtt_configure_channel(inst, option, value); +} + +static int mqtt_push_subscriptions(instance* inst){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + uint8_t variable_header[3] = {0}; + uint8_t payload[MQTT_BUFFER_LENGTH]; + size_t u, subs = 0, payload_offset = 0; + + //FIXME might want to aggregate multiple subscribes into one packet + for(u = 0; u < data->nchannels; u++){ + payload_offset = 0; + if(data->channel[u].flags & mmchannel_input){ + DBGPF("Subscribing %s.%s, channel %" PRIsize_t ", flags %d", inst->name, data->channel[u].topic, u, data->channel[u].flags); + variable_header[0] = (data->packet_identifier >> 8) & 0xFF; + variable_header[1] = (data->packet_identifier) & 0xFF; + + payload_offset += mqtt_push_utf8(payload + payload_offset, sizeof(payload) - payload_offset, data->channel[u].topic); + payload[payload_offset++] = (data->mqtt_version == 0x05) ? MQTT5_NO_LOCAL : 0; + + data->packet_identifier++; + //zero is not a valid packet identifier + if(!data->packet_identifier){ + data->packet_identifier++; + } + + mqtt_transmit(inst, MSG_SUBSCRIBE, data->mqtt_version == 0x05 ? 3 : 2, variable_header, payload_offset, payload); + subs++; + } + } + + LOGPF("Subscribed %" PRIsize_t " channels on %s", subs, inst->name); + return 0; +} + +static int mqtt_instance(instance* inst){ + mqtt_instance_data* data = calloc(1, sizeof(mqtt_instance_data)); + + if(!data){ + LOG("Failed to allocate memory"); + return 1; + } + + data->fd = -1; + data->mqtt_version = MQTT_VERSION_DEFAULT; + data->packet_identifier = 1; + data->current_alias = 1; + inst->impl = data; + + if(mqtt_generate_instanceid(inst)){ + return 1; + } + return 0; +} + +static channel* mqtt_channel(instance* inst, char* spec, uint8_t flags){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + size_t u; + + //check spec for compliance + if(strchr(spec, '+') || strchr(spec, '#')){ + LOGPF("Invalid character in channel specification %s", spec); + return NULL; + } + + //find matching channel + for(u = 0; u < data->nchannels; u++){ + if(!strcmp(spec, data->channel[u].topic)){ + data->channel[u].flags |= flags; + DBGPF("Reusing existing channel %" PRIsize_t " for spec %s.%s, flags are now %02X", u, inst->name, spec, data->channel[u].flags); + break; + } + } + + //allocate new channel + if(u == data->nchannels){ + data->channel = realloc(data->channel, (data->nchannels + 1) * sizeof(mqtt_channel_data)); + if(!data->channel){ + LOG("Failed to allocate memory"); + return NULL; + } + + data->channel[u].topic = strdup(spec); + data->channel[u].topic_alias_sent = 0; + data->channel[u].topic_alias_rcvd = 0; + data->channel[u].flags = flags; + data->channel[u].values = 0; + data->channel[u].value = NULL; + + if(!data->channel[u].topic){ + LOG("Failed to allocate memory"); + return NULL; + } + + DBGPF("Allocated channel %" PRIsize_t " for spec %s.%s, flags are %02X", u, inst->name, spec, data->channel[u].flags); + data->nchannels++; + } + + return mm_channel(inst, u, 1); +} + +static int mqtt_maintenance(){ + size_t n, u; + instance** inst = NULL; + mqtt_instance_data* data = NULL; + + if(mm_backend_instances(BACKEND_NAME, &n, &inst)){ + LOG("Failed to fetch instance list"); + return 1; + } + + DBGPF("Running maintenance operations on %" PRIsize_t " instances", n); + for(u = 0; u < n; u++){ + data = (mqtt_instance_data*) inst[u]->impl; + if(data->fd <= 0){ + if(mqtt_reconnect(inst[u]) >= 2){ + LOGPF("Failed to reconnect instance %s, terminating", inst[u]->name); + free(inst); + return 1; + } + } + else if(data->last_control && mm_timestamp() - data->last_control >= MQTT_KEEPALIVE * 1000){ + //send keepalive ping requests + mqtt_transmit(inst[u], MSG_PINGREQ, 0, NULL, 0, NULL); + } + } + + free(inst); + return 0; +} + +static int mqtt_deserialize(instance* inst, channel* output, mqtt_channel_data* input, char* buffer, size_t length){ + char* next_token = NULL, conversion_buffer[1024] = {0}; + channel_value val; + double range, raw; + size_t u; + //FIXME implement json subchannels + + //unconfigured channel + if(!input->values){ + //the original buffer is the result of an unterminated receive, move it over + memcpy(conversion_buffer, buffer, length); + val.normalised = clamp(strtod(conversion_buffer, &next_token), 1.0, 0.0); + if(conversion_buffer == next_token){ + LOGPF("Failed to parse incoming data for %s.%s", inst->name, input->topic); + return 1; + } + } + //ranged channel + else if(!input->value[0].discrete){ + memcpy(conversion_buffer, buffer, length); + raw = clamp(strtod(conversion_buffer, &next_token), max(input->value[0].max, input->value[0].min), min(input->value[0].max, input->value[0].min)); + if(conversion_buffer == next_token){ + LOGPF("Failed to parse incoming data for %s.%s", inst->name, input->topic); + return 1; + } + range = fabs(input->value[0].max - input->value[0].min); + val.normalised = (raw - input->value[0].min) / range; + if(input->value[0].max < input->value[0].min){ + val.normalised = fabs(val.normalised); + } + } + else{ + for(u = 0; u < input->values; u++){ + if(length == strlen(input->value[u].discrete) + && !strncmp(input->value[u].discrete, buffer, length)){ + val.normalised = input->value[u].normal; + break; + } + } + + if(u == input->values){ + LOGPF("Failed to parse incoming data for %s.%s, no matching discrete token", inst->name, input->topic); + return 1; + } + } + + val.normalised = clamp(val.normalised, 1.0, 0.0); + mm_channel_event(output, val); + return 0; +} + +static size_t mqtt_serialize(instance* inst, mqtt_channel_data* input, char* output, size_t length, double value){ + double range; + size_t u, invert = 0; + + //unconfigured channel + if(!input->values){ + return snprintf(output, length, "%f", value); + } + //ranged channel + else if(!input->value[0].discrete){ + range = fabs(input->value[0].max - input->value[0].min); + if(input->value[0].max < input->value[0].min){ + invert = 1; + } + return snprintf(output, length, "%f", (value * range) * (invert ? -1 : 1) + input->value[0].min); + } + else{ + for(u = 0; u < input->values; u++){ + if(input->value[u].min <= value + && input->value[u].max >= value){ + memcpy(output, input->value[u].discrete, min(strlen(input->value[u].discrete), length)); + return min(strlen(input->value[u].discrete), length); + } + } + } + + LOGPF("No discrete value on %s.%s defined for normalized value %f", inst->name, input->topic, value); + return 0; +} + +static int mqtt_set(instance* inst, size_t num, channel** c, channel_value* v){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + uint8_t variable_header[MQTT_BUFFER_LENGTH]; + uint8_t payload[MQTT_BUFFER_LENGTH], alias_assigned = 0; + size_t vh_length = 0, payload_length = 0, u; + + for(u = 0; u < num; u++){ + vh_length = payload_length = alias_assigned = 0; + + if(data->mqtt_version == 0x05){ + if(data->channel[c[u]->ident].topic_alias_sent){ + //push zero-length topic + variable_header[vh_length++] = 0; + variable_header[vh_length++] = 0; + } + else{ + //push topic + vh_length += mqtt_push_utf8(variable_header + vh_length, sizeof(variable_header) - vh_length, data->channel[c[u]->ident].topic); + //generate topic alias if possible + if(data->current_alias <= data->server_max_alias){ + data->channel[c[u]->ident].topic_alias_sent = data->current_alias++; + DBGPF("Assigned outbound topic alias %" PRIu16 " to topic %s.%s", data->channel[c[u]->ident].topic_alias_sent, inst->name, data->channel[c[u]->ident].topic); + + alias_assigned = 1; + } + } + + //push property length + variable_header[vh_length++] = (data->channel[c[u]->ident].topic_alias_sent) ? 5 : 2; + + //push payload type (0x01) + variable_header[vh_length++] = 0x01; + variable_header[vh_length++] = 1; + + if(data->channel[c[u]->ident].topic_alias_sent){ + //push topic alias (0x23) + variable_header[vh_length++] = 0x23; + variable_header[vh_length++] = (data->channel[c[u]->ident].topic_alias_sent >> 8) & 0xFF; + variable_header[vh_length++] = data->channel[c[u]->ident].topic_alias_sent & 0xFF; + } + + payload_length = mqtt_serialize(inst, data->channel + c[u]->ident, (char*) (payload + 2), sizeof(payload) - 2, v[u].normalised); + if(payload_length){ + payload[0] = (payload_length >> 8) & 0xFF; + payload[1] = payload_length & 0xFF; + payload_length += 2; + } + } + else{ + //push topic + vh_length += mqtt_push_utf8(variable_header + vh_length, sizeof(variable_header) - vh_length, data->channel[c[u]->ident].topic); + if(data->mqtt_version == 0x05){ + //push property length + variable_header[vh_length++] = 2; + + //push payload type (0x01) + variable_header[vh_length++] = 0x01; + variable_header[vh_length++] = 1; + } + payload_length = mqtt_serialize(inst, data->channel + c[u]->ident, (char*) payload, sizeof(payload), v[u].normalised); + } + + if(payload_length){ + DBGPF("Transmitting %" PRIsize_t " bytes for %s", payload_length, inst->name); + mqtt_transmit(inst, MSG_PUBLISH, vh_length, variable_header, payload_length, payload); + } + else if(alias_assigned){ + //undo alias assignment + data->channel[c[u]->ident].topic_alias_sent = 0; + data->current_alias--; + } + } + + return 0; +} + +static int mqtt_handle_publish(instance* inst, uint8_t type, uint8_t* variable_header, size_t length){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + char* topic = NULL, *payload = NULL; + channel* changed = NULL; + uint8_t qos = (type & 0x06) >> 1, content_utf8 = 0; + uint16_t topic_alias = 0; + uint32_t property_length = 0; + size_t u = data->nchannels, property_offset, payload_offset, payload_length; + size_t topic_length = min(mqtt_pop_utf8(variable_header, length, &topic), length); + + property_offset = payload_offset = topic_length + 2 + ((qos > 0) ? 2 : 0); + if(data->mqtt_version == 0x05){ + //read properties length + payload_offset += mqtt_pop_varint(variable_header + property_offset, length - property_offset, &property_length); + payload_offset += property_length; + + property_offset += mqtt_pop_varint(variable_header + property_offset, length - property_offset, NULL); + //parse properties + while(property_offset < payload_offset && property_offset < length){ + DBGPF("Property %02X at offset %" PRIsize_t " of %" PRIu32, variable_header[property_offset], property_offset, property_length); + + //read payload format indicator + if(variable_header[property_offset] == 0x01 && property_offset < length - 1){ + content_utf8 = variable_header[property_offset + 1]; + } + //read topic alias + else if(variable_header[property_offset] == 0x23 && property_offset < length - 2){ + topic_alias = (variable_header[property_offset + 1] << 8) | variable_header[property_offset + 2]; + } + + property_offset += mqtt_pop_property(variable_header + property_offset, length - property_offset); + } + } + + //match via topic alias + if(!topic_length && topic_alias){ + for(u = 0; u < data->nchannels; u++){ + if(data->channel[u].topic_alias_rcvd == topic_alias){ + break; + } + } + } + //match via topic + else if(topic_length){ + for(u = 0; u < data->nchannels; u++){ + if(!strncmp(data->channel[u].topic, topic, topic_length)){ + break; + } + } + + if(topic_alias){ + data->channel[u].topic_alias_rcvd = topic_alias; + } + } + + if(content_utf8){ + payload_length = mqtt_pop_utf8(variable_header + payload_offset, length - payload_offset, &payload); + } + else{ + payload_length = length - payload_offset; + payload = (char*) (variable_header + payload_offset); + } + + if(u != data->nchannels && payload_length && payload){ + DBGPF("Received PUBLISH for %s.%s, QoS %d, payload length %" PRIsize_t, inst->name, data->channel[u].topic, qos, payload_length); + changed = mm_channel(inst, u, 0); + if(changed){ + mqtt_deserialize(inst, changed, data->channel + u, payload, payload_length); + } + } + return 0; +} + +static int mqtt_handle_connack(instance* inst, uint8_t type, uint8_t* variable_header, size_t length){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + size_t property_offset = 2; + + if(length >= 2){ + if(variable_header[1]){ + if(variable_header[1] == 1 && data->mqtt_version == 0x05){ + LOGPF("Connection on %s was rejected for protocol incompatibility, downgrading to protocol 3.1.1", inst->name); + data->mqtt_version = 0x04; + return 0; + } + LOGPF("Connection on %s was rejected, reason code %d", inst->name, variable_header[1]); + mqtt_disconnect(inst); + return 0; + } + + //parse response properties if present + if(data->mqtt_version == 0x05){ + property_offset += mqtt_pop_varint(variable_header + property_offset, length - property_offset, NULL); + while(property_offset < length){ + DBGPF("Property %02X at offset %" PRIsize_t " of %" PRIsize_t, variable_header[property_offset], property_offset, length); + + //read maximum topic alias + if(variable_header[property_offset] == 0x22){ + data->server_max_alias = (variable_header[property_offset + 1] << 8) | variable_header[property_offset + 2]; + DBGPF("Connection supports maximum connection alias %" PRIu16, data->server_max_alias); + } + + property_offset += mqtt_pop_property(variable_header + property_offset, length - property_offset); + } + } + + LOGPF("Connection on %s established", inst->name); + return mqtt_push_subscriptions(inst); + } + + LOGPF("Received malformed CONNACK on %s", inst->name); + return 1; +} + +static int mqtt_handle_message(instance* inst, uint8_t type, uint8_t* variable_header, size_t length){ + switch(type){ + case MSG_CONNACK: + return mqtt_handle_connack(inst, type, variable_header, length); + case MSG_PINGRESP: + case MSG_SUBACK: + //ignore most responses + //FIXME error check SUBACK + break; + default: + if((type & 0xF0) == MSG_PUBLISH){ + return mqtt_handle_publish(inst, type, variable_header, length); + } + LOGPF("Unhandled MQTT message type 0x%02X on %s", type, inst->name); + } + return 0; +} + +static int mqtt_handle_fd(instance* inst){ + mqtt_instance_data* data = (mqtt_instance_data*) inst->impl; + ssize_t bytes_read = 0, bytes_left = sizeof(data->receive_buffer) - data->receive_offset; + uint32_t message_length = 0, header_length = 0; + + bytes_read = recv(data->fd, data->receive_buffer + data->receive_offset, bytes_left, 0); + if(bytes_read < 0){ + LOGPF("Failed to receive data on instance %s: %s", inst->name, mmbackend_socket_strerror(errno)); + return 1; + } + else if(bytes_read == 0){ + //disconnected, try to reconnect + LOGPF("Instance %s disconnected, reconnection queued", inst->name); + mqtt_disconnect(inst); + return 1; + } + + DBGPF("Instance %s, offset %" PRIsize_t ", read %" PRIsize_t " bytes", inst->name, data->receive_offset, bytes_read); + data->receive_offset += bytes_read; + + while(data->receive_offset >= 2){ + //check for complete message + header_length = mqtt_pop_varint(data->receive_buffer + 1, data->receive_offset - 1, &message_length); + if(header_length && data->receive_offset >= message_length + header_length + 1){ + DBGPF("Received complete message of %" PRIu32 " bytes, total received %" PRIsize_t ", payload %" PRIu32 ", message type %02X", message_length + header_length + 1, data->receive_offset, message_length, data->receive_buffer[0]); + if(mqtt_handle_message(inst, data->receive_buffer[0], data->receive_buffer + header_length + 1, message_length)){ + //TODO handle failures properly + } + + //remove handled message + if(data->receive_offset > message_length + header_length + 1){ + memmove(data->receive_buffer, data->receive_buffer + message_length + header_length + 1, data->receive_offset - (message_length + header_length + 1)); + } + data->receive_offset -= message_length + header_length + 1; + } + else{ + break; + } + } + + return 0; +} + +static int mqtt_handle(size_t num, managed_fd* fds){ + size_t n = 0; + + for(n = 0; n < num; n++){ + if(mqtt_handle_fd((instance*) fds[n].impl) >= 2){ + //propagate critical failures + return 1; + } + } + + //keepalive/reconnect processing + if(last_maintenance && mm_timestamp() - last_maintenance >= MQTT_KEEPALIVE * 1000){ + if(mqtt_maintenance()){ + return 1; + } + last_maintenance = mm_timestamp(); + } + + return 0; +} + +static int mqtt_start(size_t n, instance** inst){ + size_t u = 0, fds = 0; + + for(u = 0; u < n; u++){ + switch(mqtt_reconnect(inst[u])){ + case 1: + LOGPF("Failed to connect to host for instance %s, will be retried", inst[u]->name); + break; + case 2: + LOGPF("Failed to connect to host for instance %s, aborting", inst[u]->name); + return 1; + default: + fds++; + break; + } + } + LOGPF("Registered %" PRIsize_t " descriptors to core", fds); + + //initialize maintenance timer + last_maintenance = mm_timestamp(); + return 0; +} + +static int mqtt_shutdown(size_t n, instance** inst){ + size_t u, p, v; + mqtt_instance_data* data = NULL; + + for(u = 0; u < n; u++){ + data = (mqtt_instance_data*) inst[u]->impl; + mqtt_disconnect(inst[u]); + + for(p = 0; p < data->nchannels; p++){ + for(v = 0; v < data->channel[p].values; v++){ + free(data->channel[p].value[v].discrete); + } + free(data->channel[p].value); + free(data->channel[p].topic); + } + free(data->channel); + free(data->host); + free(data->port); + free(data->user); + free(data->password); + free(data->client_id); + + free(inst[u]->impl); + inst[u]->impl = NULL; + } + + LOG("Backend shut down"); + return 0; +} diff --git a/backends/mqtt.h b/backends/mqtt.h new file mode 100644 index 0000000..c684f99 --- /dev/null +++ b/backends/mqtt.h @@ -0,0 +1,87 @@ +#include "midimonster.h" + +MM_PLUGIN_API int init(); +static int mqtt_configure(char* option, char* value); +static int mqtt_configure_instance(instance* inst, char* option, char* value); +static int mqtt_instance(instance* inst); +static channel* mqtt_channel(instance* inst, char* spec, uint8_t flags); +static int mqtt_set(instance* inst, size_t num, channel** c, channel_value* v); +static int mqtt_handle(size_t num, managed_fd* fds); +static int mqtt_start(size_t n, instance** inst); +static int mqtt_shutdown(size_t n, instance** inst); + +#define MQTT_PORT "1883" +#define MQTT_TLS_PORT "8883" +#define MQTT_BUFFER_LENGTH 8192 +#define MQTT_KEEPALIVE 10 +#define MQTT_VERSION_DEFAULT 0x05 + +#define MQTT5_NO_LOCAL 0x04 + +enum /*_mqtt_property_storage_classes*/ { + STORAGE_U8, + STORAGE_U16, + STORAGE_U32, + STORAGE_VARINT, + STORAGE_PREFIXED, + STORAGE_PREFIXPAIR +}; + +enum { + MSG_RESERVED = 0x00, + MSG_CONNECT = 0x10, + MSG_CONNACK = 0x20, + MSG_PUBLISH = 0x30, + MSG_PUBACK = 0x40, + MSG_PUBREC = 0x50, + MSG_PUBREL = 0x60, + MSG_PUBCOMP = 0x70, + MSG_SUBSCRIBE = 0x82, + MSG_SUBACK = 0x90, + MSG_UNSUBSCRIBE = 0xA0, + MSG_UNSUBACK = 0xB0, + MSG_PINGREQ = 0xC0, + MSG_PINGRESP = 0xD0, + MSG_DISCONNECT = 0xE0, + MSG_AUTH = 0xF0 +}; + +typedef struct /*_mqtt_value_mapping*/ { + double min; + double max; + double normal; + char* discrete; +} mqtt_channel_value; + +typedef struct /*_mqtt_channel*/ { + char* topic; + uint16_t topic_alias_sent; + uint16_t topic_alias_rcvd; + uint8_t flags; + + size_t values; + mqtt_channel_value* value; +} mqtt_channel_data; + +typedef struct /*_mqtt_instance_data*/ { + uint8_t tls; + char* host; + char* port; + uint8_t mqtt_version; + + char* user; + char* password; + char* client_id; + + size_t nchannels; + mqtt_channel_data* channel; + + int fd; + uint8_t receive_buffer[MQTT_BUFFER_LENGTH]; + size_t receive_offset; + + uint64_t last_control; + uint16_t packet_identifier; + uint16_t server_max_alias; + uint16_t current_alias; +} mqtt_instance_data; diff --git a/backends/mqtt.md b/backends/mqtt.md new file mode 100644 index 0000000..85784ef --- /dev/null +++ b/backends/mqtt.md @@ -0,0 +1,85 @@ +### The `mqtt` backend + +This backend provides input from and output to a message queueing telemetry transport (MQTT) +broker. The MQTT protocol is used in lightweight sensor/actor applications, a wide selection +of smart home implementations and as a generic message bus in many other domains. + +The backend implements both the older protocol version MQTT v3.1.1 as well as the current specification +for MQTT v5.0. + +#### Global configuration + +This backend does not take any global configuration. + +#### Instance configuration + +| Option | Example value | Default value | Description | +|---------------|-----------------------|-----------------------|---------------------------------------| +| `host` | `mqtt://10.23.23.1` | none | Host or URI of the MQTT broker | +| `user` | `midimonster` | none | User name for broker authentication | +| `password` | `mm` | none | Password for broker authentication | +| `clientid` | `MM-main` | random | MQTT client identifier (generated randomly at start if unset) | +| `protocol` | `3.1.1` | `5` | MQTT protocol version (`5` or `3.1.1`) to use for the connection | + +The `host` option can be specified as an URI of the form `mqtt[s]://[username][:password]@host.domain[:port]`. +This allows specifying all necessary settings in one configuration option. + +#### Data exchange format + +The MQTT protocol places very few restrictions on the exchanged data. Thus, it is necessary to specify the input +and output data formats accepted respectively output by the MIDIMonster. + +The basic format, without further channel-specific configuration, is an ASCII/UTF-8 string representing a floating +point number between `0.0` and `1.0`. The MIDIMonster will read these and use the value as the normalized event value. + +Channels may be specified to use a different value range or even freeform discrete values by preconfiguring +the channels in the instance configuration section. This is done by specifying options of the form + +``` +<channel> = range <min> <max> +<channel> = discrete [!]<min> [!]<max> <value> +``` + +Example configurations: +``` +/a/topic = range -10 10 +/another/topic = discrete !0.0 0.5 off +/another/topic = discrete 0.5 !1.0 on +``` + +Note that there may be only one range configuration per topic, but there may be multiple discrete configurations. + +The first channel preconfiguration example will change the channel value scale to values between `-10` and `10`. +For input channels, this sets the normalization range. The MIDIMonster will normalize the input value according to the scale. +For output channels, this sets the output scaling factors. + +The second and third channel preconfigurations define two discrete values (`on` and `off`) with accompanying normalized +values. For input channels, the normalized channel value for a discrete input will be the value marked with an exclamation mark `!`. +For output channels, the output will be the first discrete value for which the range between `<min>` and `<max>` contains +the normalized channel value. + +These examples mean +* For `/a/topic`, when mapped as input, the input value `5.0` will generate a normalized event value of `0.75`. +* For `/a/topic`, when mapped as output, a normalized event value `0.25` will generate an output of `-5.0`. +* For `/another/topic`, when mapped as an input, the input value `off` will generate a normalized event value of `0.0`. +* For `/another/topic`, when mapped as an output, a normalized event value of `0.75` will generate an output of `on`. + +Values above the maximum or below the minimum will be clamped. The MIDIMonster will not output values out of the +configured bounds. + +#### Channel specification + +A channel specification may be any MQTT topic designator not containing the wildcard characters `+` and `#`. + +Example mapping: +``` +mq1./midimonster/in > mq2./midimonster/out +``` + +#### Known bugs / problems + +If the connection to a server is lost, the connection will be retried in approximately 10 seconds. +If the server rejects the connection with reason code `0x01`, a protocol failure is assumed. If the initial +connection was made with `MQTT v5.0`, it is retried with the older protocol version `MQTT v3.1.1`. + +Support for TLS-secured connections is planned, but not yet implemented. diff --git a/backends/openpixelcontrol.md b/backends/openpixelcontrol.md index d09d412..af5e811 100644 --- a/backends/openpixelcontrol.md +++ b/backends/openpixelcontrol.md @@ -35,12 +35,12 @@ Channels can be specified by their sequential index (one-based). Example mapping (data from Strip 2 LED 66's green component is mapped to the blue component of LED 2 on strip 1): ``` -strip1.channel6 < strip2.channel200 +op1.strip1.channel6 < op1.strip2.channel200 ``` Additionally, channels may be referred to by their color component and LED index: ``` -strip1.blue2 < strip2.green66 +op1.strip1.blue2 < op2.strip2.green66 ``` #### Known bugs / problems diff --git a/backends/osc.c b/backends/osc.c index 5887a50..e8673bb 100644 --- a/backends/osc.c +++ b/backends/osc.c @@ -1,4 +1,5 @@ #define BACKEND_NAME "osc" +//#define DEBUG #include <string.h> #include <ctype.h> @@ -629,7 +630,7 @@ static channel* osc_map_channel(instance* inst, char* spec, uint8_t flags){ data->channel[u].out = calloc(data->channel[u].params, sizeof(osc_parameter_value)); } else if(data->patterns){ - LOGPF("No pattern match found for %s", spec); + LOGPF("No preconfigured pattern match found for %s", spec); } if(!data->channel[u].path diff --git a/backends/osc.md b/backends/osc.md index 1446e06..61b3324 100644 --- a/backends/osc.md +++ b/backends/osc.md @@ -78,7 +78,7 @@ configuration. #### Supported types & value ranges OSC allows controls to have individual value ranges and supports different parameter types. -The following types are currently supported by the MIDImonster: +The following types are currently supported by the MIDIMonster: * **i**: 32-bit signed integer * **f**: 32-bit IEEE floating point diff --git a/backends/python.c b/backends/python.c index bd73a20..9c0caa1 100644 --- a/backends/python.c +++ b/backends/python.c @@ -1,4 +1,9 @@ #define BACKEND_NAME "python" +//#define DEBUG + +#ifdef _WIN32 + #include <direct.h> +#endif #define PY_SSIZE_T_CLEAN #include <string.h> @@ -34,6 +39,8 @@ MM_PLUGIN_API int init(){ LOG("Failed to register backend"); return 1; } + + //Py_UnbufferedStdioFlag = 1; return 0; } @@ -112,6 +119,24 @@ static int python_prepend_str(PyObject* list, char* str){ return 0; } +static PyObject* mmpy_channels(PyObject* self, PyObject* args){ + size_t u = 0; + PyObject* list = NULL; + instance* inst = *((instance**) PyModule_GetState(self)); + python_instance_data* data = (python_instance_data*) inst->impl; + + if(!last_timestamp){ + LOG("The channels() API will not return usable results before the configuration has been read completely"); + } + + list = PyList_New(data->channels); + for(u = 0; u < data->channels; u++){ + PyList_SET_ITEM(list, u, PyUnicode_FromString(data->channel[u].name)); + } + + return list; +} + static PyObject* mmpy_output(PyObject* self, PyObject* args){ instance* inst = *((instance**) PyModule_GetState(self)); python_instance_data* data = (python_instance_data*) inst->impl; @@ -141,7 +166,7 @@ static PyObject* mmpy_output(PyObject* self, PyObject* args){ else{ mm_channel_event(mm_channel(inst, u, 0), val); } - return 0; + break; } } @@ -383,6 +408,7 @@ static int mmpy_exec(PyObject* module) { PyObject* capsule = PyDict_GetItemString(PyThreadState_GetDict(), MMPY_INSTANCE_KEY); if(capsule && inst){ *inst = PyCapsule_GetPointer(capsule, NULL); + DBGPF("Initializing extension module on instance %s", (*inst)->name); return 0; } @@ -397,6 +423,7 @@ static int python_configure_instance(instance* inst, char* option, char* value){ //load python script if(!strcmp(option, "module")){ //swap to interpreter + //PyThreadState_Swap(data->interpreter); PyEval_RestoreThread(data->interpreter); //import the module module = PyImport_ImportModule(value); @@ -432,6 +459,7 @@ static PyObject* mmpy_init(){ {"timestamp", mmpy_timestamp, METH_VARARGS, "Get the core timestamp (in milliseconds)"}, {"manage", mmpy_manage_fd, METH_VARARGS, "(Un-)register a socket or file descriptor for notifications"}, {"interval", mmpy_interval, METH_VARARGS, "Register or update an interval handler"}, + {"channels", mmpy_channels, METH_VARARGS, "List currently registered instance channels"}, {"cleanup_handler", mmpy_cleanup_handler, METH_VARARGS, "Register or update the instances cleanup handler"}, {0} }; @@ -472,8 +500,10 @@ static int python_instance(instance* inst){ Py_SetProgramName(program_name); //initialize python Py_InitializeEx(0); - //create, acquire and release the GIL + #if PY_MINOR_VERSION < 7 + //in python 3.6 and earlier, this was required to set up the GIL PyEval_InitThreads(); + #endif python_main = PyEval_SaveThread(); } @@ -698,6 +728,8 @@ static int python_start(size_t n, instance** inst){ //release interpreter PyEval_ReleaseThread(data->interpreter); } + + last_timestamp = mm_timestamp(); return 0; } diff --git a/backends/python.md b/backends/python.md index a78d972..1c0c96f 100644 --- a/backends/python.md +++ b/backends/python.md @@ -24,6 +24,7 @@ The `midimonster` module provides the following functions: | `timestamp()` | `print(midimonster.timestamp())` | Get the internal core timestamp (in milliseconds) | | `interval(function, long)` | `midimonster.interval(toggle, 100)` | Register a function to be called periodically. Interval is specified in milliseconds (accurate to 10msec). Calling `interval` with the same function again updates the interval. Specifying the interval as `0` cancels the interval | | `manage(function, socket)` | `midimonster.manage(handler, socket)` | Register a (connected/listening) socket to the MIDIMonster core. Calls `function(socket)` when the socket is ready to read. Calling this method with `None` as the function argument unregisters the socket. A socket may only have one associated handler | +| `channels()` | `midimonster.channels()` | Fetch a list of all currently known channels on the instance. Note that this function only returns useful data after the configuration has been read completely, i.e. any time after initial startup | | `cleanup_handler(function)` | `midimonster.cleanup_handler(save_all)`| Register a function to be called when the instance is destroyed (on MIDIMonster shutdown). One cleanup handler can be registered per instance. Calling this function when the instance already has a cleanup handler registered replaces the handler, returning the old one. | When a channel handler executes, calling `midimonster.inputvalue()` for that exact channel returns the previous value, @@ -78,8 +79,8 @@ The `python` backend does not take any global configuration. | Option | Example value | Default value | Description | |-----------------------|-----------------------|-----------------------|-----------------------------------------------| -| `module` | `my_handlers.py` | none | (Path to) Python module source file, relative to configuration file location | -| `default-handler` | `mu_handlers.default` | none | Function to be called as handler for all top-level channels (not belonging to a module) | +| `module` | `my_handlers` | none | Name of the python module to load (normally the name of a`.py` file without the extension) | +| `default-handler` | `my_handlers.default` | none | Function to be called as handler for all top-level channels (not belonging to a module) | A single instance may have multiple `module` options specified. This will make all handlers available within their module namespaces (see the section on channel specification). diff --git a/backends/rtpmidi.c b/backends/rtpmidi.c index 7c5aa69..d349e6f 100644 --- a/backends/rtpmidi.c +++ b/backends/rtpmidi.c @@ -427,6 +427,12 @@ static char* rtpmidi_type_name(uint8_t type){ return "aftertouch"; case pitchbend: return "pitch"; + case program: + return "program"; + case rpn: + return "rpn"; + case nrpn: + return "nrpn"; } return "unknown"; } @@ -552,7 +558,7 @@ static int rtpmidi_peer_applecommand(instance* inst, size_t peer, uint8_t contro memcpy(&dest_addr, &(data->peer[peer].dest), min(sizeof(dest_addr), data->peer[peer].dest_len)); if(control){ - //calculate remote control port from data port + //calculate remote control port from data port ((struct sockaddr_in*) &dest_addr)->sin_port = htobe16(be16toh(((struct sockaddr_in*) &dest_addr)->sin_port) - 1); } @@ -577,6 +583,13 @@ static int rtpmidi_configure_instance(instance* inst, char* option, char* value) LOGPF("Unknown instance mode %s for instance %s", value, inst->name); return 1; } + else if(!strcmp(option, "epn-tx")){ + data->epn_tx_short = 0; + if(!strcmp(value, "short")){ + data->epn_tx_short = 1; + } + return 0; + } else if(!strcmp(option, "ssrc")){ data->ssrc = strtoul(value, NULL, 0); if(!data->ssrc){ @@ -705,6 +718,14 @@ static channel* rtpmidi_channel(instance* inst, char* spec, uint8_t flags){ ident.fields.type = note; next_token += 4; } + else if(!strncmp(next_token, "rpn", 3)){ + ident.fields.type = rpn; + next_token += 3; + } + else if(!strncmp(next_token, "nrpn", 4)){ + ident.fields.type = nrpn; + next_token += 4; + } else if(!strncmp(next_token, "pressure", 8)){ ident.fields.type = pressure; next_token += 8; @@ -715,6 +736,9 @@ static channel* rtpmidi_channel(instance* inst, char* spec, uint8_t flags){ else if(!strncmp(next_token, "aftertouch", 10)){ ident.fields.type = aftertouch; } + else if(!strncmp(next_token, "program", 7)){ + ident.fields.type = program; + } else{ LOGPF("Unknown control type in spec %s", spec); return NULL; @@ -728,12 +752,38 @@ static channel* rtpmidi_channel(instance* inst, char* spec, uint8_t flags){ return NULL; } +static size_t rtpmidi_push_midi(uint8_t* payload, size_t bytes_left, uint8_t type, uint8_t channel, uint8_t control, uint16_t value){ + //FIXME this is a bit simplistic but it works for now + if(bytes_left < 4){ + return 0; + } + + //encode timestamp + payload[0] = 0; + + //encode midi command + payload[1] = type | channel; + payload[2] = control; + payload[3] = value & 0x7F; + + if(type == pitchbend){ + payload[2] = value & 0x7F; + payload[3] = (value >> 7) & 0x7F; + } + //channel-wides aftertouch and program are only 2 bytes + else if(type == aftertouch || type == program){ + payload[2] = payload[3]; + return 3; + } + return 4; +} + static int rtpmidi_set(instance* inst, size_t num, channel** c, channel_value* v){ rtpmidi_instance_data* data = (rtpmidi_instance_data*) inst->impl; uint8_t frame[RTPMIDI_PACKET_BUFFER] = ""; rtpmidi_header* rtp_header = (rtpmidi_header*) frame; rtpmidi_command_header* command_header = (rtpmidi_command_header*) (frame + sizeof(rtpmidi_header)); - size_t offset = sizeof(rtpmidi_header) + sizeof(rtpmidi_command_header), u = 0; + size_t command_length = 0, offset = sizeof(rtpmidi_header) + sizeof(rtpmidi_command_header), u = 0; uint8_t* payload = frame + offset; rtpmidi_channel_ident ident; @@ -752,27 +802,37 @@ static int rtpmidi_set(instance* inst, size_t num, channel** c, channel_value* v for(u = 0; u < num; u++){ ident.label = c[u]->ident; - //encode timestamp - payload[0] = 0; - - //encode midi command - payload[1] = ident.fields.type | ident.fields.channel; - payload[2] = ident.fields.control; - payload[3] = v[u].normalised * 127.0; - - if(ident.fields.type == pitchbend){ - payload[2] = ((int)(v[u].normalised * 16384.0)) & 0x7F; - payload[3] = (((int)(v[u].normalised * 16384.0)) >> 7) & 0x7F; + switch(ident.fields.type){ + case rpn: + case nrpn: + //transmit parameter number + command_length = rtpmidi_push_midi(payload + offset, sizeof(frame) - offset, cc, ident.fields.channel, (ident.fields.type == rpn) ? 101 : 99, (ident.fields.control >> 7) & 0x7F); + command_length += rtpmidi_push_midi(payload + offset + command_length, sizeof(frame) - offset, cc, ident.fields.channel, (ident.fields.type == rpn) ? 100 : 98, ident.fields.control & 0x7F); + + //transmit parameter value + command_length += rtpmidi_push_midi(payload + offset + command_length, sizeof(frame) - offset, cc, ident.fields.channel, 6, (((uint16_t) (v[u].normalised * 16383.0)) >> 7) & 0x7F); + command_length += rtpmidi_push_midi(payload + offset + command_length, sizeof(frame) - offset, cc, ident.fields.channel, 38, ((uint16_t) (v[u].normalised * 16383.0)) & 0x7F); + + if(!data->epn_tx_short){ + //clear active parameter + command_length += rtpmidi_push_midi(payload + offset + command_length, sizeof(frame) - offset, cc, ident.fields.channel, 101, 127); + command_length += rtpmidi_push_midi(payload + offset + command_length, sizeof(frame) - offset, cc, ident.fields.channel, 100, 127); + } + break; + case pitchbend: + //TODO check whether this works + command_length = rtpmidi_push_midi(payload + offset, sizeof(frame) - offset, ident.fields.type, ident.fields.channel, ident.fields.control, v[u].normalised * 16383.0); + break; + default: + command_length = rtpmidi_push_midi(payload + offset, sizeof(frame) - offset, ident.fields.type, ident.fields.channel, ident.fields.control, v[u].normalised * 127.0); } - //channel-wide aftertouch is only 2 bytes - else if(ident.fields.type == aftertouch){ - payload[2] = payload[3]; - payload -= 1; - offset -= 1; + + if(command_length == 0){ + LOGPF("Transmit buffer size exceeded on %s", inst->name); + break; } - payload += 4; - offset += 4; + offset += command_length; } //update command section length @@ -784,7 +844,9 @@ static int rtpmidi_set(instance* inst, size_t num, channel** c, channel_value* v for(u = 0; u < data->peers; u++){ if(data->peer[u].active && data->peer[u].connected){ - sendto(data->fd, frame, offset, 0, (struct sockaddr*) &data->peer[u].dest, data->peer[u].dest_len); + if(sendto(data->fd, frame, offset, 0, (struct sockaddr*) &data->peer[u].dest, data->peer[u].dest_len) <= 0){ + LOGPF("Failed to transmit to peer: %s", mmbackend_socket_strerror(errno)); + } } } @@ -924,6 +986,79 @@ static int rtpmidi_handle_applemidi(instance* inst, int fd, uint8_t* frame, size return 0; } +//this state machine was copied more-or-less verbatim from the alsa midi implementation - fixes there will need to be integrated +static void rtpmidi_handle_epn(instance* inst, uint8_t chan, uint16_t control, uint16_t value){ + rtpmidi_instance_data* data = (rtpmidi_instance_data*) inst->impl; + rtpmidi_channel_ident ident = { + .label = 0 + }; + channel* changed = NULL; + channel_value val; + + //switching between nrpn and rpn clears all valid bits + if(((data->epn_status[chan] & EPN_NRPN) && (control == 101 || control == 100)) + || (!(data->epn_status[chan] & EPN_NRPN) && (control == 99 || control == 98))){ + data->epn_status[chan] &= ~(EPN_NRPN | EPN_PARAMETER_LO | EPN_PARAMETER_HI); + } + + //setting an address always invalidates the value valid bits + if(control >= 98 && control <= 101){ + data->epn_status[chan] &= ~EPN_VALUE_HI; + } + + //parameter hi + if(control == 101 || control == 99){ + data->epn_control[chan] &= 0x7F; + data->epn_control[chan] |= value << 7; + data->epn_status[chan] |= EPN_PARAMETER_HI | ((control == 99) ? EPN_NRPN : 0); + if(control == 101 && value == 127){ + data->epn_status[chan] &= ~EPN_PARAMETER_HI; + } + } + + //parameter lo + if(control == 100 || control == 98){ + data->epn_control[chan] &= ~0x7F; + data->epn_control[chan] |= value & 0x7F; + data->epn_status[chan] |= EPN_PARAMETER_LO | ((control == 98) ? EPN_NRPN : 0); + if(control == 100 && value == 127){ + data->epn_status[chan] &= ~EPN_PARAMETER_LO; + } + } + + //value hi, clears low, mark as update candidate + if(control == 6 + //check if parameter is set before accepting value update + && ((data->epn_status[chan] & (EPN_PARAMETER_HI | EPN_PARAMETER_LO)) == (EPN_PARAMETER_HI | EPN_PARAMETER_LO))){ + data->epn_value[chan] = value << 7; + data->epn_status[chan] |= EPN_VALUE_HI; + } + + //value lo, flush the value + if(control == 38 + && data->epn_status[chan] & EPN_VALUE_HI){ + data->epn_value[chan] &= ~0x7F; + data->epn_value[chan] |= value & 0x7F; + data->epn_status[chan] &= ~EPN_VALUE_HI; + + if(cfg.detect){ + LOGPF("Incoming EPN data on channel %s.ch%d.%s%d", inst->name, chan, data->epn_status[chan] & EPN_NRPN ? "nrpn" : "rpn", data->epn_control[chan]); + } + + //find the updated channel + ident.fields.type = data->epn_status[chan] & EPN_NRPN ? nrpn : rpn; + ident.fields.channel = chan; + ident.fields.control = data->epn_control[chan]; + val.normalised = (double) data->epn_value[chan] / 16383.0; + + //push the new value + changed = mm_channel(inst, ident.label, 0); + if(changed){ + mm_channel_event(changed, val); + } + } +} + static int rtpmidi_parse(instance* inst, uint8_t* frame, size_t bytes){ uint16_t length = 0; size_t offset = 1, decode_time = 0, command_bytes = 0; @@ -996,9 +1131,10 @@ static int rtpmidi_parse(instance* inst, uint8_t* frame, size_t bytes){ ident.fields.channel = midi_status & 0x0F; //single byte command - if(ident.fields.type == aftertouch){ + if(ident.fields.type == aftertouch || ident.fields.type == program){ ident.fields.control = 0; val.normalised = (double) frame[offset] / 127.0; + val.raw.u64 = frame[offset]; offset++; } //two-byte command @@ -1010,17 +1146,20 @@ static int rtpmidi_parse(instance* inst, uint8_t* frame, size_t bytes){ if(ident.fields.type == pitchbend){ ident.fields.control = 0; - val.normalised = (double)((frame[offset] << 7) | frame[offset - 1]) / 16384.0; + val.normalised = (double)((frame[offset] << 7) | frame[offset - 1]) / 16383.0; + val.raw.u64 = (frame[offset] << 7) | frame[offset - 1]; } else{ ident.fields.control = frame[offset - 1]; val.normalised = (double) frame[offset] / 127.0; + val.raw.u64 = frame[offset]; } //fix-up note off events if(ident.fields.type == 0x80){ ident.fields.type = note; val.normalised = 0; + val.raw.u64 = 0; } offset++; @@ -1029,8 +1168,18 @@ static int rtpmidi_parse(instance* inst, uint8_t* frame, size_t bytes){ DBGPF("Decoded command type %02X channel %d control %d value %f", ident.fields.type, ident.fields.channel, ident.fields.control, val.normalised); + //forward EPN CCs to the EPN state machine + if(ident.fields.type == cc + && ((ident.fields.control <= 101 && ident.fields.control >= 98) + || ident.fields.control == 6 + || ident.fields.control == 38)){ + rtpmidi_handle_epn(inst, ident.fields.channel, ident.fields.control, val.raw.u64); + } + if(cfg.detect){ - if(ident.fields.type == pitchbend || ident.fields.type == aftertouch){ + if(ident.fields.type == pitchbend + || ident.fields.type == aftertouch + || ident.fields.type == program){ LOGPF("Incoming data on channel %s.ch%d.%s, value %f", inst->name, ident.fields.channel, rtpmidi_type_name(ident.fields.type), val.normalised); @@ -1150,8 +1299,10 @@ static int rtpmidi_mdns_broadcast(uint8_t* frame, size_t len){ }; //send to ipv4 and ipv6 mcasts - sendto(cfg.mdns_fd, frame, len, 0, (struct sockaddr*) &mcast6, sizeof(mcast6)); - sendto(cfg.mdns4_fd, frame, len, 0, (struct sockaddr*) &mcast, sizeof(mcast)); + if((sendto(cfg.mdns_fd, frame, len, 0, (struct sockaddr*) &mcast6, sizeof(mcast6)) != len) + | (sendto(cfg.mdns4_fd, frame, len, 0, (struct sockaddr*) &mcast, sizeof(mcast)) != len)){ + LOG("Failed to transmit mDNS frame"); + } return 0; } @@ -1180,13 +1331,14 @@ static int rtpmidi_mdns_detach(instance* inst){ } offset += bytes; - //TODO length-checks here - frame[offset++] = strlen(inst->name); - memcpy(frame + offset, inst->name, strlen(inst->name)); - offset += strlen(inst->name); + //calculate maximum permitted instance name length + bytes = min(min(strlen(inst->name), sizeof(frame) - offset - 3), 255); + frame[offset++] = bytes; + memcpy(frame + offset, inst->name, bytes); + offset += bytes; frame[offset++] = 0xC0; frame[offset++] = sizeof(dns_header); - rr->data = htobe16(1 + strlen(inst->name) + 2); + rr->data = htobe16(1 + bytes + 2); free(name.name); return rtpmidi_mdns_broadcast(frame, offset); diff --git a/backends/rtpmidi.h b/backends/rtpmidi.h index 7e6eccc..e88530f 100644 --- a/backends/rtpmidi.h +++ b/backends/rtpmidi.h @@ -32,13 +32,21 @@ static int rtpmidi_shutdown(size_t n, instance** inst); #define DNS_OPCODE(a) (((a) & 0x78) >> 3) #define DNS_RESPONSE(a) ((a) & 0x80) +#define EPN_NRPN 8 +#define EPN_PARAMETER_HI 4 +#define EPN_PARAMETER_LO 2 +#define EPN_VALUE_HI 1 + enum /*_rtpmidi_channel_type*/ { none = 0, note = 0x90, - cc = 0xB0, pressure = 0xA0, + cc = 0xB0, + program = 0xC0, aftertouch = 0xD0, - pitchbend = 0xE0 + pitchbend = 0xE0, + rpn = 0xF1, + nrpn = 0xF2 }; typedef enum /*_rtpmidi_instance_mode*/ { @@ -49,10 +57,10 @@ typedef enum /*_rtpmidi_instance_mode*/ { typedef union { struct { - uint8_t pad[5]; + uint8_t pad[4]; uint8_t type; uint8_t channel; - uint8_t control; + uint16_t control; } fields; uint64_t label; } rtpmidi_channel_ident; @@ -67,7 +75,7 @@ typedef struct /*_rtpmidi_peer*/ { ssize_t invite; //invite-list index for apple-mode learned peers (used to track ipv6/ipv4 overlapping invitations) } rtpmidi_peer; -typedef struct /*_rtmidi_instance_data*/ { +typedef struct /*_rtpmidi_instance_data*/ { rtpmidi_instance_mode mode; int fd; @@ -79,6 +87,11 @@ typedef struct /*_rtmidi_instance_data*/ { uint32_t ssrc; uint16_t sequence; + uint8_t epn_tx_short; + uint16_t epn_control[16]; + uint16_t epn_value[16]; + uint8_t epn_status[16]; + //apple-midi config char* accept; uint64_t last_announce; diff --git a/backends/rtpmidi.md b/backends/rtpmidi.md index 82548bf..8014572 100644 --- a/backends/rtpmidi.md +++ b/backends/rtpmidi.md @@ -38,6 +38,7 @@ Common instance configuration parameters | `ssrc` | `0xDEADBEEF` | Randomly generated | 32-bit synchronization source identifier | | `mode` | `direct` | none | Instance session management mode (`direct` or `apple`) | | `peer` | `10.1.2.3 9001` | none | MIDI session peer, may be specified multiple times. Bypasses session discovery (but still performs session negotiation) | +| `epn-tx` | `short` | `full` | Configure whether to clear the active parameter number after transmitting an `nrpn` or `rpn` parameter. | `direct` mode instance configuration parameters @@ -63,16 +64,22 @@ The `rtpmidi` backend supports mapping different MIDI events to MIDIMonster chan * `pressure` - Note pressure/aftertouch messages * `aftertouch` - Channel-wide aftertouch messages * `pitch` - Channel pitchbend messages +* `program` - Channel program change messages +* `rpn` - Registered parameter numbers (14-bit extension) +* `nrpn` - Non-registered parameter numbers (14-bit extension) A MIDIMonster channel is specified using the syntax `channel<channel>.<type><index>`. The shorthand `ch` may be used instead of the word `channel` (Note that `channel` here refers to the MIDI channel number). -The `pitch` and `aftertouch` events are channel-wide, thus they can be specified as `channel<channel>.<type>`. +The `pitch`, `aftertouch` program messages/events are channel-wide, thus they can be specified as `channel<channel>.<type>`. MIDI channels range from `0` to `15`. Each MIDI channel consists of 128 notes (numbered `0` through `127`), which additionally each have a pressure control, 128 CC's (numbered likewise), a channel pressure control (also called 'channel aftertouch') and a pitch control which may all be mapped to individual MIDIMonster channels. +Every MIDI channel also provides `rpn` and `nrpn` controls, which are implemented on top of the MIDI protocol, using +the CC controls 101/100/99/98/38/6. Both control types have 14-bit IDs and 14-bit values. + Example mappings: ``` @@ -80,6 +87,8 @@ rmidi1.ch0.note9 > rmidi2.channel1.cc4 rmidi1.channel15.pressure1 > rmidi1.channel0.note0 rmidi1.ch1.aftertouch > rmidi2.ch2.cc0 rmidi1.ch0.pitch > rmidi2.ch1.pitch +rmidi2.ch15.note1 > rmidi2.ch2.program +rmidi2.ch0.nrpn900 > rmidi1.ch1.rpn1 ``` #### Known bugs / problems @@ -91,6 +100,12 @@ The mDNS and DNS-SD implementations in this backend are extremely terse, to the specifications in multiple cases. Due to the complexity involved in supporting these protocols, problems arising from this will be considered a bug only in cases where they hinder normal operation of the backend. +Extended parameter numbers (EPNs, the `rpn` and `nrpn` control types) will also generate events on the controls (CC 101 through +98, 38 and 6) that are used as the lower layer transport. When using EPNs, mapping those controls is probably not useful. + +EPN control types support only the full 14-bit transfer encoding, not the shorter variant transmitting only the 7 +high-order bits. This may be changed if there is sufficient interest in the functionality. + mDNS discovery may announce flawed records when run on a host with multiple active interfaces. While this backend should be reasonably stable, there may be problematic edge cases simply due to the diff --git a/backends/sacn.c b/backends/sacn.c index 0c0fd10..e395ae2 100644 --- a/backends/sacn.c +++ b/backends/sacn.c @@ -29,13 +29,15 @@ static struct /*_sacn_global_config*/ { sacn_fd* fd; uint64_t last_announce; uint32_t next_frame; + uint8_t detect; } global_cfg = { .source_name = "MIDIMonster", .cid = {'M', 'I', 'D', 'I', 'M', 'o', 'n', 's', 't', 'e', 'r'}, .fds = 0, .fd = NULL, .last_announce = 0, - .next_frame = 0 + .next_frame = 0, + .detect = 0 }; MM_PLUGIN_API int init(){ @@ -130,6 +132,16 @@ static int sacn_configure(char* option, char* value){ global_cfg.cid[u] = (strtoul(next, &next, 0) & 0xFF); } } + else if(!strcmp(option, "detect")){ + global_cfg.detect = 0; + if(!strcmp(value, "on")){ + global_cfg.detect = 1; + } + else if(!strcmp(value, "verbose")){ + global_cfg.detect = 2; + } + return 0; + } else if(!strcmp(option, "bind")){ mmbackend_parse_hostspec(value, &host, &port, &next); @@ -138,8 +150,13 @@ static int sacn_configure(char* option, char* value){ return 1; } - if(next && !strncmp(next, "local", 5)){ - flags = mcast_loop; + //parse additional socket options + if(next){ + for(next = strtok(next, " "); next; next = strtok(NULL, " ")){ + if(!strcmp(next, "local")){ + flags |= mcast_loop; + } + } } if(sacn_listener(host, port ? port : SACN_PORT, flags)){ @@ -368,7 +385,7 @@ static int sacn_set(instance* inst, size_t num, channel** c, channel_value* v){ //send packet if required if(mark){ - //find output instance data + //find output control data for the instance for(u = 0; u < global_cfg.fd[data->fd_index].universes; u++){ if(global_cfg.fd[data->fd_index].universe[u].universe == data->uni){ break; @@ -401,6 +418,9 @@ static int sacn_process_frame(instance* inst, sacn_frame_root* frame, sacn_frame //source filtering if(inst_data->filter_enabled && memcmp(inst_data->cid_filter, frame->sender_cid, 16)){ + if(global_cfg.detect > 1){ + LOGPF("Discarding data for instance %s due to source filter rule", inst->name); + } return 0; } @@ -418,11 +438,19 @@ static int sacn_process_frame(instance* inst, sacn_frame_root* frame, sacn_frame //handle source priority (currently a 1-bit counter) if(inst_data->data.last_priority > data->priority){ + if(global_cfg.detect > 1){ + LOGPF("Ignoring lower-priority (%d) source on %s, current source is %d", data->priority, inst->name, inst_data->data.last_priority); + } inst_data->data.last_priority = data->priority; return 0; } inst_data->data.last_priority = data->priority; + if(!inst_data->last_input && global_cfg.detect){ + LOGPF("Valid data on instance %s (Universe %u): Source name %.*s, priority %d", inst->name, inst_data->uni, 64, data->source_name, data->priority); + } + inst_data->last_input = mm_timestamp(); + //read data (except start code), mark changed channels for(u = 1; u < be16toh(data->channels); u++){ if(IS_ACTIVE(inst_data->data.map[u - 1]) @@ -583,6 +611,10 @@ static int sacn_handle(size_t num, managed_fd* fds){ if(inst && sacn_process_frame(inst, frame, data)){ LOG("Failed to process frame"); } + else if(!inst && global_cfg.detect > 1){ + //this will only happen with unicast input + LOGPF("Received data for unconfigured universe %d on descriptor %" PRIsize_t, be16toh(data->universe), ((uint64_t) fds[u].impl) & 0xFFFF); + } } } } while(bytes_read > 0); @@ -604,6 +636,45 @@ static int sacn_handle(size_t num, managed_fd* fds){ return 0; } +static int sacn_start_multicast(instance* inst){ + sacn_instance_data* data = (sacn_instance_data*) inst->impl; + struct sockaddr_storage bound_name = { + 0 + }; + #ifdef _WIN32 + struct ip_mreq mcast_req = { + .imr_interface.s_addr = INADDR_ANY, + #else + struct ip_mreqn mcast_req = { + .imr_address.s_addr = INADDR_ANY, + #endif + .imr_multiaddr.s_addr = htobe32(((uint32_t) 0xefff0000) | ((uint32_t) data->uni)) + }; + socklen_t bound_length = sizeof(bound_name); + + //select the specific interface to join the mcast group on based on the bind address + if(getsockname(global_cfg.fd[data->fd_index].fd, (struct sockaddr*) &bound_name, &bound_length)){ + LOGPF("Failed to read back local bind address on socket %" PRIsize_t, data->fd_index); + return 1; + } + else if(bound_name.ss_family != AF_INET || !((struct sockaddr_in*) &bound_name)->sin_addr.s_addr){ + LOGPF("Socket %" PRIsize_t " not bound to a specific IPv4 address, joining multicast input group for instance %s (universe %u) on default interface", data->fd_index, inst->name, data->uni); + } + else{ + #ifdef _WIN32 + mcast_req.imr_interface = ((struct sockaddr_in*) &bound_name)->sin_addr; + #else + mcast_req.imr_address = ((struct sockaddr_in*) &bound_name)->sin_addr; + #endif + } + + if(setsockopt(global_cfg.fd[data->fd_index].fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, (uint8_t*) &mcast_req, sizeof(mcast_req))){ + LOGPF("Failed to join Multicast group for universe %u on instance %s: %s", data->uni, inst->name, mmbackend_socket_strerror(errno)); + } + + return 0; +} + static int sacn_start(size_t n, instance** inst){ size_t u, p; int rv = 1; @@ -611,9 +682,6 @@ static int sacn_start(size_t n, instance** inst){ sacn_instance_id id = { .label = 0 }; - struct ip_mreq mcast_req = { - .imr_interface = { INADDR_ANY } - }; struct sockaddr_in* dest_v4 = NULL; if(!global_cfg.fds){ @@ -641,11 +709,8 @@ static int sacn_start(size_t n, instance** inst){ } } - if(!data->unicast_input){ - mcast_req.imr_multiaddr.s_addr = htobe32(((uint32_t) 0xefff0000) | ((uint32_t) data->uni)); - if(setsockopt(global_cfg.fd[data->fd_index].fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, (uint8_t*) &mcast_req, sizeof(mcast_req))){ - LOGPF("Failed to join Multicast group for universe %u on instance %s: %s", data->uni, inst[u]->name, mmbackend_socket_strerror(errno)); - } + if(!data->unicast_input && sacn_start_multicast(inst[u])){ + return 1; } if(data->xmit_prio){ @@ -667,7 +732,7 @@ static int sacn_start(size_t n, instance** inst){ dest_v4 = (struct sockaddr_in*) (&data->dest_addr); dest_v4->sin_family = AF_INET; dest_v4->sin_port = htobe16(strtoul(SACN_PORT, NULL, 10)); - dest_v4->sin_addr = mcast_req.imr_multiaddr; + dest_v4->sin_addr.s_addr = htobe32(((uint32_t) 0xefff0000) | ((uint32_t) data->uni)); } } } diff --git a/backends/sacn.h b/backends/sacn.h index 4138f45..0f24538 100644 --- a/backends/sacn.h +++ b/backends/sacn.h @@ -40,6 +40,7 @@ typedef struct /*_sacn_universe_model*/ { } sacn_universe; typedef struct /*_sacn_instance_model*/ { + uint64_t last_input; uint16_t uni; uint8_t realtime; uint8_t xmit_prio; diff --git a/backends/sacn.md b/backends/sacn.md index 598f430..244b4c4 100644 --- a/backends/sacn.md +++ b/backends/sacn.md @@ -11,6 +11,7 @@ containing all write-enabled universes. | `name` | `sACN source` | `MIDIMonster` | sACN source name | | `cid` | `0xAA 0xBB 0xCC` ... | `MIDIMonster` | Source CID (16 bytes) | | `bind` | `0.0.0.0 5568` | none | Binds a network address to listen for data. This option may be set multiple times, with each descriptor being assigned an index starting from 0 to be used with the `interface` instance configuration option. At least one descriptor is required for operation. | +| `detect` | `on`, `verbose` | `off` | Output additional information on received data packets to help with configuring complex scenarios | The `bind` configuration value can be extended by the keyword `local` to allow software on the local host to process the sACN output frames from the MIDIMonster (e.g. `bind = 0.0.0.0 5568 local`). @@ -58,3 +59,7 @@ To use multicast input, all networking hardware in the path must support the IGM The Linux kernel limits the number of multicast groups an interface may join to 20. An instance configured for input automatically joins the multicast group for its universe, unless configured in `unicast` mode. This limit can be raised by changing the kernel option in `/proc/sys/net/ipv4/igmp_max_memberships`. + +When using this backend for output with a fast event source, some events may appear to be lost due to the packet output rate limiting +mandated by the E1.31 specification (Section `6.6.1 Transmission rate`). +The rate limiter can be disabled on a per-instance basis using the `realtime` option. diff --git a/backends/visca.c b/backends/visca.c new file mode 100644 index 0000000..a36b139 --- /dev/null +++ b/backends/visca.c @@ -0,0 +1,466 @@ +#define BACKEND_NAME "visca" +//#define DEBUG + +#include <string.h> +#include <math.h> + +#ifndef _WIN32 + #include <sys/ioctl.h> + #include <asm/termbits.h> +#endif + +#include "visca.h" +#include "libmmbackend.h" + +/* TODO + * VISCA server + * Command output rate limiting / deduplication + * Inquiry + * Reconnect on connection close + * Speed updates should send motor outputs + * + */ + +MM_PLUGIN_API int init(){ + backend ptz = { + .name = BACKEND_NAME, + .conf = ptz_configure, + .create = ptz_instance, + .conf_instance = ptz_configure_instance, + .channel = ptz_channel, + .handle = ptz_set, + .process = ptz_handle, + .start = ptz_start, + .shutdown = ptz_shutdown + }; + + //register backend + if(mm_backend_register(ptz)){ + LOG("Failed to register backend"); + return 1; + } + return 0; +} + +static int ptz_configure(char* option, char* value){ + LOG("No backend configuration possible"); + return 1; +} + +static int ptz_configure_instance(instance* inst, char* option, char* value){ + char* host = NULL, *port = NULL, *options = NULL; + ptz_instance_data* data = (ptz_instance_data*) inst->impl; + uint8_t mode = 0; + + if(!strcmp(option, "id")){ + data->cam_address = strtoul(value, NULL, 10); + return 0; + } + else if(!strcmp(option, "connect")){ + if(data->fd >= 0){ + LOGPF("Instance %s already connected", inst->name); + return 1; + } + + mmbackend_parse_hostspec(value, &host, &port, &options); + if(!host || !port){ + LOGPF("Invalid destination address specified for instance %s", inst->name); + return 1; + } + + if(options && !strcmp(options, "udp")){ + mode = 1; + } + + data->fd = mmbackend_socket(host, port, mode ? SOCK_DGRAM : SOCK_STREAM, 0, 0, 1); + if(data->fd < 0){ + LOGPF("Failed to connect instance %s", inst->name); + return 1; + } + return 0; + } + else if(!strcmp(option, "device")){ + if(data->fd >= 0){ + LOGPF("Instance %s already connected", inst->name); + return 1; + } + + #ifdef _WIN32 + LOG("Direct device connections are not possible on Windows"); + return 1; + #else + + struct termios2 device_config; + + options = strchr(value, ' '); + if(options){ + //terminate port name + *options = 0; + options++; + } + + data->fd = open(value, O_RDWR | O_NONBLOCK); + if(data->fd < 0){ + LOGPF("Failed to connect instance %s to device %s", inst->name, value); + return 1; + } + data->direct_device = 1; + + //configure baudrate + if(options){ + //get current port config + if(ioctl(data->fd, TCGETS2, &device_config)){ + LOGPF("Failed to get port configuration data for %s: %s", value, strerror(errno)); + return 0; + } + + device_config.c_cflag &= ~CBAUD; + device_config.c_cflag |= BOTHER; + device_config.c_ispeed = strtoul(options, NULL, 10); + device_config.c_ospeed = strtoul(options, NULL, 10); + + //set updated config + if(ioctl(data->fd, TCSETS2, &device_config)){ + LOGPF("Failed to set port configuration data for %s: %s", value, strerror(errno)); + } + } + return 0; + #endif + } + else if(!strcmp(option, "deadzone")){ + data->deadzone = strtod(value, NULL); + return 0; + } + + LOGPF("Unknown instance configuration parameter %s for instance %s", option, inst->name); + return 1; +} + +static int ptz_instance(instance* inst){ + ptz_instance_data* data = calloc(1, sizeof(ptz_instance_data)); + if(!data){ + LOG("Failed to allocate memory"); + return 1; + } + + data->fd = -1; + data->cam_address = 1; + //start with maximum speeds + data->max_pan = ptz_channels[panspeed].max; + data->max_tilt = ptz_channels[tiltspeed].max; + //start with a bit of slack/deadzone in relative movement axes + data->deadzone = 0.1; + + inst->impl = data; + return 0; +} + +static channel* ptz_channel(instance* inst, char* spec, uint8_t flags){ + uint64_t command = 0; + + if(flags & mmchannel_input){ + LOG("This backend currently only supports output channels"); + return NULL; + } + + for(command = 0; command < sentinel; command++){ + if(!strncmp(spec, ptz_channels[command].name, strlen(ptz_channels[command].name))){ + break; + } + } + + DBGPF("Matched spec %s as %s", spec, ptz_channels[command].name ? ptz_channels[command].name : "sentinel"); + + if(command == sentinel){ + LOGPF("Unknown channel spec %s", spec); + return NULL; + } + + //store the memory to be called above the command type + if(command == call || command == store){ + command |= (strtoul(spec + strlen(ptz_channels[command].name), NULL, 10) << 8); + } + + //store relative move direction + else if(command == relmove){ + if(!strcmp(spec + strlen(ptz_channels[relmove].name), ".up") + || !strcmp(spec + strlen(ptz_channels[relmove].name), ".y")){ + command |= (rel_up << 8); + } + else if(!strcmp(spec + strlen(ptz_channels[relmove].name), ".left") + || !strcmp(spec + strlen(ptz_channels[relmove].name), ".x")){ + command |= (rel_left << 8); + } + + if(!strcmp(spec + strlen(ptz_channels[relmove].name), ".down") + || !strcmp(spec + strlen(ptz_channels[relmove].name), ".y")){ + command |= (rel_down << 8); + } + else if(!strcmp(spec + strlen(ptz_channels[relmove].name), ".right") + || !strcmp(spec + strlen(ptz_channels[relmove].name), ".x")){ + command |= (rel_right << 8); + } + + if(command >> 8 == 0){ + LOGPF("Could not parse relative movement command %s", spec); + return NULL; + } + } + + return mm_channel(inst, command, 1); +} + +static size_t ptz_set_pantilt(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + ptz_instance_data* data = (ptz_instance_data*) inst->impl; + + if(c->ident == pan){ + data->x = ((ptz_channels[pan].max - ptz_channels[pan].min) * v->normalised) + ptz_channels[pan].min - ptz_channels[pan].offset; + } + else{ + data->y = ((ptz_channels[tilt].max - ptz_channels[tilt].min) * v->normalised) + ptz_channels[tilt].min - ptz_channels[tilt].offset; + } + + //absolute movements happen with maximum speed + msg[4] = data->max_pan; + msg[5] = data->max_tilt; + + //either i'm doing this wrong or visca is just weird. + msg[6] = ((data->x & 0xF000) >> 12); + msg[7] = ((data->x & 0x0F00) >> 8); + msg[8] = ((data->x & 0xF0) >> 4); + msg[9] = (data->x & 0x0F); + + msg[10] = ((data->y & 0xF000) >> 12); + msg[11] = ((data->y & 0x0F00) >> 8); + msg[12] = ((data->y & 0xF0) >> 4); + msg[13] = (data->y & 0x0F); + + return ptz_channels[pan].bytes; +} + +static size_t ptz_set_ptspeed(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + ptz_instance_data* data = (ptz_instance_data*) inst->impl; + if(c->ident == panspeed){ + data->max_pan = ((ptz_channels[panspeed].max - ptz_channels[panspeed].min) * v->normalised) + ptz_channels[panspeed].min - ptz_channels[panspeed].offset; + } + else{ + data->max_tilt = ((ptz_channels[tiltspeed].max - ptz_channels[tiltspeed].min) * v->normalised) + ptz_channels[tiltspeed].min - ptz_channels[tiltspeed].offset; + } + + return 0; +} + +static size_t ptz_set_relmove(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + ptz_instance_data* data = (ptz_instance_data*) inst->impl; + + uint8_t direction = c->ident >> 8, movement = data->relative_movement; + double speed_factor = v->normalised; + + if(direction == rel_x + || direction == rel_y){ + //select only one move event + direction &= (speed_factor > 0.5) ? (rel_up | rel_left) : (rel_down | rel_right); + + //scale event value to full axis + speed_factor = fabs((speed_factor - 0.5) * 2); + + //clamp to deadzone + speed_factor = (speed_factor < 2 * data->deadzone) ? 0 : speed_factor; + } + + //clear modified axis + if(direction & rel_x){ + movement &= ~rel_x; + data->factor_tilt = speed_factor; + } + else{ + movement &= ~rel_y; + data->factor_pan = speed_factor; + } + + if(speed_factor){ + movement |= direction; + } + + //only transmit if something actually changed + if(!movement && !data->relative_movement){ + return 0; + } + data->relative_movement = movement; + + //set stored axis speed + msg[4] = data->max_pan * data->factor_pan; + msg[5] = data->max_tilt * data->factor_tilt; + + //update motor control from movement data + msg[6] |= (movement & (rel_left | rel_right)) >> 2; + msg[7] |= movement & (rel_up | rel_down); + + //stop motors if unset + msg[6] = msg[6] ? msg[6] : 3; + msg[7] = msg[7] ? msg[7] : 3; + + DBGPF("Moving axis %d with factor %f, total movement now %02X, commanding %d / %d, %d / %d", + direction, speed_factor, data->relative_movement, + msg[6], msg[4], msg[7], msg[5]); + + return ptz_channels[relmove].bytes; +} + +static size_t ptz_set_zoom(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + uint16_t position = ((ptz_channels[zoom].max - ptz_channels[zoom].min) * v->normalised) + ptz_channels[zoom].min - ptz_channels[zoom].offset; + msg[4] = ((position & 0xF000) >> 12); + msg[5] = ((position & 0x0F00) >> 8); + msg[6] = ((position & 0xF0) >> 4); + msg[7] = (position & 0x0F); + return ptz_channels[zoom].bytes; +} + +static size_t ptz_set_focus(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + uint16_t position = ((ptz_channels[focus].max - ptz_channels[focus].min) * v->normalised) + ptz_channels[focus].min - ptz_channels[focus].offset; + msg[4] = ((position & 0xF000) >> 12); + msg[5] = ((position & 0x0F00) >> 8); + msg[6] = ((position & 0xF0) >> 4); + msg[7] = (position & 0x0F); + return ptz_channels[focus].bytes; +} + +static size_t ptz_set_focus_mode(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + msg[4] = (v->normalised > 0.9) ? 2 : 3; + return ptz_channels[focus_mode].bytes; +} + +static size_t ptz_set_wb_mode(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + msg[4] = (v->normalised > 0.9) ? 0 : 5; + return ptz_channels[wb_mode].bytes; +} + +static size_t ptz_set_wb(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + uint8_t command = c->ident & 0xFF; + uint8_t value = ((ptz_channels[command].max - ptz_channels[command].min) * v->normalised) + ptz_channels[command].min - ptz_channels[command].offset; + msg[6] = value >> 4; + msg[7] = value & 0x0F; + return ptz_channels[command].bytes; +} + +static size_t ptz_set_memory(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + if(v->normalised < 0.9){ + return 0; + } + + msg[5] = (c->ident >> 8); + return ptz_channels[call].bytes; +} + +static size_t ptz_set_memory_store(instance* inst, channel* c, channel_value* v, uint8_t* msg){ + if(v->normalised < 0.9){ + return 0; + } + + msg[5] = (c->ident >> 8); + return ptz_channels[store].bytes; +} + +static int ptz_write_serial(int fd, uint8_t* data, size_t bytes){ + ssize_t total = 0, sent; + + while(total < bytes){ + sent = write(fd, data + total, bytes - total); + if(sent < 0){ + LOGPF("Failed to write to serial port: %s", strerror(errno)); + return 1; + } + total += sent; + } + + return 0; +} + +static int ptz_set(instance* inst, size_t num, channel** c, channel_value* v){ + ptz_instance_data* data = (ptz_instance_data*) inst->impl; + size_t n = 0, bytes = 0; + uint8_t tx[VISCA_BUFFER_LENGTH] = ""; + uint8_t command = 0; + + for(n = 0; n < num; n++){ + bytes = 0; + command = c[n]->ident & 0xFF; + + if(ptz_channels[command].bytes){ + memcpy(tx, ptz_channels[command].pattern, ptz_channels[command].bytes); + //if no handler function set, assume a parameterless command and send verbatim + bytes = ptz_channels[command].bytes; + } + tx[0] = 0x80 | (data->cam_address & 0xF); + + if(ptz_channels[command].set){ + bytes = ptz_channels[command].set(inst, c[n], v + n, tx); + } + + if(data->direct_device && bytes && ptz_write_serial(data->fd, tx, bytes)){ + LOGPF("Failed to write %s command on instance %s", ptz_channels[command].name, inst->name); + } + else if(!data->direct_device && bytes && mmbackend_send(data->fd, tx, bytes)){ + LOGPF("Failed to push %s command on instance %s", ptz_channels[command].name, inst->name); + } + } + return 0; +} + +static int ptz_handle(size_t num, managed_fd* fds){ + uint8_t recv_buf[VISCA_BUFFER_LENGTH]; + size_t u; + ssize_t bytes_read; + instance* inst = NULL; + + //read and ignore any responses for now + for(u = 0; u < num; u++){ + inst = (instance*) fds[u].impl; + bytes_read = recv(fds[u].fd, recv_buf, sizeof(recv_buf), 0); + if(bytes_read <= 0){ + LOGPF("Failed to receive on signaled fd for instance %s", inst->name); + //TODO handle failure + } + else{ + DBGPF("Ignored %" PRIsize_t " incoming bytes for instance %s", bytes_read, inst->name); + } + } + + return 0; +} + +static int ptz_start(size_t n, instance** inst){ + size_t u, fds = 0; + ptz_instance_data* data = NULL; + + for(u = 0; u < n; u++){ + data = (ptz_instance_data*) inst[u]->impl; + if(data->fd >= 0){ + if(mm_manage_fd(data->fd, BACKEND_NAME, 1, inst[u])){ + LOGPF("Failed to register descriptor for instance %s", inst[u]->name); + return 1; + } + fds++; + } + } + + LOGPF("Registered %" PRIsize_t " descriptors to core", fds); + return 0; +} + +static int ptz_shutdown(size_t n, instance** inst){ + size_t u; + ptz_instance_data* data = NULL; + + for(u = 0; u < n; u++){ + data = (ptz_instance_data*) inst[u]->impl; + if(data->fd >= 0){ + close(data->fd); + } + free(data); + inst[u]->impl = NULL; + } + + LOG("Backend shut down"); + return 0; +} diff --git a/backends/visca.h b/backends/visca.h new file mode 100644 index 0000000..37f21b1 --- /dev/null +++ b/backends/visca.h @@ -0,0 +1,94 @@ +#include "midimonster.h" + +MM_PLUGIN_API int init(); +static int ptz_configure(char* option, char* value); +static int ptz_configure_instance(instance* inst, char* option, char* value); +static int ptz_instance(instance* inst); +static channel* ptz_channel(instance* inst, char* spec, uint8_t flags); +static int ptz_set(instance* inst, size_t num, channel** c, channel_value* v); +static int ptz_handle(size_t num, managed_fd* fds); +static int ptz_start(size_t n, instance** inst); +static int ptz_shutdown(size_t n, instance** inst); + +#define VISCA_BUFFER_LENGTH 50 + +enum /*_ptz_relmove_channel */ { + rel_up = 1, + rel_down = 2, + rel_left = 4, + rel_right = 8, + rel_x = rel_up | rel_down, + rel_y = rel_left | rel_right +}; + +typedef struct /*_ptz_instance_data*/ { + int fd; + uint8_t cam_address; + uint16_t x; + uint16_t y; + uint8_t max_pan, max_tilt; + double factor_pan, factor_tilt; + uint8_t relative_movement; + double deadzone; + uint8_t direct_device; +} ptz_instance_data; + +enum /*ptz_channels*/ { + //channels with a name that includes another channels as prefix + //go first so the channel matching logic works + panspeed = 0, + tiltspeed, + pan, + tilt, + zoom, + focus, + focus_mode, + wb_red, + wb_blue, + wb_mode, + call, + store, + home, + stop, + relmove, + sentinel +}; + +typedef size_t (*ptz_channel_set)(instance*, channel*, channel_value*, uint8_t* msg); +static size_t ptz_set_pantilt(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_ptspeed(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_zoom(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_focus(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_focus_mode(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_wb_mode(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_wb(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_memory(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_memory_store(instance* inst, channel* c, channel_value* v, uint8_t* msg); +static size_t ptz_set_relmove(instance* inst, channel* c, channel_value* v, uint8_t* msg); + +static struct { + char* name; + size_t bytes; + uint8_t pattern[VISCA_BUFFER_LENGTH]; + size_t min; //channel range = max - min + size_t max; + size_t offset; //channel value = normalised * range - offset + ptz_channel_set set; +} ptz_channels[] = { + [panspeed] = {"panspeed", 0, {0}, 0x01, 0x18, 0, ptz_set_ptspeed}, + [tiltspeed] = {"tiltspeed", 0, {0}, 0x01, 0x14, 0, ptz_set_ptspeed}, + [pan] = {"pan", 15, {0x80, 0x01, 0x06, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF}, 0, 0x990 * 2, 0x990, ptz_set_pantilt}, + [tilt] = {"tilt", 15, {0x80, 0x01, 0x06, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF}, 0, 0x510 * 2, 0x510, ptz_set_pantilt}, + [zoom] = {"zoom", 9, {0x80, 0x01, 0x04, 0x47, 0, 0, 0, 0, 0xFF}, 0, 0x4000, 0, ptz_set_zoom}, + [focus] = {"focus", 9, {0x80, 0x01, 0x04, 0x48, 0, 0, 0, 0, 0xFF}, 0, 0x4000, 0, ptz_set_focus}, + [focus_mode] = {"autofocus", 6, {0x80, 0x01, 0x04, 0x38, 0, 0xFF}, 0, 1, 0, ptz_set_focus_mode}, + [wb_mode] = {"wb.auto", 6, {0x80, 0x01, 0x04, 0x35, 0, 0xFF}, 0, 1, 0, ptz_set_wb_mode}, + [wb_red] = {"wb.red", 9, {0x80, 0x01, 0x04, 0x43, 0x00, 0x00, 0, 0, 0xFF}, 0, 255, 0, ptz_set_wb}, + [wb_blue] = {"wb.blue", 9, {0x80, 0x01, 0x04, 0x44, 0x00, 0x00, 0, 0, 0xFF}, 0, 255, 0, ptz_set_wb}, + [call] = {"memory", 7, {0x80, 0x01, 0x04, 0x3F, 0x02, 0, 0xFF}, 0, 254, 0, ptz_set_memory}, + [store] = {"store", 7, {0x80, 0x01, 0x04, 0x3F, 0x01, 0, 0xFF}, 0, 254, 0, ptz_set_memory_store}, + [home] = {"home", 5, {0x80, 0x01, 0x06, 0x04, 0xFF}, 0, 0, 0, NULL}, + [relmove] = {"move", 9, {0x80, 0x01, 0x06, 0x01, 0, 0, 0, 0, 0xFF}, 0, 1, 0, ptz_set_relmove}, + [stop] = {"stop", 9, {0x80, 0x01, 0x06, 0x01, 0, 0, 0x03, 0x03, 0xFF}, 0, 0, 0, ptz_set_relmove}, + [sentinel] = {"SENTINEL"} +}; diff --git a/backends/visca.md b/backends/visca.md new file mode 100644 index 0000000..7b1bcc3 --- /dev/null +++ b/backends/visca.md @@ -0,0 +1,70 @@ +### The `visca` backend + +The `visca` backend provides control of compatible PTZ (Pan, Tilt, Zoom) controllable cameras +via the network. The VISCA protocol has, with some variations, been implemented by multiple manufacturers +in their camera equipment. There may be some specific limits on the command set depending on the make +and model of your equipment. + +This backend can connect to both UDP and TCP based camera control interfaces. On Linux, it can also control +devices attached to a serial/RS485 adapter. + +#### Global configuration + +The `visca` backend does not take any global configuration. + +#### Instance configuration + +| Option | Example value | Default value | Description | +|---------------|-----------------------|-----------------------|---------------------------------------------------------------| +| `id` | `5` | `1` | VISCA Camera address (normally 1 for network communication | +| `connect` | `10.10.10.1 5678` | none | Camera network address and port. Default connection is TCP, when optionally suffixed with the `udp` keyword, connection will be UDP | +| `device` | `/dev/ttyUSB0 115200` | none | (Linux only) Device node for a serial port adapter connecting to the camera, optionally followed by the baudrate | +| `deadzone` | `0.1` | `0.1` | Amount of event value variation to be ignored for relative movement commands | + +#### Channel specification + +Each instance exposes the following channels + +* `pan`: Pan axis (absolute) +* `tilt`: Tilt axis (absolute) +* `panspeed`: Pan speed +* `tiltspeed`: Tilt speed +* `zoom`: Zoom position +* `focus`: Focus position +* `autofocus`: Switch between autofocus (events > 0.9) and manual focus drive mode +* `wb.auto`: Switch between automatic white balance mode (events > 0.9) and manual white balance mode +* `wb.red`, `wb.blue`: Red/Blue channel white balance gain values +* `home`: Return to home position +* `memory<n>`: Call memory <n> (if incoming event value is greater than 0.9) +* `store<n>`: Store current pan/tilt/zoom setup to memory <n> (if incoming event value is greater than 0.9) +* `move.left`, `move.right`, `move.up`, `move.down`: Move relative to the current position. Set speed is multiplied by the event value. +* `move.x`, `move.y`: Move relative to the current position along the specified axis. Set speed is multiplied by the event value scaled to the full range (ie. `0.0` to `0.5` moves in one direction, `0.5` to `1.0` in the other). + + +Example mappings: + +``` +control.pan > visca.pan +control.tilt > visca.tilt +control.btn1 > visca.memory1 +control.stick_x > visca.move.x +control.stick_y > visca.move.y +``` + +#### Compatability list + +| Manufacturer | Exact model(s) tested | Compatible models | Result / Notes | +|---------------|-------------------------------|-----------------------------------------------|-------------------------------------------------------| +| ValueHD | VHD-V61 | Probably all ValueHD Visca-capable devices | Everything works except for absolute focus control | +| PTZOptics | | Probably all of their PTZ cameras | See ValueHD | + +#### Known bugs / problems + +Value readback / Inquiry is not yet implemented. This backend currently only does output. + +Some manufacturers use VISCA, but require special framing for command flow control. This may be implemented +in the future if there is sufficient interest. Some commands may not work with some manufacturer's cameras due to +different value ranges or command ordering. + +Please file a ticket if you can confirm this backend working/nonworking with a new make or model +of camera so we can add it to the compatibility list! diff --git a/backends/wininput.c b/backends/wininput.c new file mode 100644 index 0000000..1d1c85b --- /dev/null +++ b/backends/wininput.c @@ -0,0 +1,766 @@ +#define BACKEND_NAME "wininput" +//#define DEBUG + +#include <string.h> +#include "wininput.h" + +#include <mmsystem.h> + +//TODO check whether feedback elimination is required +//TODO might want to store virtual desktop extents in request->limit + +static key_info keys[] = { + {VK_LBUTTON, "lmb", button}, {VK_RBUTTON, "rmb", button}, {VK_MBUTTON, "mmb", button}, + {VK_XBUTTON1, "xmb1", button}, {VK_XBUTTON2, "xmb2", button}, + {VK_BACK, "backspace"}, + {VK_TAB, "tab"}, + {VK_CLEAR, "clear"}, + {VK_RETURN, "enter"}, + {VK_SHIFT, "shift"}, + {VK_CONTROL, "control"}, {VK_MENU, "alt"}, + {VK_CAPITAL, "capslock"}, + {VK_ESCAPE, "escape"}, + {VK_SPACE, "space"}, + {VK_PRIOR, "pageup"}, {VK_NEXT, "pagedown"}, + {VK_END, "end"}, {VK_HOME, "home"}, + {VK_PAUSE, "pause"}, {VK_NUMLOCK, "numlock"}, {VK_SCROLL, "scrolllock"}, + {VK_INSERT, "insert"}, {VK_DELETE, "delete"}, {VK_SNAPSHOT, "printscreen"}, + {VK_LEFT, "left"}, {VK_UP, "up"}, {VK_RIGHT, "right"}, {VK_DOWN, "down"}, + {VK_SELECT, "select"}, + {VK_PRINT, "print"}, + {VK_EXECUTE, "execute"}, + {VK_HELP, "help"}, + {VK_APPS, "apps"}, + {VK_SLEEP, "sleep"}, + {VK_NUMPAD0, "num0"}, {VK_NUMPAD1, "num1"}, {VK_NUMPAD2, "num2"}, {VK_NUMPAD3, "num3"}, + {VK_NUMPAD4, "num4"}, {VK_NUMPAD5, "num5"}, {VK_NUMPAD6, "num6"}, {VK_NUMPAD7, "num7"}, + {VK_NUMPAD8, "num8"}, {VK_NUMPAD9, "num9"}, {VK_MULTIPLY, "multiply"}, {VK_ADD, "plus"}, + {VK_SEPARATOR, "comma"}, {VK_SUBTRACT, "minus"}, {VK_DECIMAL, "dot"}, {VK_DIVIDE, "divide"}, + {VK_F1, "f1"}, {VK_F2, "f2"}, {VK_F3, "f3"}, {VK_F4, "f4"}, {VK_F5, "f5"}, + {VK_F6, "f6"}, {VK_F7, "f7"}, {VK_F8, "f8"}, {VK_F9, "f9"}, {VK_F10, "f10"}, + {VK_F11, "f11"}, {VK_F12, "f12"}, {VK_F13, "f13"}, {VK_F14, "f14"}, {VK_F15, "f15"}, + {VK_F16, "f16"}, {VK_F17, "f17"}, {VK_F18, "f18"}, {VK_F19, "f19"}, {VK_F20, "f20"}, + {VK_F21, "f21"}, {VK_F22, "f22"}, {VK_F23, "f23"}, {VK_F24, "f24"}, + {VK_LWIN, "lwin"}, {VK_RWIN, "rwin"}, + {VK_LSHIFT, "lshift"}, {VK_RSHIFT, "rshift"}, + {VK_LCONTROL, "lctrl"}, {VK_RCONTROL, "rctrl"}, + {VK_LMENU, "lmenu"}, {VK_RMENU, "rmenu"}, + {VK_BROWSER_BACK, "previous"}, {VK_BROWSER_FORWARD, "next"}, {VK_BROWSER_REFRESH, "refresh"}, + {VK_BROWSER_STOP, "stop"}, {VK_BROWSER_SEARCH, "search"}, {VK_BROWSER_FAVORITES, "favorites"}, + {VK_BROWSER_HOME, "homepage"}, + {VK_VOLUME_MUTE, "mute"}, {VK_VOLUME_DOWN, "voldown"}, {VK_VOLUME_UP, "volup"}, + {VK_MEDIA_NEXT_TRACK, "nexttrack"}, {VK_MEDIA_PREV_TRACK, "prevtrack"}, + {VK_MEDIA_STOP, "stopmedia"}, {VK_MEDIA_PLAY_PAUSE, "togglemedia"}, + {VK_LAUNCH_MEDIA_SELECT, "mediaselect"}, + {VK_LAUNCH_MAIL, "mail"}, {VK_LAUNCH_APP1, "app1"}, {VK_LAUNCH_APP2, "app2"}, + {VK_OEM_PLUS, "plus"}, {VK_OEM_COMMA, "comma"}, + {VK_OEM_MINUS, "minus"}, {VK_OEM_PERIOD, "period"}, + {VK_ZOOM, "zoom"} +}; + +static struct { + int virtual_x, virtual_y, virtual_width, virtual_height; + long mouse_x, mouse_y; + size_t requests; + //sorted in _start + wininput_request* request; + uint32_t interval; + uint64_t wheel, wheel_max, wheel_delta; + uint8_t wheel_inverted; +} cfg = { + .requests = 0, + .interval = 50, + .wheel_max = 0xFFFF, + .wheel_delta = 1 +}; + +MM_PLUGIN_API int init(){ + backend wininput = { + .name = BACKEND_NAME, + .conf = wininput_configure, + .create = wininput_instance, + .interval = wininput_interval, + .conf_instance = wininput_configure_instance, + .channel = wininput_channel, + .handle = wininput_set, + .process = wininput_handle, + .start = wininput_start, + .shutdown = wininput_shutdown + }; + + if(sizeof(wininput_channel_ident) != sizeof(uint64_t)){ + LOG("Channel identification union out of bounds"); + return 1; + } + + //register backend + if(mm_backend_register(wininput)){ + LOG("Failed to register backend"); + return 1; + } + return 0; +} + +static int request_comparator(const void * raw_a, const void * raw_b){ + wininput_request* a = (wininput_request*) raw_a, *b = (wininput_request*) raw_b; + + //sort by type first + if(a->ident.fields.type != b->ident.fields.type){ + return a->ident.fields.type - b->ident.fields.type; + } + + //joysticks need to be sorted by controller id first so we can query them once + if(a->ident.fields.type == joystick){ + //joystick id is in the upper bits of control and we dont actually care about anything else + return a->ident.fields.control - b->ident.fields.control; + } + + //the rest doesnt actually need to be sorted at all + return 0; +} + +static uint32_t wininput_interval(){ + return cfg.interval; +} + +static int wininput_configure(char* option, char* value){ + int64_t parameter = 0; + char* next_token = NULL; + + if(!strcmp(option, "interval")){ + cfg.interval = strtoul(value, NULL, 0); + return 0; + } + else if(!strcmp(option, "wheel")){ + parameter = strtoll(value, &next_token, 0); + + cfg.wheel_max = parameter; + if(parameter < 0){ + LOG("Inverting mouse wheel data"); + cfg.wheel_max = -parameter; + cfg.wheel_inverted = 1; + } + else if(!parameter){ + LOGPF("Invalid mouse wheel configuration %s", value); + return 1; + } + + if(next_token && *next_token){ + cfg.wheel = strtoul(next_token, NULL, 0); + } + + if(cfg.wheel > cfg.wheel_max){ + LOG("Mouse wheel initial value out of range"); + return 1; + } + + return 0; + } + else if(!strcmp(option, "wheeldelta")){ + cfg.wheel_delta = strtoul(value, NULL, 0); + return 0; + } + + + LOGPF("Unknown backend configuration option %s", option); + return 1; +} + +static int wininput_configure_instance(instance* inst, char* option, char* value){ + LOG("The backend does not take any instance configuration"); + return 0; +} + +static int wininput_instance(instance* inst){ + return 0; +} + +static int wininput_subscribe(uint64_t ident, channel* chan){ + size_t u, n; + + //find an existing request + for(u = 0; u < cfg.requests; u++){ + if(cfg.request[u].ident.label == ident){ + break; + } + } + + if(u == cfg.requests){ + //create a new request + cfg.request = realloc(cfg.request, (cfg.requests + 1) * sizeof(wininput_request)); + if(!cfg.request){ + cfg.requests = 0; + LOG("Failed to allocate memory"); + return 1; + } + + cfg.request[u].ident.label = ident; + cfg.request[u].channels = 0; + cfg.request[u].channel = NULL; + cfg.request[u].state = cfg.request[u].min = cfg.request[u].max = 0; + cfg.requests++; + } + + //check if already in subscriber list + for(n = 0; n < cfg.request[u].channels; n++){ + if(cfg.request[u].channel[n] == chan){ + return 0; + } + } + + //add to subscriber list + cfg.request[u].channel = realloc(cfg.request[u].channel, (cfg.request[u].channels + 1) * sizeof(channel*)); + if(!cfg.request[u].channel){ + cfg.request[u].channels = 0; + LOG("Failed to allocate memory"); + return 1; + } + cfg.request[u].channel[n] = chan; + cfg.request[u].channels++; + return 0; +} + +static uint64_t wininput_channel_mouse(instance* inst, char* spec, uint8_t flags){ + size_t u; + wininput_channel_ident ident = { + .fields.type = mouse + }; + + if(!strcmp(spec, "x")){ + ident.fields.channel = position; + } + else if(!strcmp(spec, "y")){ + ident.fields.channel = position; + ident.fields.control = 1; + } + else if(!strcmp(spec, "wheel")){ + ident.fields.channel = wheel; + if(flags & mmchannel_input){ + LOG("The mouse wheel can only be used as an output channel"); + return 0; + } + } + else{ + //check the buttons + for(u = 0; u < sizeof(keys) / sizeof(keys[0]); u++){ + if(keys[u].channel == button && !strcmp(keys[u].name, spec)){ + DBGPF("Using keymap %" PRIsize_t " (%d) for spec %s", u, keys[u].keycode, spec); + ident.fields.channel = button; + ident.fields.control = keys[u].keycode; + break; + } + } + + if(u == sizeof(keys) / sizeof(keys[0])){ + LOGPF("Unknown mouse control %s", spec); + return 0; + } + } + + return ident.label; +} + +static uint64_t wininput_channel_key(instance* inst, char* spec, uint8_t flags){ + size_t u; + uint16_t scancode = 0; + wininput_channel_ident ident = { + .fields.type = keyboard, + .fields.channel = keypress + }; + + for(u = 0; u < sizeof(keys) / sizeof(keys[0]); u++){ + if(keys[u].channel == keypress && !strcmp(keys[u].name, spec)){ + DBGPF("Using keymap %" PRIsize_t " (%d) for spec %s", u, keys[u].keycode, spec); + ident.fields.control = keys[u].keycode; + return ident.label; + } + } + + //no entry in translation table + if(strlen(spec) == 1){ + //try to translate + scancode = VkKeyScan(spec[0]); + if(scancode != 0x7f7f){ + DBGPF("Using keyscan result %02X (via %04X) for spec %s", scancode & 0xFF, scancode, spec); + ident.fields.control = scancode & 0xFF; + return ident.label; + } + } + else if(strlen(spec) > 1){ + //try to use as literal + scancode = strtoul(spec, NULL, 0); + if(scancode){ + DBGPF("Using direct conversion %d for spec %s", scancode & 0xFF, spec); + ident.fields.control = scancode & 0xFF; + return ident.label; + } + } + + LOGPF("Unknown keyboard control %s", spec); + return 0; +} + +static uint64_t wininput_channel_joystick(instance* inst, char* spec, uint8_t flags){ + char* token = NULL, *axes = "xyzruvp"; + uint16_t controller = strtoul(spec, &token, 0); + wininput_channel_ident ident = { + .fields.type = joystick + }; + + if(flags & mmchannel_output){ + LOG("Joystick channels can only be mapped as inputs on Windows"); + return 0; + } + + if(!controller || !token || *token != '.'){ + LOGPF("Invalid joystick specification %s", spec); + return 0; + } + token++; + + if(strlen(token) == 1 || !strcmp(token, "pov")){ + if(strchr(axes, token[0])){ + ident.fields.channel = position; + ident.fields.control = ((controller - 1) << 8) | token[0]; + return ident.label; + } + + LOGPF("Unknown joystick axis specification %s", token); + return 0; + } + + if(!strncmp(token, "button", 6)){ + ident.fields.control = strtoul(token + 6, NULL, 10); + if(!ident.fields.control || ident.fields.control > 32){ + LOGPF("Button index out of range for specification %s", token); + return 0; + } + ident.fields.channel = button; + ident.fields.control |= (controller << 8); + return ident.label; + } + + LOGPF("Invalid joystick control %s", spec); + return 0; +} + +static channel* wininput_channel(instance* inst, char* spec, uint8_t flags){ + channel* chan = NULL; + uint64_t label = 0; + + if(!strncmp(spec, "mouse.", 6)){ + label = wininput_channel_mouse(inst, spec + 6, flags); + } + else if(!strncmp(spec, "key.", 4)){ + label = wininput_channel_key(inst, spec + 4, flags); + } + else if(!strncmp(spec, "joy", 3)){ + label = wininput_channel_joystick(inst, spec + 3, flags); + } + else{ + LOGPF("Unknown channel spec type %s", spec); + } + + if(label){ + chan = mm_channel(inst, label, 1); + if(chan && (flags & mmchannel_input) && wininput_subscribe(label, chan)){ + return NULL; + } + return chan; + } + return NULL; +} + +//for some reason, sendinput only takes "normalized absolute coordinates", which are never again used in the API +static void wininput_mouse_normalize(long* x, long* y){ + long normalized_x = (double) (*x - cfg.virtual_x) * (65535.0f / (double) cfg.virtual_width); + long normalized_y = (double) (*y - cfg.virtual_y) * (65535.0f / (double) cfg.virtual_height); + + *x = normalized_x; + *y = normalized_y; +} + +static INPUT wininput_event_mouse(uint8_t channel, uint8_t control, double value){ + DWORD flags_down = 0, flags_up = 0; + INPUT ev = { + .type = INPUT_MOUSE + }; + + if(channel == position){ + if(control){ + cfg.mouse_y = value * 0xFFFF; + } + else{ + cfg.mouse_x = value * 0xFFFF; + } + + ev.mi.dwFlags |= MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_VIRTUALDESK; + ev.mi.dx = cfg.mouse_x; + ev.mi.dy = cfg.mouse_y; + } + else if(channel == button){ + switch(control){ + case VK_LBUTTON: + flags_up |= MOUSEEVENTF_LEFTUP; + flags_down |= MOUSEEVENTF_LEFTDOWN; + break; + case VK_RBUTTON: + flags_up |= MOUSEEVENTF_RIGHTUP; + flags_down |= MOUSEEVENTF_RIGHTDOWN; + break; + case VK_MBUTTON: + flags_up |= MOUSEEVENTF_MIDDLEUP; + flags_down |= MOUSEEVENTF_MIDDLEDOWN; + break; + case VK_XBUTTON1: + case VK_XBUTTON2: + ev.mi.mouseData = (control == VK_XBUTTON1) ? XBUTTON1 : XBUTTON2; + flags_up |= MOUSEEVENTF_XUP; + flags_down |= MOUSEEVENTF_XDOWN; + break; + } + + if(value > 0.9){ + ev.mi.dwFlags |= flags_down; + } + else{ + ev.mi.dwFlags |= flags_up; + } + } + else if(channel == wheel){ + ev.mi.dwFlags |= MOUSEEVENTF_WHEEL; + ev.mi.mouseData = ((value * cfg.wheel_max) - cfg.wheel) * cfg.wheel_delta; + if(cfg.wheel_inverted){ + ev.mi.mouseData *= -1; + } + DBGPF("Moving wheel %d (invert %d) with delta %d: %d", (value * cfg.wheel_max) - cfg.wheel, cfg.wheel_inverted, cfg.wheel_delta, ev.mi.mouseData); + cfg.wheel = (value * cfg.wheel_max); + } + + return ev; +} + +static INPUT wininput_event_keyboard(uint8_t channel, uint8_t control, double value){ + INPUT ev = { + .type = INPUT_KEYBOARD + }; + + if(channel == keypress){ + ev.ki.wVk = control; + if(value < 0.9){ + ev.ki.dwFlags |= KEYEVENTF_KEYUP; + } + } + + return ev; +} + +static int wininput_set(instance* inst, size_t num, channel** c, channel_value* v){ + wininput_channel_ident ident = { + .label = 0 + }; + size_t n = 0, offset = 0; + INPUT events[500]; + + if(num > sizeof(events) / sizeof(events[0])){ + LOGPF("Truncating output on %s to the last %" PRIsize_t " events, please notify the developers", inst->name, sizeof(events) / sizeof(events[0])); + offset = num - sizeof(events) / sizeof(events[0]); + } + + for(n = 0; n + offset < num; n++){ + ident.label = c[n + offset]->ident; + if(ident.fields.type == mouse){ + events[n] = wininput_event_mouse(ident.fields.channel, ident.fields.control, v[n + offset].normalised); + } + else if(ident.fields.type == keyboard){ + events[n] = wininput_event_keyboard(ident.fields.channel, ident.fields.control, v[n + offset].normalised); + } + else{ + n--; + offset++; + } + } + + if(n){ + offset = SendInput(n, events, sizeof(INPUT)); + if(offset != n){ + LOGPF("Output %" PRIsize_t " of %" PRIsize_t " events on %s", offset, n, inst->name); + } + } + return 0; +} + +static int wininput_handle(size_t num, managed_fd* fds){ + channel_value val = { + .normalised = 0 + }; + uint8_t mouse_updated = 0, synthesize_off = 0, push_event = 0, current_joystick = 0; + uint16_t key_state = 0; + size_t u = 0, n; + POINT cursor_position; + JOYINFOEX joy_info; + + for(u = 0; u < cfg.requests; u++){ + synthesize_off = 0; + push_event = 0; + val.normalised = 0; + + if(cfg.request[u].ident.fields.type == mouse + && cfg.request[u].ident.fields.channel == position){ + if(!mouse_updated){ + //update mouse coordinates + if(!GetCursorPos(&cursor_position)){ + LOG("Failed to update mouse position"); + continue; + } + wininput_mouse_normalize(&cursor_position.x, &cursor_position.y); + mouse_updated = 1; + if(cfg.mouse_x != cursor_position.x + || cfg.mouse_y != cursor_position.y){ + cfg.mouse_x = cursor_position.x; + cfg.mouse_y = cursor_position.y; + mouse_updated = 2; + } + } + + val.normalised = (double) cfg.mouse_x / (double) 0xFFFF; + if(cfg.request[u].ident.fields.control){ + val.normalised = (double) cfg.mouse_y / (double) 0xFFFF; + } + + if(mouse_updated == 2){ + push_event = 1; + } + } + else if(cfg.request[u].ident.fields.type == mouse + && cfg.request[u].ident.fields.channel == wheel){ + //ignore wheel requests, can't read that + } + else if(cfg.request[u].ident.fields.type == keyboard + || cfg.request[u].ident.fields.type == mouse){ + //check key state + key_state = GetAsyncKeyState(cfg.request[u].ident.fields.control); + if(key_state == 1){ + //pressed and released? + synthesize_off = 1; + } + if((key_state & ~1) != cfg.request[u].state){ + //key state changed + if(key_state){ + val.normalised = 1.0; + } + cfg.request[u].state = key_state & ~1; + push_event = 1; + } + } + else if(cfg.request[u].ident.fields.type == joystick){ + if(cfg.request[u].ident.fields.control >> 8 != current_joystick){ + joy_info.dwSize = sizeof(joy_info); + joy_info.dwFlags = JOY_RETURNALL | JOY_RETURNPOVCTS; + if(joyGetPosEx((cfg.request[u].ident.fields.control >> 8) - 1, &joy_info) != JOYERR_NOERROR){ + LOGPF("Failed to query joystick %d", cfg.request[u].ident.fields.control >> 8); + //early exit because other joystick probably won't be connected either (though this may be wrong) + //else we would need to think of a way to mark the data invalid for subsequent requests on the same joystick + return 0; + } + current_joystick = cfg.request[u].ident.fields.control >> 8; + } + + if(cfg.request[u].ident.fields.channel == button){ + //button query + if(joy_info.dwFlags & JOY_RETURNBUTTONS){ + key_state = (joy_info.dwButtons & (1 << ((cfg.request[u].ident.fields.control & 0xFF) - 1))) > 0 ? 1 : 0; + if(key_state != cfg.request[u].state){ + if(key_state){ + val.normalised = 1.0; + } + cfg.request[u].state = key_state; + push_event = 1; + DBGPF("Joystick %d button %d: %d", + cfg.request[u].ident.fields.control >> 8, + cfg.request[u].ident.fields.control & 0xFF, + key_state); + } + } + else{ + LOGPF("No button data received for joystick %d", cfg.request[u].ident.fields.control >> 8); + } + } + else{ + if(!cfg.request[u].max){ + cfg.request[u].max = 0xFFFF; + } + val.raw.u64 = cfg.request[u].state; + + //axis requests, every single access to these structures is stupid. + switch(cfg.request[u].ident.fields.control & 0xFF){ + case 'x': + if(joy_info.dwFlags & JOY_RETURNX){ + val.raw.u64 = joy_info.dwXpos; + } + break; + case 'y': + if(joy_info.dwFlags & JOY_RETURNY){ + val.raw.u64 = joy_info.dwYpos; + } + break; + case 'z': + if(joy_info.dwFlags & JOY_RETURNZ){ + val.raw.u64 = joy_info.dwZpos; + } + break; + case 'r': + if(joy_info.dwFlags & JOY_RETURNR){ + val.raw.u64 = joy_info.dwRpos; + } + break; + case 'u': + if(joy_info.dwFlags & JOY_RETURNU){ + val.raw.u64 = joy_info.dwUpos; + } + break; + case 'v': + if(joy_info.dwFlags & JOY_RETURNV){ + val.raw.u64 = joy_info.dwVpos; + } + break; + case 'p': + if(joy_info.dwFlags & (JOY_RETURNPOV | JOY_RETURNPOVCTS)){ + val.raw.u64 = joy_info.dwPOV; + } + break; + } + + if(val.raw.u64 != cfg.request[u].state){ + val.normalised = (double) (val.raw.u64 - cfg.request[u].min) / (double) (cfg.request[u].max - cfg.request[u].min); + cfg.request[u].state = val.raw.u64; + push_event = 1; + } + } + } + + if(push_event){ + //clamp value just to be safe + val.normalised = clamp(val.normalised, 1.0, 0.0); + //push current value to all channels + DBGPF("Pushing event %f on request %" PRIsize_t, val.normalised, u); + for(n = 0; n < cfg.request[u].channels; n++){ + mm_channel_event(cfg.request[u].channel[n], val); + } + + if(synthesize_off){ + val.normalised = 0; + //push synthesized value to all channels + DBGPF("Synthesizing event %f on request %" PRIsize_t, val.normalised, u); + for(n = 0; n < cfg.request[u].channels; n++){ + mm_channel_event(cfg.request[u].channel[n], val); + } + } + } + } + return 0; +} + +static void wininput_start_joystick(){ + size_t u, p; + JOYINFOEX joy_info; + JOYCAPS joy_caps; + + DBGPF("This system supports a maximum of %u joysticks", joyGetNumDevs()); + for(u = 0; u < joyGetNumDevs(); u++){ + joy_info.dwSize = sizeof(joy_info); + joy_info.dwFlags = 0; + if(joyGetPosEx(u, &joy_info) == JOYERR_NOERROR){ + if(joyGetDevCaps(u, &joy_caps, sizeof(joy_caps)) == JOYERR_NOERROR){ + LOGPF("Joystick %" PRIsize_t " (%s) is available for input", u + 1, joy_caps.szPname ? joy_caps.szPname : "unknown model"); + for(p = 0; p < cfg.requests; p++){ + if(cfg.request[p].ident.fields.type == joystick + && cfg.request[p].ident.fields.channel == position + && (cfg.request[p].ident.fields.control >> 8) == u){ + //this looks really dumb, but the structure is defined in a way that prevents us from doing anything clever here + switch(cfg.request[p].ident.fields.control & 0xFF){ + case 'x': + cfg.request[p].min = joy_caps.wXmin; + cfg.request[p].max = joy_caps.wXmax; + break; + case 'y': + cfg.request[p].min = joy_caps.wYmin; + cfg.request[p].max = joy_caps.wYmax; + break; + case 'z': + cfg.request[p].min = joy_caps.wZmin; + cfg.request[p].max = joy_caps.wZmax; + break; + case 'r': + cfg.request[p].min = joy_caps.wRmin; + cfg.request[p].max = joy_caps.wRmax; + break; + case 'u': + cfg.request[p].min = joy_caps.wUmin; + cfg.request[p].max = joy_caps.wUmax; + break; + case 'v': + cfg.request[p].min = joy_caps.wVmin; + cfg.request[p].max = joy_caps.wVmax; + break; + } + DBGPF("Updated limits on request %" PRIsize_t " (%c) to %" PRIu32 " / %" PRIu32, p, cfg.request[p].ident.fields.control & 0xFF, cfg.request[p].min, cfg.request[p].max); + } + } + } + else{ + LOGPF("Joystick %" PRIsize_t " available for input, but no capabilities reported", u + 1); + } + } + } +} + +static int wininput_start(size_t n, instance** inst){ + POINT cursor_position; + + //if no input requested, don't request polling + if(!cfg.requests){ + cfg.interval = 0; + } + + wininput_start_joystick(); + + //read virtual desktop extents for later normalization + cfg.virtual_width = GetSystemMetrics(SM_CXVIRTUALSCREEN); + cfg.virtual_height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + cfg.virtual_x = GetSystemMetrics(SM_XVIRTUALSCREEN); + cfg.virtual_y = GetSystemMetrics(SM_YVIRTUALSCREEN); + DBGPF("Virtual screen is %dx%d with offset %dx%d", cfg.virtual_width, cfg.virtual_height, cfg.virtual_x, cfg.virtual_y); + + //sort requests to allow querying each joystick only once + qsort(cfg.request, cfg.requests, sizeof(wininput_request), request_comparator); + + //initialize mouse position + if(!GetCursorPos(&cursor_position)){ + LOG("Failed to read initial mouse position"); + return 1; + } + + DBGPF("Current mouse coordinates: %dx%d (%04Xx%04X)", cursor_position.x, cursor_position.y, cursor_position.x, cursor_position.y); + wininput_mouse_normalize(&cursor_position.x, &cursor_position.y); + DBGPF("Current normalized mouse position: %04Xx%04X", cursor_position.x, cursor_position.y); + cfg.mouse_x = cursor_position.x; + cfg.mouse_y = cursor_position.y; + + DBGPF("Tracking %" PRIsize_t " input requests", cfg.requests); + return 0; +} + +static int wininput_shutdown(size_t n, instance** inst){ + size_t u; + + for(u = 0; u < cfg.requests; u++){ + free(cfg.request[u].channel); + } + free(cfg.request); + cfg.request = NULL; + cfg.requests = 0; + + LOG("Backend shut down"); + return 0; +} diff --git a/backends/wininput.h b/backends/wininput.h new file mode 100644 index 0000000..0939cc3 --- /dev/null +++ b/backends/wininput.h @@ -0,0 +1,54 @@ +#include "midimonster.h" + +MM_PLUGIN_API int init(); +static int wininput_configure(char* option, char* value); +static int wininput_configure_instance(instance* inst, char* option, char* value); +static int wininput_instance(instance* inst); +static channel* wininput_channel(instance* inst, char* spec, uint8_t flags); +static uint32_t wininput_interval(); +static int wininput_set(instance* inst, size_t num, channel** c, channel_value* v); +static int wininput_handle(size_t num, managed_fd* fds); +static int wininput_start(size_t n, instance** inst); +static int wininput_shutdown(size_t n, instance** inst); + +enum /*wininput_channel_type*/ { + none = 0, + mouse, + keyboard, + joystick +}; + +enum /*wininput_control_channel*/ { + keypress = 0, + button, + position, + wheel, + + key_unicode +}; + +typedef struct /*_wininput_key_info*/ { + uint8_t keycode; + char* name; + uint8_t channel; +} key_info; + +typedef union { + struct { + uint8_t pad[4]; + uint8_t type; + uint8_t channel; + uint16_t control; + } fields; + uint64_t label; +} wininput_channel_ident; + +typedef struct /*_input_request*/ { + wininput_channel_ident ident; + size_t channels; + channel** channel; + uint32_t state; + + //used for jostick axes + uint32_t min, max; +} wininput_request; diff --git a/backends/wininput.md b/backends/wininput.md new file mode 100644 index 0000000..797d879 --- /dev/null +++ b/backends/wininput.md @@ -0,0 +1,135 @@ +### The `wininput` backend + +This backend allows using the mouse and keyboard as input and output channels on a Windows system. +For example, it can be used to create hotkey-like behaviour (by reading keyboard input) or to control +a computer remotely. + +As Windows merges all keyboard and mouse input into a single data stream, no fine-grained per-device +access (as is available under Linux) is possible. + +#### Global configuration + +| Option | Example value | Default value | Description | +|---------------|-----------------------|-----------------------|---------------------------------------| +| `interval` | `100` | `50` | Data polling interval in milliseconds. Lower intervals lead to higher CPU load. This value should normally not be changed. | +| `wheel` | `-4000 2000` | `65535 0` | Mouse wheel range and optional initial value. To invert the mouse wheel control, specify the range as a negative integer. As the mouse wheel is a relative control, we need to specify a range incoming absolute values are mapped to. This can be used control the wheel resolution and travel size. | +| `wheeldelta` | `20` | `1` | Multiplier for wheel travel | + +#### Instance configuration + +This backend does not take any instance-specific configuration. + +#### Channel specification + +The mouse is exposed as two channels for the position (with the origin being the upper-left corner of the desktop) + +* `mouse.x` +* `mouse.y` + +as well as one channel per mouse button + +* `mouse.lmb`: Left mouse button +* `mouse.rmb`: Right mouse button +* `mouse.mmb`: Middle mouse button +* `mouse.xmb1`: Extra mouse button 1 +* `mouse.xmb2`: Extra mouse button 2 + +The (vertical) mouse wheel can be controlled from the MIDIMonster using the `mouse.wheel` channel, but it can not be used +as an input channel due to limitations in the Windows API. All instances share one `wheel` control (see the section on known +bugs below). The mouse wheel sensitivity can be controlled by adjusting the absolute travel range, its initial value and +a wheel delta multiplier. + +All keyboard keys that have an [assigned virtual keycode](https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes) +are mappable as MIDIMonster channels using the syntax `key.<keyname>`, with *keyname* being one of the following specifiers: + +* One of the keynames listed below (e.g., `key.enter`) +* For "simple" keys (A-z, 0-9, etc), simply the key glyph (e.g. `key.a`) +* A hexadecimal number specifying the virtual keycode + +Keys are pressed once the normalized event value is greater than `0.9`, and released if under that. + +The following keynames are defined in an internal mapping table: + +| Key name | Description | Key name | Description | +|-------------------------------|-----------------------|-------------------------------|-----------------------| +| `backspace` | | `tab` | | +| `clear` | | `enter` | | +| `shift` | | `control` | | +| `alt` | | `capslock` | | +| `escape` | | `space` | | +| `pageup`, `pagedown` | | `end` | | +| `home` | | `pause` | | +| `numlock` | | `scrolllock` | | +| `insert` | | `delete` | | +| `printscreen` | | `up`, `down`, `left`, `right` | | +| `select` | | `print` | | +| `execute` | | `help` | | +| `apps` | | `sleep` | | +| `num0` - `num9` | | `multiply` | | +| `plus` | | `comma` | | +| `minus` | | `dot` | | +| `divide` | | `f1` - `f24` | | +| `lwin`, `rwin` | | `lshift`, `rshift` | | +| `lctrl`, `rctrl` | | `lmenu`, `rmenu` | | +| `previous`, `next` | Browser controls | `refresh` | Browser controls | +| `stop` | Browser controls | `search` | Browser controls | +| `favorites` | Browser controls | `homepage` | Browser controls | +| `mute` | | `voldown`, `volup` | | +| `nexttrack`, `prevtrack` | | `stopmedia`, `togglemedia` | | +| `mediaselect` | | `mail` | | +| `app1`, `app2` | | `zoom` | | + +Example mappings: +``` +generator.x > wi1.mouse.x +input.a > wi1.key.a +input.X > wi1.key.escape +``` + +Joystick and gamepad controllers with up to 32 buttons and 6 axes plus POV hat can be mapped as inputs to the +MIDIMonster. When starting up, the MIDIMonster will output a list of all connected and usable game controllers. + +Controllers can be mapped using the syntax + +* `joy<n>.<axisname>` for axes, where `<n>` is the ID of the controller and `<axisname>` is one of + * `x`, `y`: Main joystick / analog controller axes + * `z`: Third axis / joystick rotation + * `r`: Fourth axis / Rudder controller / Slider + * `u`, `v`: non-specific fifth/sixth axis +* `joy<n>.button<b>` for buttons, with `<n>` again being the controller ID and `b` being the button number between + 1 and 32 (the maximum supported by Windows) + +Use the Windows game controller input calibration and configuration tool to identify the axes and button IDs +relevant to your controller. + +For button channels, the channel value will either be `0` or `1.0`, for axis channels it will be the normalized +value of the axis (with calibration offsets applied), with the exception of the POV axis, where the channel value +will be in some way correlated with the direction of view. + +Example mappings: +``` +input.joy1.x > movinghead.pan +input.joy1.y > movinghead.tilt +input.joy1.button1 > movinghead.dim +``` + +#### Known bugs / problems + +Joysticks can only be used as input to the MIDIMonster, as Windows does not provide a method to emulate +Joystick input from user space. This is unlikely to change. + +Keyboard and mouse input is subject to UIPI. You can not send input to applications that run at a higher +privilege level than the MIDIMonster. This limitation is by design and will not change. + +Due to inconsistencies in the Windows API, mouse position input and output may differ for the same cursor location. +This may be correlated with the use and arrangement of multi-monitor desktops. If you encounter problems with either +receiving or sending mouse positions, please include a description of your monitor alignment in the issue. + +Some antivirus applications may detect this backend as problematic because it uses the same system +interfaces to read keyboard and mouse input as any malicious application would. While it is definitely +possible to configure the MIDIMonster to do malicious things, the code itself does not log anything. +You can verify this by reading the backend code yourself. + +Since the Windows input system merges all keyboard/mouse input data into one data stream, using multiple +instances of this backend is not necessary or useful. It is still supported for technical reasons. +There may be unexpected side effects when mapping the mouse wheel in multiple instances. diff --git a/backends/winmidi.c b/backends/winmidi.c index 030062d..649af2e 100644 --- a/backends/winmidi.c +++ b/backends/winmidi.c @@ -74,7 +74,7 @@ static int winmidi_configure(char* option, char* value){ static int winmidi_configure_instance(instance* inst, char* option, char* value){ winmidi_instance_data* data = (winmidi_instance_data*) inst->impl; - if(!strcmp(option, "read")){ + if(!strcmp(option, "read") || !strcmp(option, "source")){ if(data->read){ LOGPF("Instance %s already connected to an input device", inst->name); return 1; @@ -82,7 +82,7 @@ static int winmidi_configure_instance(instance* inst, char* option, char* value) data->read = strdup(value); return 0; } - if(!strcmp(option, "write")){ + else if(!strcmp(option, "write") || !strcmp(option, "target")){ if(data->write){ LOGPF("Instance %s already connected to an output device", inst->name); return 1; @@ -90,6 +90,13 @@ static int winmidi_configure_instance(instance* inst, char* option, char* value) data->write = strdup(value); return 0; } + else if(!strcmp(option, "epn-tx")){ + data->epn_tx_short = 0; + if(!strcmp(value, "short")){ + data->epn_tx_short = 1; + } + return 0; + } LOGPF("Unknown instance configuration option %s on instance %s", option, inst->name); return 1; @@ -148,12 +155,23 @@ static channel* winmidi_channel(instance* inst, char* spec, uint8_t flags){ ident.fields.type = pressure; next_token += 8; } + else if(!strncmp(next_token, "rpn", 3)){ + ident.fields.type = rpn; + next_token += 3; + } + else if(!strncmp(next_token, "nrpn", 4)){ + ident.fields.type = nrpn; + next_token += 4; + } else if(!strncmp(next_token, "pitch", 5)){ ident.fields.type = pitchbend; } else if(!strncmp(next_token, "aftertouch", 10)){ ident.fields.type = aftertouch; } + else if(!strncmp(next_token, "program", 7)){ + ident.fields.type = program; + } else{ LOGPF("Unknown control type in %s", spec); return NULL; @@ -167,11 +185,7 @@ static channel* winmidi_channel(instance* inst, char* spec, uint8_t flags){ return NULL; } -static int winmidi_set(instance* inst, size_t num, channel** c, channel_value* v){ - winmidi_instance_data* data = (winmidi_instance_data*) inst->impl; - winmidi_channel_ident ident = { - .label = 0 - }; +static void winmidi_tx(HMIDIOUT port, uint8_t type, uint8_t channel, uint8_t control, uint16_t value){ union { struct { uint8_t status; @@ -183,6 +197,28 @@ static int winmidi_set(instance* inst, size_t num, channel** c, channel_value* v } output = { .dword = 0 }; + + output.components.status = type | channel; + output.components.data1 = control; + output.components.data2 = value & 0x7F; + + if(type == pitchbend){ + output.components.data1 = value & 0x7F; + output.components.data2 = (value >> 7) & 0x7F; + } + else if(type == aftertouch || type == program){ + output.components.data1 = value; + output.components.data2 = 0; + } + + midiOutShortMsg(port, output.dword); +} + +static int winmidi_set(instance* inst, size_t num, channel** c, channel_value* v){ + winmidi_instance_data* data = (winmidi_instance_data*) inst->impl; + winmidi_channel_ident ident = { + .label = 0 + }; size_t u; if(!data->device_out){ @@ -193,20 +229,29 @@ static int winmidi_set(instance* inst, size_t num, channel** c, channel_value* v for(u = 0; u < num; u++){ ident.label = c[u]->ident; - //build output message - output.components.status = ident.fields.type | ident.fields.channel; - output.components.data1 = ident.fields.control; - output.components.data2 = v[u].normalised * 127.0; - if(ident.fields.type == pitchbend){ - output.components.data1 = ((int)(v[u].normalised * 16384.0)) & 0x7F; - output.components.data2 = (((int)(v[u].normalised * 16384.0)) >> 7) & 0x7F; - } - else if(ident.fields.type == aftertouch){ - output.components.data1 = v[u].normalised * 127.0; - output.components.data2 = 0; + switch(ident.fields.type){ + case rpn: + case nrpn: + //transmit parameter number + winmidi_tx(data->device_out, cc, ident.fields.channel, (ident.fields.type == rpn) ? 101 : 99, (ident.fields.control >> 7) & 0x7F); + winmidi_tx(data->device_out, cc, ident.fields.channel, (ident.fields.type == rpn) ? 100 : 98, ident.fields.control & 0x7F); + + //transmit parameter value + winmidi_tx(data->device_out, cc, ident.fields.channel, 6, (((uint16_t) (v[u].normalised * 16383.0)) >> 7) & 0x7F); + winmidi_tx(data->device_out, cc, ident.fields.channel, 38, ((uint16_t) (v[u].normalised * 16383.0)) & 0x7F); + + if(!data->epn_tx_short){ + //clear active parameter + winmidi_tx(data->device_out, cc, ident.fields.channel, 101, 127); + winmidi_tx(data->device_out, cc, ident.fields.channel, 100, 127); + } + break; + case pitchbend: + winmidi_tx(data->device_out, ident.fields.type, ident.fields.channel, ident.fields.control, v[u].normalised * 16383.0); + break; + default: + winmidi_tx(data->device_out, ident.fields.type, ident.fields.channel, ident.fields.control, v[u].normalised * 127.0); } - - midiOutShortMsg(data->device_out, output.dword); } return 0; @@ -218,12 +263,18 @@ static char* winmidi_type_name(uint8_t typecode){ return "note"; case cc: return "cc"; + case rpn: + return "rpn"; + case nrpn: + return "nrpn"; case pressure: return "pressure"; case aftertouch: return "aftertouch"; case pitchbend: return "pitch"; + case program: + return "program"; } return "unknown"; } @@ -248,7 +299,8 @@ static int winmidi_handle(size_t num, managed_fd* fds){ if(backend_config.detect){ //pretty-print channel-wide events if(backend_config.event[u].channel.fields.type == pitchbend - || backend_config.event[u].channel.fields.type == aftertouch){ + || backend_config.event[u].channel.fields.type == aftertouch + || backend_config.event[u].channel.fields.type == program){ LOGPF("Incoming data on channel %s.ch%d.%s, value %f", backend_config.event[u].inst->name, backend_config.event[u].channel.fields.channel, @@ -275,11 +327,98 @@ static int winmidi_handle(size_t num, managed_fd* fds){ return 0; } -static void CALLBACK winmidi_input_callback(HMIDIIN device, unsigned message, DWORD_PTR inst, DWORD param1, DWORD param2){ +static int winmidi_enqueue_input(instance* inst, winmidi_channel_ident ident, channel_value val){ + EnterCriticalSection(&backend_config.push_events); + if(backend_config.events_alloc <= backend_config.events_active){ + backend_config.event = realloc((void*) backend_config.event, (backend_config.events_alloc + 1) * sizeof(winmidi_event)); + if(!backend_config.event){ + LOG("Failed to allocate memory"); + backend_config.events_alloc = 0; + backend_config.events_active = 0; + LeaveCriticalSection(&backend_config.push_events); + return 1; + } + backend_config.events_alloc++; + } + backend_config.event[backend_config.events_active].inst = inst; + backend_config.event[backend_config.events_active].channel.label = ident.label; + backend_config.event[backend_config.events_active].value = val; + backend_config.events_active++; + LeaveCriticalSection(&backend_config.push_events); + return 0; +} + +//this state machine was copied more-or-less verbatim from the alsa midi implementation - fixes there will need to be integrated +static void winmidi_handle_epn(instance* inst, uint8_t chan, uint16_t control, uint16_t value){ + winmidi_instance_data* data = (winmidi_instance_data*) inst->impl; winmidi_channel_ident ident = { .label = 0 }; channel_value val; + + //switching between nrpn and rpn clears all valid bits + if(((data->epn_status[chan] & EPN_NRPN) && (control == 101 || control == 100)) + || (!(data->epn_status[chan] & EPN_NRPN) && (control == 99 || control == 98))){ + data->epn_status[chan] &= ~(EPN_NRPN | EPN_PARAMETER_LO | EPN_PARAMETER_HI); + } + + //setting an address always invalidates the value valid bits + if(control >= 98 && control <= 101){ + data->epn_status[chan] &= ~EPN_VALUE_HI; + } + + //parameter hi + if(control == 101 || control == 99){ + data->epn_control[chan] &= 0x7F; + data->epn_control[chan] |= value << 7; + data->epn_status[chan] |= EPN_PARAMETER_HI | ((control == 99) ? EPN_NRPN : 0); + if(control == 101 && value == 127){ + data->epn_status[chan] &= ~EPN_PARAMETER_HI; + } + } + + //parameter lo + if(control == 100 || control == 98){ + data->epn_control[chan] &= ~0x7F; + data->epn_control[chan] |= value & 0x7F; + data->epn_status[chan] |= EPN_PARAMETER_LO | ((control == 98) ? EPN_NRPN : 0); + if(control == 100 && value == 127){ + data->epn_status[chan] &= ~EPN_PARAMETER_LO; + } + } + + //value hi, clears low, mark as update candidate + if(control == 6 + //check if parameter is set before accepting value update + && ((data->epn_status[chan] & (EPN_PARAMETER_HI | EPN_PARAMETER_LO)) == (EPN_PARAMETER_HI | EPN_PARAMETER_LO))){ + data->epn_value[chan] = value << 7; + data->epn_status[chan] |= EPN_VALUE_HI; + } + + //value lo, flush the value + if(control == 38 + && data->epn_status[chan] & EPN_VALUE_HI){ + data->epn_value[chan] &= ~0x7F; + data->epn_value[chan] |= value & 0x7F; + data->epn_status[chan] &= ~EPN_VALUE_HI; + + //find the updated channel + ident.fields.type = data->epn_status[chan] & EPN_NRPN ? nrpn : rpn; + ident.fields.channel = chan; + ident.fields.control = data->epn_control[chan]; + val.normalised = (double) data->epn_value[chan] / 16383.0; + + winmidi_enqueue_input(inst, ident, val); + } +} + +static void CALLBACK winmidi_input_callback(HMIDIIN device, unsigned message, DWORD_PTR inst, DWORD param1, DWORD param2){ + winmidi_channel_ident ident = { + .label = 0 + }; + channel_value val = { + 0 + }; union { struct { uint8_t status; @@ -305,18 +444,22 @@ static void CALLBACK winmidi_input_callback(HMIDIIN device, unsigned message, DW ident.fields.type = input.components.status & 0xF0; ident.fields.control = input.components.data1; val.normalised = (double) input.components.data2 / 127.0; + val.raw.u64 = input.components.data2; if(ident.fields.type == 0x80){ ident.fields.type = note; val.normalised = 0; + val.raw.u64 = 0; } else if(ident.fields.type == pitchbend){ ident.fields.control = 0; - val.normalised = (double)((input.components.data2 << 7) | input.components.data1) / 16384.0; + val.normalised = (double) ((input.components.data2 << 7) | input.components.data1) / 16383.0; + val.raw.u64 = input.components.data2 << 7 | input.components.data1; } - else if(ident.fields.type == aftertouch){ + else if(ident.fields.type == aftertouch || ident.fields.type == program){ ident.fields.control = 0; val.normalised = (double) input.components.data1 / 127.0; + val.raw.u64 = input.components.data1; } break; case MIM_LONGDATA: @@ -332,26 +475,19 @@ static void CALLBACK winmidi_input_callback(HMIDIIN device, unsigned message, DW return; } + //pass changes in the (n)rpn CCs to the EPN state machine + if(ident.fields.type == cc + && ((ident.fields.control <= 101 && ident.fields.control >= 98) + || ident.fields.control == 6 + || ident.fields.control == 38)){ + winmidi_handle_epn((instance*) inst, ident.fields.channel, ident.fields.control, val.raw.u64); + } + DBGPF("Incoming message type %d channel %d control %d value %f", ident.fields.type, ident.fields.channel, ident.fields.control, val.normalised); - - EnterCriticalSection(&backend_config.push_events); - if(backend_config.events_alloc <= backend_config.events_active){ - backend_config.event = realloc((void*) backend_config.event, (backend_config.events_alloc + 1) * sizeof(winmidi_event)); - if(!backend_config.event){ - LOG("Failed to allocate memory"); - backend_config.events_alloc = 0; - backend_config.events_active = 0; - LeaveCriticalSection(&backend_config.push_events); - return; - } - backend_config.events_alloc++; + if(winmidi_enqueue_input((instance*) inst, ident, val)){ + LOG("Failed to enqueue incoming data"); } - backend_config.event[backend_config.events_active].inst = (instance*) inst; - backend_config.event[backend_config.events_active].channel.label = ident.label; - backend_config.event[backend_config.events_active].value = val; - backend_config.events_active++; - LeaveCriticalSection(&backend_config.push_events); if(message != MIM_MOREDATA){ //alert the main loop diff --git a/backends/winmidi.h b/backends/winmidi.h index 4c740ea..40b3554 100644 --- a/backends/winmidi.h +++ b/backends/winmidi.h @@ -10,9 +10,20 @@ static int winmidi_handle(size_t num, managed_fd* fds); static int winmidi_start(size_t n, instance** inst); static int winmidi_shutdown(size_t n, instance** inst); +#define EPN_NRPN 8 +#define EPN_PARAMETER_HI 4 +#define EPN_PARAMETER_LO 2 +#define EPN_VALUE_HI 1 + typedef struct /*_winmidi_instance_data*/ { char* read; char* write; + + uint8_t epn_tx_short; + uint16_t epn_control[16]; + uint16_t epn_value[16]; + uint8_t epn_status[16]; + HMIDIIN device_in; HMIDIOUT device_out; } winmidi_instance_data; @@ -20,18 +31,21 @@ typedef struct /*_winmidi_instance_data*/ { enum /*_winmidi_channel_type*/ { none = 0, note = 0x90, - cc = 0xB0, pressure = 0xA0, + cc = 0xB0, + program = 0xC0, aftertouch = 0xD0, - pitchbend = 0xE0 + pitchbend = 0xE0, + rpn = 0xF1, + nrpn = 0xF2 }; typedef union { struct { - uint8_t pad[5]; + uint8_t pad[4]; uint8_t type; uint8_t channel; - uint8_t control; + uint16_t control; } fields; uint64_t label; } winmidi_channel_ident; diff --git a/backends/winmidi.md b/backends/winmidi.md index 25a6378..9e7d9cc 100644 --- a/backends/winmidi.md +++ b/backends/winmidi.md @@ -15,10 +15,11 @@ some deviations may still be present. #### Instance configuration -| Option | Example value | Default value | Description | -|---------------|-----------------------|-----------------------|-----------------------| -| `read` | `2` | none | MIDI device to connect for input | -| `write` | `DeviceName` | none | MIDI device to connect for output | +| Option | Example value | Default value | Description | +|-----------------------|-----------------------|-----------------------|-----------------------| +| `read` / `source` | `2` | none | MIDI device to connect for input | +| `write` / `target` | `DeviceName` | none | MIDI device to connect for output | +| `epn-tx` | `short` | `full` | Configure whether to clear the active parameter number after transmitting an `nrpn` or `rpn` parameter. | Input/output device names may either be prefixes of MIDI device names or numeric indices corresponding to the listing shown at startup when using the global `list` option. @@ -32,26 +33,40 @@ The `winmidi` backend supports mapping different MIDI events as MIDIMonster chan * `pressure` - Note pressure/aftertouch messages * `aftertouch` - Channel-wide aftertouch messages * `pitch` - Channel pitchbend messages +* `program` - Channel program change messages +* `rpn` - Registered parameter numbers (14-bit extension) +* `nrpn` - Non-registered parameter numbers (14-bit extension) A MIDIMonster channel is specified using the syntax `channel<channel>.<type><index>`. The shorthand `ch` may be used instead of the word `channel` (Note that `channel` here refers to the MIDI channel number). -The `pitch` and `aftertouch` events are channel-wide, thus they can be specified as `channel<channel>.<type>`. +The `pitch`, `aftertouch` and `program` messages/events are channel-wide, thus they can be specified as `channel<channel>.<type>`. MIDI channels range from `0` to `15`. Each MIDI channel consists of 128 notes (numbered `0` through `127`), which additionally each have a pressure control, 128 CC's (numbered likewise), a channel pressure control (also called 'channel aftertouch') and a pitch control which may all be mapped to individual MIDIMonster channels. +Every MIDI channel also provides `rpn` and `nrpn` controls, which are implemented on top of the MIDI protocol, using +the CC controls 101/100/99/98/38/6. Both control types have 14-bit IDs and 14-bit values. + Example mappings: ``` midi1.ch0.note9 > midi2.channel1.cc4 midi1.channel15.pressure1 > midi1.channel0.note0 midi1.ch1.aftertouch > midi2.ch2.cc0 midi1.ch0.pitch > midi2.ch1.pitch +midi2.ch0.nrpn900 > midi1.ch1.rpn1 +midi2.ch15.note1 > midi1.ch2.program ``` #### Known bugs / problems +Extended parameter numbers (EPNs, the `rpn` and `nrpn` control types) will also generate events on the controls (CC 101 through +98, 38 and 6) that are used as the lower layer transport. When using EPNs, mapping those controls is probably not useful. + +EPN control types support only the full 14-bit transfer encoding, not the shorter variant transmitting only the 7 +high-order bits. This may be changed if there is sufficient interest in the functionality. + Currently, no Note Off messages are sent (instead, Note On messages with a velocity of 0 are generated, which amount to the same thing according to the spec). This may be implemented as a configuration option at a later time. diff --git a/configs/launchctl-sacn.cfg b/configs/launchctl-sacn.cfg index 10a736a..612ac25 100644 --- a/configs/launchctl-sacn.cfg +++ b/configs/launchctl-sacn.cfg @@ -7,7 +7,8 @@ name = MIDIMonster [backend sacn] -bind = 0.0.0.0 5568 local +bind = 0.0.0.0 5568 +detect = verbose [midi lc] read = Launch Control diff --git a/configs/midi-gamepad.cfg b/configs/midi-gamepad.cfg new file mode 100644 index 0000000..f91ed4f --- /dev/null +++ b/configs/midi-gamepad.cfg @@ -0,0 +1,25 @@ +; Play games using a MIDI controller! See https://kb.midimonster.net/usecases/DolphinController.html +; This configuration will create a new input device (a joystick), which is controlled by a MIDI input. +; It could, for example, be used to play games in an emulator. +; This will only work on Linux. + +; This evdev instance will provide the new joystick device +[evdev controller] +; Set up the axis constraints - see the evdev backend documentation for what the parameters mean +axis.ABS_X = 34300 0 65536 255 4095 +axis.ABS_Y = 34300 0 65536 255 4095 +relaxis.REL_X = 65535 +; Set the output device name and enable the instance for output +output = MIDIMonster + +; This midi instance will be used as input to control the new joystick +[midi lc] +read = Launch Control + +[map] +; Use two CC's/rotaries to control the main joystick +lc.ch0.cc0 > controller.EV_ABS.ABS_X +lc.ch0.cc1 > controller.EV_ABS.ABS_Y +; Use two buttons to control the joystick buttons +lc.ch0.note0 > controller.EV_KEY.BTN_A +lc.ch0.note1 > controller.EV_KEY.BTN_B diff --git a/configs/pyexample.py b/configs/pyexample.py new file mode 100644 index 0000000..7213005 --- /dev/null +++ b/configs/pyexample.py @@ -0,0 +1,8 @@ +# Import the MIDIMonster Python API +import midimonster + +def channel1(value): + # Print current input value + print("Python channel 1 is at %s" % (value,)) + # Send inverse on py1.out1 + midimonster.output("out1", 1.0 - value) diff --git a/configs/returnone.lua b/configs/returnone.lua new file mode 100644 index 0000000..cde0b03 --- /dev/null +++ b/configs/returnone.lua @@ -0,0 +1,24 @@ +-- ReturnOne by Paul Hedderly +-- Sometimes you just want an on/off from linear pads +-- For example I want to activate scenes in OBS from a Korg NanoPad2 +-- But I dont want to have to thump the pads to get a 1.0 output +-- +-- You could use this as: +-- [midi nanoP] +-- read = nanoPAD2 +-- write = nanoPAD2 +-- [lua trackpad] +-- script = trackpad.lua +-- default-handler = returnone +-- .. +-- nanoP.ch0.note{36..51} > returnone.one{1..16} -- To feed all the 16 pads to +-- returnone.outone1 > obs./obs/scene/1/preview +-- returnone.outone2 > obs./obs/scene/2/preview +-- etc +-- The output channel will be the same as the channel you feed prepended "out" + + +function returnone(v) -- Use a default function - then you can use any input channel name + if v>0 then output("out"..input_channel(),1) end; + if v==0 then output("out"..input_channel(),0) end; +end diff --git a/configs/scripting-example.cfg b/configs/scripting-example.cfg new file mode 100644 index 0000000..fb9d6ca --- /dev/null +++ b/configs/scripting-example.cfg @@ -0,0 +1,22 @@ +; Turn on debugging to see what is coming in +[backend osc] +detect = on + +[python py1] +; This will load the pyexample.py script into this instance +module = pyexample + +[lua lua1] +; This will load the print.lua script into this instance +script = print.lua +; This will send all mapped channels to the `printchannel` function in there +default-handler = printchannel + +[osc in] +; Listen on port 8000 and send answers on port 9000 +bind = 0.0.0.0 8000 +dest = learn@9000 + +[map] +in./1/fader1 > py1.pyexample.channel1 +py1.out1 > lua1.lua-input diff --git a/configs/trackpad.lua b/configs/trackpad.lua new file mode 100644 index 0000000..0aa9de7 --- /dev/null +++ b/configs/trackpad.lua @@ -0,0 +1,59 @@ +-- Trackpad input by Paul Hedderly +-- Expects three sources X, Y and touch +-- On the Korg Nanopad2 these would be nanoP.ch0.cc1, nanoP.ch0.cc2, nanoP.ch0.cc16 +-- so you could map and feed this script with something like: +-- [midi nanoP] +-- read = nanoPAD2 +-- write = nanoPAD2 +-- [lua trackpad] +-- script = trackpad.lua +-- .. +-- nanoP.ch0.cc1 > trackpad.x +-- nanoP.ch0.cc2 > trackpad.y +-- nanoP.ch0.cc16 > trackpad.touch +-- +-- Each touch will generate four outputs +-- - on[1-9] - the first point of touch (might not be very useful!) +-- - off[1-9] - the final point of touch +-- - swipe[1-9][1-9] - the first and last as a *simple* gesture or swipe +-- - gesture[1-9]..[1-9] - every segment you touch in order so you can do complicated gestures +-- +-- Each output of 1 is followed by an output of 0 +-- You would map these as +-- trackpad.on3 > ... +-- trackpad.off9 > .... +-- trackpad.swipe17 > .... -- would catch a line from top left to bottom left but could go anywhere in between +-- trackpad.gesture78965 > .... would catch a backwards capital L starting at the bottom left + +-- -- Reserve state variables +contact=0; +trace=""; +x=0; y=0 +lpos="" + +function x(v) -- NOTE the code assumes that we get an X before the Y - Some devices might differ! + x=math.floor((v+0.09)*2.55) +end + +function y(v) + y=2-math.floor((v+0.09)*2.55) -- 2- so that we have 1 at the top + pos=""..x+1+y*3 -- we need a string to compare + lpos=string.sub(trace,-1) + print("pos"..pos.." lpos"..lpos.." = "..trace) + if pos ~= lpos then trace=trace..pos end +end + +function touch(v) + -- print("TOUCH .."..contact..".... trace"..trace) + if v==1 then contact=1 + elseif v==0 then + first=string.sub(trace,1,1); last=string.sub(trace,-1) + ends=first..last + output("on"..last,1); output ("on"..last,0) + output("off"..last,1); output ("off"..last,0) + output("swipe"..ends,1); output ("swipe"..ends,0) + output("gesture"..trace,1); output ("gesture"..trace,0) + print("TRACKPAD>>>"..trace.." ends.."..ends) + trace="" -- reset tracking + end; +end diff --git a/configs/visca.cfg b/configs/visca.cfg new file mode 100644 index 0000000..ac4b7a3 --- /dev/null +++ b/configs/visca.cfg @@ -0,0 +1,34 @@ +; This configuration controls a simple VISCA-capable Pan/Tilt/Zoom (PTZ) +; camera over the network via OSC (For example the "Mix 16" layout shipped +; with TouchOSC). + +[backend osc] +; Turn on the detect option to see what comes in +detect = on + +[visca cam] +; This is where we can reach the camera control server +connect = 10.23.23.127 5678 + +[osc touch] +; Bind a local OSC server to which TouchOSC will connect +bind = 0.0.0.0 8000 + +[map] +; Map the XY-pad to camera pan and tilt +touch./1/xy > cam.tilt +touch./1/xy:1 > cam.pan + +; Map control speeds +touch./1/fader1 > cam.panspeed +touch./1/fader2 > cam.tiltspeed + +; Map zoom to a fader +touch./1/fader4 > cam.zoom + +; Map some presets +touch./1/push1 > cam.home +touch./1/push2 > cam.memory1 +touch./1/push3 > cam.memory2 +touch./1/push4 > cam.store1 +touch./1/push5 > cam.store2 diff --git a/backend.c b/core/backend.c index 003980f..83121bd 100644 --- a/backend.c +++ b/core/backend.c @@ -51,7 +51,7 @@ int backends_handle(size_t nfds, managed_fd* fds){ //handle if there is data ready or the backend has active instances for polling if(n || registry.instances[u]){ - DBGPF("Notifying backend %s of %" PRIsize_t " waiting FDs\n", registry.backends[u].name, n); + DBGPF("Notifying backend %s of %" PRIsize_t " waiting FDs", registry.backends[u].name, n); rv |= registry.backends[u].process(n, fds); if(rv){ fprintf(stderr, "Backend %s failed to handle input\n", registry.backends[u].name); @@ -85,7 +85,7 @@ int backends_notify(size_t nev, channel** c, channel_value* v){ } //TODO eliminate duplicates - DBGPF("Calling handler for instance %s with %" PRIsize_t " events\n", c[u]->instance->name, n - u); + DBGPF("Calling handler for instance %s with %" PRIsize_t " events", c[u]->instance->name, n - u); rv |= c[u]->instance->backend->handle(c[u]->instance, n - u, c + u, v + u); } @@ -94,20 +94,22 @@ int backends_notify(size_t nev, channel** c, channel_value* v){ MM_API channel* mm_channel(instance* inst, uint64_t ident, uint8_t create){ size_t u, bucket = channelstore_hash(inst, ident); + DBGPF("\tSearching for inst %" PRIu64 " ident %" PRIu64, (uint64_t) inst, ident); for(u = 0; u < channels.n[bucket]; u++){ + DBGPF("\tBucket %" PRIsize_t " entry %" PRIsize_t " inst %" PRIu64 " ident %" PRIu64, bucket, u, (uint64_t) channels.entry[bucket][u]->instance, channels.entry[bucket][u]->ident); if(channels.entry[bucket][u]->instance == inst && channels.entry[bucket][u]->ident == ident){ - DBGPF("Requested channel %" PRIu64 " on instance %s already exists, reusing (%" PRIsize_t " search steps)\n", ident, inst->name, u); + DBGPF("Requested channel %" PRIu64 " on instance %s already exists, reusing (bucket %" PRIsize_t ", %" PRIsize_t " search steps)\n", ident, inst->name, bucket, u); return channels.entry[bucket][u]; } } if(!create){ - DBGPF("Requested unknown channel %" PRIu64 " on instance %s\n", ident, inst->name); + DBGPF("Requested unknown channel %" PRIu64 " (bucket %" PRIsize_t ") on instance %s", ident, bucket, inst->name); return NULL; } - DBGPF("Creating previously unknown channel %" PRIu64 " on instance %s, bucket %" PRIsize_t "\n", ident, inst->name, bucket); + DBGPF("Creating previously unknown channel %" PRIu64 " on instance %s, bucket %" PRIsize_t, ident, inst->name, bucket); channels.entry[bucket] = realloc(channels.entry[bucket], (channels.n[bucket] + 1) * sizeof(channel*)); if(!channels.entry[bucket]){ fprintf(stderr, "Failed to allocate memory\n"); @@ -126,6 +128,49 @@ MM_API channel* mm_channel(instance* inst, uint64_t ident, uint8_t create){ return channels.entry[bucket][(channels.n[bucket]++)]; } +MM_API void mm_channel_update(channel* chan, uint64_t ident){ + size_t bucket = channelstore_hash(chan->instance, chan->ident), new_bucket = channelstore_hash(chan->instance, ident); + size_t u; + + DBGPF("Updating identifier for inst %" PRIu64 " ident %" PRIu64 " (bucket %" PRIsize_t " to %" PRIsize_t ") to %" PRIu64, (uint64_t) chan->instance, chan->ident, bucket, new_bucket, ident); + + if(bucket == new_bucket){ + chan->ident = ident; + return; + } + + for(u = 0; u < channels.n[bucket]; u++){ + if(channels.entry[bucket][u]->instance == chan->instance + && channels.entry[bucket][u]->ident == chan->ident){ + break; + } + } + + if(u == channels.n[bucket]){ + DBGPF("Failed to find channel to update in bucket %" PRIsize_t, bucket); + return; + } + + DBGPF("Removing channel from slot %" PRIsize_t " of %" PRIsize_t " of bucket %" PRIsize_t, u, channels.n[bucket], bucket); + //remove channel from old bucket + for(; u < channels.n[bucket] - 1; u++){ + channels.entry[bucket][u] = channels.entry[bucket][u + 1]; + } + + //add to new bucket + channels.entry[new_bucket] = realloc(channels.entry[new_bucket], (channels.n[new_bucket] + 1) * sizeof(channel*)); + if(!channels.entry[new_bucket]){ + fprintf(stderr, "Failed to allocate memory\n"); + channels.n[new_bucket] = 0; + return; + } + + channels.entry[new_bucket][channels.n[new_bucket]] = chan; + chan->ident = ident; + channels.n[bucket]--; + channels.n[new_bucket]++; +} + instance* mm_instance(backend* b){ size_t u = 0, n = 0; @@ -234,12 +279,12 @@ struct timeval backend_timeout(){ //only call interval if backend has instances if(registry.instances[u] && registry.backends[u].interval){ res = registry.backends[u].interval(); - if((res / 1000) < secs){ + if(res && (res / 1000) < secs){ DBGPF("Updating interval to %" PRIu32 " msecs by request from %s", res, registry.backends[u].name); secs = res / 1000; msecs = res % 1000; } - else if(res / 1000 == secs && (res % 1000) < msecs){ + else if(res && res / 1000 == secs && (res % 1000) < msecs){ DBGPF("Updating interval to %" PRIu32 " msecs by request from %s", res, registry.backends[u].name); msecs = res % 1000; } @@ -308,7 +353,7 @@ static void channels_free(){ for(u = 0; u < sizeof(channels.n) / sizeof(channels.n[0]); u++){ DBGPF("Cleaning up channel registry bucket %" PRIsize_t " with %" PRIsize_t " channels", u, channels.n[u]); for(p = 0; p < channels.n[u]; p++){ - DBGPF("Destroying channel %" PRIu64 " on instance %s\n", channels.entry[u][p]->ident, channels.entry[u][p]->instance->name); + DBGPF("Destroying channel %" PRIu64 " on instance %s", channels.entry[u][p]->ident, channels.entry[u][p]->instance->name); //call the channel_free function if the backend supports it if(channels.entry[u][p]->impl && channels.entry[u][p]->instance->backend->channel_free){ channels.entry[u][p]->instance->backend->channel_free(channels.entry[u][p]); diff --git a/backend.h b/core/backend.h index 6a69508..46c6c3a 100644 --- a/backend.h +++ b/core/backend.h @@ -12,6 +12,7 @@ instance* mm_instance(backend* b); /* Backend API */ MM_API channel* mm_channel(instance* inst, uint64_t ident, uint8_t create); +MM_API void mm_channel_update(channel* chan, uint64_t ident); MM_API instance* mm_instance_find(char* name, uint64_t ident); MM_API int mm_backend_instances(char* name, size_t* ninst, instance*** inst); MM_API int mm_backend_register(backend b); @@ -76,7 +76,7 @@ static ssize_t getline(char** line, size_t* alloc, FILE* stream){ } //input broken - if(ferror(stream) || c < 0){ + if(ferror(stream)){ return -1; } @@ -99,9 +99,10 @@ static char* config_trim_line(char* in){ return in; } -static int config_glob_parse(channel_glob* glob, char* spec, size_t length){ - char* parse_offset = NULL; +static int config_glob_parse_range(channel_glob* glob, char* spec, size_t length){ //FIXME might want to allow negative delimiters at some point + char* parse_offset = NULL; + glob->type = glob_range; //first interval member glob->limits.u64[0] = strtoul(spec, &parse_offset, 10); @@ -130,6 +131,39 @@ static int config_glob_parse(channel_glob* glob, char* spec, size_t length){ return 0; } +static int config_glob_parse_list(channel_glob* glob, char* spec, size_t length){ + size_t u = 0; + glob->type = glob_list; + glob->values = 1; + + //count number of values in list + for(u = 0; u < length; u++){ + if(spec[u] == ','){ + glob->values++; + } + } + return 0; +} + +static int config_glob_parse(channel_glob* glob, char* spec, size_t length){ + size_t u = 0; + + //detect glob type + for(u = 0; u < length; u++){ + if(length - u > 2 && !strncmp(spec + u, "..", 2)){ + DBGPF("Detected glob %.*s as range type", (int) length, spec); + return config_glob_parse_range(glob, spec, length); + } + else if(spec[u] == ','){ + DBGPF("Detected glob %.*s as list type", (int) length, spec); + return config_glob_parse_list(glob, spec, length); + } + } + + LOGPF("Failed to detect glob type for spec %.*s", (int) length, spec); + return 1; +} + static int config_glob_scan(instance* inst, channel_spec* spec){ char* glob_start = spec->spec, *glob_end = NULL; size_t u; @@ -182,50 +216,89 @@ static int config_glob_scan(instance* inst, channel_spec* spec){ return 0; } +static ssize_t config_glob_resolve_range(char* spec, size_t length, channel_glob* glob, uint64_t n){ + uint64_t current_value = glob->limits.u64[0] + (n % glob->values); + //if counting down + if(glob->limits.u64[0] > glob->limits.u64[1]){ + current_value = glob->limits.u64[0] - (n % glob->values); + } + + //write out value + return snprintf(spec, length, "%" PRIu64, current_value); +} + +static ssize_t config_glob_resolve_list(char* spec, size_t length, channel_glob* glob, uint64_t n){ + uint64_t current_replacement = 0; + size_t replacement_length = 0; + char* source = spec + 1; + n %= glob->values; + + //find start of replacement value + DBGPF("Searching instance %" PRIu64 " of spec %.*s", n, (int) length, spec); + for(current_replacement = 0; current_replacement < n; current_replacement++){ + for(; source[0] != ','; source++){ + } + source++; + } + + //calculate replacement length + for(; source[replacement_length] != ',' && source[replacement_length] != '}'; replacement_length++){ + } + + //write out new value + memmove(spec, source, replacement_length); + return replacement_length; +} + static channel* config_glob_resolve(instance* inst, channel_spec* spec, uint64_t n, uint8_t map_direction){ size_t glob = 0, glob_length; ssize_t bytes = 0; - uint64_t current_value = 0; channel* result = NULL; char* resolved_spec = strdup(spec->spec); if(!resolved_spec){ - fprintf(stderr, "Failed to allocate memory\n"); + LOG("Failed to allocate memory"); return NULL; } //TODO if not internal, try to resolve externally - //iterate and resolve globs for(glob = spec->globs; glob > 0; glob--){ - current_value = spec->glob[glob - 1].limits.u64[0] + (n % spec->glob[glob - 1].values); - if(spec->glob[glob - 1].limits.u64[0] > spec->glob[glob - 1].limits.u64[1]){ - current_value = spec->glob[glob - 1].limits.u64[0] - (n % spec->glob[glob - 1].values); - } glob_length = spec->glob[glob - 1].offset[1] - spec->glob[glob - 1].offset[0]; - n /= spec->glob[glob - 1].values; - //write out value - bytes = snprintf(resolved_spec + spec->glob[glob - 1].offset[0], - glob_length, - "%" PRIu64, - current_value); - if(bytes > glob_length){ - fprintf(stderr, "Internal error resolving glob %s\n", spec->spec); - goto bail; + switch(spec->glob[glob - 1].type){ + case glob_range: + bytes = config_glob_resolve_range(resolved_spec + spec->glob[glob - 1].offset[0], + glob_length, + spec->glob + (glob - 1), + n); + break; + case glob_list: + bytes = config_glob_resolve_list(resolved_spec + spec->glob[glob - 1].offset[0], + glob_length, + spec->glob + (glob - 1), + n); + break; } + n /= spec->glob[glob - 1].values; + //move trailing data - if(bytes < glob_length){ + if(bytes > 0 && bytes < glob_length){ memmove(resolved_spec + spec->glob[glob - 1].offset[0] + bytes, resolved_spec + spec->glob[glob - 1].offset[1] + 1, strlen(spec->spec) - spec->glob[glob - 1].offset[1]); } + else{ + LOGPF("Failure parsing glob spec %s", resolved_spec); + goto bail; + } } + DBGPF("Resolved spec %s to %s", spec->spec, resolved_spec); result = inst->backend->channel(inst, resolved_spec, map_direction); if(spec->globs && !result){ - fprintf(stderr, "Failed to match multichannel evaluation %s to a channel\n", resolved_spec); + LOGPF("Failed to match multichannel evaluation %s to a channel", resolved_spec); } bail: @@ -472,7 +545,7 @@ static int config_line(char* line){ //find separator separator = strchr(line, '='); if(!separator){ - fprintf(stderr, "Not an assignment: %s\n", line); + fprintf(stderr, "Not an assignment (currently expecting %s configuration): %s\n", line, (parser_state == backend_cfg) ? "backend" : "instance"); return 1; } @@ -1,4 +1,12 @@ /* + * Channel glob type + */ +enum /*_mm_channel_glob_type */ { + glob_range, + glob_list +}; + +/* * Channel specification glob */ typedef struct /*_mm_channel_glob*/ { @@ -7,6 +15,7 @@ typedef struct /*_mm_channel_glob*/ { void* impl; uint64_t u64[2]; } limits; + uint8_t type; uint64_t values; } channel_glob; diff --git a/installer.sh b/installer.sh index 66eef99..2b9f799 100755 --- a/installer.sh +++ b/installer.sh @@ -1,7 +1,7 @@ #!/bin/bash ################################################ SETUP ################################################ -deps=( +dependencies=( libasound2-dev libevdev-dev liblua5.3-dev @@ -77,24 +77,24 @@ ARGS(){ exit 0 ;; --install-dependencies) - install_dependencies + install_dependencies "${dependencies[@]}" exit 0 ;; -h|--help|*) assign_defaults - printf "${bold}Usage:${normal} ${0} ${c_green}[OPTIONS]${normal}" - printf "\n\t${c_green}--prefix=${normal}${c_red}<path>${normal}\t\tSet the installation prefix.\t\t${c_mag}Default:${normal} ${dim}%s${normal}" "$VAR_PREFIX" + printf "%sUsage: %s[OPTIONS]%s" "${bold}" "${normal} ${0} ${c_green}" "${normal}" + printf "\n\t%s--prefix=%s<path>%s\t\tSet the installation prefix.\t\t%sDefault:%s" "${c_green}" "${normal}${c_red}" "${normal}" "${c_mag}" "${normal} ${dim}$VAR_PREFIX${normal}" printf "\n\t${c_green}--plugins=${normal}${c_red}<path>${normal}\tSet the plugin install path.\t\t${c_mag}Default:${normal} ${dim}%s${normal}" "$VAR_PLUGINS" printf "\n\t${c_green}--defcfg=${normal}${c_red}<path>${normal}\t\tSet the default configuration path.\t${c_mag}Default:${normal} ${dim}%s${normal}" "$VAR_DEFAULT_CFG" printf "\n\t${c_green}--examples=${normal}${c_red}<path>${normal}\tSet the path for example configurations.\t${c_mag}Default:${normal} ${dim}%s${normal}\n" "$VAR_EXAMPLE_CFGS" - printf "\n\t${c_green}--dev${normal}\t\t\tInstall nightly version." - printf "\n\t${c_green}-d,\t--default${normal}\tUse default values to install." - printf "\n\t${c_green}-fu,\t--forceupdate${normal}\tForce the updater to update without a version check." - printf "\n\t${c_green}--selfupdate${normal}\t\tUpdates this script to the newest version and exit." - printf "\n\t${c_green}--install-updater${normal}\tInstall the updater (Run with midimonster-updater) and exit." - printf "\n\t${c_green}--install-dependencies${normal}\tInstall dependencies and exit" - printf "\n\t${c_green}-h,\t--help${normal}\t\tShow this message and exit." - printf "\n\t${uline}${bold}${c_mag}Each argument can be overwritten by another, the last one is used!.${normal}\n" + printf "\n\t%s--dev%s\t\t\tInstall nightly version." "${c_green}" "${normal}" + printf "\n\t%s-d,\t--default%s\tUse default values to install." "${c_green}" "${normal}" + printf "\n\t%s-fu,\t--forceupdate%s\tForce the updater to update without a version check." "${c_green}" "${normal}" + printf "\n\t%s--selfupdate%s\t\tUpdates this script to the newest version and exit." "${c_green}" "${normal}" + printf "\n\t%s--install-updater%s\tInstall the updater (Run with midimonster-updater) and exit." "${c_green}" "${normal}" + printf "\n\t%s--install-dependencies%s\tInstall dependencies and exit" "${c_green}" "${normal}" + printf "\n\t%s-h,\t--help%s\t\tShow this message and exit." "${c_green}" "${normal}" + printf "\n\t%sEach argument can be overwritten by another, the last one is used!.%s\n" "${uline}${bold}${c_mag}" "${normal}" rmdir "$tmp_path" exit 0 ;; @@ -105,40 +105,50 @@ ARGS(){ # Install unmatched dependencies install_dependencies(){ - for dependency in ${deps[@]}; do + DEBIAN_FRONTEND=noninteractive apt-get update -y -qq > /dev/null || error_handler "There was an error doing apt update." +# unset "$deps" + for dependency in "$@"; do if [ "$(dpkg-query -W -f='${Status}' "$dependency" 2>/dev/null | grep -c "ok installed")" -eq 0 ]; then - printf "Installing %s\n" "$dependency" - apt-get install "$dependency" + deps+=("$dependency") # Add not installed dependency to the "to be installed array". else - printf "%s already installed!\n" "$dependency" + printf "%s already installed!\n" "$dependency" # If the dependency is already installed print it. fi done + +if [ ! "${#deps[@]}" -ge "1" ]; then # If nothing needs to get installed don't start apt. + printf "\nAll dependencies are fulfilled!\n" # Dependency array empty! Not running apt! +else + printf "\nThen following dependencies are going to be installed:\n" # Dependency array contains items. Running apt. + printf "\n%s\n" "${deps[@]}" | sed 's/ /, /g' + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-suggests --no-install-recommends "${deps[@]}" > /dev/null || error_handler "There was an error doing dependency installation." + printf "\nAll dependencies are installed now!\n" # Dependency array empty! Not running apt! +fi printf "\n" } -ask_questions(){ +ask_questions(){ # Only say if necessary - if [ -n "$VAR_PREFIX" ] || [ -n "$VAR_PLUGINS" ] || [ -n "$VAR_DEFAULT_CFG" ] || [ -n "$VAR_EXAMPLE_CFGS" ]; then - printf "${bold}If you don't know what you're doing, just hit enter a few times.${normal}\n\n" + if [ -z "$VAR_PREFIX" ] || [ -z "$VAR_PLUGINS" ] || [ -z "$VAR_DEFAULT_CFG" ] || [ -z "$VAR_EXAMPLE_CFGS" ]; then + printf "%sIf you don't know what you're doing, just hit enter a few times.%s\n\n" "${bold}" "${normal}" fi if [ -z "$VAR_PREFIX" ]; then - read -e -i "$DEFAULT_PREFIX" -p "PREFIX (Install root directory): " input + read -r -e -i "$DEFAULT_PREFIX" -p "PREFIX (Install root directory): " input VAR_PREFIX="${input:-$VAR_PREFIX}" fi if [ -z "$VAR_PLUGINS" ]; then - read -e -i "$VAR_PREFIX$DEFAULT_PLUGINPATH" -p "PLUGINS (Plugin directory): " input + read -r -e -i "$VAR_PREFIX$DEFAULT_PLUGINPATH" -p "PLUGINS (Plugin directory): " input VAR_PLUGINS="${input:-$VAR_PLUGINS}" fi if [ -z "$VAR_DEFAULT_CFG" ]; then - read -e -i "$DEFAULT_CFGPATH" -p "Default config path: " input + read -r -e -i "$DEFAULT_CFGPATH" -p "Default config path: " input VAR_DEFAULT_CFG="${input:-$VAR_DEFAULT_CFG}" fi if [ -z "$VAR_EXAMPLE_CFGS" ]; then - read -e -i "$VAR_PREFIX$DEFAULT_EXAMPLES" -p "Example config directory: " input + read -r -e -i "$VAR_PREFIX$DEFAULT_EXAMPLES" -p "Example config directory: " input VAR_EXAMPLE_CFGS="${input:-$VAR_EXAMPLE_CFGS}" fi } @@ -147,21 +157,22 @@ ask_questions(){ prepare_repo(){ printf "Cloning the repository\n" git clone "https://github.com/cbdevnet/midimonster.git" "$tmp_path" + printf "\n" # If not set via argument, ask whether to install development build if [ -z "$NIGHTLY" ]; then - read -p "Do you want to install the latest development version? (y/n)? " magic + read -r -p "Do you want to install the latest development version? (y/n)? " magic case "$magic" in y|Y) - printf "OK! You´re a risky person ;D\n" + printf "OK! You´re a risky person ;D\n\n" NIGHTLY=1 ;; n|N) - printf "That´s OK - installing the latest stable version for you ;-)\n" + printf "That´s OK - installing the latest stable version for you ;-)\n\n" NIGHTLY=0 ;; *) - printf "${bold}Invalid input -- INSTALLING LATEST STABLE VERSION!${normal}\n" + printf "%sInvalid input -- INSTALLING LATEST STABLE VERSION!%s\n\n" "${bold}" "${normal}" NIGHTLY=0 ;; esac @@ -169,12 +180,13 @@ prepare_repo(){ # Roll back to last tag if a stable version was requested if [ "$NIGHTLY" != 1 ]; then - cd "$tmp_path" + cd "$tmp_path" || error_handler "Error doing cd to $tmp_path" printf "Finding latest stable version...\n" last_tag=$(git describe --abbrev=0) printf "Checking out %s...\n" "$last_tag" git checkout -f -q "$last_tag" fi + printf "\n" } # Build and install the software @@ -185,7 +197,7 @@ build(){ export DEFAULT_CFG="$VAR_DEFAULT_CFG" export EXAMPLES="$VAR_EXAMPLE_CFGS" - cd "$tmp_path" + cd "$tmp_path" || error_handler "Error doing cd to $tmp_path" make clean make "$makeargs" make install @@ -210,8 +222,11 @@ install_script(){ } error_handler(){ - printf "\nAborting\n" - exit 1 + [[ -n $1 ]] && printf "\n%s\n" "$1" + printf "\nAborting" + for i in {1..3}; do sleep 0.3s && printf "." && sleep 0.2s; done + printf "\n" + exit "1" } cleanup(){ @@ -237,13 +252,14 @@ fi # Check if we can download the sources if [ "$(wget -q --spider http://github.com)" ]; then - printf "The installer/updater requires internet connectivity to download the midimonster sources\n" + printf "The installer/updater requires internet connectivity to download the midimonster sources and dependencies\n" exit 1 fi # Check whether the updater needs to run if [ -f "$updater_dir/updater.conf" ] || [ "$UPDATER_FORCE" = "1" ]; then if [ -f "$updater_dir/updater.conf" ]; then + # shellcheck source=/dev/null . "$updater_dir/updater.conf" # Parse arguments again to compensate overwrite from source ARGS "$@" @@ -254,11 +270,11 @@ if [ -f "$updater_dir/updater.conf" ] || [ "$UPDATER_FORCE" = "1" ]; then printf "Forcing the updater to start...\n\n" elif [ -x "$VAR_PREFIX/bin/midimonster" ]; then installed_version="$(midimonster --version)" - if [[ "$installed_version" =~ "$latest_version" ]]; then - printf "The installed version ${bold}$installed_version${normal} seems to be up to date\nDoing nothing\n\n" + if [[ "$installed_version" =~ $latest_version ]]; then + printf "The installed version %s seems to be up to date\nDoing nothing\n\n" "${bold}$installed_version${normal}" exit 0 else - printf "The installed version ${bold}$installed_version${normal} does not match the latest version ${bold}$latest_version${normal}\nMaybe you are running a development version?\n\n" + printf "The installed version %s does not match the latest version %s\nMaybe you are running a development version?\n\n" "${bold}$installed_version${normal}" "${bold}$latest_version${normal}" fi fi @@ -269,7 +285,7 @@ if [ -f "$updater_dir/updater.conf" ] || [ "$UPDATER_FORCE" = "1" ]; then build else # Run installer steps - install_dependencies + install_dependencies "${dependencies[@]}" prepare_repo ask_questions install_script diff --git a/midimonster.c b/midimonster.c index b418711..b73eeff 100644 --- a/midimonster.c +++ b/midimonster.c @@ -4,17 +4,17 @@ #include <errno.h> #include <time.h> #ifndef _WIN32 -#include <sys/select.h> -#define MM_API __attribute__((visibility("default"))) + #include <sys/select.h> + #define MM_API __attribute__((visibility("default"))) #else -#define MM_API __attribute__((dllexport)) + #define MM_API __attribute__((dllexport)) #endif #define BACKEND_NAME "core" #define MM_SWAP_LIMIT 20 #include "midimonster.h" -#include "config.h" -#include "backend.h" -#include "plugin.h" +#include "core/config.h" +#include "core/backend.h" +#include "core/plugin.h" /* Core-internal structures */ typedef struct /*_event_collection*/ { @@ -263,7 +263,7 @@ static fd_set fds_collect(int* max_fd){ *max_fd = -1; } - DBGPF("Building selector set from %lu FDs registered to core\n", fds); + DBGPF("Building selector set from %" PRIsize_t " FDs registered to core", fds); FD_ZERO(&rv_fds); for(u = 0; u < fds; u++){ if(fd[u].fd >= 0){ @@ -346,7 +346,7 @@ static int core_process(size_t nfds, managed_fd* signaled_fds){ size_t u, swaps = 0; //run backend processing, collect events - DBGPF("%lu backend FDs signaled\n", nfds); + DBGPF("%" PRIsize_t " backend FDs signaled", nfds); if(backends_handle(nfds, signaled_fds)){ return 1; } @@ -354,7 +354,7 @@ static int core_process(size_t nfds, managed_fd* signaled_fds){ //limit number of collector swaps per iteration to prevent complete deadlock while(routing.events->n && swaps < MM_SWAP_LIMIT){ //swap primary and secondary event collectors - DBGPF("Swapping event collectors, %lu events in primary\n", routing.events->n); + DBGPF("Swapping event collectors, %" PRIsize_t " events in primary", routing.events->n); for(u = 0; u < sizeof(routing.pool) / sizeof(routing.pool[0]); u++){ if(routing.events != routing.pool + u){ secondary = routing.events; @@ -472,6 +472,7 @@ int main(int argc, char** argv){ return EXIT_FAILURE; } + version(); if(platform_initialize()){ fprintf(stderr, "Failed to perform platform-specific initialization\n"); return EXIT_FAILURE; diff --git a/midimonster.h b/midimonster.h index 75eb30a..89688c4 100644 --- a/midimonster.h +++ b/midimonster.h @@ -7,7 +7,7 @@ /* Core version unless set by the build process */ #ifndef MIDIMONSTER_VERSION - #define MIDIMONSTER_VERSION "v0.5-dist" + #define MIDIMONSTER_VERSION "v0.6-dist" #endif /* Set backend name if unset */ @@ -129,6 +129,8 @@ struct _managed_fd; * * (optional) mmbackend_interval * Return the maximum sleep interval for this backend in milliseconds. * If not implemented, a maximum interval of one second is used. + * Returning 0 signals that the backend does not have a minimum + * interval. * * mmbackend_shutdown * Clean up all allocations, finalize all hardware connections. All registered * backends receive the shutdown call, regardless of whether they have been @@ -225,15 +227,21 @@ MM_API int mm_backend_register(backend b); MM_API instance* mm_instance_find(char* backend, uint64_t ident); /* - * Provides a pointer to a channel structure, pre-filled with the provided - * instance reference and identifier. + * This function is the main interface to the core-provided channel registry. + * This API is just a convenience function. Creating and managing a + * backend-internal channel store is possible (and encouraged for performance + * reasons). + * + * Channels are identified by the (instance, ident) tuple within the registry. + * + * This API provides a pointer to a channel structure, pre-filled with the + * provided instance reference and identifier. * The `create` parameter is a boolean flag indicating whether a channel * matching the `ident` parameter should be created in the global channel store * if none exists yet. If the instance already registered a channel matching * `ident`, a pointer to the existing channel is returned. - * This API is just a convenience function. Creating and managing a - * backend-internal channel store is possible (and encouraged for performance - * reasons). When returning pointers from a backend-local channel store, the + * + * When returning pointers from a backend-local channel store, the * returned pointers must stay valid over the lifetime of the instance and * provide valid `instance` members, as they are used for callbacks. * For each channel with a non-NULL `impl` field registered using @@ -243,6 +251,15 @@ MM_API instance* mm_instance_find(char* backend, uint64_t ident); MM_API channel* mm_channel(instance* i, uint64_t ident, uint8_t create); /* + * When using the core-provided channel registry, the identification + * member of the structure must only be updated using this API. + * The tuple of (instance, ident) is used as key to the backing + * storage of the channel registry, thus the registry must be notified + * of changes. + */ +MM_API void mm_channel_update(channel* c, uint64_t ident); + +/* * Register (manage = 1) or unregister (manage = 0) a file descriptor to be * selected on. The backend will be notified when the descriptor becomes ready * to read via its registered mmbackend_process_fd call. The `impl` argument |