Skip to content

CLI Reference

Prakhar Yadav edited this page Sep 16, 2026 · 1 revision

CLI Reference

Complete reference for afanctl <verb> [options]. Hand-rolled arg parser (src/cli.rs, no clap); src/main.rs is wiring only (init_tracing + cli::run dispatch). Verbatim usage text (from afanctl -h):

afanctl 0.1.0 - applesmc fan supervisor (pre-T2 Intel Mac)

USAGE:  afanctl <verb> [options]

VERBS:
  daemon [--mode observe|curve]     supervisor loop (default observe)
  status [--json]                   temps, t_eff, fan, mode, config provenance
  doctor [--json] [--roundtrip] [--compare <s>]   diagnostics (exit 1 on FAIL)
  once [--at-temp <C>] [--dry-run] [--json]   one control iteration, print the decision
        --at-temp <C>  simulate the sensor at C (in-process mock; never touches sysfs)
        --dry-run      compute the decision but write nothing (no cmd/state file)
  observe | curve | hold <rpm>      write the command file (R8; daemon clamps)
  selftest-panic                    hidden: deliberate panic to prove L2

GLOBALS:
  --config <path>      config TOML (default /etc/afanctl/afanctl.toml)
  --sysfs-root <dir>   sysfs root (default /sys)
  --version, -h        version / this help

EXIT CODES: 0 success   1 runtime failure   2 usage/CLI error

Verbs

Verb Syntax Behavior
daemon afanctl daemon [--mode observe|curve] Run the supervisor loop (systemd Type=notify; default observe). Arms L2, reconciles stale Manual state, notifies READY, loops forever.
status afanctl status [--json] Per-sensor temps, t_eff, mode (marked (monitor-only) when degraded), fan actual/target/min/max, manual?, config provenance, recent errors.
doctor afanctl doctor [--json] [--roundtrip] [--compare <s>] Diagnostics; exit 1 if any check FAILs. Read-only except --roundtrip. Full checklist in Diagnostics (doctor).
once afanctl once [--at-temp <C>] [--dry-run] [--json] Exactly one control iteration, print the decision (scripts/CI).
observe afanctl observe Write {"schema":"afanctl.cmd.v1","mode":"observe"} to cmd.json; the daemon applies it (releases Manual → AUTO on the verified path).
curve afanctl curve Write {"schema":"afanctl.cmd.v1","mode":"curve"}; the daemon applies it.
hold afanctl hold <rpm> Write {"schema":"afanctl.cmd.v1","mode":"hold","rpm":N}. Rejected below fan1_min (exit 1, no cmd.json written); clamped to fan1_max.
selftest-panic afanctl selftest-panic Hidden. Arms L2 against the --sysfs-root backend, then panics deliberately (panic!("afanctl selftest-panic: …") in safety.rs). Exit 101 by design.
--version afanctl --version Print afanctl 0.1.0, exit 0.
-h, --help afanctl -h Print the usage text above, exit 0.

daemon [--mode observe|curve]

  • Default mode is observe. The packaged unit uses --mode observe; curve at startup is opt-in via the unit's ExecStart argument or a later afanctl curve command.
  • Requires root against live /sys (writes fan1_manual/fan1_output, pre-opens the L2 fd, writes /run/afanctl/state.json).
  • Startup order: polls = 0 → record l2_absent → degrade-to-observe if L2 absent and a writing mode was requested → arm L2 → reconcile stale Manual state → startup evidence INFO line → sd_statussd_ready → poll loop.
  • Never daemonizes itself; systemd owns supervision (Type=notify).

status [--json]

Merges three sources (gather_status in cli.rs): config + provenance, state.json (if a daemon is alive), and direct sysfs reads (fan rpm/mode even when the daemon is down). daemon.running = state.json exists.

Human output (live /run daemon doing a hold; fixture example below shows the no-daemon shape):

daemon: not running
mode: observe
sensor Package id 0: 45.0 C
sensor Core 0: 44.0 C
sensor Core 1: 43.0 C
t_eff: 45.0 C
fan: 1200 rpm (1200..7200, manual=false)
target: n/a
config: high=66 max=86 min_rpm=1200 max_rpm=6200 interval_s=1 source=packaging/afanctl.toml.default
recent_errors: none

Command that produced it (no root needed against fixtures):

AFANCTL_RUNTIME_DIR=/tmp/afanctl-wiki-demo ./target/release/afanctl status \
  --sysfs-root tests/fixtures/sysfs --config packaging/afanctl.toml.default

JSON output (status --json, schema afanctl.status.v1 — full field list in JSON Schemas):

AFANCTL_RUNTIME_DIR=/tmp/afanctl-wiki-demo ./target/release/afanctl status --json \
  --sysfs-root tests/fixtures/sysfs --config packaging/afanctl.toml.default
{"config":{"high_c":66,"interval_s":1,"max_c":86,"max_rpm":6200,"min_rpm":1200,"source":"packaging/afanctl.toml.default"},"daemon":{"auto_restore_pending":false,"mode":"observe","monitor_only":false,"running":false,"uptime_s":0,"watchdog_armed":false},"effective":{"method":"max","temp_c":45.0},"fan":{"manual":false,"max_rpm":7200,"min_rpm":1200,"rpm":1200,"target_rpm":null},"recent_errors":[],"schema":"afanctl.status.v1","sensors":[{"label":"Package id 0","temp_c":45.0},{"label":"Core 0","temp_c":44.0},{"label":"Core 1","temp_c":43.0}]}

Human status renders degraded mode as mode: curve (monitor-only) / mode: hold (monitor-only); JSON keeps mode as commanded and exposes the separate booleans monitor_only / auto_restore_pending. A plugin must render the latch, not the mode alone. uptime_s = polls × interval_s (with a watchdog_pings × interval_s fallback for state files written by older builds). target_rpm is null when the daemon is down.

doctor [--json] [--roundtrip] [--compare <s>]

See Diagnostics (doctor) for the full 10-check table, the 2-second --roundtrip write procedure, and --compare sampling/stats/verdicts. --json renders the same fields as the human output (schema afanctl.doctor.v1).

once [--at-temp <C>] [--dry-run] [--json]

  • Exactly one control iteration, then print the decision and exit. For scripts/CI. Restores AUTO on exit (the once path releases Manual even from a pre-existing Manual state).
  • --at-temp <C> simulates the sensor at C through an in-process MockSmc seeded with MilliC::from_c(C) — it never opens sysfs, even when --sysfs-root is set (proved by test). Negative integers are accepted (--at-temp -5); a --prefixed token after a value-taking flag is otherwise a missing-value error.
  • --dry-run computes the controller decision via Controller::step_curve without constructing a Supervisor: no smc write, no cmd.json, no state.json.
  • --json renders mode / t_eff_c / decision / applied_rpm / verified / notes (additive verb flag).
  • The overshoot guard (OVERSHOOT_POLLS = 3 consecutive polls inside one process) is unreachable from stateless once; once --at-temp 86 --dry-run prints the slew-limited step, not EscalateMax. The guard is proven by the policy trace tests instead.

Real examples:

AFANCTL_RUNTIME_DIR=/tmp/afanctl-wiki-demo ./target/release/afanctl once --at-temp 80 --dry-run \
  --sysfs-root tests/fixtures/sysfs --config packaging/afanctl.toml.default
# mode=curve t_eff=80.0C decision=SetSpeed(1950) applied_rpm=none verified=false notes=[dry-run: decision only, no smc writes]

AFANCTL_RUNTIME_DIR=/tmp/afanctl-wiki-demo ./target/release/afanctl once --at-temp 80 --dry-run --json \
  --sysfs-root tests/fixtures/sysfs --config packaging/afanctl.toml.default
# {"applied_rpm":null,"decision":"SetSpeed(1950)","mode":"curve","notes":["dry-run: decision only, no smc writes"],"t_eff_c":80.0,"verified":false}

observe / curve / hold <rpm>

  • These verbs only write cmd.json (atomically, tmp + rename, root-owned under RuntimeDirectory=afanctl). The daemon re-reads it every poll, validates it, and applies it through the same write-verify path. The CLI never writes sysfs on this path (except hold's pre-check read, below).
  • hold opens SysfsSmc first and checks against the discovered band before writing cmd.json: below fan1_min → exit 1 with a key+fix message and no cmd.json written; above fan1_max → clamped; in-range → written exactly.
  • Freshness gate: a re-issued command whose bytes are byte-identical to the last applied one is ignored. To re-arm after monitor-only degradation, re-write the command with a changed payload or restart the daemon.
  • Unknown mode or out-of-range rpm found by the daemon at apply time is logged and ignored (previous mode kept).

selftest-panic (hidden)

  • Opens SysfsSmc on --sysfs-root, installs the death path when a pre-opened fd exists, then calls safety::arm_test_panic() (deliberate panic!). The L2 hook writes b"0" (AUTO) in a single syscall; the process exits 101 (deterministic Rust panic exit code).
  • Fixture proof: fan1_manual=1 → run → exit nonzero ∧ file reads 0. Hardware proof: sudo afanctl selftest-panic → exit 101, fan1_manual reads 0.
  • Without a writable --sysfs-root it still panics and exits nonzero, just without a death write (fail-loud posture unchanged).

Globals

Flag Default Notes
--config <path> /etc/afanctl/afanctl.toml Config TOML. Accepted before or after the verb.
--sysfs-root <dir> /sys Redirects all sysfs access. Intended for tests and fixtures; all sysfs access is redirected through it.
--version Print version, exit 0.
-h, --help Print usage, exit 0.

Parsing rule (F12): a token starting with - after a value-taking flag is a missing-value error, not a value — except negative integers for --at-temp.

Environment

Variable Default Purpose
AFANCTL_RUNTIME_DIR /run/afanctl Directory holding cmd.json / state.json. Exists so tests can redirect the runtime files; you normally do not set it.

Exit codes

Code Meaning Examples
0 Success status, once, doctor with no FAIL, hold/observe/curve cmd write
1 Runtime failure doctor with any FAIL; invalid config; hold below fan1_min; sysfs error; doctor --roundtrip refused without L2 fd
2 Usage / CLI error Unknown verb/flag, missing value, bad integer, hold with no rpm, --mode not observe|curve. Usage text goes to stderr. A bad flag never exits 0.
101 Deliberate panic selftest-panic only — the panic is the probe (F6: probe vs. failure codes are documented apart on purpose).

Real usage-error examples (exit 2, usage follows on stderr):

afanctl: unknown verb `frobnicate`
afanctl: hold needs an rpm
afanctl: --config needs a value, got flag-like '--json' (fix: pass the value directly after --config)
afanctl: --mode expects observe|curve, got 'turbo'
afanctl: hold: rpm must be non-negative, got '-1'
afanctl: invalid hold rpm: 300 is below fan1_min 1200 (fix: hold a value in 1200..=7200 rpm)   # exit 1

Logging

tracing → stderr (journald captures when run under systemd): info = mode changes, escalations, fallbacks, watchdog events; debug = per-poll detail; errors are never swallowed. cli.rs output formatting is the only place println! is allowed.

Clone this wiki locally