diff --git a/README.md b/README.md index 431195b..f6b8d99 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # dgm20-linux -Control the **Maono DGM20** USB microphone on Linux. Maono Link only runs on Windows and macOS; this -covers the same settings with a local web page and a command-line tool. +Control the **Maono DGM20** USB microphone on Linux. Maono Link only runs on Windows and macOS; +this covers the same settings with a local web page and a command-line tool. ![The control page](docs/screenshot.png) @@ -12,59 +12,129 @@ covers the same settings with a local web page and a command-line tool. - RGB ring brightness - Factory reset -Pure Python 3.9+, no dependencies. Nothing leaves your machine: the page is served from localhost -and loads no external fonts or scripts. +Pure Python, no dependencies. Nothing leaves your machine: the page is served from your own +computer and loads no external fonts or scripts. -> Unofficial. Not affiliated with or endorsed by Maono. Tested on a DGM20 with USB ID -> `352f:0105`, firmware 2.1.2. Newer DGM20 revisions (`352f:0112`) and the DGM20G/DGM20W -> use different firmware and are not supported yet. +> Unofficial. Not affiliated with or endorsed by Maono. + +## Supported hardware + +| Model | USB ID | Status | +|-------|--------|--------| +| DGM20 | `352f:0105` | Supported, tested on firmware 2.1.2 | +| DGM20 (newer revision) | `352f:0112` | Not yet | +| DGM20G, DGM20W | `352f:0113`, `352f:041a`-`041d` | Not yet | + +Find your mic's ID with `lsusb | grep -i maono`. If you have one of the unsupported models and want +to help, open an issue with that output. ## Install -```sh -pipx install git+https://git.reiners.dev/ReinersCloudConsulting/dgm20-linux.git -# or, from a checkout -pipx install . -``` - -Let your user reach the mic without root, then unplug and replug it: +You need Python 3.9 or newer with `venv` (Debian and Ubuntu: `sudo apt install python3-venv`). ```sh -sudo cp udev/70-maono-dgm20.rules /etc/udev/rules.d/ -sudo udevadm control --reload && sudo udevadm trigger +git clone https://git.reiners.dev/ReinersCloudConsulting/dgm20-linux.git +cd dgm20-linux +./install.sh ``` -The rule grants access to the logged-in desktop user (`uaccess`) and to the `wheel` group, which -covers ssh sessions. Change the group if your distro uses a different one. +Then unplug the mic and plug it back in once. + +The installer: + +1. puts the program in its own virtualenv under `~/.local/share/dgm20-linux`, with the commands + `dgm20ctl` and `dgm20-web` linked into `~/.local/bin`, and +2. asks for sudo once to add a udev rule (`/etc/udev/rules.d/70-maono-dgm20.rules`) that lets you + use the mic's control channel without root. + +Run it as your normal user, not with sudo. + +### Options + +| Option | What it does | +|--------|--------------| +| `--service` | Runs the control page in the background now and every time you log in (systemd user service). | +| `--lan` | Lets other devices on your network open the page too. Opens the port in firewalld or ufw for your local subnet only. Implies `--service`. | +| `--port N` | Uses a different port (default 8720). | +| `--group NAME` | Group allowed to use the mic from ssh sessions. Default: the first of `plugdev`, `wheel`, `sudo` that you belong to. Desktop logins always have access. | +| `--no-udev` | Skips the udev rule. | +| `--dry-run` | Prints every change instead of making it. | + +`--lan` has no login. Anyone on your network can change your mic settings, so only use it on a +network you trust. If you run a firewall other than firewalld or ufw, the installer tells you what +to allow. + +### Uninstall + +```sh +./uninstall.sh +``` + +This removes everything the installer added: the service, firewall rule, udev rule and program +files. The installer keeps a record of what it changed, so only those changes are undone. Your mic +keeps its current settings. ## Use ```sh -dgm20-web --open # control page at http://localhost:8720 -dgm20ctl # show settings -dgm20ctl set nc on nc-level 80 enhance off -dgm20ctl watch # print changes made on the mic itself +dgm20-web --open # open the control page (http://localhost:8720) +dgm20ctl # show current settings +dgm20ctl set nc on nc-level 80 +dgm20ctl set monitor off enhance on brightness 40 +dgm20ctl watch # print changes made on the mic itself +dgm20ctl info # model and firmware dgm20ctl factory-reset --yes ``` -To keep the page running in the background: +| Setting | Values | +|---------|--------| +| `mute` | on, off | +| `nc` | on, off: noise cancellation | +| `nc-level` | 0-100: noise cancellation strength | +| `enhance` | on, off: vocal enhancement | +| `monitor` | on, off: hear yourself in headphones plugged into the mic | +| `brightness` | 0-100: RGB ring | -```sh -cp contrib/dgm20-web.service ~/.config/systemd/user/ -systemctl --user enable --now dgm20-web -``` - -`dgm20-web` only listens on localhost. `--host 0.0.0.0` makes it reachable from your network, and -anyone on that network can then change your mic settings, because there is no login. +Settings are stored on the mic, so they stay the same after a replug and on other computers. Input gain and headphone volume are standard USB audio controls. Set them in your desktop's sound -settings, `pavucontrol` or `alsamixer`. The mic does not expose them on its control channel. +settings, `pavucontrol` or `alsamixer`. + +### Using the page from another computer without opening a port + +If the mic is plugged into a machine you reach over ssh, forward the port instead of using `--lan`: + +```sh +ssh -L 8720:localhost:8720 that-machine +``` + +Then open http://localhost:8720 on your own computer. + +## Troubleshooting + +**"no permission to open /dev/hidrawN"**: the udev rule isn't applied yet. Unplug and replug the +mic. Over ssh, make sure you're in the group the installer printed (`id -nG`). If you were just +added to that group, log in again. + +**"DGM20 (352f:0105) not found"**: check `lsusb | grep -i maono`. A different USB ID means a +model that isn't supported yet (see above). + +**The page says "Mic not found"**: plug the mic in. The page reconnects by itself. + +**`dgm20-web: cannot listen on 127.0.0.1:8720`**: it's already running, for example as the +service. Open http://localhost:8720, or pick another port with `--port`. + +**Commands not found after installing**: `~/.local/bin` isn't on your `PATH`. Add +`export PATH="$HOME/.local/bin:$PATH"` to your shell profile. + +**Service stops when you log out**: run `sudo loginctl enable-linger $USER`. ## How it works -The settings were found by reading Maono Link's DGM20 plugin. See [PROTOCOL.md](PROTOCOL.md) for -the packet format and register map. `dgm20ctl raw` gives direct register access if you want to -explore. +The mic has a vendor HID channel next to its audio interfaces. The settings were found by reading +Maono Link's DGM20 plugin and then checked against a real mic. [PROTOCOL.md](PROTOCOL.md) documents +the packet format and register map. `dgm20ctl raw get|set|dump` gives direct register access for +exploring. ## Development diff --git a/contrib/dgm20-web.service b/contrib/dgm20-web.service deleted file mode 100644 index 9aca271..0000000 --- a/contrib/dgm20-web.service +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Maono DGM20 web control panel (http://localhost:8720) - -[Service] -ExecStart=%h/.local/bin/dgm20-web -Restart=on-failure - -[Install] -WantedBy=default.target diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..556d5a6 --- /dev/null +++ b/install.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Install dgm20-linux for the current user. +# +# ./install.sh commands + udev rule (localhost use) +# ./install.sh --service ...and run the web page in the background +# ./install.sh --lan ...and make it reachable from your local network +# +# Run it as yourself, not root; it asks for sudo only for the udev rule and, +# with --lan, the firewall. Undo everything with ./uninstall.sh. +set -euo pipefail + +APP=dgm20-linux +SRC=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +DATA=${XDG_DATA_HOME:-$HOME/.local/share}/$APP +BIN=$HOME/.local/bin +UNIT_DIR=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user +RULE=/etc/udev/rules.d/70-maono-dgm20.rules +STATE=$DATA/install.state + +PORT=8720 +SERVICE=0 +LAN=0 +UDEV=1 +GROUP= +DRY=0 + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac + shift +done + +say() { printf '\033[1m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +# run a command, or print it under --dry-run +run() { if [ $DRY = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; } +record() { [ $DRY = 1 ] || echo "$1" >> "$STATE"; } + +[ "$(id -u)" != 0 ] || die "run this as your normal user, not root (it uses sudo where needed)" +[ "$(uname -s)" = Linux ] || die "this tool is for Linux" +[[ $PORT =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "bad port: $PORT" + +command -v python3 >/dev/null || die "python3 not found; install it with your package manager" +python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' || die "python 3.9 or newer is required" +python3 -c 'import venv, ensurepip' 2>/dev/null \ + || die "python venv support is missing (Debian/Ubuntu: sudo apt install python3-venv)" + +# 1. the program, in its own virtualenv +say "Installing $APP into $DATA" +run mkdir -p "$DATA" "$BIN" +[ $DRY = 1 ] || : > "$STATE" +run python3 -m venv --clear "$DATA/venv" +run "$DATA/venv/bin/python" -m pip install --quiet --disable-pip-version-check "$SRC" +for cmd in dgm20ctl dgm20-web; do + run ln -sf "$DATA/venv/bin/$cmd" "$BIN/$cmd" +done +case ":$PATH:" in + *":$BIN:"*) ;; + *) warn "$BIN is not on your PATH; add it in your shell profile" ;; +esac + +# 2. udev rule so the mic's control channel is usable without root +if [ $UDEV = 1 ]; then + if [ -z "$GROUP" ]; then + for g in plugdev wheel sudo; do + if id -nG | tr ' ' '\n' | grep -qx "$g"; then GROUP=$g; break; fi + done + fi + if [ -n "$GROUP" ]; then + getent group "$GROUP" >/dev/null || die "group $GROUP does not exist" + line="SUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"352f\", ATTRS{idProduct}==\"0105\", MODE=\"0660\", GROUP=\"$GROUP\", TAG+=\"uaccess\"" + say "Installing udev rule (desktop logins + group '$GROUP'), sudo needed" + else + line='SUBSYSTEM=="hidraw", ATTRS{idVendor}=="352f", ATTRS{idProduct}=="0105", TAG+="uaccess"' + say "Installing udev rule (desktop logins only; use --group for ssh access), sudo needed" + fi + rule="# Maono DGM20 USB microphone: control channel for dgm20-linux +$line" + if [ $DRY = 1 ]; then + printf ' [dry-run] write %s:\n%s\n' "$RULE" "$(sed 's/^/ /' <<<"$rule")" + else + printf '%s\n' "$rule" | sudo tee "$RULE" >/dev/null + fi + run sudo udevadm control --reload + run sudo udevadm trigger --subsystem-match=hidraw + record "udev $RULE" +fi + +# 3. background service +HOST=127.0.0.1 +[ $LAN = 1 ] && HOST=0.0.0.0 +if [ $SERVICE = 1 ]; then + command -v systemctl >/dev/null || die "--service needs systemd" + say "Installing systemd user service on $HOST:$PORT" + unit="[Unit] +Description=Maono DGM20 control page + +[Service] +ExecStart=$DATA/venv/bin/dgm20-web --host $HOST --port $PORT +Restart=on-failure + +[Install] +WantedBy=default.target" + run mkdir -p "$UNIT_DIR" + if [ $DRY = 1 ]; then + printf ' [dry-run] write %s/dgm20-web.service\n' "$UNIT_DIR" + else + printf '%s\n' "$unit" > "$UNIT_DIR/dgm20-web.service" + fi + run systemctl --user daemon-reload + run systemctl --user enable dgm20-web.service + run systemctl --user restart dgm20-web.service + record "service dgm20-web.service" + if [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != yes ]; then + echo " The page runs while you're logged in. To keep it running after you log out:" + echo " sudo loginctl enable-linger $USER" + fi +fi + +# 4. firewall, only with --lan, only for the local subnet +if [ $LAN = 1 ]; then + dev=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* dev \([^ ]*\).*/\1/p') + SUBNET=$(ip -o -f inet route show dev "$dev" scope link 2>/dev/null | awk '{print $1; exit}') + [ -n "$SUBNET" ] || die "could not work out your local subnet; open tcp/$PORT yourself" + if command -v firewall-cmd >/dev/null && sudo firewall-cmd --state >/dev/null 2>&1; then + rich="rule family=ipv4 source address=$SUBNET port port=$PORT protocol=tcp accept" + say "Opening tcp/$PORT to $SUBNET in firewalld" + run sudo firewall-cmd --permanent --add-rich-rule="$rich" + run sudo firewall-cmd --reload + record "firewalld $rich" + elif command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q 'Status: active'; then + say "Opening tcp/$PORT to $SUBNET in ufw" + run sudo ufw allow from "$SUBNET" to any port "$PORT" proto tcp comment dgm20-web + record "ufw $SUBNET $PORT" + else + warn "no active firewalld or ufw found. If another firewall is running, allow tcp/$PORT from $SUBNET yourself." + fi +fi + +echo +say "Done." +if [ $UDEV = 1 ]; then + echo " Unplug and replug the mic once so the new permissions apply." +fi +if [ $SERVICE = 1 ]; then + if [ $LAN = 1 ]; then + ip=$(ip -o -f inet addr show dev "$dev" | awk '{sub("/.*", "", $4); print $4; exit}') + echo " Control page: http://localhost:$PORT/ (from other devices: http://$ip:$PORT/)" + else + echo " Control page: http://localhost:$PORT/" + fi +else + echo " Start the control page with: dgm20-web --open" +fi +echo " Command line: dgm20ctl --help" diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..e01be51 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Remove everything install.sh set up: the service, firewall rule, udev rule, +# the commands and the virtualenv. Mic settings are left as they are. +set -euo pipefail + +APP=dgm20-linux +DATA=${XDG_DATA_HOME:-$HOME/.local/share}/$APP +BIN=$HOME/.local/bin +UNIT=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/dgm20-web.service +STATE=$DATA/install.state +DRY=0 + +case ${1:-} in + --dry-run) DRY=1 ;; + -h|--help) echo "Usage: ./uninstall.sh [--dry-run]"; exit 0 ;; + "") ;; + *) echo "unknown option: $1" >&2; exit 2 ;; +esac + +say() { printf '\033[1m==>\033[0m %s\n' "$*"; } +run() { if [ $DRY = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; } + +[ "$(id -u)" != 0 ] || { echo "run this as your normal user, not root" >&2; exit 1; } + +if [ -f "$STATE" ]; then + while IFS= read -r entry; do + kind=${entry%% *} + arg=${entry#* } + case $kind in + service) + say "Stopping the background service" + run systemctl --user disable --now "$arg" || true + run rm -f "$UNIT" + run systemctl --user daemon-reload + ;; + firewalld) + say "Removing the firewalld rule" + run sudo firewall-cmd --permanent --remove-rich-rule="$arg" || true + run sudo firewall-cmd --reload + ;; + ufw) + read -r subnet port <<<"$arg" + say "Removing the ufw rule" + run sudo ufw delete allow from "$subnet" to any port "$port" proto tcp || true + ;; + udev) + say "Removing the udev rule, sudo needed" + run sudo rm -f "$arg" + run sudo udevadm control --reload + ;; + esac + done < <(tac "$STATE") +else + echo "No install record found in $STATE; removing the program files only." +fi + +say "Removing the program" +for cmd in dgm20ctl dgm20-web; do + if [ -L "$BIN/$cmd" ] && [[ $(readlink "$BIN/$cmd") == "$DATA"/* ]]; then + run rm -f "$BIN/$cmd" + fi +done +run rm -rf "$DATA" +say "Done."