Drop the brightness control, survive and explain a frozen mic

- Remove RGB brightness: firmware 2.1.2 stores the value but the LEDs
  never change, so the control did nothing. PROTOCOL.md notes why.
- Lock the device per transaction (flock) so the CLI and the web
  service can't interleave and steal each other's replies.
- Detect a mic that is plugged in but no longer answering and tell the
  user to replug it (a USB reset is not enough; settings survive).
  Seen when hardware buttons are held while settings are being written.
- Web page keeps the last known value when a poll comes back partial,
  instead of drawing unanswered settings as off.
- New screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:45:18 -05:00
co-authored by Claude Opus 5.5
parent 66d159b0d2
commit 1fcf73ed9d
7 changed files with 50 additions and 15 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ The rest of the 64 bytes is zero-padded.
| 0x2026 | headphone monitoring | 2000 = on, 3000 = off | | 0x2026 | headphone monitoring | 2000 = on, 3000 = off |
| 0x2027 | noise cancellation | 0 / 1 | | 0x2027 | noise cancellation | 0 / 1 |
| 0x2028 | noise cancellation strength | 0-100 | | 0x2028 | noise cancellation strength | 0-100 |
| 0x202A | RGB ring brightness | 0-100 (the app does not show it for this model) | | 0x202A | labelled RGB ring brightness in the app's shared code | stores 0-100, but the LEDs do not change on firmware 2.1.2; not exposed |
| 0x202C | vocal enhancement | 0 / 1 | | 0x202C | vocal enhancement | 0 / 1 |
| 0x204B | level-meter enable | not answered by firmware 2.1.2 | | 0x204B | level-meter enable | not answered by firmware 2.1.2 |
+6 -3
View File
@@ -9,7 +9,6 @@ this covers the same settings with a local web page and a command-line tool.
- Vocal enhancement - Vocal enhancement
- Hear yourself (headphone monitoring) - Hear yourself (headphone monitoring)
- Mute, kept in sync with the mic's touch pad - Mute, kept in sync with the mic's touch pad
- RGB ring brightness
- Factory reset - Factory reset
Pure Python, no dependencies. Nothing leaves your machine: the page is served from your own Pure Python, no dependencies. Nothing leaves your machine: the page is served from your own
@@ -80,7 +79,7 @@ keeps its current settings.
dgm20-web --open # open the control page (http://localhost:8720) dgm20-web --open # open the control page (http://localhost:8720)
dgm20ctl # show current settings dgm20ctl # show current settings
dgm20ctl set nc on nc-level 80 dgm20ctl set nc on nc-level 80
dgm20ctl set monitor off enhance on brightness 40 dgm20ctl set monitor off enhance on
dgm20ctl watch # print changes made on the mic itself dgm20ctl watch # print changes made on the mic itself
dgm20ctl info # model and firmware dgm20ctl info # model and firmware
dgm20ctl factory-reset --yes dgm20ctl factory-reset --yes
@@ -93,7 +92,6 @@ dgm20ctl factory-reset --yes
| `nc-level` | 0-100: noise cancellation strength | | `nc-level` | 0-100: noise cancellation strength |
| `enhance` | on, off: vocal enhancement | | `enhance` | on, off: vocal enhancement |
| `monitor` | on, off: hear yourself in headphones plugged into the mic | | `monitor` | on, off: hear yourself in headphones plugged into the mic |
| `brightness` | 0-100: RGB ring |
Settings are stored on the mic, so they stay the same after a replug and on other computers. Settings are stored on the mic, so they stay the same after a replug and on other computers.
@@ -124,6 +122,11 @@ model that isn't supported yet (see above).
**`dgm20-web: cannot listen on 127.0.0.1:8720`**: it's already running, for example as the **`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`. service. Open http://localhost:8720, or pick another port with `--port`.
**"the mic is connected but has stopped answering"**: the mic's settings channel froze. This can
happen if you hold or tap the mic's buttons while settings are being changed from the computer.
Unplug the mic and plug it back in; its settings are kept. A software USB reset is not enough,
the mic needs to lose power.
**Commands not found after installing**: `~/.local/bin` isn't on your `PATH`. Add **Commands not found after installing**: `~/.local/bin` isn't on your `PATH`. Add
`export PATH="$HOME/.local/bin:$PATH"` to your shell profile. `export PATH="$HOME/.local/bin:$PATH"` to your shell profile.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 76 KiB

+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "dgm20-linux" name = "dgm20-linux"
version = "0.1.0" version = "0.1.0"
description = "Control the Maono DGM20 USB microphone on Linux: noise cancellation, monitoring, vocal enhancement, mute, RGB brightness" description = "Control the Maono DGM20 USB microphone on Linux: noise cancellation, monitoring, vocal enhancement, mute"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.9" requires-python = ">=3.9"
+21 -2
View File
@@ -20,6 +20,7 @@ Both share one framing:
Settings are 16-bit registers. Input gain and headphone volume are standard Settings are 16-bit registers. Input gain and headphone volume are standard
USB audio controls and are not part of this channel. USB audio controls and are not part of this channel.
""" """
import fcntl
import glob import glob
import os import os
import select import select
@@ -37,7 +38,6 @@ SETTINGS = {
"nc-level": (0x2028, (0, 100), "Noise cancellation strength"), "nc-level": (0x2028, (0, 100), "Noise cancellation strength"),
"enhance": (0x202C, "bool", "Vocal enhancement"), "enhance": (0x202C, "bool", "Vocal enhancement"),
"monitor": (0x2026, {"on": 2000, "off": 3000}, "Hear yourself in headphones"), "monitor": (0x2026, {"on": 2000, "off": 3000}, "Hear yourself in headphones"),
"brightness": (0x202A, (0, 100), "RGB ring brightness"),
} }
REG_FACTORY_RESET = 0x100A REG_FACTORY_RESET = 0x100A
REG_DFU = 0x1008 REG_DFU = 0x1008
@@ -48,6 +48,14 @@ class DeviceError(Exception):
pass pass
class NotResponding(DeviceError):
"""The mic is plugged in but its settings channel has stopped answering."""
def __init__(self):
super().__init__("the mic is connected but has stopped answering. Unplug it and plug it back in; "
"your settings are kept")
def find_hidraw(): def find_hidraw():
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")): for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
try: try:
@@ -152,8 +160,11 @@ class Mic:
os.read(self.fd, 64) os.read(self.fd, 64)
def _io(self, typ, words, accept, timeout=0.5): def _io(self, typ, words, accept, timeout=0.5):
# the flock makes other processes (CLI, web service) wait their turn, so nobody
# mistakes another process's reply or ack for their own
with self.lock: with self.lock:
try: try:
fcntl.flock(self.fd, fcntl.LOCK_EX)
self._drain() self._drain()
os.write(self.fd, frame(typ, words)) os.write(self.fd, frame(typ, words))
while select.select([self.fd], [], [], timeout)[0]: while select.select([self.fd], [], [], timeout)[0]:
@@ -162,6 +173,11 @@ class Mic:
return p[1] return p[1]
except OSError as e: except OSError as e:
raise DeviceError(f"mic disconnected ({e.strerror})") from None raise DeviceError(f"mic disconnected ({e.strerror})") from None
finally:
try:
fcntl.flock(self.fd, fcntl.LOCK_UN)
except OSError:
pass
return None return None
def read(self, addr): def read(self, addr):
@@ -188,7 +204,10 @@ class Mic:
return self.get(name) return self.get(name)
def state(self): def state(self):
return {name: self.get(name) for name in SETTINGS} state = {name: self.get(name) for name in SETTINGS}
if all(v is None for v in state.values()):
raise NotResponding()
return state
def info(self): def info(self):
model = self.read_range(REG_MODEL, 8) model = self.read_range(REG_MODEL, 8)
+5 -8
View File
@@ -179,11 +179,6 @@ footer { margin-top: 28px; display: flex; justify-content: space-between; flex-w
<p>Plays the mic through headphones plugged into the DGM20.</p> <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> <button class="switch ctl" role="switch" aria-checked="false" aria-labelledby="l-monitor" data-name="monitor" disabled></button>
</li> </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> </ul>
<footer> <footer>
@@ -243,6 +238,9 @@ function render(snap) {
if (/permission/i.test(err)) { if (/permission/i.test(err)) {
notice("The mic is plugged in, but this page can't talk to it.", 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/"); "Install the udev rule, then unplug and replug the mic:", "sudo cp udev/70-maono-dgm20.rules /etc/udev/rules.d/");
} else if (/stopped answering/i.test(err)) {
notice("The mic stopped answering.",
"Unplug it and plug it back in. Your settings are kept, and this page reconnects on its own.");
} else { } else {
notice("Mic not found.", "Plug in the DGM20. This page reconnects on its own."); notice("Mic not found.", "Plug in the DGM20. This page reconnects on its own.");
} }
@@ -258,7 +256,7 @@ function render(snap) {
$("fw").textContent = `Firmware ${snap.info.firmware}. Unofficial tool, not affiliated with Maono.`; $("fw").textContent = `Firmware ${snap.info.firmware}. Unofficial tool, not affiliated with Maono.`;
} }
if (!busy.has("mute")) { if (!busy.has("mute") && state.mute != null) {
pad.setAttribute("aria-pressed", String(!!state.mute)); pad.setAttribute("aria-pressed", String(!!state.mute));
pad.setAttribute("aria-label", state.mute ? "Unmute microphone" : "Mute microphone"); pad.setAttribute("aria-label", state.mute ? "Unmute microphone" : "Mute microphone");
$("pad-state").textContent = state.mute ? "Muted" : "Live"; $("pad-state").textContent = state.mute ? "Muted" : "Live";
@@ -269,6 +267,7 @@ function render(snap) {
const n = s.dataset.name; const n = s.dataset.name;
if (busy.has(n)) continue; if (busy.has(n)) continue;
const v = state[n]; const v = state[n];
if (v == null) continue; // mic busy for a moment: keep the last known value
s.setAttribute("aria-checked", String(v === true || v === "on")); s.setAttribute("aria-checked", String(v === true || v === "on"));
} }
for (const s of sliders) { for (const s of sliders) {
@@ -278,7 +277,6 @@ function render(snap) {
$(`${n}-out`).textContent = state[n]; $(`${n}-out`).textContent = state[n];
} }
$("nc-level-row").dataset.disabled = state.nc ? "0" : "1"; $("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; lastConnected = true;
} }
@@ -314,7 +312,6 @@ for (const s of sliders) {
s.addEventListener("input", () => { s.addEventListener("input", () => {
busy.add(n); busy.add(n);
$(`${n}-out`).textContent = s.value; $(`${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); clearTimeout(timer);
timer = setTimeout(() => api("/api/set", { name: n, value: Number(s.value) }).catch(() => {}), 120); timer = setTimeout(() => api("/api/set", { name: n, value: Number(s.value) }).catch(() => {}), 120);
}); });
+16
View File
@@ -60,5 +60,21 @@ class Encoding(unittest.TestCase):
encode("volume", 3) encode("volume", 3)
class NotRespondingDetection(unittest.TestCase):
def test_all_unanswered_reads_raise(self):
from dgm20.device import Mic, NotResponding
mic = Mic.__new__(Mic) # no device needed: reads always time out
mic.read = lambda addr: None
with self.assertRaises(NotResponding):
mic.state()
def test_partial_answers_are_fine(self):
from dgm20.device import Mic
mic = Mic.__new__(Mic)
mic.read = lambda addr: 1 if addr == 0x2022 else None
self.assertTrue(mic.state()["mute"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()