DGM20 control for Linux: protocol library, CLI and local web UI
Reverse-engineered from Maono Link 4.0.88 (DGM20_OLD.dll) and verified against a DGM20 (352f:0105, firmware 2.1.2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Justin Reiners
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# DGM20 control protocol
|
||||||
|
|
||||||
|
These notes come from Maono Link 4.0.88 for Windows. The app loads a per-model plugin, and the
|
||||||
|
plugin for USB `352f:0105` is `DGM20_OLD.dll`. Every value below was checked against a real mic
|
||||||
|
(firmware 2.1.2) unless it is marked otherwise.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
The mic has USB audio interfaces 0-2 and a HID interface 3. Interface 3 carries a vendor usage page
|
||||||
|
(`0xFF01`) with four reports:
|
||||||
|
|
||||||
|
| report | direction | size |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| 0x50 | out | 15 |
|
||||||
|
| 0x51 | out | 63 |
|
||||||
|
| 0x10 | in | 15 |
|
||||||
|
| 0x11 | in | 63 |
|
||||||
|
|
||||||
|
The app uses only 0x51 (requests) and 0x11 (replies). On Linux the interface is a hidraw node: a
|
||||||
|
65-byte `write()` (the report id plus 64 data bytes) sends a request, and `read()` returns replies.
|
||||||
|
|
||||||
|
## Packet
|
||||||
|
|
||||||
|
```
|
||||||
|
offset size field
|
||||||
|
0 1 report id: 0x51 request, 0x11 reply
|
||||||
|
1 1 0xC4
|
||||||
|
2 1 length of bytes 1..end, including the checksum
|
||||||
|
3 2 0x0000
|
||||||
|
5 1 type
|
||||||
|
6 n payload, u16 little-endian words
|
||||||
|
6+n 2 checksum, u16 LE = -(sum of bytes 1 .. 5+n)
|
||||||
|
```
|
||||||
|
|
||||||
|
The rest of the 64 bytes is zero-padded.
|
||||||
|
|
||||||
|
| type | request payload | reply |
|
||||||
|
|------|-----------------|-------|
|
||||||
|
| 2 | (reply only) | write ack: `status` (0 = ok) |
|
||||||
|
| 3 | `addr, value` pairs | type-2 ack. The mic also sends type-3 packets on its own when a setting changes on the hardware, such as the touch pad. |
|
||||||
|
| 4 | `addr` | `addr, value` |
|
||||||
|
| 5 | `addr, count, data...` | bulk write. The app uses it only to write the serial number; don't. |
|
||||||
|
| 6 | `addr, count` | `addr, count, values...` (range read) |
|
||||||
|
|
||||||
|
## Registers
|
||||||
|
|
||||||
|
| reg | meaning | values |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| 0x0003 | model name, 8 words, ASCII | `"DGM20"` |
|
||||||
|
| 0x000B | firmware version, 3 words | `2, 1, 2` |
|
||||||
|
| 0x001E | serial number, up to 20 words | |
|
||||||
|
| 0x1008 | DFU mode (read) | 0 = normal |
|
||||||
|
| 0x100A | factory reset | write 1 |
|
||||||
|
| 0x2022 | mute | 0 / 1 |
|
||||||
|
| 0x2026 | headphone monitoring | 2000 = on, 3000 = off |
|
||||||
|
| 0x2027 | noise cancellation | 0 / 1 |
|
||||||
|
| 0x2028 | noise cancellation strength | 0-100 |
|
||||||
|
| 0x202A | RGB ring brightness | 0-100 (the app does not show it for this model) |
|
||||||
|
| 0x202C | vocal enhancement | 0 / 1 |
|
||||||
|
| 0x204B | level-meter enable | not answered by firmware 2.1.2 |
|
||||||
|
|
||||||
|
Maono Link reads settings once, in this order: 0x204B, then 0x2022, then a range read of 0x2027
|
||||||
|
(count 2), then 0x2026, then 0x202C. After that it relies on type-3 packets the mic sends on its
|
||||||
|
own.
|
||||||
|
|
||||||
|
## Present in the plugin but not used for this model
|
||||||
|
|
||||||
|
The DLL shares widgets with the PD200X plugin, so it contains EQ and RGB-colour code. That code
|
||||||
|
is never instantiated for the DGM20, and the mic does not answer reads of these registers. They
|
||||||
|
are listed here for anyone exploring.
|
||||||
|
|
||||||
|
- **EQ:** 5 parametric bands, each 5 words, starting at 0x204D + 5·i. The HPF is at 0x2066 and the
|
||||||
|
LPF at 0x206B. Each band is `[enable, type, freq Hz, gain dB×10+2000, Q×100]`.
|
||||||
|
- **Light on/off:** 0x2036. **Colour index:** 0x2038.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Noise cancellation on/off and strength
|
||||||
|
- Vocal enhancement
|
||||||
|
- Hear yourself (headphone monitoring)
|
||||||
|
- Mute, kept in sync with the mic's touch pad
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo cp udev/70-maono-dgm20.rules /etc/udev/rules.d/
|
||||||
|
sudo udevadm control --reload && sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
dgm20ctl factory-reset --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
To keep the page running in the background:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PYTHONPATH=src python3 -m unittest discover -s tests
|
||||||
|
PYTHONPATH=src python3 -m dgm20.web
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Maono DGM20 web control panel (http://localhost:8720)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=%h/.local/bin/dgm20-web
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=64"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "dgm20-linux"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Control the Maono DGM20 USB microphone on Linux: noise cancellation, monitoring, vocal enhancement, mute, RGB brightness"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = []
|
||||||
|
classifiers = ["Operating System :: POSIX :: Linux", "Topic :: Multimedia :: Sound/Audio"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
dgm20ctl = "dgm20.cli:main"
|
||||||
|
dgm20-web = "dgm20.web:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
dgm20 = ["static/*.html"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Linux control for the Maono DGM20 USB microphone."""
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""dgm20ctl: command-line control for the Maono DGM20."""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .device import SETTINGS, DeviceError, Mic, describe
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(value):
|
||||||
|
if value is None:
|
||||||
|
return "?"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "on" if value else "off"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
prog="dgm20ctl",
|
||||||
|
description="Control Maono DGM20 microphone settings.",
|
||||||
|
epilog="settings: " + ", ".join(f"{n} ({describe(e)})" for n, (_, e, _) in SETTINGS.items()))
|
||||||
|
ap.add_argument("--dev", help="hidraw node (default: autodetect)")
|
||||||
|
sub = ap.add_subparsers(dest="cmd")
|
||||||
|
sub.add_parser("status", help="show current settings (default)")
|
||||||
|
sub.add_parser("info", help="model and firmware version")
|
||||||
|
sub.add_parser("watch", help="print changes made on the mic itself")
|
||||||
|
s = sub.add_parser("set", help="change settings, e.g. set nc on nc-level 80")
|
||||||
|
s.add_argument("pairs", nargs="+", metavar="NAME VALUE")
|
||||||
|
r = sub.add_parser("factory-reset", help="restore factory defaults")
|
||||||
|
r.add_argument("--yes", action="store_true", help="confirm the reset")
|
||||||
|
raw = sub.add_parser("raw", help="raw register access, for exploring")
|
||||||
|
raw.add_argument("op", choices=["get", "set", "dump"])
|
||||||
|
raw.add_argument("addr", type=lambda v: int(v, 0))
|
||||||
|
raw.add_argument("value", type=lambda v: int(v, 0), nargs="?", help="value (set) or count (dump)")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if a.cmd == "set":
|
||||||
|
if len(a.pairs) % 2:
|
||||||
|
ap.error("set takes NAME VALUE pairs")
|
||||||
|
for name in a.pairs[0::2]:
|
||||||
|
if name not in SETTINGS:
|
||||||
|
ap.error(f"unknown setting {name!r}; known: {', '.join(SETTINGS)}")
|
||||||
|
if a.cmd == "factory-reset" and not a.yes:
|
||||||
|
ap.error("factory-reset resets every setting on the mic; rerun with --yes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Mic(a.dev) as mic:
|
||||||
|
run(mic, a)
|
||||||
|
except (DeviceError, ValueError) as e:
|
||||||
|
sys.exit(f"dgm20ctl: {e}")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run(mic, a):
|
||||||
|
if a.cmd in (None, "status"):
|
||||||
|
for name, value in mic.state().items():
|
||||||
|
print(f"{name:11} {fmt(value):>5} {SETTINGS[name][2]}")
|
||||||
|
elif a.cmd == "info":
|
||||||
|
for k, v in mic.info().items():
|
||||||
|
print(f"{k + ':':9} {v or '?'}")
|
||||||
|
elif a.cmd == "watch":
|
||||||
|
print("watching for changes made on the mic, ctrl-c to stop")
|
||||||
|
for name, value in mic.events():
|
||||||
|
print(f"{name} = {fmt(value)}", flush=True)
|
||||||
|
elif a.cmd == "set":
|
||||||
|
for name, value in zip(a.pairs[0::2], a.pairs[1::2]):
|
||||||
|
print(f"{name} = {fmt(mic.set(name, value))}")
|
||||||
|
elif a.cmd == "factory-reset":
|
||||||
|
print("settings restored to factory defaults" if mic.factory_reset()
|
||||||
|
else "the mic rejected the reset")
|
||||||
|
elif a.op == "get":
|
||||||
|
v = mic.read(a.addr)
|
||||||
|
print("no reply" if v is None else v)
|
||||||
|
elif a.op == "set":
|
||||||
|
if a.value is None:
|
||||||
|
raise ValueError("raw set needs a value")
|
||||||
|
print(f"{a.addr:#06x} = {mic.read(a.addr)}" if mic.write(a.addr, a.value) else "rejected")
|
||||||
|
else:
|
||||||
|
vals = mic.read_range(a.addr, a.value or 1)
|
||||||
|
if vals is None:
|
||||||
|
raise DeviceError("no reply")
|
||||||
|
for i, v in enumerate(vals):
|
||||||
|
print(f"{a.addr + i:#06x} {v:5} {v:#06x}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Maono DGM20 vendor HID protocol.
|
||||||
|
|
||||||
|
The mic exposes a vendor HID channel next to its USB audio interfaces. The
|
||||||
|
host writes output report 0x51 and the mic answers with input report 0x11.
|
||||||
|
Both share one framing:
|
||||||
|
|
||||||
|
[0] report id (0x51 out / 0x11 in)
|
||||||
|
[1] 0xC4 magic
|
||||||
|
[2] length of bytes [1..] including the checksum
|
||||||
|
[3-4] 00 00
|
||||||
|
[5] type: 2 = write ack (payload u16 status, 0 = ok)
|
||||||
|
3 = write (addr,val) pairs; the mic also sends these when
|
||||||
|
a setting changes on the hardware
|
||||||
|
4 = read one register
|
||||||
|
5 = bulk write
|
||||||
|
6 = read a range (addr, count)
|
||||||
|
[6..] payload, u16 little-endian
|
||||||
|
[-2:] checksum u16 LE = -(sum of bytes [1..end-2])
|
||||||
|
|
||||||
|
Settings are 16-bit registers. Input gain and headphone volume are standard
|
||||||
|
USB audio controls and are not part of this channel.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import threading
|
||||||
|
|
||||||
|
VID, PID = 0x352F, 0x0105
|
||||||
|
REPORT_OUT, REPORT_IN, MAGIC = 0x51, 0x11, 0xC4
|
||||||
|
T_ACK, T_WRITE, T_READ, T_BULK, T_RANGE = 2, 3, 4, 5, 6
|
||||||
|
|
||||||
|
# name: (register, encoding, description)
|
||||||
|
# encodings: "bool" = 0/1, (lo, hi) = integer range, dict = word -> raw value
|
||||||
|
SETTINGS = {
|
||||||
|
"mute": (0x2022, "bool", "Mute the mic (same as the touch pad)"),
|
||||||
|
"nc": (0x2027, "bool", "Noise cancellation"),
|
||||||
|
"nc-level": (0x2028, (0, 100), "Noise cancellation strength"),
|
||||||
|
"enhance": (0x202C, "bool", "Vocal enhancement"),
|
||||||
|
"monitor": (0x2026, {"on": 2000, "off": 3000}, "Hear yourself in headphones"),
|
||||||
|
"brightness": (0x202A, (0, 100), "RGB ring brightness"),
|
||||||
|
}
|
||||||
|
REG_FACTORY_RESET = 0x100A
|
||||||
|
REG_DFU = 0x1008
|
||||||
|
REG_MODEL, REG_VERSION = 0x0003, 0x000B
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def find_hidraw():
|
||||||
|
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(node, "device/uevent")) as f:
|
||||||
|
uevent = f.read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if f"HID_ID=0003:{VID:08X}:{PID:08X}" in uevent:
|
||||||
|
return "/dev/" + os.path.basename(node)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def frame(typ, words):
|
||||||
|
body = bytearray([MAGIC, 0, 0, 0, typ])
|
||||||
|
for w in words:
|
||||||
|
body += bytes([w & 0xFF, (w >> 8) & 0xFF])
|
||||||
|
body[1] = len(body) + 2
|
||||||
|
ck = -sum(body) & 0xFFFF
|
||||||
|
body += bytes([ck & 0xFF, ck >> 8])
|
||||||
|
return bytes([REPORT_OUT]) + bytes(body) + bytes(64 - len(body))
|
||||||
|
|
||||||
|
|
||||||
|
def parse(r):
|
||||||
|
"""Return (type, [u16 words]) for a well-formed reply, else None."""
|
||||||
|
if len(r) < 8 or r[0] != REPORT_IN or r[1] != MAGIC:
|
||||||
|
return None
|
||||||
|
n = r[2]
|
||||||
|
if n < 7 or n + 1 > len(r) or (sum(r[1:n - 1]) + (r[n - 1] | r[n] << 8)) & 0xFFFF:
|
||||||
|
return None
|
||||||
|
payload = r[6:n - 1]
|
||||||
|
return r[5], [payload[i] | payload[i + 1] << 8 for i in range(0, len(payload) - 1, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def decode(name, raw):
|
||||||
|
"""Raw register value -> friendly value (bool, int or str)."""
|
||||||
|
enc = SETTINGS[name][1]
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if enc == "bool":
|
||||||
|
return bool(raw)
|
||||||
|
if isinstance(enc, dict):
|
||||||
|
return next((k for k, v in enc.items() if v == raw), raw)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def encode(name, value):
|
||||||
|
"""Friendly value (bool, int, or 'on'/'off'/number string) -> raw register value."""
|
||||||
|
if name not in SETTINGS:
|
||||||
|
raise ValueError(f"unknown setting {name!r}; known: {', '.join(SETTINGS)}")
|
||||||
|
enc = SETTINGS[name][1]
|
||||||
|
if isinstance(enc, tuple):
|
||||||
|
if isinstance(value, str) and value.strip().isdigit():
|
||||||
|
value = int(value)
|
||||||
|
if isinstance(value, int) and not isinstance(value, bool) and enc[0] <= value <= enc[1]:
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = {"on": True, "true": True, "1": True,
|
||||||
|
"off": False, "false": False, "0": False}.get(value.strip().lower(), value)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return enc["on" if value else "off"] if isinstance(enc, dict) else int(value)
|
||||||
|
raise ValueError(f"{name}: expected {describe(enc)}, got {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def describe(enc):
|
||||||
|
if isinstance(enc, tuple):
|
||||||
|
return f"{enc[0]}-{enc[1]}"
|
||||||
|
return "on|off"
|
||||||
|
|
||||||
|
|
||||||
|
def words_to_ascii(words):
|
||||||
|
return bytes(b for w in words for b in (w & 0xFF, w >> 8)).split(b"\0")[0].decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
class Mic:
|
||||||
|
"""One open handle to the mic. Thread-safe: each transaction holds a lock."""
|
||||||
|
|
||||||
|
def __init__(self, path=None):
|
||||||
|
self.path = path or find_hidraw()
|
||||||
|
if not self.path:
|
||||||
|
raise DeviceError("DGM20 (352f:0105) not found")
|
||||||
|
try:
|
||||||
|
self.fd = os.open(self.path, os.O_RDWR)
|
||||||
|
except PermissionError:
|
||||||
|
raise DeviceError(f"no permission to open {self.path}; install the udev rule") from None
|
||||||
|
except OSError as e:
|
||||||
|
raise DeviceError(f"cannot open {self.path}: {e.strerror}") from None
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self._drain()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
os.close(self.fd)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _drain(self):
|
||||||
|
while select.select([self.fd], [], [], 0)[0]:
|
||||||
|
os.read(self.fd, 64)
|
||||||
|
|
||||||
|
def _io(self, typ, words, accept, timeout=0.5):
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self._drain()
|
||||||
|
os.write(self.fd, frame(typ, words))
|
||||||
|
while select.select([self.fd], [], [], timeout)[0]:
|
||||||
|
p = parse(os.read(self.fd, 64))
|
||||||
|
if p and accept(p):
|
||||||
|
return p[1]
|
||||||
|
except OSError as e:
|
||||||
|
raise DeviceError(f"mic disconnected ({e.strerror})") from None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self, addr):
|
||||||
|
w = self._io(T_READ, [addr], lambda p: p[0] == T_READ and p[1][:1] == [addr])
|
||||||
|
return None if w is None else w[1]
|
||||||
|
|
||||||
|
def read_range(self, addr, count):
|
||||||
|
w = self._io(T_RANGE, [addr, count], lambda p: p[0] == T_RANGE and p[1][:1] == [addr])
|
||||||
|
return None if w is None else w[2:2 + count]
|
||||||
|
|
||||||
|
def write(self, addr, value):
|
||||||
|
w = self._io(T_WRITE, [addr, value], lambda p: p[0] == T_ACK)
|
||||||
|
return w == [0]
|
||||||
|
|
||||||
|
# friendly layer
|
||||||
|
|
||||||
|
def get(self, name):
|
||||||
|
return decode(name, self.read(SETTINGS[name][0]))
|
||||||
|
|
||||||
|
def set(self, name, value):
|
||||||
|
raw = encode(name, value)
|
||||||
|
if not self.write(SETTINGS[name][0], raw):
|
||||||
|
raise DeviceError(f"mic rejected {name} = {value}")
|
||||||
|
return self.get(name)
|
||||||
|
|
||||||
|
def state(self):
|
||||||
|
return {name: self.get(name) for name in SETTINGS}
|
||||||
|
|
||||||
|
def info(self):
|
||||||
|
model = self.read_range(REG_MODEL, 8)
|
||||||
|
ver = self.read_range(REG_VERSION, 3)
|
||||||
|
return {
|
||||||
|
"model": words_to_ascii(model) if model else None,
|
||||||
|
"firmware": ".".join(map(str, ver)) if ver else None,
|
||||||
|
"path": self.path,
|
||||||
|
}
|
||||||
|
|
||||||
|
def factory_reset(self):
|
||||||
|
return self.write(REG_FACTORY_RESET, 1)
|
||||||
|
|
||||||
|
def events(self):
|
||||||
|
"""Yield (name_or_addr, value) for changes the mic reports on its own."""
|
||||||
|
by_addr = {a: n for n, (a, _, _) in SETTINGS.items()}
|
||||||
|
while True:
|
||||||
|
select.select([self.fd], [], [])
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
p = parse(os.read(self.fd, 64))
|
||||||
|
except OSError as e:
|
||||||
|
raise DeviceError(f"mic disconnected ({e.strerror})") from None
|
||||||
|
if not p or p[0] != T_WRITE:
|
||||||
|
continue
|
||||||
|
w = p[1]
|
||||||
|
for addr, val in zip(w[0::2], w[1::2]):
|
||||||
|
name = by_addr.get(addr)
|
||||||
|
yield (name, decode(name, val)) if name else (f"{addr:#06x}", val)
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DGM20 Control</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='13' fill='none' stroke='%232E7D5B' stroke-width='4'/%3E%3Ccircle cx='16' cy='16' r='6' fill='%231D232B'/%3E%3C/svg%3E">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--base: #E4E6E3;
|
||||||
|
--panel: #F4F5F2;
|
||||||
|
--ink: #1D232B;
|
||||||
|
--ink-2: #5B6470;
|
||||||
|
--rule: #CFD3CF;
|
||||||
|
--live: #2E7D5B;
|
||||||
|
--muted: #C8442B;
|
||||||
|
--off: #9AA1A8;
|
||||||
|
--grille: #2A3038;
|
||||||
|
--grille-dot: #3A424C;
|
||||||
|
--focus: #1F6FD1;
|
||||||
|
--ring-glow: 1;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--base: #16191D;
|
||||||
|
--panel: #1F2327;
|
||||||
|
--ink: #E8EAEC;
|
||||||
|
--ink-2: #9AA3AD;
|
||||||
|
--rule: #30363C;
|
||||||
|
--live: #4FB386;
|
||||||
|
--muted: #E5634A;
|
||||||
|
--off: #59616A;
|
||||||
|
--grille: #0F1215;
|
||||||
|
--grille-dot: #262C33;
|
||||||
|
--focus: #6AA8FF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--base);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Cantarell, "Noto Sans", sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 16px 48px;
|
||||||
|
}
|
||||||
|
.shell { width: 100%; max-width: 460px; }
|
||||||
|
|
||||||
|
header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 28px; }
|
||||||
|
.wordmark { font-size: 1.375rem; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
|
||||||
|
.wordmark span { font-weight: 400; color: var(--ink-2); }
|
||||||
|
.conn { font-size: 0.8125rem; color: var(--ink-2); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--off); }
|
||||||
|
.conn[data-on="1"]::before { background: var(--live); }
|
||||||
|
|
||||||
|
/* The mute pad: the mic's capacitive top, seen from above. */
|
||||||
|
.pad-wrap { display: flex; flex-direction: column; align-items: center; margin-bottom: 32px; }
|
||||||
|
.pad {
|
||||||
|
--state: var(--live);
|
||||||
|
position: relative;
|
||||||
|
width: min(232px, 64vw);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 50%, var(--grille-dot) 1.6px, transparent 2.2px) 0 0 / 11px 11px,
|
||||||
|
var(--grille);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 6px var(--panel),
|
||||||
|
0 0 0 10px color-mix(in srgb, var(--state) calc(var(--ring-glow) * 100%), var(--rule)),
|
||||||
|
0 0 calc(var(--ring-glow) * 46px) calc(var(--ring-glow) * 6px) color-mix(in srgb, var(--state) 45%, transparent);
|
||||||
|
transition: box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
.pad[aria-pressed="true"] { --state: var(--muted); }
|
||||||
|
.pad:disabled { --state: var(--off); --ring-glow: 0; cursor: default; }
|
||||||
|
.pad:focus-visible { outline: 3px solid var(--focus); outline-offset: 18px; }
|
||||||
|
.pad svg { position: absolute; inset: 0; margin: auto; width: 34%; height: 34%; color: #F4F5F2; opacity: .92; }
|
||||||
|
.pad .slash { opacity: 0; transition: opacity 160ms ease; }
|
||||||
|
.pad[aria-pressed="true"] .slash { opacity: 1; }
|
||||||
|
.pad-state { margin: 26px 0 2px; font-size: 1.25rem; font-weight: 650; color: var(--state-ink, var(--live)); }
|
||||||
|
.pad-state[data-s="muted"] { --state-ink: var(--muted); }
|
||||||
|
.pad-state[data-s="none"] { --state-ink: var(--ink-2); }
|
||||||
|
.pad-hint { font-size: 0.875rem; color: var(--ink-2); margin: 0; }
|
||||||
|
|
||||||
|
/* Settings list */
|
||||||
|
.settings { background: var(--panel); border-radius: 14px; padding: 4px 20px; margin: 0; list-style: none; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr auto; gap: 4px 16px; align-items: center; padding: 16px 0; }
|
||||||
|
.row + .row { border-top: 1px solid var(--rule); }
|
||||||
|
.row.sub { border-top: 0; padding-top: 0; }
|
||||||
|
.row h2 { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
.row p { grid-column: 1; font-size: 0.875rem; color: var(--ink-2); margin: 0; }
|
||||||
|
.row .ctl { grid-column: 2; grid-row: 1 / span 2; }
|
||||||
|
.row.slider { grid-template-columns: auto 1fr 3ch; gap: 14px; }
|
||||||
|
.row.slider label { font-size: 0.9375rem; color: var(--ink-2); }
|
||||||
|
.row.slider output { font-variant-numeric: tabular-nums; text-align: right; font-weight: 600; }
|
||||||
|
.row[data-disabled="1"] { opacity: .45; }
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
width: 48px; height: 28px; border-radius: 14px; border: 0; padding: 3px; cursor: pointer;
|
||||||
|
background: var(--off); display: flex; transition: background 160ms ease;
|
||||||
|
}
|
||||||
|
.switch::after {
|
||||||
|
content: ""; width: 22px; height: 22px; border-radius: 50%; background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.25); transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
.switch[aria-checked="true"] { background: var(--live); }
|
||||||
|
.switch[aria-checked="true"]::after { transform: translateX(20px); }
|
||||||
|
.switch:focus-visible, input[type=range]:focus-visible, .link:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
|
||||||
|
.switch:disabled { cursor: default; }
|
||||||
|
|
||||||
|
input[type=range] { width: 100%; accent-color: var(--live); margin: 0; }
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
background: var(--panel); border-left: 4px solid var(--muted); border-radius: 8px;
|
||||||
|
padding: 14px 16px; margin-bottom: 20px; font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.notice[hidden] { display: none; }
|
||||||
|
.notice strong { display: block; margin-bottom: 2px; }
|
||||||
|
.notice code { font-size: 0.8125rem; word-break: break-all; }
|
||||||
|
|
||||||
|
footer { margin-top: 28px; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 0.8125rem; color: var(--ink-2); }
|
||||||
|
.link { background: none; border: 0; padding: 0; font: inherit; color: var(--ink-2); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
|
||||||
|
.link.danger { color: var(--muted); }
|
||||||
|
.reset-confirm { display: flex; gap: 14px; align-items: baseline; }
|
||||||
|
.reset-confirm[hidden] { display: none; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<header>
|
||||||
|
<h1 class="wordmark">DGM20 <span>control</span></h1>
|
||||||
|
<div class="conn" id="conn" data-on="0">Connecting</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="notice" id="notice" hidden>
|
||||||
|
<strong id="notice-title"></strong>
|
||||||
|
<span id="notice-body"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pad-wrap">
|
||||||
|
<button class="pad" id="pad" aria-pressed="false" aria-label="Mute microphone" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="9" y="3" width="6" height="11" rx="3"/>
|
||||||
|
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M8.5 21h7"/>
|
||||||
|
<path class="slash" d="M4 4l16 16" stroke-width="2.2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<p class="pad-state" id="pad-state" data-s="none">Waiting for mic</p>
|
||||||
|
<p class="pad-hint" id="pad-hint"> </p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="settings">
|
||||||
|
<li class="row">
|
||||||
|
<h2 id="l-nc">Noise cancellation</h2>
|
||||||
|
<p>Filters out keyboard clatter, fans and room hum.</p>
|
||||||
|
<button class="switch ctl" role="switch" aria-checked="false" aria-labelledby="l-nc" data-name="nc" disabled></button>
|
||||||
|
</li>
|
||||||
|
<li class="row slider sub" id="nc-level-row">
|
||||||
|
<label for="nc-level">Strength</label>
|
||||||
|
<input type="range" id="nc-level" min="0" max="100" step="1" data-name="nc-level" disabled>
|
||||||
|
<output for="nc-level" id="nc-level-out">–</output>
|
||||||
|
</li>
|
||||||
|
<li class="row">
|
||||||
|
<h2 id="l-enhance">Vocal enhancement</h2>
|
||||||
|
<p>Brings your voice forward over background sound.</p>
|
||||||
|
<button class="switch ctl" role="switch" aria-checked="false" aria-labelledby="l-enhance" data-name="enhance" disabled></button>
|
||||||
|
</li>
|
||||||
|
<li class="row">
|
||||||
|
<h2 id="l-monitor">Hear yourself</h2>
|
||||||
|
<p>Plays the mic through headphones plugged into the DGM20.</p>
|
||||||
|
<button class="switch ctl" role="switch" aria-checked="false" aria-labelledby="l-monitor" data-name="monitor" disabled></button>
|
||||||
|
</li>
|
||||||
|
<li class="row slider">
|
||||||
|
<label for="brightness">Ring brightness</label>
|
||||||
|
<input type="range" id="brightness" min="0" max="100" step="1" data-name="brightness" disabled>
|
||||||
|
<output for="brightness" id="brightness-out">–</output>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span id="fw">Unofficial tool, not affiliated with Maono.</span>
|
||||||
|
<span>
|
||||||
|
<button class="link" id="reset">Restore factory settings</button>
|
||||||
|
<span class="reset-confirm" id="reset-confirm" hidden>
|
||||||
|
<span>Reset every setting?</span>
|
||||||
|
<button class="link danger" id="reset-yes">Reset</button>
|
||||||
|
<button class="link" id="reset-no">Cancel</button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const pad = $("pad");
|
||||||
|
const switches = [...document.querySelectorAll(".switch")];
|
||||||
|
const sliders = [...document.querySelectorAll("input[type=range]")];
|
||||||
|
let state = null;
|
||||||
|
let busy = new Set(); // settings with a write in flight or a drag in progress
|
||||||
|
let lastConnected = null;
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const opts = body === undefined ? {} : {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok && !("connected" in data)) throw new Error(data.error || `HTTP ${r.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// body is plain text; `code` (optional) is shown after it in a <code> element
|
||||||
|
function notice(title, body, code) {
|
||||||
|
$("notice").hidden = !title;
|
||||||
|
$("notice-title").textContent = title || "";
|
||||||
|
const b = $("notice-body");
|
||||||
|
b.textContent = body || "";
|
||||||
|
if (code) { const c = document.createElement("code"); c.textContent = code; b.append(" ", c); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(snap) {
|
||||||
|
const on = !!snap.connected;
|
||||||
|
$("conn").dataset.on = on ? "1" : "0";
|
||||||
|
$("conn").textContent = on ? "Connected" : "Not connected";
|
||||||
|
pad.disabled = !on;
|
||||||
|
switches.forEach((s) => (s.disabled = !on));
|
||||||
|
sliders.forEach((s) => (s.disabled = !on));
|
||||||
|
|
||||||
|
if (!on) {
|
||||||
|
const err = snap.error || "";
|
||||||
|
if (/permission/i.test(err)) {
|
||||||
|
notice("The mic is plugged in, but this page can't talk to it.",
|
||||||
|
"Install the udev rule, then unplug and replug the mic:", "sudo cp udev/70-maono-dgm20.rules /etc/udev/rules.d/");
|
||||||
|
} else {
|
||||||
|
notice("Mic not found.", "Plug in the DGM20. This page reconnects on its own.");
|
||||||
|
}
|
||||||
|
$("pad-state").textContent = "Waiting for mic";
|
||||||
|
$("pad-state").dataset.s = "none";
|
||||||
|
$("pad-hint").textContent = "\u00a0";
|
||||||
|
lastConnected = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notice(null);
|
||||||
|
state = snap.state;
|
||||||
|
if (snap.info && snap.info.firmware) {
|
||||||
|
$("fw").textContent = `Firmware ${snap.info.firmware}. Unofficial tool, not affiliated with Maono.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!busy.has("mute")) {
|
||||||
|
pad.setAttribute("aria-pressed", String(!!state.mute));
|
||||||
|
pad.setAttribute("aria-label", state.mute ? "Unmute microphone" : "Mute microphone");
|
||||||
|
$("pad-state").textContent = state.mute ? "Muted" : "Live";
|
||||||
|
$("pad-state").dataset.s = state.mute ? "muted" : "live";
|
||||||
|
$("pad-hint").textContent = state.mute ? "Click to unmute" : "Click to mute";
|
||||||
|
}
|
||||||
|
for (const s of switches) {
|
||||||
|
const n = s.dataset.name;
|
||||||
|
if (busy.has(n)) continue;
|
||||||
|
const v = state[n];
|
||||||
|
s.setAttribute("aria-checked", String(v === true || v === "on"));
|
||||||
|
}
|
||||||
|
for (const s of sliders) {
|
||||||
|
const n = s.dataset.name;
|
||||||
|
if (busy.has(n) || state[n] == null) continue;
|
||||||
|
s.value = state[n];
|
||||||
|
$(`${n}-out`).textContent = state[n];
|
||||||
|
}
|
||||||
|
$("nc-level-row").dataset.disabled = state.nc ? "0" : "1";
|
||||||
|
document.documentElement.style.setProperty("--ring-glow", ((state.brightness ?? 100) / 100 * 0.85 + 0.15).toFixed(2));
|
||||||
|
lastConnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try { render(await api("/api/state")); }
|
||||||
|
catch (e) { render({ connected: false, error: "server unreachable" }); notice("This page lost its server.", "Start it again with", "dgm20-web"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setValue(name, value) {
|
||||||
|
busy.add(name);
|
||||||
|
try { render(await api("/api/set", { name, value })); }
|
||||||
|
catch (e) { notice(`Couldn't change ${name}.`, e.message); }
|
||||||
|
finally { busy.delete(name); }
|
||||||
|
}
|
||||||
|
|
||||||
|
pad.addEventListener("click", () => {
|
||||||
|
const next = pad.getAttribute("aria-pressed") !== "true";
|
||||||
|
pad.setAttribute("aria-pressed", String(next));
|
||||||
|
setValue("mute", next);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const s of switches) {
|
||||||
|
s.addEventListener("click", () => {
|
||||||
|
const next = s.getAttribute("aria-checked") !== "true";
|
||||||
|
s.setAttribute("aria-checked", String(next));
|
||||||
|
setValue(s.dataset.name, next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of sliders) {
|
||||||
|
const n = s.dataset.name;
|
||||||
|
let timer = null;
|
||||||
|
s.addEventListener("input", () => {
|
||||||
|
busy.add(n);
|
||||||
|
$(`${n}-out`).textContent = s.value;
|
||||||
|
if (n === "brightness") document.documentElement.style.setProperty("--ring-glow", (s.value / 100 * 0.85 + 0.15).toFixed(2));
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => api("/api/set", { name: n, value: Number(s.value) }).catch(() => {}), 120);
|
||||||
|
});
|
||||||
|
s.addEventListener("change", () => { clearTimeout(timer); setValue(n, Number(s.value)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
$("reset").addEventListener("click", () => { $("reset").hidden = true; $("reset-confirm").hidden = false; $("reset-yes").focus(); });
|
||||||
|
$("reset-no").addEventListener("click", () => { $("reset").hidden = false; $("reset-confirm").hidden = true; });
|
||||||
|
$("reset-yes").addEventListener("click", async () => {
|
||||||
|
$("reset-confirm").hidden = true; $("reset").hidden = false;
|
||||||
|
try { await api("/api/factory-reset", { confirm: true }); setTimeout(poll, 2500); }
|
||||||
|
catch (e) { notice("The reset didn't go through.", e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
poll();
|
||||||
|
setInterval(() => { if (!document.hidden) poll(); }, 1500);
|
||||||
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""dgm20-web: a small local web UI for the Maono DGM20.
|
||||||
|
|
||||||
|
Serves one page plus a JSON API. Binds to 127.0.0.1 unless told otherwise.
|
||||||
|
Requests from other sites are refused: the Host header must match, and API
|
||||||
|
writes must be application/json, which browsers won't send cross-origin
|
||||||
|
without a CORS preflight that this server never approves.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
|
from .device import SETTINGS, DeviceError, Mic, describe
|
||||||
|
|
||||||
|
INDEX = files("dgm20").joinpath("static/index.html").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
class MicHolder:
|
||||||
|
"""Keeps one Mic open and reopens it after an unplug."""
|
||||||
|
|
||||||
|
def __init__(self, path=None):
|
||||||
|
self.path, self.mic, self.info, self.lock = path, None, None, threading.Lock()
|
||||||
|
|
||||||
|
def call(self, fn):
|
||||||
|
with self.lock:
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
if self.mic is None:
|
||||||
|
self.mic = Mic(self.path)
|
||||||
|
self.info = self.mic.info()
|
||||||
|
return fn(self.mic)
|
||||||
|
except DeviceError:
|
||||||
|
if self.mic is not None:
|
||||||
|
try:
|
||||||
|
self.mic.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.mic = None
|
||||||
|
if attempt == 2:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
try:
|
||||||
|
state = self.call(lambda m: m.state())
|
||||||
|
return {"connected": True, "info": self.info, "state": state}
|
||||||
|
except DeviceError as e:
|
||||||
|
return {"connected": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def schema():
|
||||||
|
out = {}
|
||||||
|
for name, (_, enc, desc) in SETTINGS.items():
|
||||||
|
kind = {"type": "range", "min": enc[0], "max": enc[1]} if isinstance(enc, tuple) \
|
||||||
|
else {"type": "toggle"}
|
||||||
|
out[name] = {**kind, "label": desc, "accepts": describe(enc)}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_handler(holder, allowed_hosts):
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "dgm20-web"
|
||||||
|
|
||||||
|
def log_message(self, format, *args): # noqa: A002 (stdlib signature)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send(self, code, body, ctype="application/json"):
|
||||||
|
if not isinstance(body, bytes):
|
||||||
|
body = json.dumps(body).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Content-Security-Policy",
|
||||||
|
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def host_ok(self):
|
||||||
|
host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]")
|
||||||
|
if host in allowed_hosts:
|
||||||
|
return True
|
||||||
|
self.send(403, {"error": "unexpected Host header"})
|
||||||
|
return False
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.host_ok():
|
||||||
|
return
|
||||||
|
if self.path in ("/", "/index.html"):
|
||||||
|
self.send(200, INDEX, "text/html; charset=utf-8")
|
||||||
|
elif self.path == "/api/state":
|
||||||
|
self.send(200, holder.snapshot())
|
||||||
|
elif self.path == "/api/schema":
|
||||||
|
self.send(200, schema())
|
||||||
|
else:
|
||||||
|
self.send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if not self.host_ok():
|
||||||
|
return
|
||||||
|
if self.headers.get("Content-Type", "").split(";")[0] != "application/json":
|
||||||
|
return self.send(415, {"error": "send application/json"})
|
||||||
|
try:
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(min(n, 4096)) or b"{}")
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return self.send(400, {"error": "invalid JSON"})
|
||||||
|
try:
|
||||||
|
if self.path == "/api/set":
|
||||||
|
name, value = body.get("name"), body.get("value")
|
||||||
|
if name not in SETTINGS:
|
||||||
|
return self.send(400, {"error": f"unknown setting {name!r}"})
|
||||||
|
holder.call(lambda m: m.set(name, value))
|
||||||
|
self.send(200, holder.snapshot())
|
||||||
|
elif self.path == "/api/factory-reset":
|
||||||
|
if body.get("confirm") is not True:
|
||||||
|
return self.send(400, {"error": "confirm required"})
|
||||||
|
ok = holder.call(lambda m: m.factory_reset())
|
||||||
|
self.send(200 if ok else 502, {"ok": ok})
|
||||||
|
else:
|
||||||
|
self.send(404, {"error": "not found"})
|
||||||
|
except ValueError as e:
|
||||||
|
self.send(400, {"error": str(e)})
|
||||||
|
except DeviceError as e:
|
||||||
|
self.send(503, {"error": str(e)})
|
||||||
|
|
||||||
|
return Handler
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(prog="dgm20-web", description="Local web UI for the Maono DGM20.")
|
||||||
|
ap.add_argument("--host", default="127.0.0.1",
|
||||||
|
help="address to listen on (default 127.0.0.1; use 0.0.0.0 for your LAN, "
|
||||||
|
"there is no login)")
|
||||||
|
ap.add_argument("--port", type=int, default=8720)
|
||||||
|
ap.add_argument("--dev", help="hidraw node (default: autodetect)")
|
||||||
|
ap.add_argument("--open", action="store_true", help="open the page in your browser")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
|
||||||
|
allowed = {"localhost", "127.0.0.1", "::1"}
|
||||||
|
if a.host not in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
import socket
|
||||||
|
allowed |= {a.host, socket.gethostname(), socket.getfqdn()}
|
||||||
|
if a.host in ("0.0.0.0", "::"):
|
||||||
|
allowed |= {ip for ip in socket.gethostbyname_ex(socket.gethostname())[2]}
|
||||||
|
allowed |= set(_local_ips())
|
||||||
|
try:
|
||||||
|
srv = ThreadingHTTPServer((a.host, a.port), make_handler(MicHolder(a.dev), allowed))
|
||||||
|
except OSError as e:
|
||||||
|
raise SystemExit(f"dgm20-web: cannot listen on {a.host}:{a.port}: {e.strerror}"
|
||||||
|
" (already running? try --port)") from None
|
||||||
|
url = f"http://{'localhost' if a.host in ('127.0.0.1', '0.0.0.0', '::') else a.host}:{a.port}/"
|
||||||
|
print(f"dgm20-web listening on {url} (ctrl-c to stop)")
|
||||||
|
if a.open:
|
||||||
|
webbrowser.open(url)
|
||||||
|
try:
|
||||||
|
srv.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _local_ips():
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=2).stdout
|
||||||
|
return out.split()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Framing tests against packets captured from a real DGM20 (firmware 2.1.2)."""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from dgm20.device import decode, encode, frame, parse, words_to_ascii
|
||||||
|
|
||||||
|
|
||||||
|
def h(s):
|
||||||
|
return bytes.fromhex(s) + bytes(64 - len(bytes.fromhex(s)))
|
||||||
|
|
||||||
|
|
||||||
|
class Framing(unittest.TestCase):
|
||||||
|
def test_read_request_matches_app(self):
|
||||||
|
# checksum = -(0xC4+0x09+0x04+0x28+0x20) = -0x119 = 0xFEE7
|
||||||
|
p = frame(4, [0x2028])
|
||||||
|
self.assertEqual(p[:10].hex(" "), "51 c4 09 00 00 04 28 20 e7 fe")
|
||||||
|
self.assertEqual(len(p), 65)
|
||||||
|
|
||||||
|
def test_write_request_checksum(self):
|
||||||
|
p = frame(3, [0x2028, 64])
|
||||||
|
self.assertEqual(p[1:3], bytes([0xC4, 0x0B]))
|
||||||
|
self.assertEqual((sum(p[1:10]) + (p[10] | p[11] << 8)) & 0xFFFF, 0)
|
||||||
|
|
||||||
|
def test_parse_read_reply(self):
|
||||||
|
self.assertEqual(parse(h("11 c4 0b 00 00 04 28 20 40 00 a5 fe")), (4, [0x2028, 64]))
|
||||||
|
|
||||||
|
def test_parse_range_reply(self):
|
||||||
|
self.assertEqual(parse(h("11 c4 0f 00 00 06 27 20 02 00 01 00 40 00 9d fe")),
|
||||||
|
(6, [0x2027, 2, 1, 64]))
|
||||||
|
|
||||||
|
def test_parse_ack(self):
|
||||||
|
self.assertEqual(parse(h("11 c4 09 00 00 02 00 00 31 ff")), (2, [0]))
|
||||||
|
|
||||||
|
def test_parse_rejects_bad_checksum(self):
|
||||||
|
self.assertIsNone(parse(h("11 c4 0b 00 00 04 28 20 40 00 a5 ff")))
|
||||||
|
|
||||||
|
def test_model_name(self):
|
||||||
|
self.assertEqual(words_to_ascii([0x4744, 0x324D, 0x0030, 0]), "DGM20")
|
||||||
|
|
||||||
|
|
||||||
|
class Encoding(unittest.TestCase):
|
||||||
|
def test_bool(self):
|
||||||
|
self.assertEqual(encode("nc", "on"), 1)
|
||||||
|
self.assertEqual(encode("mute", False), 0)
|
||||||
|
self.assertIs(decode("nc", 1), True)
|
||||||
|
|
||||||
|
def test_monitor_uses_2000_3000(self):
|
||||||
|
self.assertEqual(encode("monitor", True), 2000)
|
||||||
|
self.assertEqual(encode("monitor", "off"), 3000)
|
||||||
|
self.assertEqual(decode("monitor", 2000), "on")
|
||||||
|
|
||||||
|
def test_range(self):
|
||||||
|
self.assertEqual(encode("nc-level", "0"), 0)
|
||||||
|
self.assertEqual(encode("nc-level", 100), 100)
|
||||||
|
for bad in (101, -1, True, "loud"):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
encode("nc-level", bad)
|
||||||
|
|
||||||
|
def test_unknown(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
encode("volume", 3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Maono DGM20 USB microphone: vendor HID channel for dgm20ctl.
|
||||||
|
# uaccess covers local desktop logins; the group covers ssh/tmux sessions.
|
||||||
|
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="352f", ATTRS{idProduct}=="0105", MODE="0660", GROUP="wheel", TAG+="uaccess"
|
||||||
Reference in New Issue
Block a user