Metadata-Version: 2.5
Name: ulanzi-studio-niri
Version: 2.0.0
Summary: Driver and Niri integration for the Ulanzi Stream Controller D200X
Project-URL: Homepage, https://github.com/doitian/ulanzi-studio-niri
Project-URL: Issues, https://github.com/doitian/ulanzi-studio-niri/issues
Author: ulanzi-studio-niri contributors
License: MIT License
        
        Copyright (c) 2026 ulanzi-studio-niri contributors
        
        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.
License-File: LICENSE
Keywords: d200x,niri,stream-deck,ulanzi,wayland
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Desktop Environment :: Window Managers
Requires-Python: >=3.11
Requires-Dist: cairosvg>=2.7
Requires-Dist: construct~=2.10
Requires-Dist: deepdiff>=7
Requires-Dist: nvidia-ml-py>=11
Requires-Dist: pillow>=10
Requires-Dist: psutil>=5.9
Requires-Dist: pydantic>=2
Requires-Dist: watchfiles>=0.21
Description-Content-Type: text/markdown

# ulanzi-studio-niri

A Linux daemon that drives the **Ulanzi Stream Controller D200X** and integrates
it with the [Niri](https://github.com/YaLTeR/niri) Wayland compositor.

Buttons can:

- Trigger Niri actions (`niri msg action ...`)
- Launch arbitrary commands
- Control media (`playerctl` / `wpctl`)
- Send keystrokes (`wtype` / `ydotool`)
- Switch between configured pages
- Adjust deck brightness
- Refresh usage widgets on demand

The wide bottom-right LCD displays a clock (digital or dial, optionally with
date/weekday), system stats, or live encoder information.

LCD buttons can show remaining Claude, Codex, OpenCode Go, and xAI (Grok) plan
usage, plus the Moonshot (Kimi API) account balance. The daemon reads credentials
maintained by `claude /login`, `codex login`, and OpenCode `/connect`, then
fetches usage directly from each provider. Configure one `[[page.widget]]` per
provider/window; each renders on the button at its `pos`:

```toml
[[page.widget]]
pos = 1
provider = "claude"
account = ""                 # direct integration uses the active CLI account
limit = "five_hour"          # five_hour | seven_day | seven_day_fable (Claude)
label = "5H"
icon = "claude-desktop"
```

OpenCode Go provides `rolling`, `weekly`, and `monthly` windows:

```toml
[[page.widget]]
pos = 2
provider = "opencode-go"
limit = "rolling"           # rolling | weekly | monthly
label = "GO 5H"
```

Its API key is read from the `opencode-go` entry in
`$XDG_DATA_HOME/opencode/auth.json` (normally
`~/.local/share/opencode/auth.json`). Set `OPENCODE_GO_API_KEY` to override it.

xAI (Grok) reports SuperGrok remaining weekly allowance from `grok login`
credentials in `~/.grok/auth.json` (override with `GROK_HOME` or
`ULANZI_GROK_CREDENTIALS`):

```toml
[[page.widget]]
pos = 4
provider = "xai"
limit = "weekly"           # weekly only
label = "7D"
icon = "xai"
```

Moonshot's Kimi API is pay-as-you-go, so its widget shows the remaining
account balance instead of a percentage, colored green / yellow / red as it
drops below ¥70 / ¥36 (CNY) or $12 / $6 (USD):

```toml
[[page.widget]]
pos = 3
provider = "moonshot"
limit = "balance"           # balance only
label = "BAL"
icon = "moonshot"
```

The API key is read from `MOONSHOT_API_KEY` (international platform,
api.moonshot.ai, USD; set `MOONSHOT_BASE_URL` including `/v1` for the China
platform) or from the `moonshotai` / `moonshotai-cn` entries in OpenCode's
`auth.json`, which select the international / China (api.moonshot.cn, CNY)
platforms respectively.

Pressing a usage button requests a refresh and opens the provider's usage page in the browser.
Defaults: `https://claude.ai/new#settings/usage` (Claude),
`https://chatgpt.com/#settings/Usage` (Codex), `https://opencode.ai/go`
(OpenCode Go), `https://grok.com/?_s=usage` (xAI),
`https://platform.kimi.com/console/account` (Moonshot). Set
`url` on a widget to override:

```toml
[[page.widget]]
pos = 4
provider = "opencode-go"
limit = "rolling"
url = "https://opencode.ai/workspace/wrk_xxxxx/go"
```

Use `seven_day_fable` to show Claude's weekly Fable model allowance. Each
widget shows the remaining percentage and reset time. Provider data is fetched
concurrently in the background (never blocking the event loop) and updated
automatically when the fetch completes. Results are cached for 30 minutes to
limit provider API traffic. Claude and Codex access tokens near expiry are
refreshed using the CLI's refresh token and the rotated credentials are written
back atomically. If a usage request returns HTTP 401, its access token is
refreshed and the request is retried once.
HTTP 429 responses are not retried automatically. Press any usage widget to
request a manual refresh. Manual refreshes are throttled to one fetch every
90 seconds.

The same data is available in a terminal:

```sh
ulanzi-niri ai-usage
```

## Hardware

- 13 LCD buttons at 196×196
- 1 wide LCD button at 458×196 (bottom-right; driven by the small-window
  subsystem)
- 2 plain physical buttons
- 3 rotary encoders (each with click, free rotate, and press-rotate)

Each `[[page.encoder]]` can bind `on_press`, `on_rotate_cw` / `on_rotate_ccw`,
and `on_press_rotate_cw` / `on_press_rotate_ccw`. Click fires on release and is
cancelled if the knob turned while held. Unset press-rotate keys fall through
to the free-rotate actions. An explicit `{ type = "noop" }` press-rotate
binding silences that direction instead.

## Installation

This project uses [uv](https://github.com/astral-sh/uv) and runs against the
system Python interpreter.

```sh
# Development install
uv venv --python /usr/bin/python3 .venv
uv sync

# End-user install (creates ~/.local/bin/ulanzi-niri)
uv tool install .
```

Or run the one-shot installer, which performs the tool install, udev rule,
and systemd service setup together:

```sh
./bin/install
```

### udev rule (required)

Out of the box the deck's `hidraw` nodes are owned by root. Install the udev
rule so the daemon can talk to it as your user:

```sh
ulanzi-niri install-udev
# follow the printed `sudo` commands; replug the deck afterwards
```

### Run as a service

```sh
mkdir -p ~/.config/systemd/user
cp packaging/ulanzi-niri.service ~/.config/systemd/user/
systemctl --user enable --now ulanzi-niri
```

## Configuration

Configuration lives at `~/.config/ulanzi-niri/config.toml`. See
[`examples/config.toml`](examples/config.toml).

### Pages and layers

Each `[[page]]` belongs to a `layer` (default `"default"`). A `page` action
with `cycle = 1` / `cycle = -1` moves to the next/previous page *within the
current layer*, wrapping around. A `goto` (or `toggle`) that targets a page in
another layer switches layers — use this for "folder" buttons that open a
multi-page group (e.g. a `"web"` layer with several pages).

The wide tile shows a 12px-high page indicator near its bottom edge, with 12px
of padding on each side.
The current page's segment is `#9974F8` on a `#707070` track, in configuration
order within the current layer. Layers with only one page have no indicator.

### Icons

Icon names in `[[page.button]]` are resolved in this order, first match
wins:

1. `~/.config/ulanzi-niri/icons/<name>` — your own overrides
2. `<install>/assets/icons/<name>` — bundled icons (if any)
3. `~/.local/share/icons/`, `/usr/share/icons/`, `/usr/share/pixmaps/` —
   freedesktop icon directories, searched recursively

A name with an extension (`firefox.png`) matches that filename anywhere
under the search roots. A bare name (`firefox`) matches `firefox.png` or
`firefox.xpm`, preferring the largest available pixel size (parsed from
`NxN` directory components). SVG icons are not currently supported — drop
a PNG into `~/.config/ulanzi-niri/icons/` for SVG-only themes.

## Development

```sh
uv run ulanzi-niri doctor       # diagnose environment
uv run ulanzi-niri push         # one-shot push of current config
uv run ulanzi-niri sniff        # observe HID traffic
uv run pytest                   # tests
uv run ruff check .             # lint
```

## Status

All physical inputs are wired: the 13 LCD buttons, the wide tile (pos 13),
the two plain hardware buttons (pos 14, 15), and all three rotary encoders
(press, rotate, and press-rotate). Streaming for the 4th-row buttons and encoders requires a
one-time `ENABLE_INPUT_STREAMING` (cmd 0x0002) packet on connect, which the
daemon sends automatically; without it the firmware silently consumes
encoder rotates (routing them to its built-in brightness handler). Opcode
discovered empirically by sweep.

## Credits

Protocol details adapted from [redphx/strmdck](https://github.com/redphx/strmdck).
