From 4a6ace0fdf7ec693e3cfc6146afae6bb2f0c581f Mon Sep 17 00:00:00 2001 From: onyx-and-iris Date: Fri, 30 Jan 2026 22:42:11 +0000 Subject: [PATCH] initial commit add internal/xair module --- .github/workflows/release.yml | 28 +++ .github/workflows/update-go-modules.yml | 30 +++ .gitignore | 38 ++++ .goreleaser.yml | 55 +++++ LICENSE | 0 README.md | 28 +++ go.mod | 40 ++++ go.sum | 88 ++++++++ internal/xair/client.go | 276 ++++++++++++++++++++++++ internal/xair/models.go | 7 + internal/xair/parser.go | 213 ++++++++++++++++++ internal/xair/util.go | 42 ++++ makefile | 40 ++++ 13 files changed, 885 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/update-go-modules.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/xair/client.go create mode 100644 internal/xair/models.go create mode 100644 internal/xair/parser.go create mode 100644 internal/xair/util.go create mode 100644 makefile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bafdbf0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: goreleaser + +on: + push: + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-go-modules.yml b/.github/workflows/update-go-modules.yml new file mode 100644 index 0000000..eca8185 --- /dev/null +++ b/.github/workflows/update-go-modules.yml @@ -0,0 +1,30 @@ +name: Auto-Update Go Modules + +on: + schedule: + - cron: '0 0 * * 1' # Runs every Monday at midnight + +jobs: + update-go-modules: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Update Dependencies + run: | + go get -u ./... + go mod tidy + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add go.mod go.sum + git commit -m "chore: auto-update Go modules" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf2af18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Generated by ignr: github.com/onyx-and-iris/ignr + +## Go ## +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ + +# End of ignr diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..daa83d1 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,55 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + goarch: + - amd64 + +archives: + - formats: ['tar.gz'] + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: ['zip'] + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + +release: + footer: >- + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6245ba --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Xair-CLI + +### Use + +```console +xair-cli is a command-line tool that allows users to send OSC messages +to Behringer X Air mixers for remote control and configuration. It supports +various commands to manage mixer settings directly from the terminal. + +Usage: + xair-cli [flags] + xair-cli [command] + +Available Commands: + bus A brief description of your command + completion Generate the autocompletion script for the specified shell + help Help about any command + main A brief description of your command + strip A brief description of your command + +Flags: + -h, --help help for xair-cli + -H, --host string host address of the X Air mixer (default "mixer.local") + -l, --loglevel string Log level (debug, info, warn, error, fatal, panic) (default "info") + -p, --port int Port number of the X Air mixer (default 10024) + +Use "xair-cli [command] --help" for more information about a command. +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5317de8 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/onyx-and-iris/xair-cli + +go 1.23.0 + +require ( + github.com/charmbracelet/log v0.4.2 + github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..68ddae9 --- /dev/null +++ b/go.sum @@ -0,0 +1,88 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 h1:fqwINudmUrvGCuw+e3tedZ2UJ0hklSw6t8UPomctKyQ= +github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5/go.mod h1:lqMjoCs0y0GoRRujSPZRBaGb4c5ER6TfkFKSClxkMbY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/xair/client.go b/internal/xair/client.go new file mode 100644 index 0000000..bfa05b8 --- /dev/null +++ b/internal/xair/client.go @@ -0,0 +1,276 @@ +package xair + +import ( + "fmt" + "net" + "time" + + "github.com/charmbracelet/log" + + "github.com/hypebeast/go-osc/osc" +) + +type parser interface { + Parse(data []byte) (*osc.Message, error) +} + +type XAirClient struct { + conn *net.UDPConn + mixerAddr *net.UDPAddr + + parser parser + + done chan bool + respChan chan *osc.Message +} + +// NewClient creates a new XAirClient instance +func NewClient(mixerIP string, mixerPort int) (*XAirClient, error) { + localAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", 0)) + if err != nil { + return nil, fmt.Errorf("failed to resolve local address: %v", err) + } + + conn, err := net.ListenUDP("udp", localAddr) + if err != nil { + return nil, fmt.Errorf("failed to create UDP connection: %v", err) + } + + mixerAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", mixerIP, mixerPort)) + if err != nil { + conn.Close() + return nil, fmt.Errorf("failed to resolve mixer address: %v", err) + } + + log.Debugf("Local UDP connection: %s ", conn.LocalAddr().String()) + + return &XAirClient{ + conn: conn, + mixerAddr: mixerAddr, + parser: newParser(), + done: make(chan bool), + respChan: make(chan *osc.Message), + }, nil +} + +// Start begins listening for messages in a goroutine +func (x *XAirClient) StartListening() { + go x.receiveLoop() + log.Debugf("Started listening on %s...", x.conn.LocalAddr().String()) +} + +// receiveLoop handles incoming OSC messages +func (x *XAirClient) receiveLoop() { + buffer := make([]byte, 4096) + + for { + select { + case <-x.done: + return + default: + // Set read timeout to avoid blocking forever + x.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + + n, _, err := x.conn.ReadFromUDP(buffer) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + // Timeout is expected, continue loop + continue + } + // Check if we're shutting down to avoid logging expected errors + select { + case <-x.done: + return + default: + log.Errorf("Read error: %v", err) + return + } + } + + msg, err := x.parseOSCMessage(buffer[:n]) + if err != nil { + log.Errorf("Failed to parse OSC message: %v", err) + continue + } + x.respChan <- msg + } + } +} + +// parseOSCMessage parses raw bytes into an OSC message with improved error handling +func (x *XAirClient) parseOSCMessage(data []byte) (*osc.Message, error) { + msg, err := x.parser.Parse(data) + if err != nil { + return nil, err + } + + return msg, nil +} + +// Stop stops the client and closes the connection +func (x *XAirClient) Stop() { + close(x.done) + if x.conn != nil { + x.conn.Close() + } +} + +// SendMessage sends an OSC message to the mixer using the unified connection +func (x *XAirClient) SendMessage(address string, args ...any) error { + return x.SendToAddress(x.mixerAddr, address, args...) +} + +// SendToAddress sends an OSC message to a specific address (enables replying to different ports) +func (x *XAirClient) SendToAddress(addr *net.UDPAddr, oscAddress string, args ...any) error { + msg := osc.NewMessage(oscAddress) + for _, arg := range args { + msg.Append(arg) + } + + log.Debugf("Sending to %v: %s", addr, msg.String()) + if len(args) > 0 { + log.Debug(" - Arguments: ") + for i, arg := range args { + if i > 0 { + log.Debug(", ") + } + log.Debugf("%v", arg) + } + } + log.Debug("") + + data, err := msg.MarshalBinary() + if err != nil { + return fmt.Errorf("failed to marshal message: %v", err) + } + + _, err = x.conn.WriteToUDP(data, addr) + return err +} + +// RequestInfo requests mixer information +func (x *XAirClient) RequestInfo() (error, InfoResponse) { + err := x.SendMessage("/xinfo") + if err != nil { + return err, InfoResponse{} + } + + val := <-x.respChan + var info InfoResponse + if len(val.Arguments) >= 3 { + info.Host = val.Arguments[0].(string) + info.Name = val.Arguments[1].(string) + info.Model = val.Arguments[2].(string) + } + return nil, info +} + +// KeepAlive sends keep-alive message (required for multi-client usage) +func (x *XAirClient) KeepAlive() error { + return x.SendMessage("/xremote") +} + +// RequestStatus requests mixer status +func (x *XAirClient) RequestStatus() error { + return x.SendMessage("/status") +} + +// SetChannelGain sets gain for a specific channel (1-based indexing) +func (x *XAirClient) SetChannelGain(channel int, gain float32) error { + address := fmt.Sprintf("/ch/%02d/mix/fader", channel) + return x.SendMessage(address, gain) +} + +// MuteChannel mutes/unmutes a specific channel (1-based indexing) +func (x *XAirClient) MuteChannel(channel int, muted bool) error { + address := fmt.Sprintf("/ch/%02d/mix/on", channel) + var value int32 = 0 + if !muted { + value = 1 + } + return x.SendMessage(address, value) +} + +// GetMainLRFader requests the current main L/R fader level +func (x *XAirClient) GetMainLRFader() (float64, error) { + err := x.SendMessage("/lr/mix/fader") + if err != nil { + return 0, err + } + + resp := <-x.respChan + val, ok := resp.Arguments[0].(float32) + if !ok { + return 0, fmt.Errorf("unexpected argument type for main LR fader value") + } + return mustDbFrom(float64(val)), nil +} + +// SetMainLRFader sets the main L/R fader level +func (x *XAirClient) SetMainLRFader(level float64) error { + return x.SendMessage("/lr/mix/fader", float32(mustDbInto(level))) +} + +// GetChannelFader requests the current fader level for a channel +func (x *XAirClient) GetChannelFader(channel int) (float64, error) { + address := fmt.Sprintf("/ch/%02d/mix/fader", channel) + err := x.SendMessage(address) + if err != nil { + return 0, err + } + + resp := <-x.respChan + val, ok := resp.Arguments[0].(float32) + if !ok { + return 0, fmt.Errorf("unexpected argument type for fader value") + } + + return mustDbFrom(float64(val)), nil +} + +// GetChannelMute requests the current mute state for a channel +func (x *XAirClient) GetChannelMute(channel int) error { + address := fmt.Sprintf("/ch/%02d/mix/on", channel) + return x.SendMessage(address) +} + +// SetChannelName sets the name for a specific channel +func (x *XAirClient) SetChannelName(channel int, name string) error { + address := fmt.Sprintf("/ch/%02d/config/name", channel) + return x.SendMessage(address, name) +} + +// GetChannelName requests the name for a specific channel +func (x *XAirClient) GetChannelName(channel int) error { + address := fmt.Sprintf("/ch/%02d/config/name", channel) + return x.SendMessage(address) +} + +// GetChannelColor requests the color for a specific channel +func (x *XAirClient) GetChannelColor(channel int) error { + address := fmt.Sprintf("/ch/%02d/config/color", channel) + return x.SendMessage(address) +} + +// SetChannelColor sets the color for a specific channel (0-15) +func (x *XAirClient) SetChannelColor(channel int, color int32) error { + address := fmt.Sprintf("/ch/%02d/config/color", channel) + return x.SendMessage(address, color) +} + +// GetAllChannelInfo requests information for all channels +func (x *XAirClient) GetAllChannelInfo(maxChannels int) error { + fmt.Printf("\n=== REQUESTING ALL CHANNEL INFO (1-%d) ===\n", maxChannels) + for ch := 1; ch <= maxChannels; ch++ { + fmt.Printf("Requesting info for channel %d...\n", ch) + x.GetChannelName(ch) + time.Sleep(100 * time.Millisecond) + x.GetChannelFader(ch) + time.Sleep(100 * time.Millisecond) + x.GetChannelMute(ch) + time.Sleep(100 * time.Millisecond) + x.GetChannelColor(ch) + time.Sleep(200 * time.Millisecond) // Longer pause between channels + } + return nil +} diff --git a/internal/xair/models.go b/internal/xair/models.go new file mode 100644 index 0000000..0e94c3d --- /dev/null +++ b/internal/xair/models.go @@ -0,0 +1,7 @@ +package xair + +type InfoResponse struct { + Host string + Name string + Model string +} diff --git a/internal/xair/parser.go b/internal/xair/parser.go new file mode 100644 index 0000000..f7d2a9a --- /dev/null +++ b/internal/xair/parser.go @@ -0,0 +1,213 @@ +package xair + +import ( + "bytes" + "encoding/binary" + "fmt" + "math" + + "github.com/charmbracelet/log" + "github.com/hypebeast/go-osc/osc" +) + +type xairParser struct { +} + +func newParser() *xairParser { + return &xairParser{} +} + +// parseOSCMessage parses raw bytes into an OSC message with improved error handling +func (p *xairParser) Parse(data []byte) (*osc.Message, error) { + log.Debug("=== PARSING OSC MESSAGE BEGIN ===") + defer log.Debug("=== PARSING OSC MESSAGE END ===") + + if err := p.validateOSCData(data); err != nil { + return nil, err + } + + address, addressEnd, err := p.extractOSCAddress(data) + if err != nil { + return nil, err + } + + msg := osc.NewMessage(address) + + typeTags, typeTagsEnd, err := p.extractOSCTypeTags(data, addressEnd) + if err != nil || typeTags == "" { + log.Debug("No valid type tags, returning address-only message") + return msg, nil + } + + if err := p.parseOSCArguments(data, typeTagsEnd, typeTags, msg); err != nil { + return nil, err + } + + log.Debugf("Successfully parsed message with %d arguments", len(msg.Arguments)) + return msg, nil +} + +// validateOSCData performs basic validation on OSC message data +func (p *xairParser) validateOSCData(data []byte) error { + if len(data) < 4 { + return fmt.Errorf("data too short for OSC message") + } + if data[0] != '/' { + return fmt.Errorf("invalid OSC message: does not start with '/'") + } + return nil +} + +// extractOSCAddress extracts the OSC address from the message data +func (p *xairParser) extractOSCAddress(data []byte) (address string, nextPos int, err error) { + nullPos := bytes.IndexByte(data, 0) + if nullPos <= 0 { + return "", 0, fmt.Errorf("no null terminator found for address") + } + + address = string(data[:nullPos]) + log.Debugf("Parsed OSC address: %s", address) + + // Calculate next 4-byte aligned position + nextPos = ((nullPos + 4) / 4) * 4 + return address, nextPos, nil +} + +// extractOSCTypeTags extracts and validates OSC type tags +func (p *xairParser) extractOSCTypeTags(data []byte, start int) (typeTags string, nextPos int, err error) { + if start >= len(data) { + return "", start, nil // No type tags available + } + + typeTagsEnd := bytes.IndexByte(data[start:], 0) + if typeTagsEnd <= 0 { + return "", start, nil // No type tags found + } + + typeTags = string(data[start : start+typeTagsEnd]) + log.Debugf("Parsed type tags: %s", typeTags) + + if len(typeTags) == 0 || typeTags[0] != ',' { + log.Debug("Invalid type tags format") + return "", start, nil + } + + // Calculate arguments start position (4-byte aligned) + nextPos = ((start + typeTagsEnd + 4) / 4) * 4 + return typeTags, nextPos, nil +} + +// parseOSCArguments parses OSC arguments based on type tags +func (p *xairParser) parseOSCArguments(data []byte, argsStart int, typeTags string, msg *osc.Message) error { + argData := data[argsStart:] + argNum := 0 + + for i := 1; i < len(typeTags) && len(argData) > 0; i++ { + var consumed int + var err error + + switch typeTags[i] { + case 's': + consumed, err = p.parseStringArgument(argData, msg, argNum) + case 'i': + consumed, err = p.parseInt32Argument(argData, msg, argNum) + case 'f': + consumed, err = p.parseFloat32Argument(argData, msg, argNum) + case 'b': + consumed, err = p.parseBlobArgument(argData, msg, argNum) + default: + log.Debugf("Unknown type tag: %c (skipping)", typeTags[i]) + consumed = p.skipUnknownArgument(argData) + } + + if err != nil { + log.Debugf("Error parsing argument %d: %v", argNum+1, err) + break + } + + if consumed == 0 { + break // No more data to consume + } + + argData = argData[consumed:] + if typeTags[i] != '?' { // Don't count skipped arguments + argNum++ + } + } + + return nil +} + +// parseStringArgument parses a string argument from OSC data +func (p *xairParser) parseStringArgument(data []byte, msg *osc.Message, argNum int) (int, error) { + nullPos := bytes.IndexByte(data, 0) + if nullPos < 0 { + return 0, fmt.Errorf("no null terminator found for string") + } + + argStr := string(data[:nullPos]) + log.Debugf("Parsed string argument %d: %s", argNum+1, argStr) + msg.Append(argStr) + + // Return next 4-byte aligned position + return ((nullPos + 4) / 4) * 4, nil +} + +// parseInt32Argument parses an int32 argument from OSC data +func (p *xairParser) parseInt32Argument(data []byte, msg *osc.Message, argNum int) (int, error) { + if len(data) < 4 { + return 0, fmt.Errorf("insufficient data for int32") + } + + val := int32(binary.BigEndian.Uint32(data[:4])) + log.Debugf("Parsed int32 argument %d: %d", argNum+1, val) + msg.Append(val) + + return 4, nil +} + +// parseFloat32Argument parses a float32 argument from OSC data +func (p *xairParser) parseFloat32Argument(data []byte, msg *osc.Message, argNum int) (int, error) { + if len(data) < 4 { + return 0, fmt.Errorf("insufficient data for float32") + } + + val := math.Float32frombits(binary.BigEndian.Uint32(data[:4])) + log.Debugf("Parsed float32 argument %d: %f", argNum+1, val) + msg.Append(val) + + return 4, nil +} + +// parseBlobArgument parses a blob argument from OSC data +func (p *xairParser) parseBlobArgument(data []byte, msg *osc.Message, argNum int) (int, error) { + if len(data) < 4 { + return 0, fmt.Errorf("insufficient data for blob size") + } + + size := int32(binary.BigEndian.Uint32(data[:4])) + if size < 0 || size >= 10000 { + return 0, fmt.Errorf("invalid blob size: %d", size) + } + + if len(data) < int(4+size) { + return 0, fmt.Errorf("insufficient data for blob content") + } + + blob := make([]byte, size) + copy(blob, data[4:4+size]) + log.Debugf("Parsed blob argument %d (%d bytes)", argNum+1, size) + msg.Append(blob) + + // Return next 4-byte aligned position + return ((4 + int(size) + 3) / 4) * 4, nil +} + +// skipUnknownArgument skips an unknown argument type +func (p *xairParser) skipUnknownArgument(data []byte) int { + // Skip unknown types by moving 4 bytes if available + if len(data) >= 4 { + return 4 + } + return 0 +} diff --git a/internal/xair/util.go b/internal/xair/util.go new file mode 100644 index 0000000..1f20d56 --- /dev/null +++ b/internal/xair/util.go @@ -0,0 +1,42 @@ +package xair + +import "math" + +func mustDbInto(db float64) float64 { + switch { + case db >= 10: + return 1 + case db >= -10: + return float64((db + 30) / 40) + case db >= -30: + return float64((db + 50) / 80) + case db >= -60: + return float64((db + 70) / 160) + case db >= -90: + return float64((db + 90) / 480) + default: + return 0 + } +} + +func mustDbFrom(level float64) float64 { + switch { + case level >= 1: + return 10 + case level >= 0.5: + return toFixed(float64(level*40)-30, 1) + case level >= 0.25: + return toFixed(float64(level*80)-50, 1) + case level >= 0.0625: + return toFixed(float64(level*160)-70, 1) + case level >= 0: + return toFixed(float64(level*480)-90, 1) + default: + return -90 + } +} + +func toFixed(num float64, precision int) float64 { + output := math.Pow(10, float64(precision)) + return float64(math.Round(num*output)) / output +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..2ea3353 --- /dev/null +++ b/makefile @@ -0,0 +1,40 @@ +program = xair-cli + +GO = @go +BIN_DIR := bin + +WINDOWS=$(BIN_DIR)/$(program)_windows_amd64.exe +LINUX=$(BIN_DIR)/$(program)_linux_amd64 +VERSION=$(shell git describe --tags --always --long --dirty) + +.DEFAULT_GOAL := build + +.PHONY: fmt vet build windows linux test clean +fmt: + $(GO) fmt ./... + +vet: fmt + $(GO) vet ./... + +build: vet windows linux | $(BIN_DIR) + @echo version: $(VERSION) + +windows: $(WINDOWS) + +linux: $(LINUX) + + +$(WINDOWS): + env GOOS=windows GOARCH=amd64 go build -v -o $(WINDOWS) -ldflags="-s -w -X main.version=$(VERSION)" . + +$(LINUX): + env GOOS=linux GOARCH=amd64 go build -v -o $(LINUX) -ldflags="-s -w -X main.version=$(VERSION)" . + +test: + $(GO) test ./... + +$(BIN_DIR): + @mkdir -p $@ + +clean: + @rm -rv $(BIN_DIR) \ No newline at end of file