Add install/uninstall scripts and rewrite the README around them

install.sh sets up a private virtualenv, the udev rule (group picked
automatically), and optionally a systemd user service (--service) and
LAN access with a subnet-limited firewalld/ufw rule (--lan). It records
what it changed so uninstall.sh can undo exactly that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:48:49 -05:00
co-authored by Claude Opus 5.5
parent d239c214b4
commit 690c9dbebd
4 changed files with 349 additions and 43 deletions
+103 -33
View File
@@ -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
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
-9
View File
@@ -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
Executable
+181
View File
@@ -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 <<EOF
Usage: ./install.sh [options]
--service start the web page now and at login (systemd user service)
--lan listen on all interfaces and open the port to your local
subnet in firewalld or ufw (implies --service). There is no
login on the page: anyone on that subnet can change settings.
--port N web page port (default $PORT)
--group NAME group allowed to use the mic over ssh (default: first of
plugdev, wheel, sudo that you belong to)
--no-udev skip the udev rule
--dry-run show system changes instead of making them
-h, --help show this help
EOF
}
while [ $# -gt 0 ]; do
case $1 in
--service) SERVICE=1 ;;
--lan) LAN=1; SERVICE=1 ;;
--port) PORT=${2:?--port needs a number}; shift ;;
--group) GROUP=${2:?--group needs a name}; shift ;;
--no-udev) UDEV=0 ;;
--dry-run) DRY=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&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"
Executable
+64
View File
@@ -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."