Documentation
¶
Overview ¶
Package power holds the TDP and fan-curve rules the drawer needs in order to avoid offering the user a state the z13ctl daemon would refuse.
It exists to be testable. The GTK code in internal/gui cannot be unit tested without CGO and GTK4 headers, so everything here is pure Go operating on plain values — no widgets, no daemon calls. internal/gui holds the widgets and delegates every decision to this package.
Device limits ¶
The numbers live in a Limits value rather than in package constants, because z13ctl is being extended to other AMD devices whose chips have different power limits and per-profile PPT defaults. DefaultLimits returns the 2025 Flow Z13's values, which are correct for the only device supported today.
The daemon does not yet serve its limits over the API — they live in z13ctl's internal/cli, which is not exported through the api module, so they have to be duplicated here for now. When that API lands the only change is where the Limits value comes from: fetch once at startup, Sanitized, falling back to DefaultLimits. Nothing else in the drawer moves. See the design brief in z13ctl's .claude/plans/device-limits-api.md.
If the two ever disagree the daemon wins: it validates against hardware, and these rules only exist so the UI does not present an option that gets rejected.
Index ¶
- Constants
- func FanCurveIsCustom(fc *api.FanCurveState) bool
- func FloorPWMAt(floor []api.FanCurvePoint, temp int) int
- type Curve
- type Limits
- func (l Limits) ActiveFloor(pl1 int) []api.FanCurvePoint
- func (l Limits) BasicSliderMax() int
- func (l Limits) DefaultCurve() Curve
- func (l Limits) EnforceCurve(c *Curve, idx int, floor []api.FanCurvePoint)
- func (l Limits) FanFloorPWM(pl1 int) int
- func (l Limits) ForceRequired(pl1 int) bool
- func (l Limits) IsStockPPT(t api.TDPState) bool
- func (l Limits) NeedsAdvanced(isCustom bool, t api.TDPState) bool
- func (l Limits) Sanitized() Limits
Constants ¶
const ( PWMMin = 0 PWMMax = 255 )
PWM bounds. Unlike the TDP limits these are the hwmon interface's own range, not a device characteristic.
const ( FanModeFullSpeed = 0 FanModeCustom = 1 FanModeAuto = 2 )
Fan pwm_enable modes as reported by sysfs and passed through by the daemon's get-state. Note these are the raw hwmon values, not the 0=auto/1=custom shorthand the api.FanCurveState doc comment suggests.
const CurvePoints = 8
CurvePoints is the number of points in a fan curve. It is fixed at 8 because Curve is a fixed-size array; if a future device needs a different count this becomes a Limits field and Curve becomes a slice, losing the compile-time length guarantee. Worth deciding deliberately rather than by accident.
const ProfileCustom = "custom"
ProfileCustom is the daemon's default custom profile name — the one created implicitly by the first custom TDP, fan curve or undervolt setting made while a firmware profile is active. Since z13ctl v1.3 it is one of several possible custom profiles, so testing a profile name against it no longer answers "do custom settings apply"; use api.State.InCustomProfile for that. The stock profiles are firmware-managed.
Variables ¶
This section is empty.
Functions ¶
func FanCurveIsCustom ¶
func FanCurveIsCustom(fc *api.FanCurveState) bool
FanCurveIsCustom reports whether a fan curve reported by the daemon is actually in force, and therefore worth displaying.
The curve registers keep the last written points even after the fans are released to firmware auto, so the points alone cannot tell you anything: switching to a stock profile resets the mode to FanModeAuto but leaves the old custom points perfectly readable. Drawing them then shows the user a curve the firmware is not following.
func FloorPWMAt ¶ added in v1.4.1
func FloorPWMAt(floor []api.FanCurvePoint, temp int) int
FloorPWMAt evaluates a floor curve at a temperature, mirroring the daemon's semantics exactly (z13ctl cli.FloorPWMAt): below the first point it returns that point's PWM — a floor does not taper off at low temperature — above the last point it returns the last PWM, and between points it interpolates linearly. An empty floor is PWMMin everywhere. The mirroring is the point: what this accepts and what the daemon accepts must be the same set of curves.
Types ¶
type Curve ¶
type Curve [CurvePoints]api.FanCurvePoint
Curve is an 8-point fan curve, ordered by ascending temperature.
type Limits ¶
type Limits struct {
Model string // e.g. "GZ302EA"; for logs and bug reports
TDPMin int // absolute minimum sustained limit
TDPMaxSafe int // above this the daemon requires the force flag
TDPMaxForced int // absolute hardware maximum
HighTDPMinPWM int // FloorCurve's bottom, for display text; 0 = no floor
TempMin int // fan curve temperature axis, Celsius
TempMax int
// FloorCurve is the per-point fan floor the daemon enforces while the
// sustained limit exceeds TDPMaxSafe. It is a floor *curve*, not a scalar:
// the daemon measures each user point against this curve at the point's own
// temperature (z13ctl cli.FloorPWMAt), so 50% is enough at idle temperatures
// while 80°C requires full speed. Empty = the device has no floor.
// Sanitized keeps HighTDPMinPWM equal to this curve's bottom.
FloorCurve []api.FanCurvePoint
// StockProfilePPT holds each stock profile's firmware PPT defaults, used to
// tell "the firmware's numbers" from "numbers the user chose". Only the three
// limits the drawer displays are listed; the daemon also tracks APU/Platform
// sPPT, which it mirrors from PL2 and which no UI shows.
StockProfilePPT map[string]api.TDPState
}
Limits describes one device's power and thermal envelope — everything the drawer needs that varies with the hardware.
Presentation policy is deliberately not in here. BasicSliderMax is a method rather than a field because "cap the simple slider a little under the safe max" is the drawer's choice; only the safe max itself is a device fact.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits returns the 2025 ROG Flow Z13 (GZ302) values. These mirror z13ctl's cli.TDPMin / TDPMaxSafe / TDPMaxForced / cli.HighTDPFanCurve and cli.StockProfilePPT. Until the daemon serves them over the API, any change on the daemon side must be copied here by hand — the floor dropped from a flat 80% to this 50%-bottomed ramp in z13ctl v1.3.x and the drawer kept clamping at 80% for a release, which is the drift this comment is warning about.
func (Limits) ActiveFloor ¶ added in v1.4.1
func (l Limits) ActiveFloor(pl1 int) []api.FanCurvePoint
ActiveFloor returns the floor curve in force for the applied sustained limit: nil while pl1 is at or below TDPMaxSafe, or when the device declares no floor.
While the floor is in force the daemon rejects any curve containing a point below the floor at that point's temperature, and refuses a fan reset outright — firmware auto has no floor at all, so releasing the fans there would remove the very protection the power limit requires. Resetting the TDP is the way back out.
func (Limits) BasicSliderMax ¶
BasicSliderMax is the ceiling of the drawer's single-slider basic view.
Presentation policy, but derived rather than fixed: it only means anything as "a little under the safe max". A hardcoded 70 would be nonsense on a device whose safe sustained limit is 54.
func (Limits) DefaultCurve ¶
DefaultCurve returns the curve shown before the daemon reports one, fitted to this device's temperature range.
The shape is hand-tuned for the Z13 and is returned unchanged there. On a device with a narrower range EnforceCurve pulls it into bounds; the result is no longer hand-tuned, but it is valid, which is what matters for a placeholder.
func (Limits) EnforceCurve ¶
func (l Limits) EnforceCurve(c *Curve, idx int, floor []api.FanCurvePoint)
EnforceCurve repairs the curve after point idx has been moved, so that it always satisfies what the firmware and daemon require:
- temperatures strictly increase
- PWM never decreases
- every point sits within [minPWM, PWMMax] and [TempMin, TempMax]
floor comes from ActiveFloor: nil when unconstrained, the device's floor curve while a high sustained limit is applied. Each point's PWM is held at or above the floor at that point's own temperature (FloorPWMAt) — the daemon measures curves the same way, so a drag the editor allows is a curve the daemon accepts. Passing the floor in rather than deriving it here keeps the rule in one place and makes the clamping directly testable with and without a floor.
The moved point is clamped first so it wins over its neighbours, then the change cascades outward in both directions, then a final pass re-clamps everything — cascading can push a neighbour past a bound.
The moved point's temperature is clamped into a range that leaves room for the points on either side: each of the idx points below it needs at least one degree, as does each of the points above. Clamping it to the raw TempMin instead would push its left-hand neighbours below the minimum, and the final clamp would then pile them all onto TempMin — producing duplicate temperatures that are not strictly increasing.
func (Limits) FanFloorPWM ¶
FanFloorPWM returns the lowest fan PWM the daemon will accept anywhere on the curve given the applied sustained limit — the active floor's bottom — or PWMMin when unconstrained. Per-point clamping goes through ActiveFloor and FloorPWMAt; this scalar remains for "is a floor in force" gates and for display text about the floor's minimum.
func (Limits) ForceRequired ¶
ForceRequired reports whether a TDP request needs the force flag, which the daemon demands for a sustained limit above TDPMaxSafe.
func (Limits) IsStockPPT ¶
IsStockPPT reports whether a TDP reading matches some stock profile's firmware defaults exactly, meaning the user has not diverged from what the firmware would set on its own.
An exact match on a genuinely user-chosen triple is possible but harmless: it only means the drawer offers the basic view for values the basic view would reproduce unchanged.
func (Limits) NeedsAdvanced ¶
NeedsAdvanced reports whether a TDP state can only be shown accurately in the drawer's advanced view.
Basic mode is a single slider that applies one value to all three power limits and stops at BasicSliderMax, so it cannot represent either a sustained limit above that ceiling or a state where the three limits differ. Showing such a state in basic mode would clamp the slider and misreport the hardware — and worse, a subsequent save would send the clamped value and quietly lower the limit.
Only settings the user actually chose count, which takes two checks rather than one. On a stock profile the daemon reports that profile's own PPT defaults, whose limits legitimately differ — balanced is 52/71/70 — so isCustom must be true (the caller passes api.State.InCustomProfile(), which covers named custom profiles as well as the default "custom"). But saving a fan curve or an undervolt is enough to flip the daemon to a custom profile on its own, leaving the power limits at the firmware's values, so the reading must also differ from the stock defaults.
A basic save round-trips as PL1 == PL2 == FPPT, because the daemon defaults the blank PL fields to the single value, so an equal triple never trips this.
func (Limits) Sanitized ¶
Sanitized returns l with any unset field replaced by its default.
This is the guard for the day the daemon serves limits over the API: a client newer than the daemon receives zero for fields the daemon does not know about, and a zero TDPMaxSafe would make every fan curve fail the floor check and every TDP request demand the force flag. Falling back per-field degrades gracefully instead of catastrophically.
HighTDPMinPWM is deliberately not defaulted — zero is a legitimate value there, meaning a device with no fan floor at all.