Skip to content
dlnilssonPublic

About

Linux & Windows tray app for checking Dygma wireless battery status

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

dygmate

Battery status for Dygma wireless keyboards — the Defy, the Raise 2, and the Sonsei — right in your system tray, over RF and Bluetooth, on Windows and Linux. Right-click for the menu, and get an optional on-screen overlay when you switch layers.

Dygmate battery notification Dygmate context menu

Dygmate layer overlay

Install

Windows

Until dygmate is on winget, paste this in PowerShell — it downloads the latest release, verifies it against SHA256SUMS.txt, and installs to %LOCALAPPDATA%\Programs\dygmate.

Download, verify, and extract:

$d="$env:LOCALAPPDATA\Programs\dygmate"; New-Item -Force -ItemType Directory $d | Out-Null
$rel=curl.exe -s https://api.github.com/repos/dlnilsson/dygmate/releases/latest | ConvertFrom-Json
curl.exe -sL -o "$env:TEMP\dygmate.zip" $rel.assets.Where({$_.name -like '*windows-x86_64.zip'}).browser_download_url
$sums=curl.exe -sL $rel.assets.Where({$_.name -eq 'SHA256SUMS.txt'}).browser_download_url
if ((Get-FileHash "$env:TEMP\dygmate.zip").Hash -notin ($sums -split '\s+')) { throw 'SHA256 mismatch' }
tar.exe -xf "$env:TEMP\dygmate.zip" -C $d

To update to the latest release later, just re-run this block.

Add it to your user PATH:

if (([Environment]::GetEnvironmentVariable('Path','User') -split ';') -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', "$([Environment]::GetEnvironmentVariable('Path','User'));$d", 'User') }

Create a Startup shortcut and launch the tray:

$s=(New-Object -ComObject WScript.Shell).CreateShortcut("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\dygmate-tray.lnk"); $s.TargetPath="$d\dygmate-tray.exe"; $s.Save()
Start-Process "$d\dygmate-tray.exe"

Linux

AUR

yay -S dygmate-bin

The package installs the udev rule and the dygmate-tray user service, so you can skip the manual steps under Linux setup — just replug the keyboard and enable the service:

systemctl --user enable --now dygmate-tray.service

Build

Requires Zig 0.16.0.

Build for the host (produces the CLI dygmate and the tray dygmate-tray):

zig build -Doptimize=ReleaseSmall

Cross-build both platforms at once into zig-out/{windows,linux}/:

zig build release

Run the tray directly:

zig build run-tray-linux   # Linux
zig build run-tray         # Windows

Status bar integration (yasb, waybar, ...)

While dygmate-tray runs it serves its latest battery snapshot over local IPC, one JSON line per connection:

  • Windows: named pipe \\.\pipe\dygmate
  • Linux: unix socket $XDG_RUNTIME_DIR/dygmate/status.sock
# Windows
cmd /c more < \\.\pipe\dygmate
# Linux
socat -u UNIX-CONNECT:$XDG_RUNTIME_DIR/dygmate/status.sock -
{"state":"connected","connected":true,"model":"Dygma Defy","sides":2,
 "left":{"level":80,"status":"discharging","text":"80% (discharging)"},
 "right":{"level":75,"status":"charging","text":"75% (charging)"},
 "level":75,"text":"75%","low":false,"updated":1752669000}

state is missing/available/connected/paused; level/text is the lower of the visible sides (what the tray icon shows, null/"--" before the first reading); sides is 1 on the Sonsei; a side awaiting verification after wake reports level: null and "?% (...)". When every battery-reporting side is explicitly disconnected (firmware status 4) while connected, each side keeps its last-known level but the aggregate level/text reads null/"?", matching the tray icon's ?; the stale per-side number is no longer surfaced as the headline. On disconnect at the USB level the last-known levels stay, marked "connected": false. The endpoint disappears when the tray exits, so a missing read means "tray not running".

Readings come from the tray's plausibility-gated pipeline — the same numbers the tray shows — and reading the pipe never touches the keyboard's serial port, so it composes with the running tray (unlike polling dygmate --once, which needs the exclusive port for itself).

Example

yasb

yasb widget (config.yaml; prefer more < over type — cmd's type pre-opens the path to probe it, which can race the pipe server's listener re-arm):

# config.yaml
widgets:
  dygma:
    type: yasb.custom.CustomWidget
    options:
      label: "<span></span> {data[text]}"
      label_alt: "<span></span> L {data[left][text]}  R {data[right][text]}"
      class_name: dygma-widget
      exec_options:
        run_cmd: 'cmd /c more < \\.\pipe\dygmate'
        run_interval: 30000
        return_format: json
        hide_empty: true   # tray not running -> widget hidden
      callbacks:
        on_left: toggle_label
/* style.css Dygma keyboard battery (dygmate) */
.dygma-widget:hover {
    background-color: var(--yasb-white-alpha-08);
}
.dygma-widget .icon {
    padding-right: 4px;
}

yasb default yasb extended


waybar module:

"custom/dygma": {
    "exec": "socat -u UNIX-CONNECT:$XDG_RUNTIME_DIR/dygmate/status.sock - 2>/dev/null | jq --unbuffered -c '{text: .text, tooltip: (.left.text + \" | \" + .right.text), class: (if .low then \"low\" else \"\" end)}'",
    "return-type": "json",
    "interval": 30
}

Debugging: the events feed

dygmate-tray also serves a live debug events stream — a separate endpoint from the status snapshot above — so you can watch what the neuron actually sends without a log file:

  • Windows: named pipe \\.\pipe\dygmate-events
  • Linux: unix socket $XDG_RUNTIME_DIR/dygmate/events.sock

Tail it with the CLI (a read-only client — it never touches the serial port, so it runs alongside the tray):

dygmate tail          # pretty, hides the 250ms layer poll
dygmate tail --all    # include the layer-state poll traffic
dygmate tail --raw    # NDJSON, one event per line, for jq
20:14:17.139 reading    raw L100/R?  ->  acc L?/R?  [suspect]
20:14:32.708 reading    raw L100/R?  ->  acc L100/R?
20:14:33.001 state      missing -> connected
20:14:41.550 focus_rx   <- wireless.battery.left.level = "87" (1ms)
20:15:02.114 force_read  forceRead issued

Each line is one NDJSON event (envelope {"t","seq","ts", ...}):

  • focus_tx / focus_rx / focus_err — every Focus command sent to the neuron and its response (with round-trip ms), i.e. the raw wire.
  • reading — a battery read through the plausibility gate: the raw wire reading, the accepted (gated) reading, and the verdict (suspect, needs_verification, authoritative). This is where you see the neuron's bogus post-wake 100 being held until it's confirmed.
  • state — connection-state transitions (missing/available/connected/paused).
  • force_read — the RF re-poll lifecycle (issued/settled/failed).
  • wake — the machine woke; the sides are re-guarded.
  • dropped — the events ring lapped a slow reader (n events lost).

The endpoint serves one subscriber at a time and replays the last ~1 min of retained events as backlog on connect, then streams live. Pipe the raw form to jq to filter, e.g. only battery reads:

dygmate tail --raw | jq -c 'select(.t == "reading")'

Linux setup

Start dygmate-tray from your compositor's autostart, or enable the packaged user service:

systemctl --user enable --now dygmate-tray.service

The service uses the user manager's session D-Bus and device permissions. If you want the optional layer-shell OSD on Wayland, make sure the user manager also receives the Wayland environment. Run this from your compositor's autostart (or once in a terminal inside the Wayland session), then restart the service:

systemctl --user import-environment WAYLAND_DISPLAY XDG_RUNTIME_DIR
systemctl --user restart dygmate-tray.service

Serial port permissions

The keyboard shows up as a CDC-ACM serial device (/dev/ttyACM0). By default it's owned by root and a serial group, so a normal user can't open it and dygmate reports:

failed to open /dev/ttyACM0 (AccessDenied) ...

Fix it in one of two ways.

Add your user to the serial group (simplest). Check the device's group first:

ls -l /dev/ttyACM0     # e.g. crw-rw---- root uucp ...

Then join that group — uucp on Arch, dialout on Debian/Ubuntu:

sudo usermod -aG uucp "$USER"     # or: dialout

Log out and back in (or run newgrp uucp) for it to take effect.

Or install a udev rule (targeted to the Dygma, no group change, survives replug). The AUR package already ships this rule — replug the keyboard and skip this step. Manual install:

printf '%s\n' \
  'SUBSYSTEM=="tty", ATTRS{idVendor}=="35ef", ATTRS{idProduct}=="0012", TAG+="uaccess"' \
  'SUBSYSTEM=="tty", ATTRS{idVendor}=="35ef", ATTRS{idProduct}=="0021", TAG+="uaccess"' \
  'SUBSYSTEM=="tty", ATTRS{idVendor}=="35ef", ATTRS{idProduct}=="0031", TAG+="uaccess"' \
  | sudo tee /etc/udev/rules.d/99-dygmate.rules
sudo udevadm control --reload-rules && sudo udevadm trigger

TAG+="uaccess" grants access to the user of the active login session, so no group membership is needed and it works for the tray too. Replug the keyboard after adding or changing the rule.

Close Bazecor before running dygmate either way — the serial port is exclusive.

About

Linux & Windows tray app for checking Dygma wireless battery status

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages