Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/devices/src/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
matchProfileName,
effectiveMaxWatts,
isBatteryLimited,
applyFirmwareRange,
BATTERY_SAFE_MAX_WATTS,
} from "./devices";

Expand Down Expand Up @@ -224,3 +225,100 @@ describe("battery safety ceiling", () => {
).toBe(BATTERY_SAFE_MAX_WATTS);
});
});

describe("isFallback", () => {
it("is false for a table match", () => {
expect(matchDevice("ONEXPLAYER APEX", "AMD").isFallback).toBe(false);
});

it("is true for a vendor fallback", () => {
expect(matchDevice("Some Unknown Handheld", "Intel").isFallback).toBe(true);
expect(matchDevice("Some Unknown Handheld", "AMD").isFallback).toBe(true);
expect(matchDevice("", "Unknown").isFallback).toBe(true);
});
});

// A handheld whose vendor driver implements the kernel's firmware-attributes
// interface publishes the envelope its firmware will accept. That's the only
// correct source for a device released after this table was last touched —
// but it says nothing about thermals, so a matched row keeps its judgment.
describe("applyFirmwareRange", () => {
it("gives an unknown device the firmware's range and derived presets", () => {
// The shape an Arc G3 Extreme handheld reports: 8-35 W on PL1.
const device = applyFirmwareRange(matchDevice("BRAND NEW HANDHELD", "Intel"), {
min: 8,
max: 35,
});

expect(device.minTdp).toBe(8);
expect(device.maxTdp).toBe(35);
expect(device.profiles).toEqual({ Silent: 13, Balanced: 22, Performance: 35 });
expect(device.batteryMaxTdp).toBe(28);
});

it("keeps a known device's hand-tuned battery cap", () => {
const matched = matchDevice("ROG Ally RC71", "AMD"); // 25 W plugged, 20 W battery
const device = applyFirmwareRange(matched, { min: 7, max: 30 });

expect(device.maxTdp).toBe(30);
expect(device.batteryMaxTdp).toBe(20);
});

it("never lets a known device's battery cap exceed the firmware max", () => {
const matched = matchDevice("ONEXPLAYER APEX", "AMD"); // 55 W battery cap
const device = applyFirmwareRange(matched, { min: 5, max: 40 });

expect(device.batteryMaxTdp).toBe(40);
});

it("never lets an unknown device's battery cap fall under the firmware min", () => {
// A narrow firmware envelope is where the 80% fallback goes under the
// floor: round(17 * 0.8) = 14, below a 15 W min. Offering a battery
// ceiling the rail would reject is worse than offering the floor.
const device = applyFirmwareRange(matchDevice("BRAND NEW HANDHELD", "Intel"), {
min: 15,
max: 17,
});

expect(device.batteryMaxTdp).toBe(15);
expect(device.batteryMaxTdp).toBeGreaterThanOrEqual(device.minTdp);
});

it("never lets a known device's battery cap fall under the firmware min", () => {
const matched = matchDevice("ROG Ally RC71", "AMD"); // 20 W battery cap
const device = applyFirmwareRange(matched, { min: 25, max: 35 });

expect(device.batteryMaxTdp).toBe(25);
});

it("clamps a known device's presets into the firmware's interval", () => {
const matched = matchDevice("ONEXPLAYER APEX", "AMD"); // 15 / 30 / 50
const device = applyFirmwareRange(matched, { min: 20, max: 40 });

expect(device.profiles).toEqual({ Silent: 20, Balanced: 30, Performance: 40 });
});

it("leaves the device name and fallback flag alone", () => {
const matched = matchDevice("ONEXPLAYER APEX", "AMD");
const device = applyFirmwareRange(matched, { min: 8, max: 35 });

expect(device.name).toBe("OneXPlayer APEX");
expect(device.isFallback).toBe(false);
});

it("still defers to the global on-battery ceiling", () => {
const device = applyFirmwareRange(matchDevice("BIG UNKNOWN RIG", "AMD"), {
min: 10,
max: 120,
});

expect(device.batteryMaxTdp).toBe(96);
expect(
effectiveMaxWatts({
acOnline: false,
maxTdp: device.maxTdp,
batteryMaxTdp: device.batteryMaxTdp,
}),
).toBe(BATTERY_SAFE_MAX_WATTS);
});
});
68 changes: 68 additions & 0 deletions packages/devices/src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,14 @@ const KNOWN_DEVICES: DeviceInfo[] = [
},
];

/**
* Share of a firmware-declared max allowed on battery for a device we have no
* table row for. Matches the ~80% relationship the hand-tuned rows above use
* between `maxTdp` and `batteryMaxTdp`. `BATTERY_SAFE_MAX_WATTS` still caps
* the result.
*/
const FALLBACK_BATTERY_FRACTION = 0.8;

/** Default ranges when device is unknown. */
const DEFAULT_AMD: Omit<DeviceInfo, "match"> = {
name: "Generic AMD",
Expand Down Expand Up @@ -321,6 +329,13 @@ export interface DeviceMatch {
/** Max TDP when on battery (<= maxTdp). */
batteryMaxTdp: number;
profiles: Record<string, number>;
/**
* True when no row in the device table matched and this is a vendor-keyed
* guess. Callers that have a better source of truth for the envelope — a
* firmware-declared range, say — use this to tell "we know this device"
* from "we're guessing from the CPU vendor".
*/
isFallback: boolean;
}

/**
Expand All @@ -340,6 +355,7 @@ export function matchDevice(
maxTdp: device.maxTdp,
batteryMaxTdp: device.batteryMaxTdp,
profiles: { ...device.profiles },
isFallback: false,
};
}
}
Expand All @@ -355,6 +371,58 @@ export function matchDevice(
maxTdp: fallback.maxTdp,
batteryMaxTdp: fallback.batteryMaxTdp,
profiles: { ...fallback.profiles },
isFallback: true,
};
}

/**
* Fold a firmware-declared TDP envelope into a device match.
*
* Handhelds whose vendor driver implements the kernel's `firmware-attributes`
* interface publish the exact envelope their firmware will accept
* (`min_value`/`max_value` on the PL1 rail). That beats this table: it is
* per-unit accurate, and it is the only source of truth for a device that
* shipped after the table was last touched.
*
* What it does NOT beat is the hand-tuned judgment in a matched row. A device
* we know about keeps its battery ceiling and its presets — those encode
* thermals and runtime, not just what the silicon tolerates — clamped into
* the firmware's interval so we never ask for a wattage the rail will reject.
* A fallback match has no such judgment to preserve, so its presets are
* derived from the range instead. Pure.
*/
export function applyFirmwareRange(
device: DeviceMatch,
range: { min: number; max: number },
): DeviceMatch {
const { min, max } = range;
const clamp = (w: number) => Math.min(Math.max(w, min), max);
const span = max - min;
// Both battery-ceiling paths run through clamp() so neither can land under
// the floor. A narrow firmware range makes this reachable: 15–17 W gives
// round(17 * 0.8) = 14 on the fallback path, and a known row whose
// batteryMaxTdp predates a firmware that raised min would slip under it
// too — either way the UI would offer a battery ceiling the rail rejects.

return {
...device,
minTdp: min,
maxTdp: max,
batteryMaxTdp: device.isFallback
? clamp(Math.round(max * FALLBACK_BATTERY_FRACTION))
: clamp(device.batteryMaxTdp),
profiles: device.isFallback
? {
Silent: Math.round(min + span * 0.2),
Balanced: Math.round(min + span * 0.5),
Performance: max,
}
: Object.fromEntries(
Object.entries(device.profiles).map(([name, watts]) => [
name,
clamp(watts),
]),
),
};
}

Expand Down
Loading
Loading