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:
2026-10-07 22:45:59 -05:00
co-authored by Claude Opus 5.5
commit d239c214b4
14 changed files with 1097 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Linux control for the Maono DGM20 USB microphone."""
+87
View File
@@ -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()
+220
View File
@@ -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)
+337
View File
@@ -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">&nbsp;</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>
+175
View File
@@ -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()