A native desktop GUI for Framework laptop fan control, battery charge limits, and live hardware telemetry. Built with Rust and Iced 0.14, using framework_lib for direct EC hardware access.
Project site (with Privacy Policy and License): https://vinctw22.github.io/Framework-Crate/
Inspired by ozturkkl/framework-control.
Prebuilt Windows binaries are attached to each GitHub Release as framework-crate.exe. The release notes list what changed in that version.
Download the .exe, then run it as administrator. PawnIO is optional and only needed for the CPU Power card; everything else works with the Framework EC driver alone.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
|
AMD CPU Power — experimental (preview data; writes require the experimental opt-in)
Note: 0.9.7 adds Intel CPU package power telemetry (MSR energy counters), the probe tool, and keeps the 0.9.6 AMD RyzenSMU support and review fixes.
- Fan Control — Auto (firmware), manual (0–100% duty, optional per-fan; at or above 95°C duty is floored at 90%), and curve mode with draggable points on a canvas (default 5 points, hysteresis, and rate limiting; live sensor markers shown on the curve; optional per-fan curves on multi-fan machines via the Unified switch; calibration wizard measures per-fan RPM tables for accurate ramp seeding)
- Battery Management — Maximum charge limit (25–100%) with enable/disable toggle; saved limit is applied on startup. Health uses last-full / design capacity. Details shows charger voltage/input on AC plus smart-battery data (per-cell voltages, pack temperature, safety alerts) when readable
- Live Telemetry — Real-time temperature chart (selectable 15/30/60s window, default 30s), per-sensor display with colored indicators, and fan RPM in the header
- Misc Panel — Camera/mic privacy switch states, keyboard backlight slider, fingerprint LED level, and USB-C / HDMI / DP port classification inferred from PD state
- CPU Power — Intel: read/write PL1/PL2 via PawnIO (optional; SHA-256 verified module download); live turbo readout (current/limit GHz) when readable; original factory limits are saved on the first run while on AC for
Reset.Applysaves the selected AC or Battery profile; while "Keep power limits applied" is enabled on the About page (persisted across launches), the matching profile is applied at startup and on every AC↔battery change. Writes go to MSR 0x610 only: the official IntelMCHBAR module is read-only, so the MMIO mirror is displayed but never written. Writes are capped per model (Laptop 12 → 40W, 13 → 70W, 16 → 100W, Desktop → 120W), require ≥5W when enabled, and require PL1 ≤ PL2. AMD (experimental): a RyzenSMU card shows code name, SMU version, STAPM/PPT Fast/PPT Slow and Tctl from the PM table; AC/Battery profiles and the keep-alive toggle now work the same way as Intel. Writes require the explicit experimental opt-in and still need community hardware validation. - About Page — Hardware info (CPU, RAM, display, BIOS, board revision), PD controller firmware versions, software settings (poll rate, refresh interval, launch at startup, keep power limits applied), GitHub link, one-click copy buttons for the manual-download URLs, and third-party license notices
- Settings Backup — Export settings to a file (native save dialog, machine-stamped) and import them back; strict validation rejects unknown fields and version mismatches, and a different chassis size is refused outright
- System Tray — Minimize to tray, tray icon with context menu (Show / Quit) and hover tooltip with the current temperature, icon restored automatically if Explorer restarts
- Launch at Startup — Registers a per-user logon task so the tray app comes back after a reboot. The task document is generated in-process and handed to
schtasks /Create /XML; the settings that matter (unlimited execution limit, battery conditions cleared, restart on failure) are asserted by tests without needing elevation - File Logging — Warnings and errors are written to
%APPDATA%/framework-crate/app.logwith size-based rotation, because a logon-launched process has no console to print them to
- Platform: Intel Core Ultra Series 1 (Meteor Lake) — tested on this platform
- Windows 10/11
- Administrator privileges — required for EC access, for writing CPU power limits, and for enabling launch at startup
Architecture & internals
src/
main.rs — Iced 0.14 application entry point, boot function, rotating file log sink
app/ — App struct, Message enum, dispatch, handler submodules
mod.rs — App/AppState/Message types, new()/subscription()/update()/view(), update_inner dispatcher
config.rs — Config-related message handlers + save/mutate_config helpers
cpu_power.rs — CPU power (PL1/PL2) message handlers + input validation
tray.rs — Tray message handlers (minimize/restore/quit, power-resume)
quit.rs — Quit flow handlers (restore auto/duty fan on exit, flush charge limit)
tick.rs — Self-rescheduling UI tick, autosize_task, snapshot rebuild
misc.rs — One-off dispatch (settings toggles, debug report, peripheral writes)
tasks.rs — Shared async task helpers (quit handover, EcOpResult reporting, ...); EC I/O itself goes through ec_serial
sub_state.rs — AppState groups (fan, thermal, peripherals, battery, system, lifecycle)
views/ — UI layout split into per-card submodules
mod.rs — ViewSnapshot, view_main assembly, shared components
header.rs — Top system info header + About button
sensors.rs — Temperature sensors, chart settings
fan_control.rs — Fan mode, duty slider, curve canvas + settings panel
cpu_power.rs — PL1/PL2 control, MSR/MMIO display
battery.rs — Battery info, charge limit section
misc.rs — Keyboard backlight, ports section
settings.rs — About / Settings popup
quit_warning.rs — Quit-before fan state confirmation dialog
types.rs — Config structs, FanControlMode, CurveConfig, validation
style.rs — Colors, fonts, layout constants
config/ — Persisted settings, split by concern
mod.rs — Re-exports; every crate::config::* path keeps working
paths.rs — Config dir resolution, test override validation, reparse check
acl.rs — DACL hardening (broad-group write ACE detection/removal)
io.rs — Load, versioned save, atomic replace, corrupt backup
strict.rs — Strict import/export, unknown-field rejection, machine check
config_save_task.rs — Debounced config save (100ms) and applies battery settings
background_task/ — EC polling loop, fan control, expansion/PD scans
mod.rs — Spawn loop, refresh_all_data, shared constants
helpers.rs — Idle, AC transition, PD history, thermal sampling helpers
fan_ctx.rs — Per-round fan context and firmware handover
poll/ — Eight poll phases split by concern (EC/thermal/resume/scans/fan/CPU)
cpu_power/ — CPU power (PL1/PL2) via PawnIO, split by concern
mod.rs — CpuPowerState and the public forwarding surface
types.rs — Shared currency types (info, params, limits) for read and write sides
modules.rs — PawnIO Modules download, hashing, extraction, install
limits/ — PL1/PL2 register encoding, validation, and writes
decode.rs — Register encode/decode and read-back verification
validate.rs — RAPL unit and power/time range validation
write.rs — MSR write path and BIOS-default restore
amd/ — RyzenSMU telemetry and experimental power-limit writes
ffi.rs — PawnIOLib.dll binding and RAPL register access
sync.rs — Periodic PL1/PL2 re-assert (5 min keepalive)
bios.rs — Factory default capture and persistence
read.rs — PL1/PL2 reading
temp_chart.rs — Canvas-based temperature line chart (selectable history window)
curve_canvas.rs — Interactive canvas-based fan curve editor (drag points, sensor markers)
fan_control.rs — CurveStepper, rate limiting, duty calculation
system_info.rs — Windows API FFI (CPU, RAM, OS, display, tray)
probe.rs — HeightProbe widget for dynamic window sizing
util.rs — Time utilities, lock helpers (read_lock, with_write_lock), PawnIO timeout guard
ec_serial.rs — Single-writer EC dispatcher: all EC driver calls go through one FIFO worker, so hardware execution order always matches submission order
cli/ — EC wrapper only (not a command-line interface)
ec_wrapper.rs — EcClient wrapper around framework_lib's CrosEc
mod.rs
tray/
mod.rs — TrayManager (Windows tray icon)
event.rs — TrayIcon events
message_pump.rs — Dedicated message pump thread for tray icon
framework_lib (CrosEc) → ec_serial (single FIFO writer) → background_task → Arc<RwLock> → UI (view reads)
↕
config_save_task → config.toml
- Hardware access: The
framework_libcrate calls the EC directly via the kernel driver (no subprocess) - EC serialization: every EC call is dispatched through one FIFO worker (
ec_serial), so at most one driver call is ever in flight and hardware execution order always matches submission order; callers time out individually without wedging the pool - Config:
dirs::config_dir() / framework-crate/config.toml - Background polling: tokio task, 200ms–2s interval (idle slowdown)
- UI refresh: self-rescheduling tick (50–1000ms), idle 1s, hidden 2s
- Lock strategy:
Arc<RwLock<Arc<T>>>for shared state, narrow lock scope (<1µs) - State organization:
AppStatesplit intoFanState,ThermalState,PeripheralState,BatteryState,SystemState,LifecycleState, plusCpuPowerState
EcClientis shared viaArc— no hardware re-initialization on cloneThermalData.tempsusesArc<BTreeMap>— history samples share dataSensorCache.sorted/colorsusesArc<Vec>— zero-copy per UI tick- Config locks are held for less than 1µs by reading only the needed fields and dropping immediately
curve_full_pointsis debounced (100ms after the last drag edit)- PD port history keeps the last few scans (needed to distinguish USB-A cards from transient states)
- The fan curve keeps running during idle periods to maintain temperature response
- The
mutate_confighelper reduces boilerplate for config mutations - Canvas editor uses cached rendering with content-based invalidation (points, sensor marks, hover/drag state)
Measured on target/release/framework-crate.exe (Windows, iced 0.14 + wgpu, cargo build --release with opt-level = "z" lto = "fat" strip = true, unwinding enabled), elevated, on Windows 11 / 31.5 GB RAM / Intel Arc. Mean of 3 launches, 60 s each, sampled once the window had appeared.
| Metric | Value |
|---|---|
| Working Set | ~158 MB |
| Private (commit charge) | ~118 MB |
| Binary Size | ~9.3 MiB |
Working Set is what Task Manager's Processes Memory column shows. To reproduce:
$p = Start-Process .\target\release\framework-crate.exe -PassThru
1..20 | ForEach-Object { Start-Sleep 3; $p.Refresh()
"{0,3}s workingSet={1,6} MB private={2,6} MB" -f `
($_*3), [math]::Round($p.WorkingSet64/1MB,1), [math]::Round($p.PrivateMemorySize64/1MB,1) }
$p.Kill()Configuration reference
Config file location: %APPDATA%/framework-crate/config.toml
On the first run while on AC with PawnIO available, the original factory PL1/PL2 (MSR + MMIO watts, enabled, clamped, time window, and RAPL units) are saved to %APPDATA%/framework-crate/bios_defaults.toml. Reset in the UI restores the persisted MSR values; the MMIO mirror is read-only with the official IntelMCHBAR module and is not overwritten. Delete that file to re-capture the current BIOS values on the next AC-powered launch.
[fan]
mode = "curve" # "disabled" | "manual" | "curve"
unified_duty = true
per_fan_duty = []
calibration_tables = [] # wizard-measured [[duty_pct, rpm]] per fan; empty = live max-RPM estimate
[fan.manual]
duty_pct = 50
[fan.curve]
poll_ms = 500 # curve polling interval in ms (500–5000)
sensors = [] # empty = hottest non-battery sensor
points = [[30, 0], [45, 20], [60, 40], [75, 80], [85, 100]] # editable 0–99°C; 100–110°C locked 100%
per_fan_points = [] # optional per-fan points (multi-fan + Unified off); empty slots inherit points
hysteresis_c = 2
rate_limit_pct_per_step = 10
# optional asymmetric down-rate limit (defaults to rate_limit_pct_per_step if omitted)
# rate_limit_down_pct_per_step = 5
[battery.charge_limit_max_pct]
enabled = true
value = 80
[cpu_power]
keep_limits = true # About -> "Keep power limits applied"
amd_write_experimental = false # AMD only; explicit opt-in for the unverified write path
[cpu_power.ac]
pl1_watts = 30.0
pl1_enabled = true
pl1_clamped = false
pl1_time_s = 28.0
pl2_watts = 60.0
pl2_enabled = true
pl2_clamped = false
[cpu_power.battery]
pl1_watts = 20.0
pl1_enabled = true
pl1_clamped = false
pl1_time_s = 20.0
pl2_watts = 45.0
pl2_enabled = true
pl2_clamped = false
# MMIO mirror seen for each source, updated only when the value changes and
# used as the default when the matching user profile is absent. Machine-local:
# excluded from settings export and cleared on import. Leave these alone
# unless you deliberately want to change the baseline.
[cpu_power.ac_initial]
pl1_watts = 30.0
pl1_time_s = 28.0
pl2_watts = 60.0
[cpu_power.battery_initial]
pl1_watts = 20.0
pl1_time_s = 20.0
pl2_watts = 45.0
# AMD only. Writes require amd_write_experimental = true above.
[cpu_power.amd_ac]
stapm_watts = 25.0
fast_watts = 45.0
slow_watts = 35.0
tctl_c = 90.0
[cpu_power.amd_battery]
stapm_watts = 15.0
fast_watts = 30.0
slow_watts = 25.0
tctl_c = 85.0
# First PM-table values per source; machine-local like the Intel initials.
# [cpu_power.amd_ac_initial] / [cpu_power.amd_battery_initial]
[telemetry]
poll_ms = 500
ui_refresh_ms = 100
selected_sensors = []The app also writes two internal top-level fields: version (config schema/migration marker) and pl_custom_applied (keeps the AC→battery PL reset armed across restarts). Leave them unchanged when editing the file by hand.
Startup, logs & environment
Toggling launch at startup on the About page registers a Windows scheduled task that runs the app at logon. The task is created from a document built in-process and passed to schtasks /Create /XML, so every setting is applied atomically in a single call.
| Setting | Value | Why |
|---|---|---|
ExecutionTimeLimit |
PT0S |
The schtasks default of PT72H silently stops the tray app three days after logon, and it never comes back |
DisallowStartIfOnBatteries / StopIfGoingOnBatteries |
cleared | A laptop on battery power would otherwise skip or stop the task |
RestartOnFailure |
3 attempts, PT1M |
The app returns if it crashes during logon |
| Encoding | UTF-16LE with BOM | Task Scheduler rejects a UTF-8 document with a parse error on the XML declaration |
Command / Arguments |
separate elements | Avoids the argument-splitting failure class of the older /TR form |
RunLevel |
HighestAvailable |
The app needs an elevated token for EC access |
Notes:
- Enabling it requires an elevated process. The task is registered with
RunLevel=HighestAvailable, so the app reports a readable message instead of lettingschtasksemit its localized access-denied text. - Portable paths are allowed, with a warning.
HighestAvailableruns the image elevated at logon, so a copy in a user-writable folder could be replaced to gain elevation; the app warns for common locations such as Downloads/Desktop/Temp. schtasksoutput is decoded using the OEM code page so non-English messages stay readable.- After creating the task the app queries it back, so success is only reported once the task is actually queryable.
- The task document is written as UTF-16LE to the admin-only
%ProgramData%\FrameworkCrate\tasksdirectory under an unpredictable hashed name, then removed. Debug/unelevated test runs use%TEMP%. A process killed mid-write never reaches the removal, so stale documents are cleaned up on the next run. - The executable path is validated before it is embedded: quotes, cmd metacharacters, control characters, bidi overrides,
DELand a trailing backslash are all rejected, since the path is embedded as XML text and wrapped in quotes for the actionCommand.
tracing output is written to %APPDATA%/framework-crate/app.log instead of only stderr, because a logon-launched process has no console — with stderr-only logging every warning and error from an auto-started run was discarded.
The file rotates once it grows past a size threshold, keeping app.log.1 through app.log.3. If the log cannot be opened the app still starts and falls back to stderr.
| Variable | Effect |
|---|---|
RUST_LOG |
Log filter (default info); e.g. RUST_LOG=debug |
FRAMEWORK_CONTROL_CONFIG_DIR |
Overrides the config directory in debug/test builds. Must be absolute, with no .. and no \\?\ / \\.\ prefix. Release builds ignore it |
FRAMEWORK_ALLOW_UNKNOWN_MODULE_HASH=1 |
Loads PawnIO module blobs whose hash is not pinned. Only after manual verification of a newer upstream release. Debug builds only; release builds always fail closed |
FRAMEWORK_ALLOW_UNSIGNED_PAWNIO=1 |
Loads the PawnIO DLL despite an Authenticode failure. For self-signed test DLLs. Debug builds only; release builds always fail closed |
- Fan Curve Coordinates (v0.3.0): sliders were replaced by direct canvas drag editing; points keep their identity when dragged across each other and the cached rendering invalidates on content changes.
- USB-C vs USB-A classification (v0.3.0): ports that ever reported a Sink role stay classified as USB-C, so plugging a device into an expansion-card port no longer flips the type.
- Sleep / Hibernate Fan Control (v0.3.0, hardened in v0.9.0): resume resets the EC client,
CurveStepper, and thermal history; fan duty is re-asserted every 30s, all EC traffic uses one FIFO writer, and a wall-clock jump fallback covers missed resume events. - MMIO mirror is read-only by design: the official
IntelMCHBAR.binexposes reads only, so Apply writes MSR 0x610; the mirror is still read for display and the effectivemin(MSR, MMIO)estimate. MSR writes are sufficient for the app's CPU power control.
- AMD CPU Power writes are experimental and opt-in: the AMD card writes STAPM/PPT Fast/PPT Slow/Tctl through the RyzenSMU MP1/PSMU mailbox, reads the PM table back, and supports AC/Battery profiles plus the About keep-alive policy. Writes stay disabled until Enable experimental AMD writes is checked (
amd_write_experimentalin the config), and Copy diagnostics exports the PM table for community validation. The maintainer has no AMD hardware, so the path is unverified; see Ryzen_Power_Testing.md (web version) for the test checklist and issue template. - HDMI/DP cards without DP-alt may be mislabeled: those cards can omit DP-alt mode while drawing ~3–5W; use Ports Debug Mode on the About page to inspect raw role/watts, then report it with the USB port report template.
- Platform-specific: only Intel Core Ultra Series 1 (Meteor Lake) Framework laptops are tested; AMD RyzenSMU support is experimental.
- Per-fan curves need multi-fan verification: per-fan curves (multi-fan machines, Unified off) are covered by unit tests with a mocked EC only — no multi-fan hardware (Framework 16 / Desktop) was available to verify on-device behavior. Single-fan machines take the exact pre-existing code path.
- EC driver: the Framework EC kernel driver must be installed for
framework_libto communicate with the hardware. - Crash leaves last-applied state: orderly quit restores firmware fan control and flushes the charge limit, but a crash or kill bypasses that path, so the EC keeps the last written fan duty until sleep, reboot, or the next launch re-asserts it.
- Logoff/restart fan handover is not reliable: on some Windows 11 elevated setups the session-end broadcast is not delivered to the tray window, so manual/curve duty can persist after sign-out until the next launch or reboot. Use Restore Auto & Exit before logging off when the fan is under manual/curve control.
CPU Power module details (PawnIO)
The CPU Power section supports Intel (PL1/PL2 via MSR 0x610) and AMD (STAPM/PPT Fast/PPT Slow/Tctl via RyzenSMU). Other vendors show Not Supported. Intel uses the IntelMSR/IntelMCHBAR modules; AMD uses RyzenSMU.bin. The AMD write path is experimental and requires the explicit amd_write_experimental opt-in; AMD read-only telemetry and the PM-table diagnostics are always available once the module is installed.
The official PawnIO Modules are not in this repository and are not embedded in the EXE.
- Install the PawnIO driver:
winget install namazso.PawnIO(or use Install PawnIO in the app). - Open CPU Power and click Download Modules.
- The app fetches
IntelMSR.bin,IntelMCHBAR.bin, andRyzenSMU.binfrom PawnIO Modules Releases (latest, falling back to 0.2.11 when the GitHub API is unreachable), checks SHA-256 (enforced) and caches them in%APPDATA%/framework-crate/modules/. Intel uses the first two; AMD telemetry and experimental writes useRyzenSMU.bin.
A missing file blocks CPU Power; a hash mismatch also blocks use — update the app for the new release, or set FRAMEWORK_ALLOW_UNKNOWN_MODULE_HASH=1 only after manual verification. Failed download shows manual instructions: download latest release_*.zip from the releases page and place IntelMSR.bin + IntelMCHBAR.bin (Intel) or RyzenSMU.bin (AMD) into %APPDATA%/framework-crate/modules/. Use Open Modules Folder and Redetect in the UI to verify — no restart needed.
The first successful RAPL read while on AC also persists the original factory limits to bios_defaults.toml (see Configuration). Reset restores the MSR values from that snapshot; the MMIO mirror is read-only and is left untouched. Apply stores the edited values in the currently selected AC or Battery profile. The app records each state's firmware power wall from the MMIO mirror (ac_initial / battery_initial in the config), updating it only when the mirror value changes (e.g. after a BIOS update), and uses it as the default when no user profile exists. Those captures are machine-local: settings export omits them and import clears them so the receiving machine records its own. With "Keep power limits applied" enabled on the About page, the matching profile (or the recorded wall) is applied once at startup, immediately after resume, on every AC↔battery change, and re-asserted every 5 minutes; the toggle is persisted in the config. When the toggle is off, AC→battery keeps the old behavior of resetting to BIOS defaults. A source with neither a profile nor a recorded wall falls back to that reset instead of guessing values.
The official IntelMCHBAR.bin exports only read ioctls (ioctl_read_qword). Apply therefore writes MSR 0x610 only; the MMIO mirror is read for display and for the effective min(MSR, MMIO) estimate. There is no MMIO write attempt (and no repeated ioctl_write_qword failure). If the MMIO mirror holds a lower limit than MSR, an MSR-only write may not change the effective limit; a custom MCHBAR write module would be required for that, which this project intentionally does not ship.
Requirements:
- PawnIO installed
- Internet connection for the first download (or place the module files manually — see above)
LGPL-2.1 Compliance:
PawnIO Modules are LGPL-2.1. This project does not ship the blobs. Source: https://github.com/namazso/PawnIO.Modules (latest, currently 0.2.11)
framework_lib— Framework EC hardware abstraction layer (from framework-system)iced— Cross-platform GUI framework for RustPawnIO— Kernel-level hardware access driver (GPL-2.0), installed viawinget install namazso.PawnIOPawnIO Modules— Pre-compiled module blobs for MSR/MMIO and RyzenSMU access (LGPL-2.1)
Build, tests & development
cargo build --release# Debug build (larger because it keeps unoptimized code and full debug symbols)
cargo build
Get-Item .\target\debug\framework-crate.exe | Select-Object Name,Length
# Release build (smaller and suitable for distribution)
cargo build --release
Get-Item .\target\release\framework-crate.exe | Select-Object Name,Length
# Compare debug and release builds in one command
cargo build; Get-Item .\target\debug\framework-crate.exe | Select-Object Name,Length; Get-Item .\target\release\framework-crate.exe | Select-Object Name,LengthNotes:
debugbuilds are intentionally larger because they keep full debug symbols and unoptimized code.- Use
releaseartifacts for packaging and distribution. - If shipping a bundle, do not include the entire
target/folder or.pdbfiles unless you need them for debugging.
# Must run as administrator
cargo run --releaseThis project is developed with AI coding agents (pair-programming / review / refactor assistance). Design decisions, hardware behavior, and releases are reviewed and owned by the maintainer.
MSRV: Rust 1.99.1 (verified with cargo +1.99.1 check --locked --all-targets).
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
# One-time: cargo install cargo-audit --version 0.22.2 --locked
cargo audit
# Long-horizon EC dispatcher soak (randomized ops + quit storm, ~2 min at 20000 iterations)
EC_SOAK_ITERS=20000 cargo test --locked --release ec_serialCI runs fmt, debug build, tests, clippy, cargo audit, and the release build on every push; a weekly soak workflow runs the dispatcher stress test.
The application icon is the "settings" icon (System category) from the Iconoir icon set, licensed under the MIT License.
Rendering parameters: optical size 32, stroke weight 1.5, color #7300ff (R 115, G 0, B 255). The source SVG (assets/settings.svg) is reproduced with attribution to Iconoir.
MIT
This project uses PawnIO (GPL-2.0) and PawnIO Modules (LGPL-2.1) at runtime. PawnIO is an optional, separately installed driver. PawnIO Modules are downloaded only when the user clicks Download Modules in the app; they are never stored in this repo or the EXE. framework_lib is BSD-3-Clause. PawnIO Modules source: https://github.com/namazso/PawnIO.Modules.








