Skip to content
Open
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
173 changes: 173 additions & 0 deletions core/systems/performance/fan_profile_model.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
extends Resource
class_name FanProfileModel
## Drafts are application preferences. PowerStation owns hardware validation and safety.
signal updated

const API_VERSION := 2
const MAX_PROFILE_BYTES := 65536
var backend: Object
var snapshot: Dictionary = {}
var draft: Dictionary = {}
var error := ""
var _loaded_preferences := false

func start(collection: Object) -> void:
backend = collection
refresh()

func refresh() -> void:
if not backend:
snapshot = {"available": false, "fans": [], "sensors": [], "error": "PowerStation is unavailable."}
else:
var state: Variant = JSON.parse_string(backend.get_snapshot_json())
snapshot = state if state is Dictionary else {"available": false, "fans": [], "sensors": [], "error": "Invalid fan service response."}
if compatible() and not _loaded_preferences:
_loaded_preferences = true
draft = automatic_profile(snapshot.fans)
var saved: Variant = JSON.parse_string(backend.get_saved_selection_json())
if saved is Dictionary and saved.get("kind") == "json" and saved.get("profile") is Dictionary:
if validate_profile(saved.profile, snapshot).is_empty():
draft = _canonical_profile(saved.profile)
else:
error = "Saved fan profile no longer matches the hardware. Select or edit a complete profile."
var saved_error: String = backend.get_last_error()
if not saved_error.is_empty():
error = saved_error
updated.emit()

func compatible() -> bool:
return snapshot.get("available", false) and snapshot.get("api_version", 0) == API_VERSION

static func automatic_profile(fans: Array) -> Dictionary:
var entries: Array = []
for fan in fans:
entries.append({"fan": fan.id, "mode": "automatic"})
return {"name": "OpenGamepadUI", "fans": entries}

func set_json(raw: String) -> bool:
if raw.to_utf8_buffer().size() > MAX_PROFILE_BYTES:
error = "Fan profiles must be at most 64 KiB."
updated.emit()
return false
var profile: Variant = JSON.parse_string(raw)
error = validate_profile(profile, snapshot)
if error.is_empty():
draft = _canonical_profile(profile)
updated.emit()
return error.is_empty()

func apply() -> bool:
error = validate_profile(draft, snapshot)
if not compatible():
error = "PowerStation fan API v2 is required."
if not error.is_empty():
updated.emit()
return false
var success: bool = backend.load_profile_json(JSON.stringify(draft), snapshot.get("owner", ""))
error = "" if success else str(backend.get_last_error())
refresh()
return success

func automatic() -> bool:
if not compatible():
return false
# An explicit Automatic action never depends on sensor validity or a saved draft.
var previous := draft.duplicate(true)
draft = automatic_profile(snapshot.fans)
if apply():
return true
draft = previous
updated.emit()
return false

func load_file(file: String) -> bool:
if not compatible():
return false
var success: bool = backend.load_profile(file, snapshot.get("owner", ""))
error = "" if success else str(backend.get_last_error())
refresh()
return success

func edit_fan(id: String, mode: String, percent: int = 60) -> void:
if not draft.get("fans") is Array:
return
var index := -1
for candidate in draft.fans.size():
if draft.fans[candidate].fan == id:
index = candidate
break
if index < 0:
return
var entry: Dictionary = {"fan": draft.fans[index].fan, "mode": mode}
if mode == "manual":
entry.percent = percent
if mode in ["software_curve", "firmware_curve"]:
# The user supplies sensor IDs and the complete curve in the JSON editor.
# Native point counts and trusted safety policy are never guessed here.
var previous: Dictionary = draft.fans[index]
entry.sensors = previous.get("sensors", []).duplicate()
entry.curve = previous.get("curve", []).duplicate(true)
draft.fans[index] = entry
error = ""
updated.emit()

static func validate_profile(profile: Variant, state: Dictionary) -> String:
if not profile is Dictionary or profile.size() != 2 or not profile.get("name") is String or str(profile.name).strip_edges().is_empty() or not profile.get("fans") is Array:
return "Use a profile name and a fans array."
var fans: Array = state.get("fans", [])
if fans.is_empty() or profile.fans.size() != fans.size():
return "A profile must contain every available fan exactly once."
var known: Dictionary = {}
for fan in fans:
known[fan.id] = fan
var sensors: Array = []
for sensor in state.get("sensors", []):
sensors.append(sensor.id)
var seen: Array = []
for entry in profile.fans:
if not entry is Dictionary or not entry.get("fan") is String or not known.has(entry.fan) or entry.fan in seen:
return "Unknown or duplicate fan ID."
seen.append(entry.fan)
if not entry.get("mode") is String or entry.mode not in known[entry.fan].available_modes:
return "Select a mode advertised by this fan."
var fields: Array = ["fan", "mode"]
match entry.mode:
"manual":
fields.append("percent")
if not _percent(entry.get("percent")):
return "Manual percentage must be an integer from 0 to 100."
"software_curve", "firmware_curve":
fields.append_array(["sensors", "curve"])
if not entry.get("sensors") is Array or entry.sensors.is_empty():
return "A curve needs sensor IDs from the available sensor list."
var seen_sensors: Array = []
for id in entry.sensors:
if id not in sensors or id in seen_sensors:
return "Unknown or duplicate sensor ID."
seen_sensors.append(id)
if not entry.get("curve") is Array or entry.curve.size() < 2:
return "Supply the complete temperature/percentage curve with at least two points."
var previous_temperature := -INF
var previous_percent := -1
for point in entry.curve:
if not point is Array or point.size() != 2 or not (point[0] is int or point[0] is float) or not is_finite(float(point[0])) or float(point[0]) <= previous_temperature or not _percent(point[1]) or int(point[1]) < previous_percent:
return "Curve temperatures must increase and percentages must not decrease."
previous_temperature = float(point[0])
previous_percent = int(point[1])
for key in entry:
if key not in fields:
return "Profile fields cannot contain hardware paths or controller policy."
return ""

static func _percent(value: Variant) -> bool:
return (value is int or value is float) and is_finite(float(value)) and float(value) == floor(float(value)) and float(value) >= 0 and float(value) <= 100

static func _canonical_profile(profile: Dictionary) -> Dictionary:
var result := profile.duplicate(true)
for entry in result.fans:
if entry.mode == "manual":
entry.percent = int(entry.percent)
if entry.mode in ["software_curve", "firmware_curve"]:
for point in entry.curve:
point[1] = int(point[1])
return result
1 change: 1 addition & 0 deletions core/systems/performance/fan_profile_model.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://bhc3re8kul408
128 changes: 128 additions & 0 deletions core/systems/performance/fan_profile_model_test.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
extends GutTest
const Model := preload("res://core/systems/performance/fan_profile_model.gd")

class Backend extends RefCounted:
var state: Dictionary = {"available": true, "api_version": 2, "owner": ":1.8", "profile_name": "", "profile_path": "",
"fans": [{"id": "test/fan", "name": "Fan", "mode": "automatic", "available_modes": ["automatic", "manual", "software_curve"], "percent": 0, "percent_valid": false, "fault": ""}],
"sensors": [{"id": "test/fan/cpu", "name": "CPU", "temperature_c": 50.0, "telemetry_valid": true}]}
var calls: Array = []
var saved: Dictionary = {}
var error := ""
var denied := false
func get_snapshot_json() -> String:
return JSON.stringify(state)
func get_saved_selection_json() -> String:
return JSON.stringify(saved)
func get_last_error() -> String:
return error
func load_profile_json(raw: String, owner: String) -> bool:
calls.append({"profile": JSON.parse_string(raw), "owner": owner})
if denied:
error = "Not applied; sensor unavailable"
return false
saved = {"kind": "json", "profile": JSON.parse_string(raw)}
state.profile_name = saved.profile.name
error = ""
return true
func load_profile(file: String, owner: String) -> bool:
calls.append({"file": file, "owner": owner})
return not denied

var backend: Backend
var model: FanProfileModel
func before_each() -> void:
backend = Backend.new()
model = Model.new()
model.start(backend)

func test_first_use_and_refresh_never_send_a_profile() -> void:
model.refresh()
assert_eq(backend.calls.size(), 0)
assert_eq(model.draft.fans[0].mode, "automatic")
assert_true(model.compatible())

func test_explicit_manual_uses_complete_profile_and_displayed_owner() -> void:
model.edit_fan("test/fan", "manual", 60)
assert_eq(backend.calls.size(), 0)
assert_true(model.apply())
assert_eq(backend.calls[0].owner, ":1.8")
assert_eq(backend.calls[0].profile.fans, [{"fan": "test/fan", "mode": "manual", "percent": 60.0}])

func test_automatic_works_without_telemetry_and_with_an_invalid_draft() -> void:
backend.state.sensors[0].telemetry_valid = false
model.draft = {"bad": "input"}
assert_true(model.automatic())
assert_eq(backend.calls[0].profile.fans[0], {"fan": "test/fan", "mode": "automatic"})

func test_failed_apply_preserves_draft_and_reports_error() -> void:
model.edit_fan("test/fan", "manual", 60)
backend.denied = true
assert_false(model.apply())
assert_eq(model.draft.fans[0].percent, 60)
assert_string_contains(model.error, "sensor unavailable")
assert_true(backend.saved.is_empty())

func test_service_restart_refresh_does_not_replay_the_draft() -> void:
model.edit_fan("test/fan", "manual", 60)
backend.state.owner = ":1.9"
model.refresh()
assert_eq(backend.calls.size(), 0)
assert_eq(model.draft.fans[0].percent, 60)

func test_complete_sensor_curve_is_accepted_without_inventing_native_limits() -> void:
var profile := {"name": "Custom", "fans": [{"fan": "test/fan", "mode": "software_curve", "sensors": ["test/fan/cpu"], "curve": [[45, 20], [95, 100]]}]}
assert_true(model.set_json(JSON.stringify(profile)))
assert_true(model.apply())
assert_eq(backend.calls[0].profile.fans[0].sensors, ["test/fan/cpu"])

func test_unknown_duplicate_unsupported_and_incomplete_profiles_are_rejected() -> void:
var profile := Model.automatic_profile(backend.state.fans)
profile.fans[0].fan = "unknown"
assert_ne(Model.validate_profile(profile, backend.state), "")
profile = Model.automatic_profile(backend.state.fans)
profile.fans.append(profile.fans[0].duplicate())
assert_ne(Model.validate_profile(profile, backend.state), "")
profile.fans = []
assert_ne(Model.validate_profile(profile, backend.state), "")
profile = Model.automatic_profile(backend.state.fans)
profile.fans[0].mode = "firmware_curve"
assert_ne(Model.validate_profile(profile, backend.state), "")

func test_untrusted_paths_policy_invalid_percentages_and_curves_never_write() -> void:
for percent in [-1, 101, 12.5, "60"]:
assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "manual", "percent": percent}]})))
for curve in [[[50, 20], [50, 100]], [[50, 60], [95, 20]], [[50, 20]], [[50, 20], [95, 101]]]:
assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "software_curve", "sensors": ["test/fan/cpu"], "curve": curve}]})))
assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "automatic", "sysfs": "/sys/fake"}]})))
assert_eq(backend.calls.size(), 0)

func test_unknown_duplicate_sensors_and_bad_json_leave_the_draft_intact() -> void:
var previous := model.draft.duplicate(true)
for sensors in [["missing"], ["test/fan/cpu", "test/fan/cpu"], []]:
assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "software_curve", "sensors": sensors, "curve": [[45, 20], [95, 100]]}]})))
assert_false(model.set_json("{"))
assert_eq(model.draft, previous)

func test_v1_future_and_disconnected_services_cannot_apply() -> void:
for version in [1, 3]:
backend.state.api_version = version
model.refresh()
assert_false(model.compatible())
assert_false(model.apply())
assert_eq(backend.calls.size(), 0)

func test_fan_edits_use_ids_when_profile_order_differs() -> void:
backend.state.fans.append({"id": "test/other", "available_modes": ["automatic", "manual"]})
model.draft = {"name": "Two", "fans": [{"fan": "test/other", "mode": "automatic"}, {"fan": "test/fan", "mode": "automatic"}]}
model.edit_fan("test/fan", "manual", 65)
assert_eq(model.draft.fans[0].mode, "automatic")
assert_eq(model.draft.fans[1].percent, 65)

func test_saved_json_loads_as_an_editable_draft_without_reapplying() -> void:
backend.saved = {"kind": "json", "profile": {"name": "Saved", "fans": [{"fan": "test/fan", "mode": "manual", "percent": 60}]}}
model = Model.new()
model.start(backend)
assert_eq(backend.calls.size(), 0)
assert_eq(model.draft.name, "Saved")
assert_true(model.draft.fans[0].percent is int)
assert_true(model.apply())
1 change: 1 addition & 0 deletions core/systems/performance/fan_profile_model_test.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://b87vwbuprp0dw
12 changes: 12 additions & 0 deletions core/systems/performance/power_station.gd
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ class_name PowerStation
# Keep a reference to instances so they are not cleaned up automatically
var _cpu: Cpu
var _gpu: Gpu
var _fan: Fan
var logger := Log.get_logger("PowerStation")


func _ready() -> void:
_cpu = instance.get_cpu()
_gpu = instance.get_gpu()
_fan = instance.get_fan()
if instance.is_running():
_restore_fan_profile()
else:
instance.started.connect(_restore_fan_profile, CONNECT_ONE_SHOT)


func _restore_fan_profile() -> void:
_fan = instance.get_fan()
if _fan:
_fan.restore_saved()


func _process(_delta: float) -> void:
Expand Down
15 changes: 15 additions & 0 deletions core/ui/card_ui/quick_bar/fan_controls_card.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[gd_scene load_steps=3 format=3]

[ext_resource type="PackedScene" path="res://core/ui/card_ui/quick_bar/qb_card.tscn" id="1_card"]
[ext_resource type="PackedScene" path="res://core/ui/common/quick_bar/fan_controls_menu.tscn" id="2_menu"]

[node name="FanControlsCard" instance=ExtResource("1_card")]
visible = false
title = "Fan Settings"

[node name="SectionLabel" parent="MarginContainer/CardVBoxContainer" index="0"]
text = "Fan Settings"

[node name="FanControls" parent="MarginContainer/CardVBoxContainer/ContentContainer" index="0" instance=ExtResource("2_menu")]
layout_mode = 2
size_flags_vertical = 0
7 changes: 6 additions & 1 deletion core/ui/card_ui/quick_bar/quick_bar_menu.tscn
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[gd_scene load_steps=30 format=3 uid="uid://hroo3ll4inrb"]
[gd_scene load_steps=31 format=3 uid="uid://hroo3ll4inrb"]

[ext_resource type="Script" uid="uid://3sik128fffg7" path="res://core/ui/card_ui/quick_bar/quick_bar_menu.gd" id="1_56jo7"]
[ext_resource type="PackedScene" uid="uid://shvyhrv5sx3v" path="res://core/systems/state/state_watcher.tscn" id="2_6rvrx"]
Expand Down Expand Up @@ -29,6 +29,7 @@
[ext_resource type="PackedScene" uid="uid://bjy50kdrebgre" path="res://core/ui/card_ui/quick_bar/notifications_card.tscn" id="19_pppbi"]
[ext_resource type="PackedScene" uid="uid://dxaeufuk7ump2" path="res://core/ui/card_ui/quick_bar/quick_settings_card.tscn" id="20_17ks0"]
[ext_resource type="PackedScene" uid="uid://dycb7m0oj13ly" path="res://core/ui/card_ui/quick_bar/performance_card.tscn" id="21_uw510"]
[ext_resource type="PackedScene" path="res://core/ui/card_ui/quick_bar/fan_controls_card.tscn" id="22_fan"]

[node name="QuickBarMenu" type="Control" groups=["quick-bar"]]
z_index = 20
Expand Down Expand Up @@ -235,3 +236,7 @@ layout_mode = 2

[node name="PerformanceCard" parent="MarginContainer/PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/Viewport" instance=ExtResource("21_uw510")]
layout_mode = 2

[node name="FanControlsCard" parent="MarginContainer/PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/Viewport" instance=ExtResource("22_fan")]
unique_name_in_owner = true
layout_mode = 2
Loading