Skip to main content

awob: protocol + listener author guide

For end-user CLI / config see Usage; for theme authoring see Themes. This document is for people writing new event-source listeners or tools that talk to the daemon directly.

Transport​

  • Unix stream socket at $XDG_RUNTIME_DIR/awob.sock (override via --socket <path> on the daemon, awob.toml's socket key, or AWOB_SOCKET env var on listeners and the CLI). An explicit --socket takes precedence over AWOB_SOCKET; an empty or unset variable uses the default.
  • JSON-lines. One Request JSON object per line (\n-terminated) in; one Response JSON object per line out. UTF-8.
  • Synchronous, single-shot per line. Send a request, read a response, repeat. No multiplexing, no streaming responses.
  • serde-tagged enum: every JSON object has a "type" discriminator.

The socket's parent must be a real directory owned by the daemon's user, with no group or other permissions (normally mode 0700). Missing parent directories are created with mode 0700; existing directories are never chmodded. This also applies to custom socket paths: use a private directory, not /tmp/awob.sock. The socket is published with mode 0600.

Startup refuses symlinks, ordinary files and sockets owned by another user at the chosen path. It only replaces a stale socket owned by the daemon's user. Shutdown removes the socket it created and leaves replacement entries alone.

Connection lifetime is up to the client. Listeners typically hold a long-lived connection; CLI invocations connect, send, disconnect.

Protocol version​

PROTOCOL_VERSION is currently 1. Clients are expected to send a Hello first (the daemon checks the version and returns Response::Hello { protocol, daemon_version } or Response::Error { … } on mismatch). The awob-client crate does this automatically on Client::connect().

Requests​

VariantPurpose
Hello { protocol: u32 }Handshake.
Send(SendPayload)Trigger an OSD. Hot path.
Query { source: Option<String> }List history entries (optionally filtered by source).
SetTheme { name: String, persist: bool }Switch active theme. With persist, also rewrites awob.toml.
ReloadReread current theme files.
VersionGet daemon version + protocol number.

The daemon admits up to 64 active client connections. Further connections receive an Error saying daemon busy: active connection limit reached and are closed. Idle persistent connections stay open and count toward the limit.

Once a client starts a request, it has two seconds to finish the existing maximum 64 KiB JSON line. Each response also has a two-second total write deadline. Sending occasional bytes does not extend these deadlines. A timeout closes the connection; theme loading and other request processing are separate from the I/O budgets. Clients must not replay requests whose outcome is unknown after a lost connection. The Rust SDK accepts response lines up to 32 MiB, including the newline. This leaves room for escaped history-query results. Once connected, writing a request and reading its response share a two-second I/O budget; slowly streamed bytes do not restart it. A safe reconnect retry uses the remaining budget.

Oversized, malformed, or timed-out responses close the transport. Requests are never replayed after any request bytes have been written, because the daemon may already have handled them. A subsequent listener event reconnects normally.

Responses​

VariantPurpose
OkGeneric success.
Error { message: String }Error string.
Hello { protocol: u32, daemon_version: String }Reply to Hello.
Query { entries: Vec<HistoryEntry> }Reply to Query.
Version { daemon_version: String, protocol: u32 }Reply to Version.

HistoryEntry: source, event, last_value, last_max, age_seconds, listener_id. One entry per (source, event) pair.

The surface command queue holds up to 64 commands. When full, Send, SetTheme, Reload, and SetForcePalette return an Error with surface busy: command queue is full; retry later. A disconnected rendering thread returns surface unavailable: rendering thread disconnected. Queue-rejected requests do not update history, change the active theme or palette, or persist a theme choice. Clients may retry an explicit busy response after a short delay. Headless mode has no surface queue.

Accepted surface commands retain their order. The existing single-slot pending OSD behavior described below still applies once commands reach the renderer. Automatic theme reload retries through its debounce window when the queue is full, keeping the previous theme active until the replacement is accepted.

SendPayload reference​

This is the hot-path message. Field-by-field:

FieldTypeDefaultMeaning
eventStringrequiredFree-form event name ("volume", "brightness", "battery", "mic", "caps", …). The daemon doesn't check it against a list — it's whatever your listener wants. Influences default icon/label via the expression language's icon($event) / label($event) builtins.
valuef64requiredCurrent measurement. Units match max.
maxf64100.0Upper bound. The bar renders (value - min) / (max - min).
listener_idOption<String>NoneStable identity for the listener type (e.g. "awob-listener-pipewire"). The daemon emits a duplicate-listener warning if it sees two distinct sources sharing one listener_id. CLI invocations leave this unset.
sourceOption<String>NonePer-instance identity (e.g. "pipewire-7a3f"). Together with event, forms the history key. Sends without source get no history → $lastValue is Null.
styleOption<String>NoneNamed style block to apply ("low", "normal", "warn", "critical", "muted", or any custom name). Defaults to "normal" at render time.
accentOption<String>NoneOne-off CSS-syntax colour override applied after style merge.
appOption<String>NoneFree-form label. Available as $app in the theme.
iconOption<String>NoneIcon name / path / data: URI. Available as $icon.
timeout_msOption<u32>NoneOne-shot override for surface.show duration.
preemptboolfalseSee Preempt semantics.

JSON shape (with all fields populated):

{
"type": "send",
"event": "volume",
"value": 0.7,
"max": 1.0,
"listener_id": "awob-listener-pipewire",
"source": "pipewire-7a3f",
"style": "normal",
"accent": null,
"app": "Speakers",
"icon": "audio-volume-high",
"timeout_ms": null,
"preempt": true
}

Preempt semantics​

preempt controls how a send interacts with an OSD that's already on screen:

  • Same (source, event) as the visible OSD → continuity update, regardless of preempt. Bar value tweens to the new target; alpha fade doesn't restart; show timer resets.
  • Different (source, event), preempt: true → hot-swap. The visible OSD is replaced immediately. Bar value continues from its current interpolated position to the new target so the swap is smooth.
  • Different (source, event), preempt: false → queued in a single-slot, last-write-wins buffer. When the active OSD reaches Phase::Done (after fade-out), the queued send drains as a fresh cycle.

When to set preempt: true:

  • Volume keys (the user just pressed a button; show it now).
  • Brightness keys.
  • Mic mute.
  • Anything user-initiated where queueing behind a battery bar would feel broken.

When to leave preempt: false:

  • Battery state changes.
  • Network connection events.
  • Any ambient/background notification that shouldn't preempt whatever the user is currently doing.

History keying​

The daemon's history map is keyed by (source, event). Distinct events on the same source — e.g. volume then mute on a single PipeWire node — don't cross-contaminate $lastValue. A listener publishing both metrics for one source uses the same source value with different event values:

{ "type": "send", "event": "volume", "value": 0.6, "source": "speaker", "preempt": true }
{ "type": "send", "event": "mute", "value": 1.0, "source": "speaker", "preempt": true, "icon": "audio-volume-muted" }

Each gets its own history slot.

History expires 30 minutes after the last update. Expired entries are excluded from queries and from $lastValue/$lastMax, including the first send after an idle period.

The daemon retains at most 4,096 history entries and 4 MiB of identifier data (source, event, and listener_id, counted per entry). Indexes share these strings; shared identifiers are still charged once per entry. When either limit is reached, the least recently updated entries are evicted first. Entries with the same update time are evicted in lexical (source, event) order. Reading or querying an entry does not refresh it.

An event whose identifiers exceed the entire 4 MiB budget is displayed but not remembered, and any previous history for that key is removed. Identifiers are never truncated. After expiry or eviction, the next event for that key has no previous value: $lastValue and $lastMax are Null, and value interpolation starts from the new value. History eviction also removes the entry's listener membership; another event from the same listener and source keeps that membership active.

Building a listener​

Long-running event source. Pattern:

  1. Connect to the daemon socket (or accept AWOB_SOCKET env var; the supervisor sets it).
  2. Subscribe to your upstream (D-Bus signal, sysfs notify, FIFO read, PipeWire registry, …).
  3. On each upstream event, build a SendPayload and write a JSON line to the socket.
  4. Reconnect on socket loss; the daemon's supervisor will respawn you on crash.

The simplest path is to depend on awob-client:

[dependencies]
awob-client = "0.0"
tracing = "0.1"
use awob_client::{ReconnectingClient, Send};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket = std::env::var_os("AWOB_SOCKET").map(std::path::PathBuf::from);
let mut client = ReconnectingClient::new(socket);
loop {
let value = read_upstream()?; // your event source
let payload = Send::new("volume", value)
.max(1.0)
.source("my-source-id")
.listener_id("awob-listener-mything")
.icon("audio-volume-high")
.app("My Source")
.preempt(true)
.auto_listener_id() // basename(argv[0]) if not set
.build();
if let Err(error) = client.send(payload) {
tracing::warn!("send: {error}");
}
}
}

ReconnectingClient opens a connection on the first event and reuses it. On a write failure it reconnects and retries once only if zero request bytes were written. Partial writes, timeouts and lost acknowledgements are returned to the caller without replaying the event; the next event opens a new connection. This avoids duplicate overlays when the daemon received a send but its response was lost. The PipeWire, battery, backlight, keyboard-backlight and power-profile listeners use this helper and log send failures.

The Send builder validates field combinations and sets sensible defaults. auto_listener_id() is a convenience that fills listener_id with the basename of the running binary if you didn't set it explicitly — used by all the bundled listeners so the daemon can warn on duplicate processes.

Choosing identifiers​

  • listener_id is per-binary or per-binary-variant. PipeWire's listener publishes awob-listener-pipewire-speaker and awob-listener-pipewire-mic from one process — they're "different listener types" semantically.
  • source is per-physical-source. One sink in PipeWire = one source. Use a stable identifier (PipeWire object id, sysfs device name like BAT0, D-Bus object path); avoid PIDs — they change on respawn and break history continuity.

The daemon flags as duplicate when two distinct sources share one listener_id. That signals two processes of the same listener running concurrently — almost always a misconfiguration.

Talking directly without awob-client​

The wire format is small enough to drive from any language:

printf '%s\n' '{"type":"hello","protocol":1}' \
'{"type":"send","event":"volume","value":50,"max":100,"preempt":true}' \
| nc -U "$XDG_RUNTIME_DIR/awob.sock"

Replace nc -U with whatever Unix-socket plumbing your runtime provides. Each output line is a Response JSON object.

Auto-discovery + supervisor​

If your listener's binary lands on $PATH or in the daemon's own directory, and you give it a stable name, you can register it for automatic spawning:

  1. Add an entry to KNOWN_LISTENERS in crates/awob-daemon/src/known_listeners.rs:

    pub const KNOWN_LISTENERS: &[KnownListener] = &[
    …,
    KnownListener { name: "mything", binary: "awob-listener-mything" },
    ];
  2. Make sure the binary works with no arguments. Listeners that need flags don't belong in the registry — they require explicit [[listeners]] blocks where the user provides args.

The daemon will then auto-spawn and supervise it on startup whenever it's installed.

Theme updates​

Theme changes and hot reloads prepare the replacement while sends and queries continue using the current theme. After preparation succeeds, the daemon queues the replacement and publishes it atomically. A load failure or rejected surface command leaves the previous theme and palette active.

Theme changes are serialized, including persistence, so an earlier save cannot overwrite a later selection. Persistence runs after the new theme becomes active; if saving fails, the error states that the theme changed in memory. Theme listing uses a snapshot of the active name and does not block sends while scanning files.

Theme persistence​

SetTheme { name, persist: true } rewrites the theme key in awob.toml using toml_edit. Comments, key order, and unrelated tables ([supervisor], [[listeners]], …) are preserved. The config path used for the rewrite is whichever was loaded at startup (--config <path> or the XDG default at $XDG_CONFIG_HOME/awob/awob.toml).

If no config path is in effect, SetTheme { persist: true } returns an Error saying so — the in-memory theme still changes.

Cold-start fallback​

If the configured theme can't be loaded at daemon start (missing directory, parse error, etc.), the daemon warns to stderr and falls back to the embedded default. This means awob always comes up with something renderable, so the user can awob theme set <real> to recover. There's no scenario where awob refuses to start because of a bad theme.

Stability​

PROTOCOL_VERSION = 1 is pre-1.0. The Request/Response shape is likely stable (well-trafficked across all listeners + the CLI), but adding fields to SendPayload is expected. Old clients omit unknown fields → serde(default) makes them roundtrip safely. New clients targeting old daemons should rely on Hello rejection rather than feature-detection.