From 6fc002fce26e12f6148ced207a5078d4d6045692 Mon Sep 17 00:00:00 2001 From: sknowledge <216877555+sknowledge1@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:32:35 +0100 Subject: [PATCH 1/2] feat: add PowerStation fan controls --- core/systems/performance/power_station.gd | 2 + .../card_ui/quick_bar/fan_controls_card.tscn | 15 + core/ui/card_ui/quick_bar/quick_bar_menu.tscn | 7 +- core/ui/common/quick_bar/fan_controls_menu.gd | 390 +++++++++++++++ .../common/quick_bar/fan_controls_menu.gd.uid | 1 + .../common/quick_bar/fan_controls_menu.tscn | 96 ++++ .../quick_bar/fan_controls_menu_test.gd | 60 +++ .../quick_bar/fan_controls_menu_test.gd.uid | 1 + extensions/core/src/dbus/powerstation.rs | 2 + extensions/core/src/dbus/powerstation/fan.rs | 13 + .../core/src/dbus/powerstation/fan_device.rs | 81 +++ .../core/src/performance/powerstation.rs | 28 ++ .../core/src/performance/powerstation/fan.rs | 106 ++++ .../performance/powerstation/fan_device.rs | 467 ++++++++++++++++++ 14 files changed, 1268 insertions(+), 1 deletion(-) create mode 100644 core/ui/card_ui/quick_bar/fan_controls_card.tscn create mode 100644 core/ui/common/quick_bar/fan_controls_menu.gd create mode 100644 core/ui/common/quick_bar/fan_controls_menu.gd.uid create mode 100644 core/ui/common/quick_bar/fan_controls_menu.tscn create mode 100644 core/ui/common/quick_bar/fan_controls_menu_test.gd create mode 100644 core/ui/common/quick_bar/fan_controls_menu_test.gd.uid create mode 100644 extensions/core/src/dbus/powerstation/fan.rs create mode 100644 extensions/core/src/dbus/powerstation/fan_device.rs create mode 100644 extensions/core/src/performance/powerstation/fan.rs create mode 100644 extensions/core/src/performance/powerstation/fan_device.rs diff --git a/core/systems/performance/power_station.gd b/core/systems/performance/power_station.gd index e714a78b9..03df9132d 100644 --- a/core/systems/performance/power_station.gd +++ b/core/systems/performance/power_station.gd @@ -12,12 +12,14 @@ 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() func _process(_delta: float) -> void: diff --git a/core/ui/card_ui/quick_bar/fan_controls_card.tscn b/core/ui/card_ui/quick_bar/fan_controls_card.tscn new file mode 100644 index 000000000..7b3d91623 --- /dev/null +++ b/core/ui/card_ui/quick_bar/fan_controls_card.tscn @@ -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 Controls" + +[node name="SectionLabel" parent="MarginContainer/CardVBoxContainer" index="0"] +text = "Fan Controls" + +[node name="FanControls" parent="MarginContainer/CardVBoxContainer/ContentContainer" index="0" instance=ExtResource("2_menu")] +layout_mode = 2 +size_flags_vertical = 0 diff --git a/core/ui/card_ui/quick_bar/quick_bar_menu.tscn b/core/ui/card_ui/quick_bar/quick_bar_menu.tscn index 5d00aec59..1edf5e26c 100644 --- a/core/ui/card_ui/quick_bar/quick_bar_menu.tscn +++ b/core/ui/card_ui/quick_bar/quick_bar_menu.tscn @@ -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"] @@ -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 @@ -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 diff --git a/core/ui/common/quick_bar/fan_controls_menu.gd b/core/ui/common/quick_bar/fan_controls_menu.gd new file mode 100644 index 000000000..e4bcb2c95 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu.gd @@ -0,0 +1,390 @@ +extends VBoxContainer + +const SLIDER_SCENE := preload("res://core/ui/components/slider.tscn") + +var power_station := load("res://core/systems/performance/power_station.tres") as PowerStationInstance +var fans: Array = [] +var selected_fan +var curve_temperature_sliders: Array[ValueSlider] = [] +var curve_percent_sliders: Array[ValueSlider] = [] +var syncing := false +var compatible := false + +@onready var focus_group := $%FocusGroup as FocusGroup +@onready var service_timer := $%ServiceTimer as Timer +@onready var apply_timer := $%ApplyTimer as Timer +@onready var fan_dropdown := $%FanDropdown as Dropdown +@onready var telemetry_label := $%TelemetryLabel as Label +@onready var backend_label := $%BackendLabel as Label +@onready var status_label := $%StatusLabel as Label +@onready var limits_label := $%LimitsLabel as Label +@onready var mode_dropdown := $%ModeDropdown as Dropdown +@onready var preset_dropdown := $%PresetDropdown as Dropdown +@onready var manual_slider := $%ManualSlider as ValueSlider +@onready var curve_section := $%CurveSection as Control +@onready var curve_points := $%CurvePoints as VBoxContainer + + +func _ready() -> void: + mode_dropdown.clear() + mode_dropdown.add_item("Automatic") + mode_dropdown.add_item("Manual") + mode_dropdown.add_item("Curve") + fan_dropdown.item_selected.connect(_on_fan_selected) + mode_dropdown.item_selected.connect(_on_mode_selected) + preset_dropdown.item_selected.connect(_on_preset_selected) + manual_slider.value_changed.connect(_on_manual_changed) + service_timer.timeout.connect(_refresh_devices) + apply_timer.timeout.connect(_apply_curve) + if power_station: + power_station.started.connect(_refresh_devices) + power_station.stopped.connect(_on_service_stopped) + _refresh_devices() + + +func _refresh_devices() -> void: + var collection = power_station.get_fan() if power_station else null + if collection: + collection.refresh() + fans = Array(collection.get_fans()) + else: + fans = [] + _set_card_available(should_show_card(fans.size())) + if fans.is_empty(): + _clear_selected_fan() + return + + var selected_path := "" + if selected_fan: + selected_path = selected_fan.get_dbus_path() + fan_dropdown.clear() + var selected_index := 0 + for index in fans.size(): + var fan = fans[index] + fan_dropdown.add_item(fan.get_name()) + if fan.get_dbus_path() == selected_path: + selected_index = index + fan_dropdown.visible = should_show_selector(fans.size()) + _select_fan(selected_index) + + +func _select_fan(index: int) -> void: + if index < 0 or index >= fans.size(): + return + var next_fan = fans[index] + if selected_fan != next_fan: + apply_timer.stop() + if selected_fan and selected_fan.updated.is_connected(_sync_view): + selected_fan.updated.disconnect(_sync_view) + _clear_curve_controls() + selected_fan = next_fan + fan_dropdown.select(index) + if not selected_fan.updated.is_connected(_sync_view): + selected_fan.updated.connect(_sync_view) + _sync_view() + + +func _sync_view() -> void: + if not selected_fan: + return + syncing = true + compatible = supports_api_version(selected_fan.get_api_version()) + var editing := not apply_timer.is_stopped() + if not compatible: + apply_timer.stop() + editing = false + var telemetry_valid: bool = selected_fan.get_telemetry_valid() + if telemetry_valid: + var effective: int = selected_fan.get_effective_percent() + var duty_text: String = "firmware" if effective < 0 else "%d%%" % effective + telemetry_label.text = "%.1f C | %d RPM | %s" % [ + selected_fan.get_temperature_c(), selected_fan.get_rpm(), duty_text] + else: + telemetry_label.text = "Telemetry unavailable" + backend_label.text = "Backend: %s" % _display_backend(selected_fan.get_backend()) + limits_label.text = describe_limits( + selected_fan.get_minimum_percent(), + selected_fan.get_minimum_running_percent(), + selected_fan.get_stop_temperature_c(), + selected_fan.get_restart_temperature_c(), + selected_fan.get_full_speed_temperature_c(), + selected_fan.get_emergency_handoff_temperature_c()) + status_label.text = describe_state({ + "compatible": compatible, + "fault": selected_fan.get_fault(), + "automatic_verified": selected_fan.get_automatic_verified(), + "suspended": selected_fan.get_suspended(), + "mode": selected_fan.get_mode(), + }) + + if not editing: + mode_dropdown.disabled = not compatible + mode_dropdown.select(mode_to_index(selected_fan.get_mode())) + manual_slider.min_value = selected_fan.get_minimum_percent() + manual_slider.value = selected_fan.get_manual_percent() + manual_slider.editable = compatible + manual_slider.visible = selected_fan.get_mode() == "manual" + _populate_presets() + var curve_visible: bool = selected_fan.get_mode() in ["curve", "preset"] + preset_dropdown.visible = curve_visible + curve_section.visible = curve_visible + if curve_visible: + _rebuild_curve() + syncing = false + focus_group.recalculate_focus.call_deferred() + + +func _populate_presets() -> void: + var presets: PackedStringArray = selected_fan.get_presets() + var active: String = selected_fan.get_active_preset() + preset_dropdown.clear() + preset_dropdown.add_item("Custom") + var selected := 0 + for index in presets.size(): + preset_dropdown.add_item(presets[index]) + if presets[index] == active: + selected = index + 1 + preset_dropdown.select(selected) + preset_dropdown.disabled = not compatible or presets.is_empty() + + +func _rebuild_curve() -> void: + var temperatures: PackedFloat64Array = selected_fan.get_curve_temperatures() + var percents: PackedInt32Array = selected_fan.get_curve_percents() + if temperatures.size() != percents.size(): + return + if _curve_matches(temperatures, percents): + return + _clear_curve_controls() + for index in temperatures.size(): + var temperature := SLIDER_SCENE.instantiate() as ValueSlider + temperature.name = "Point%dTemperature" % (index + 1) + temperature.text = "Point %d temperature (C)" % (index + 1) + temperature.min_value = selected_fan.get_minimum_valid_temperature_c() + temperature.max_value = selected_fan.get_full_speed_temperature_c() + temperature.step = 1.0 + temperature.value = temperatures[index] + temperature.editable = compatible + temperature.value_changed.connect(_on_curve_changed) + curve_points.add_child(temperature) + curve_temperature_sliders.append(temperature) + + var percent := SLIDER_SCENE.instantiate() as ValueSlider + percent.name = "Point%dDuty" % (index + 1) + percent.text = "Point %d fan (%%)" % (index + 1) + percent.min_value = selected_fan.get_minimum_percent() + percent.max_value = 100.0 + percent.step = 1.0 + percent.value = percents[index] + percent.editable = compatible + percent.value_changed.connect(_on_curve_changed) + curve_points.add_child(percent) + curve_percent_sliders.append(percent) + + +func _curve_matches(temperatures: PackedFloat64Array, percents: PackedInt32Array) -> bool: + if temperatures.size() != curve_temperature_sliders.size(): + return false + for index in temperatures.size(): + if not is_equal_approx(curve_temperature_sliders[index].value, temperatures[index]): + return false + if int(curve_percent_sliders[index].value) != percents[index]: + return false + return true + + +func _on_fan_selected(index: int) -> void: + _select_fan(index) + + +func _on_mode_selected(index: int) -> void: + if syncing or not compatible or not selected_fan: + return + var successful := false + match index: + 0: + successful = selected_fan.set_automatic() + 1: + successful = selected_fan.set_manual(int(manual_slider.value)) + 2: + if curve_temperature_sliders.size() >= 2: + successful = _send_curve() + else: + var temperatures: PackedFloat64Array = selected_fan.get_curve_temperatures() + var percents: PackedInt32Array = selected_fan.get_curve_percents() + if temperatures.size() >= 2 and temperatures.size() == percents.size(): + successful = selected_fan.set_curve(temperatures, percents) + else: + var initial := initial_curve( + selected_fan.get_restart_temperature_c(), + selected_fan.get_full_speed_temperature_c(), + selected_fan.get_minimum_percent()) + successful = selected_fan.set_curve( + initial.temperatures, initial.percents) + _apply_result(successful) + + +func _on_preset_selected(index: int) -> void: + if syncing or not compatible or not selected_fan: + return + if index == 0: + _apply_result(_send_curve()) + return + var presets: PackedStringArray = selected_fan.get_presets() + if index - 1 < presets.size(): + _apply_result(selected_fan.apply_preset(presets[index - 1])) + + +func _on_manual_changed(_value: float) -> void: + if syncing or not compatible or not selected_fan or selected_fan.get_mode() != "manual": + return + apply_timer.start() + + +func _on_curve_changed(_value: float) -> void: + if syncing or not compatible or not selected_fan: + return + preset_dropdown.select(0) + apply_timer.start() + + +func _apply_curve() -> void: + if not compatible or not selected_fan: + return + if selected_fan.get_mode() == "manual": + _apply_result(selected_fan.set_manual(int(manual_slider.value))) + return + if selected_fan.get_mode() in ["curve", "preset"]: + _apply_result(_send_curve()) + + +func _send_curve() -> bool: + var temperatures := PackedFloat64Array() + var percents := PackedInt32Array() + for slider in curve_temperature_sliders: + temperatures.append(slider.value) + for slider in curve_percent_sliders: + percents.append(int(slider.value)) + return selected_fan.set_curve(temperatures, percents) + + +func _apply_result(successful: bool) -> void: + if not successful: + status_label.text = "PowerStation rejected the change: %s" % selected_fan.get_last_error() + return + _sync_view.call_deferred() + + +func _on_service_stopped() -> void: + fans = [] + _clear_selected_fan() + compatible = false + _set_card_available(false) + + +func _clear_selected_fan() -> void: + apply_timer.stop() + if selected_fan and selected_fan.updated.is_connected(_sync_view): + selected_fan.updated.disconnect(_sync_view) + selected_fan = null + _clear_curve_controls() + + +func _clear_curve_controls() -> void: + for child in curve_points.get_children(): + curve_points.remove_child(child) + child.queue_free() + curve_temperature_sliders.clear() + curve_percent_sliders.clear() + + +func _set_card_available(available: bool) -> void: + var card := find_parent("FanControlsCard") as Control + if not card or card.visible == available: + return + card.visible = available + var viewport := card.get_parent() + if viewport and viewport.has_node("FocusGroup"): + var outer_focus := viewport.get_node("FocusGroup") as FocusGroup + outer_focus.recalculate_focus.call_deferred() + + +static func should_show_card(fan_count: int) -> bool: + return fan_count > 0 + + +static func should_show_selector(fan_count: int) -> bool: + return fan_count > 1 + + +static func supports_api_version(version: int) -> bool: + return version == 1 + + +static func initial_curve( + restart_temperature_c: float, + full_speed_temperature_c: float, + minimum_percent: int, + ) -> Dictionary: + return { + "temperatures": PackedFloat64Array([ + restart_temperature_c, + full_speed_temperature_c, + ]), + "percents": PackedInt32Array([minimum_percent, 100]), + } + + +static func mode_to_index(mode: String) -> int: + match mode: + "manual": + return 1 + "curve", "preset": + return 2 + _: + return 0 + + +static func describe_state(state: Dictionary) -> String: + if not state.get("compatible", false): + return "Incompatible PowerStation fan API. Update PowerStation." + var fault := str(state.get("fault", "")) + if not fault.is_empty(): + if state.get("automatic_verified", false): + return "Automatic fallback: %s" % fault + return "Fan fault: %s" % fault + if state.get("suspended", false): + return "Firmware automatic mode verified for sleep" + if state.get("mode", "automatic") == "automatic": + if state.get("automatic_verified", false): + return "Firmware automatic mode" + return "Waiting for firmware automatic verification" + return "PowerStation is controlling the fan" + + +static func describe_limits( + minimum_percent: int, + minimum_running_percent: int, + stop_temperature_c: float, + restart_temperature_c: float, + full_speed_temperature_c: float, + emergency_handoff_temperature_c: float, + ) -> String: + return "Duty %d-100%% | Running floor %d%% | Stop <= %.0f C / restart > %.0f C\nFull fan %.0f C | Firmware handoff %.0f C" % [ + minimum_percent, + minimum_running_percent, + stop_temperature_c, + restart_temperature_c, + full_speed_temperature_c, + emergency_handoff_temperature_c, + ] + + +static func _display_backend(backend: String) -> String: + match backend: + "software_curve": + return "Software curve" + "hardware_curve": + return "Hardware curve" + _: + return backend.capitalize() diff --git a/core/ui/common/quick_bar/fan_controls_menu.gd.uid b/core/ui/common/quick_bar/fan_controls_menu.gd.uid new file mode 100644 index 000000000..b9c3a4c20 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu.gd.uid @@ -0,0 +1 @@ +uid://dmv5615ibo4jd diff --git a/core/ui/common/quick_bar/fan_controls_menu.tscn b/core/ui/common/quick_bar/fan_controls_menu.tscn new file mode 100644 index 000000000..f917a00c8 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu.tscn @@ -0,0 +1,96 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" uid="uid://dmv5615ibo4jd" path="res://core/ui/common/quick_bar/fan_controls_menu.gd" id="1_fan"] +[ext_resource type="PackedScene" path="res://core/systems/input/focus_group.tscn" id="2_focus"] +[ext_resource type="Resource" path="res://core/ui/card_ui/quick_bar/quick_bar_menu_focus.tres" id="3_stack"] +[ext_resource type="PackedScene" path="res://core/ui/components/dropdown.tscn" id="4_drop"] +[ext_resource type="PackedScene" path="res://core/ui/components/slider.tscn" id="5_slider"] + +[node name="FanControls" type="VBoxContainer"] +anchors_preset = 10 +anchor_right = 1.0 +grow_horizontal = 2 +script = ExtResource("1_fan") + +[node name="FocusGroup" parent="." node_paths=PackedStringArray("current_focus") instance=ExtResource("2_focus")] +unique_name_in_owner = true +current_focus = NodePath("../ModeDropdown") +focus_stack = ExtResource("3_stack") + +[node name="ServiceTimer" type="Timer" parent="."] +unique_name_in_owner = true +wait_time = 2.0 +autostart = true + +[node name="ApplyTimer" type="Timer" parent="."] +unique_name_in_owner = true +wait_time = 0.5 +one_shot = true + +[node name="FanDropdown" parent="." instance=ExtResource("4_drop")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +title = "Fan" +description = "" + +[node name="TelemetryLabel" type="Label" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Telemetry unavailable" +horizontal_alignment = 1 + +[node name="BackendLabel" type="Label" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Backend" +horizontal_alignment = 1 + +[node name="StatusLabel" type="Label" parent="."] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "Waiting for PowerStation..." +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="LimitsLabel" type="Label" parent="."] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "Safety limits" +horizontal_alignment = 1 +autowrap_mode = 3 + +[node name="ModeDropdown" parent="." instance=ExtResource("4_drop")] +unique_name_in_owner = true +layout_mode = 2 +title = "Mode" +description = "" + +[node name="ManualSlider" parent="." instance=ExtResource("5_slider")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +text = "Fan (%)" +value = 50.0 + +[node name="PresetDropdown" parent="." instance=ExtResource("4_drop")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +title = "Preset" +description = "Editing a preset creates a custom curve." + +[node name="CurveSection" type="VBoxContainer" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 2 + +[node name="CurveLabel" type="Label" parent="CurveSection"] +layout_mode = 2 +text = "Fan Curve" + +[node name="CurvePoints" type="VBoxContainer" parent="CurveSection"] +unique_name_in_owner = true +layout_mode = 2 diff --git a/core/ui/common/quick_bar/fan_controls_menu_test.gd b/core/ui/common/quick_bar/fan_controls_menu_test.gd new file mode 100644 index 000000000..ce336c479 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu_test.gd @@ -0,0 +1,60 @@ +extends GutTest + +const FanControlsMenu := preload("res://core/ui/common/quick_bar/fan_controls_menu.gd") +const MENU_SCENE := preload("res://core/ui/common/quick_bar/fan_controls_menu.tscn") + + +func test_visibility_rules_cover_no_and_multiple_fans() -> void: + assert_false(FanControlsMenu.should_show_card(0)) + assert_true(FanControlsMenu.should_show_card(1)) + assert_false(FanControlsMenu.should_show_selector(1)) + assert_true(FanControlsMenu.should_show_selector(2)) + + +func test_modes_include_preset_as_editable_curve() -> void: + assert_eq(FanControlsMenu.mode_to_index("automatic"), 0) + assert_eq(FanControlsMenu.mode_to_index("manual"), 1) + assert_eq(FanControlsMenu.mode_to_index("curve"), 2) + assert_eq(FanControlsMenu.mode_to_index("preset"), 2) + + +func test_first_custom_curve_uses_device_limits_without_a_preset() -> void: + var curve := FanControlsMenu.initial_curve(45.0, 95.0, 0) + assert_eq(curve.temperatures, PackedFloat64Array([45.0, 95.0])) + assert_eq(curve.percents, PackedInt32Array([0, 100])) + + +func test_fault_message_reports_verified_automatic_fallback() -> void: + var message := FanControlsMenu.describe_state({ + "compatible": true, + "fault": "temperature sensor disappeared", + "automatic_verified": true, + "suspended": false, + "mode": "automatic", + }) + assert_true(message.begins_with("Automatic fallback:")) + + +func test_limit_summary_includes_zero_duty_hysteresis_and_thermal_handoff() -> void: + var summary := FanControlsMenu.describe_limits(0, 10, 42.0, 45.0, 95.0, 98.0) + assert_true(summary.contains("Duty 0-100%")) + assert_true(summary.contains("Running floor 10%")) + assert_true(summary.contains("Stop <= 42 C / restart > 45 C")) + assert_true(summary.contains("Full fan 95 C")) + assert_true(summary.contains("Firmware handoff 98 C")) + + +func test_future_api_is_rejected_clearly() -> void: + assert_true(FanControlsMenu.supports_api_version(1)) + assert_false(FanControlsMenu.supports_api_version(0)) + assert_false(FanControlsMenu.supports_api_version(2)) + var message := FanControlsMenu.describe_state({"compatible": false}) + assert_true(message.contains("Update PowerStation")) + + +func test_menu_has_controller_focus_group() -> void: + var menu := MENU_SCENE.instantiate() + add_child_autoqfree(menu) + var focus_group := menu.get_node("FocusGroup") as FocusGroup + assert_not_null(focus_group) + assert_eq(focus_group.current_focus.name, "ModeDropdown") diff --git a/core/ui/common/quick_bar/fan_controls_menu_test.gd.uid b/core/ui/common/quick_bar/fan_controls_menu_test.gd.uid new file mode 100644 index 000000000..04e43f5b3 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu_test.gd.uid @@ -0,0 +1 @@ +uid://gv44uv1qj414 diff --git a/extensions/core/src/dbus/powerstation.rs b/extensions/core/src/dbus/powerstation.rs index 3ae387c03..370a6f541 100644 --- a/extensions/core/src/dbus/powerstation.rs +++ b/extensions/core/src/dbus/powerstation.rs @@ -2,5 +2,7 @@ pub mod card; pub mod connector; pub mod core; pub mod cpu; +pub mod fan; +pub mod fan_device; pub mod gpu; pub mod tdp; diff --git a/extensions/core/src/dbus/powerstation/fan.rs b/extensions/core/src/dbus/powerstation/fan.rs new file mode 100644 index 000000000..1ba262f9c --- /dev/null +++ b/extensions/core/src/dbus/powerstation/fan.rs @@ -0,0 +1,13 @@ +//! D-Bus proxy for the PowerStation fan collection. + +use zbus::proxy; + +#[proxy( + interface = "org.shadowblip.Fan", + default_service = "org.shadowblip.PowerStation", + default_path = "/org/shadowblip/Performance/Fan" +)] +pub trait Fan { + /// Return every fan exposed by PowerStation. + fn enumerate_fans(&self) -> zbus::Result>; +} diff --git a/extensions/core/src/dbus/powerstation/fan_device.rs b/extensions/core/src/dbus/powerstation/fan_device.rs new file mode 100644 index 000000000..1c4acbed3 --- /dev/null +++ b/extensions/core/src/dbus/powerstation/fan_device.rs @@ -0,0 +1,81 @@ +//! D-Bus proxy for a PowerStation fan device. + +use zbus::proxy; + +#[proxy( + interface = "org.shadowblip.Fan.Device", + default_service = "org.shadowblip.PowerStation" +)] +pub trait FanDevice { + fn set_automatic(&self) -> zbus::Result<()>; + fn set_manual(&self, percent: u8) -> zbus::Result<()>; + fn set_curve(&self, points: Vec<(f64, u8)>) -> zbus::Result<()>; + fn apply_preset(&self, name: &str) -> zbus::Result<()>; + + #[zbus(property)] + fn api_version(&self) -> zbus::Result; + #[zbus(property)] + fn id(&self) -> zbus::Result; + #[zbus(property)] + fn name(&self) -> zbus::Result; + #[zbus(property)] + fn mode(&self) -> zbus::Result; + #[zbus(property)] + fn backend(&self) -> zbus::Result; + #[zbus(property)] + fn available_modes(&self) -> zbus::Result>; + #[zbus(property)] + fn temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn telemetry_valid(&self) -> zbus::Result; + #[zbus(property)] + fn rpm(&self) -> zbus::Result; + #[zbus(property)] + fn effective_percent(&self) -> zbus::Result; + #[zbus(property)] + fn manual_percent(&self) -> zbus::Result; + #[zbus(property)] + fn curve(&self) -> zbus::Result>; + #[zbus(property)] + fn presets(&self) -> zbus::Result>; + #[zbus(property)] + fn active_preset(&self) -> zbus::Result; + #[zbus(property)] + fn fault(&self) -> zbus::Result; + #[zbus(property)] + fn automatic_verified(&self) -> zbus::Result; + #[zbus(property)] + fn controlling(&self) -> zbus::Result; + #[zbus(property)] + fn suspended(&self) -> zbus::Result; + #[zbus(property)] + fn curve_point_count(&self) -> zbus::Result; + #[zbus(property)] + fn minimum_percent(&self) -> zbus::Result; + #[zbus(property)] + fn minimum_running_percent(&self) -> zbus::Result; + #[zbus(property)] + fn pwm_min(&self) -> zbus::Result; + #[zbus(property)] + fn pwm_max(&self) -> zbus::Result; + #[zbus(property)] + fn pwm_readback_tolerance(&self) -> zbus::Result; + #[zbus(property)] + fn stop_temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn restart_temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn stall_rpm(&self) -> zbus::Result; + #[zbus(property)] + fn stall_grace_seconds(&self) -> zbus::Result; + #[zbus(property)] + fn full_speed_temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn emergency_handoff_temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn minimum_valid_temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn maximum_valid_temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn maximum_valid_rpm(&self) -> zbus::Result; +} diff --git a/extensions/core/src/performance/powerstation.rs b/extensions/core/src/performance/powerstation.rs index 5400ea9d6..33ad72c00 100644 --- a/extensions/core/src/performance/powerstation.rs +++ b/extensions/core/src/performance/powerstation.rs @@ -1,5 +1,7 @@ pub mod cpu; pub mod cpu_core; +pub mod fan; +pub mod fan_device; pub mod gpu; pub mod gpu_card; pub mod gpu_connector; @@ -10,6 +12,7 @@ use std::{ }; use cpu::Cpu; +use fan::Fan; use godot::{classes::Engine, prelude::*}; use gpu::Gpu; use zbus::names::BusName; @@ -19,6 +22,7 @@ use crate::{dbus::RunError, get_dbus_system, get_dbus_system_blocking, RUNTIME}; pub const POWERSTATION_BUS: &str = "org.shadowblip.PowerStation"; const POWERSTATION_CPU_PATH: &str = "/org/shadowblip/Performance/CPU"; const POWERSTATION_GPU_PATH: &str = "/org/shadowblip/Performance/GPU"; +const POWERSTATION_FAN_PATH: &str = "/org/shadowblip/Performance/Fan"; /// Signals that can be emitted #[derive(Debug)] @@ -36,6 +40,7 @@ pub struct PowerStationInstance { conn: Option, cpu_instance: Option>, gpu_instance: Option>, + fan_instance: Option>, #[allow(dead_code)] #[var(get = get_cpu)] @@ -43,6 +48,9 @@ pub struct PowerStationInstance { #[allow(dead_code)] #[var(get = get_gpu)] gpu: Option>, + #[allow(dead_code)] + #[var(get = get_fan)] + fan: Option>, } #[godot_api] @@ -81,6 +89,13 @@ impl PowerStationInstance { self.gpu_instance.clone() } + /// Returns the PowerStation fan collection. Older PowerStation versions + /// return an empty collection because they do not expose the fan interface. + #[func] + fn get_fan(&self) -> Option> { + self.fan_instance.clone() + } + /// Process UPower signals and emit them as Godot signals. This method /// should be called every frame in the "_process" loop of a node. #[func] @@ -108,6 +123,9 @@ impl PowerStationInstance { if let Some(gpu) = self.gpu_instance.as_mut() { gpu.bind_mut().process(); } + if let Some(fan) = self.fan_instance.as_mut() { + fan.bind_mut().process(); + } } /// Process and dispatch the given signal @@ -116,10 +134,14 @@ impl PowerStationInstance { Signal::Started => { // Create an instance for the CPU self.cpu_instance = Some(Cpu::new(POWERSTATION_CPU_PATH)); + self.gpu_instance = Some(Gpu::new(POWERSTATION_GPU_PATH)); + self.fan_instance = Some(Fan::new(POWERSTATION_FAN_PATH)); self.base_mut().emit_signal("started", &[]); } Signal::Stopped => { self.cpu_instance = None; + self.gpu_instance = None; + self.fan_instance = None; self.base_mut().emit_signal("stopped", &[]); } } @@ -145,8 +167,10 @@ impl IResource for PowerStationInstance { conn, cpu_instance: Default::default(), gpu_instance: Default::default(), + fan_instance: Default::default(), cpu: Default::default(), gpu: Default::default(), + fan: Default::default(), }; } @@ -161,6 +185,8 @@ impl IResource for PowerStationInstance { let cpu = Some(Cpu::new(POWERSTATION_CPU_PATH)); // Create GPU instance let gpu = Some(Gpu::new(POWERSTATION_GPU_PATH)); + // Create fan collection. It remains empty against older PowerStation. + let fan = Some(Fan::new(POWERSTATION_FAN_PATH)); // Create a new PowerStation instance Self { @@ -169,8 +195,10 @@ impl IResource for PowerStationInstance { conn, cpu_instance: cpu, gpu_instance: gpu, + fan_instance: fan, cpu: None, gpu: None, + fan: None, } } } diff --git a/extensions/core/src/performance/powerstation/fan.rs b/extensions/core/src/performance/powerstation/fan.rs new file mode 100644 index 000000000..c6b518920 --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; + +use godot::{classes::ResourceLoader, prelude::*}; + +use crate::{dbus::powerstation::fan::FanProxyBlocking, get_dbus_system_blocking}; + +use super::{fan_device::FanDevice, POWERSTATION_BUS}; + +/// A collection of fan devices exposed by PowerStation. +#[derive(GodotClass)] +#[class(no_init, base=Resource)] +pub struct Fan { + base: Base, + path: String, + proxy: Option>, + devices: HashMap>, +} + +#[godot_api] +impl Fan { + fn from_path(path: GString) -> Gd { + Gd::from_init_fn(|base| { + let conn = get_dbus_system_blocking().ok(); + let proxy = if let Some(conn) = conn.as_ref() { + let path: String = path.clone().into(); + FanProxyBlocking::builder(conn) + .path(path) + .ok() + .and_then(|builder| builder.build().ok()) + } else { + None + }; + let mut instance = Self { + base, + proxy, + path: path.clone().into(), + devices: HashMap::new(), + }; + instance.refresh_devices(); + instance + }) + } + + pub fn new(path: &str) -> Gd { + let res_path = format!("dbus://{POWERSTATION_BUS}{path}"); + let mut resource_loader = ResourceLoader::singleton(); + if resource_loader.exists(res_path.as_str()) { + if let Some(res) = resource_loader.load(res_path.as_str()) { + let mut fan: Gd = res.cast(); + fan.bind_mut().refresh_devices(); + return fan; + } + } + let mut device = Fan::from_path(path.into()); + device.take_over_path(res_path.as_str()); + device + } + + #[func] + pub fn get_dbus_path(&self) -> GString { + self.path.as_str().into() + } + + /// Refresh fan objects after service restart or hardware rediscovery. + #[func] + pub fn refresh(&mut self) { + self.refresh_devices(); + } + + #[func] + pub fn get_fans(&self) -> Array> { + let mut result = array![]; + let mut paths: Vec<_> = self.devices.keys().collect(); + paths.sort(); + for path in paths { + if let Some(device) = self.devices.get(path) { + result.push(device); + } + } + result + } + + pub fn process(&mut self) { + for device in self.devices.values_mut() { + device.bind_mut().process(); + } + } + + fn refresh_devices(&mut self) { + let paths = self + .proxy + .as_ref() + .and_then(|proxy| proxy.enumerate_fans().ok()) + .unwrap_or_default(); + let mut devices = HashMap::new(); + for path in paths { + let key = path.to_string(); + let device = self + .devices + .remove(&key) + .unwrap_or_else(|| FanDevice::new(path.as_str())); + devices.insert(key, device); + } + self.devices = devices; + } +} diff --git a/extensions/core/src/performance/powerstation/fan_device.rs b/extensions/core/src/performance/powerstation/fan_device.rs new file mode 100644 index 000000000..72a97f11a --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_device.rs @@ -0,0 +1,467 @@ +use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError}; + +use futures_util::StreamExt; +use godot::{classes::ResourceLoader, prelude::*}; + +use crate::{ + dbus::{ + powerstation::fan_device::{FanDeviceProxy, FanDeviceProxyBlocking}, + RunError, + }, + get_dbus_system, get_dbus_system_blocking, RUNTIME, +}; + +use super::POWERSTATION_BUS; + +#[derive(Debug)] +enum Signal { + Updated, +} + +/// A single PowerStation fan and its atomic control operations. +#[derive(GodotClass)] +#[class(no_init, base=Resource)] +pub struct FanDevice { + base: Base, + dbus_path: String, + proxy: Option>, + rx: Receiver, + last_error: String, +} + +#[godot_api] +impl FanDevice { + #[signal] + fn updated(); + + fn from_path(path: GString) -> Gd { + Gd::from_init_fn(|base| { + let conn = get_dbus_system_blocking().ok(); + let (tx, rx) = channel(); + let dbus_path: String = path.clone().into(); + RUNTIME.spawn(async move { + if let Err(error) = run(tx, dbus_path).await { + log::error!("Failed to run fan device task: {error:?}"); + } + }); + let proxy = if let Some(conn) = conn.as_ref() { + let dbus_path: String = path.clone().into(); + FanDeviceProxyBlocking::builder(conn) + .path(dbus_path) + .ok() + .and_then(|builder| builder.build().ok()) + } else { + None + }; + Self { + base, + proxy, + dbus_path: path.into(), + rx, + last_error: String::new(), + } + }) + } + + pub fn new(path: &str) -> Gd { + let res_path = format!("dbus://{POWERSTATION_BUS}{path}"); + let mut resource_loader = ResourceLoader::singleton(); + if resource_loader.exists(res_path.as_str()) { + if let Some(res) = resource_loader.load(res_path.as_str()) { + return res.cast(); + } + } + let mut device = FanDevice::from_path(path.into()); + device.take_over_path(res_path.as_str()); + device + } + + #[func] + pub fn get_dbus_path(&self) -> GString { + self.dbus_path.as_str().into() + } + + #[func] + pub fn get_last_error(&self) -> GString { + self.last_error.as_str().into() + } + + #[func] + pub fn get_api_version(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.api_version().ok()) + .unwrap_or(0) as i64 + } + + #[func] + pub fn get_id(&self) -> GString { + self.string_property(|p| p.id()) + } + + #[func] + pub fn get_name(&self) -> GString { + self.string_property(|p| p.name()) + } + + #[func] + pub fn get_mode(&self) -> GString { + self.string_property(|p| p.mode()) + } + + #[func] + pub fn get_backend(&self) -> GString { + self.string_property(|p| p.backend()) + } + + #[func] + pub fn get_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_telemetry_valid(&self) -> bool { + self.proxy + .as_ref() + .and_then(|p| p.telemetry_valid().ok()) + .unwrap_or(false) + } + + #[func] + pub fn get_rpm(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.rpm().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_effective_percent(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.effective_percent().ok()) + .unwrap_or(-1) as i64 + } + + #[func] + pub fn get_manual_percent(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.manual_percent().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_presets(&self) -> PackedStringArray { + self.string_array_property(|p| p.presets()) + } + + #[func] + pub fn get_active_preset(&self) -> GString { + self.string_property(|p| p.active_preset()) + } + + #[func] + pub fn get_fault(&self) -> GString { + self.string_property(|p| p.fault()) + } + + #[func] + pub fn get_automatic_verified(&self) -> bool { + self.proxy + .as_ref() + .and_then(|p| p.automatic_verified().ok()) + .unwrap_or(false) + } + + #[func] + pub fn get_controlling(&self) -> bool { + self.proxy + .as_ref() + .and_then(|p| p.controlling().ok()) + .unwrap_or(false) + } + + #[func] + pub fn get_suspended(&self) -> bool { + self.proxy + .as_ref() + .and_then(|p| p.suspended().ok()) + .unwrap_or(false) + } + + #[func] + pub fn get_curve_temperatures(&self) -> PackedFloat64Array { + let values: Vec = self.curve().into_iter().map(|point| point.0).collect(); + PackedFloat64Array::from(values.as_slice()) + } + + #[func] + pub fn get_curve_percents(&self) -> PackedInt32Array { + let values: Vec = self + .curve() + .into_iter() + .map(|point| i32::from(point.1)) + .collect(); + PackedInt32Array::from(values.as_slice()) + } + + #[func] + pub fn get_curve_point_count(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.curve_point_count().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_minimum_percent(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.minimum_percent().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_minimum_running_percent(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.minimum_running_percent().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_pwm_min(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.pwm_min().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_pwm_max(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.pwm_max().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_pwm_readback_tolerance(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.pwm_readback_tolerance().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_stop_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.stop_temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_restart_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.restart_temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_stall_rpm(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.stall_rpm().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_stall_grace_seconds(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.stall_grace_seconds().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn get_full_speed_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.full_speed_temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_emergency_handoff_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.emergency_handoff_temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_minimum_valid_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.minimum_valid_temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_maximum_valid_temperature_c(&self) -> f64 { + self.proxy + .as_ref() + .and_then(|p| p.maximum_valid_temperature_c().ok()) + .unwrap_or_default() + } + + #[func] + pub fn get_maximum_valid_rpm(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.maximum_valid_rpm().ok()) + .unwrap_or_default() as i64 + } + + #[func] + pub fn set_automatic(&mut self) -> bool { + self.perform(|proxy| proxy.set_automatic()) + } + + #[func] + pub fn set_manual(&mut self, percent: i64) -> bool { + if !(0..=100).contains(&percent) { + self.last_error = "Fan duty must be between 0 and 100 percent".into(); + return false; + } + self.perform(|proxy| proxy.set_manual(percent as u8)) + } + + /// Apply a complete curve in one D-Bus operation. + #[func] + pub fn set_curve( + &mut self, + temperatures: PackedFloat64Array, + percents: PackedInt32Array, + ) -> bool { + if temperatures.len() != percents.len() || temperatures.len() < 2 { + self.last_error = "A fan curve needs matching temperature and duty arrays".into(); + return false; + } + let mut points = Vec::with_capacity(temperatures.len()); + for index in 0..temperatures.len() { + let percent = percents[index]; + if !(0..=100).contains(&percent) { + self.last_error = "Fan curve duty must be between 0 and 100 percent".into(); + return false; + } + points.push((temperatures[index], percent as u8)); + } + self.perform(|proxy| proxy.set_curve(points)) + } + + #[func] + pub fn apply_preset(&mut self, name: GString) -> bool { + let name = name.to_string(); + self.perform(|proxy| proxy.apply_preset(name.as_str())) + } + + pub fn process(&mut self) { + loop { + match self.rx.try_recv() { + Ok(Signal::Updated) => { + self.base_mut().emit_signal("updated", &[]); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + } + + fn curve(&self) -> Vec<(f64, u8)> { + self.proxy + .as_ref() + .and_then(|p| p.curve().ok()) + .unwrap_or_default() + } + + fn string_property(&self, getter: F) -> GString + where + F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result, + { + let value = self + .proxy + .as_ref() + .and_then(|p| getter(p).ok()) + .unwrap_or_default(); + value.as_str().into() + } + + fn string_array_property(&self, getter: F) -> PackedStringArray + where + F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result>, + { + let values: Vec = self + .proxy + .as_ref() + .and_then(|p| getter(p).ok()) + .unwrap_or_default() + .into_iter() + .map(|value| value.as_str().into()) + .collect(); + PackedStringArray::from(values.as_slice()) + } + + fn perform(&mut self, operation: F) -> bool + where + F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result<()>, + { + let Some(proxy) = self.proxy.as_ref() else { + self.last_error = "PowerStation fan service is unavailable".into(); + return false; + }; + match proxy.api_version() { + Ok(1) => {} + Ok(version) => { + self.last_error = format!( + "Incompatible PowerStation fan API version {version}; version 1 is required" + ); + return false; + } + Err(error) => { + self.last_error = format!("Unable to verify the PowerStation fan API: {error}"); + return false; + } + } + match operation(proxy) { + Ok(()) => { + self.last_error.clear(); + true + } + Err(error) => { + self.last_error = error.to_string(); + false + } + } + } +} + +async fn run(tx: Sender, path: String) -> Result<(), RunError> { + let conn = get_dbus_system().await?; + let proxy = FanDeviceProxy::builder(&conn).path(path)?.build().await?; + // PowerStation emits Mode with every telemetry/configuration snapshot, so a + // single stream keeps Godot updates coalesced to one event per controller tick. + let mut changed = proxy.receive_mode_changed().await; + while changed.next().await.is_some() { + if tx.send(Signal::Updated).is_err() { + break; + } + } + Ok(()) +} From 17c1610952530e9705d4e577ad5e3753f7794fb6 Mon Sep 17 00:00:00 2001 From: sknowledge1 <216877555+sknowledge1@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:43:50 +0100 Subject: [PATCH 2/2] feat: migrate fan controls to guarded API v2 profiles --- core/systems/performance/fan_profile_model.gd | 173 +++++++ .../performance/fan_profile_model.gd.uid | 1 + .../performance/fan_profile_model_test.gd | 128 +++++ .../performance/fan_profile_model_test.gd.uid | 1 + core/systems/performance/power_station.gd | 10 + .../card_ui/quick_bar/fan_controls_card.tscn | 4 +- core/ui/common/quick_bar/fan_controls_menu.gd | 467 ++++-------------- .../common/quick_bar/fan_controls_menu.tscn | 81 +-- .../quick_bar/fan_controls_menu_test.gd | 86 +--- docs/fans.md | 87 ++++ extensions/Cargo.lock | 1 + extensions/core/Cargo.toml | 1 + extensions/core/src/dbus/powerstation.rs | 1 + extensions/core/src/dbus/powerstation/fan.rs | 22 + .../core/src/dbus/powerstation/fan_device.rs | 64 +-- .../core/src/dbus/powerstation/fan_sensor.rs | 16 + .../core/src/performance/powerstation.rs | 2 + .../core/src/performance/powerstation/fan.rs | 151 +++++- .../performance/powerstation/fan_client.rs | 182 +++++++ .../powerstation/fan_client_test.rs | 319 ++++++++++++ .../performance/powerstation/fan_device.rs | 316 +----------- .../performance/powerstation/fan_settings.rs | 213 ++++++++ tests/fan_client_contract_test.gd | 56 +++ tests/fan_client_contract_test.gd.uid | 1 + 24 files changed, 1557 insertions(+), 826 deletions(-) create mode 100644 core/systems/performance/fan_profile_model.gd create mode 100644 core/systems/performance/fan_profile_model.gd.uid create mode 100644 core/systems/performance/fan_profile_model_test.gd create mode 100644 core/systems/performance/fan_profile_model_test.gd.uid create mode 100644 docs/fans.md create mode 100644 extensions/core/src/dbus/powerstation/fan_sensor.rs create mode 100644 extensions/core/src/performance/powerstation/fan_client.rs create mode 100644 extensions/core/src/performance/powerstation/fan_client_test.rs create mode 100644 extensions/core/src/performance/powerstation/fan_settings.rs create mode 100644 tests/fan_client_contract_test.gd create mode 100644 tests/fan_client_contract_test.gd.uid diff --git a/core/systems/performance/fan_profile_model.gd b/core/systems/performance/fan_profile_model.gd new file mode 100644 index 000000000..629014065 --- /dev/null +++ b/core/systems/performance/fan_profile_model.gd @@ -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 diff --git a/core/systems/performance/fan_profile_model.gd.uid b/core/systems/performance/fan_profile_model.gd.uid new file mode 100644 index 000000000..4b96a9a1f --- /dev/null +++ b/core/systems/performance/fan_profile_model.gd.uid @@ -0,0 +1 @@ +uid://bhc3re8kul408 diff --git a/core/systems/performance/fan_profile_model_test.gd b/core/systems/performance/fan_profile_model_test.gd new file mode 100644 index 000000000..b669ec383 --- /dev/null +++ b/core/systems/performance/fan_profile_model_test.gd @@ -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()) diff --git a/core/systems/performance/fan_profile_model_test.gd.uid b/core/systems/performance/fan_profile_model_test.gd.uid new file mode 100644 index 000000000..4507ab58c --- /dev/null +++ b/core/systems/performance/fan_profile_model_test.gd.uid @@ -0,0 +1 @@ +uid://b87vwbuprp0dw diff --git a/core/systems/performance/power_station.gd b/core/systems/performance/power_station.gd index 03df9132d..b0d5482e7 100644 --- a/core/systems/performance/power_station.gd +++ b/core/systems/performance/power_station.gd @@ -20,6 +20,16 @@ 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: diff --git a/core/ui/card_ui/quick_bar/fan_controls_card.tscn b/core/ui/card_ui/quick_bar/fan_controls_card.tscn index 7b3d91623..cc51da950 100644 --- a/core/ui/card_ui/quick_bar/fan_controls_card.tscn +++ b/core/ui/card_ui/quick_bar/fan_controls_card.tscn @@ -5,10 +5,10 @@ [node name="FanControlsCard" instance=ExtResource("1_card")] visible = false -title = "Fan Controls" +title = "Fan Settings" [node name="SectionLabel" parent="MarginContainer/CardVBoxContainer" index="0"] -text = "Fan Controls" +text = "Fan Settings" [node name="FanControls" parent="MarginContainer/CardVBoxContainer/ContentContainer" index="0" instance=ExtResource("2_menu")] layout_mode = 2 diff --git a/core/ui/common/quick_bar/fan_controls_menu.gd b/core/ui/common/quick_bar/fan_controls_menu.gd index e4bcb2c95..6b6f5a5e1 100644 --- a/core/ui/common/quick_bar/fan_controls_menu.gd +++ b/core/ui/common/quick_bar/fan_controls_menu.gd @@ -1,390 +1,131 @@ extends VBoxContainer -const SLIDER_SCENE := preload("res://core/ui/components/slider.tscn") - +const Model := preload("res://core/systems/performance/fan_profile_model.gd") +const MODE_LABELS := {"automatic": "Automatic", "manual": "Manual", "software_curve": "Software curve", "firmware_curve": "Firmware curve"} +@export var connect_backend := true var power_station := load("res://core/systems/performance/power_station.tres") as PowerStationInstance -var fans: Array = [] -var selected_fan -var curve_temperature_sliders: Array[ValueSlider] = [] -var curve_percent_sliders: Array[ValueSlider] = [] -var syncing := false -var compatible := false +var model := Model.new() +var _syncing := false +var _selected := 0 +var _modes: Array = [] @onready var focus_group := $%FocusGroup as FocusGroup -@onready var service_timer := $%ServiceTimer as Timer -@onready var apply_timer := $%ApplyTimer as Timer @onready var fan_dropdown := $%FanDropdown as Dropdown -@onready var telemetry_label := $%TelemetryLabel as Label -@onready var backend_label := $%BackendLabel as Label -@onready var status_label := $%StatusLabel as Label -@onready var limits_label := $%LimitsLabel as Label @onready var mode_dropdown := $%ModeDropdown as Dropdown -@onready var preset_dropdown := $%PresetDropdown as Dropdown @onready var manual_slider := $%ManualSlider as ValueSlider -@onready var curve_section := $%CurveSection as Control -@onready var curve_points := $%CurvePoints as VBoxContainer - +@onready var telemetry_label := $%TelemetryLabel as Label +@onready var status_label := $%StatusLabel as Label +@onready var profile_label := $%ProfileLabel as Label +@onready var profile_editor := $%ProfileEditor as TextEdit +@onready var profile_file := $%ProfileFile as LineEdit +@onready var apply_button := $%ApplyButton as Button +@onready var automatic_button := $%AutomaticButton as Button func _ready() -> void: - mode_dropdown.clear() - mode_dropdown.add_item("Automatic") - mode_dropdown.add_item("Manual") - mode_dropdown.add_item("Curve") - fan_dropdown.item_selected.connect(_on_fan_selected) - mode_dropdown.item_selected.connect(_on_mode_selected) - preset_dropdown.item_selected.connect(_on_preset_selected) - manual_slider.value_changed.connect(_on_manual_changed) - service_timer.timeout.connect(_refresh_devices) - apply_timer.timeout.connect(_apply_curve) - if power_station: + model.updated.connect(_sync_view) + fan_dropdown.item_selected.connect(_select_fan) + mode_dropdown.item_selected.connect(_select_mode) + manual_slider.value_changed.connect(_set_percent) + $%ServiceTimer.timeout.connect(_refresh_devices) + $%UseJsonButton.pressed.connect(_use_json) + $%LoadFileButton.pressed.connect(func(): model.load_file(profile_file.text)) + apply_button.pressed.connect(_apply) + automatic_button.pressed.connect(_automatic) + if connect_backend: power_station.started.connect(_refresh_devices) - power_station.stopped.connect(_on_service_stopped) - _refresh_devices() - + power_station.stopped.connect(_refresh_devices) + _refresh_devices() + else: + $%ServiceTimer.stop() + _sync_view() func _refresh_devices() -> void: - var collection = power_station.get_fan() if power_station else null - if collection: - collection.refresh() - fans = Array(collection.get_fans()) - else: - fans = [] - _set_card_available(should_show_card(fans.size())) - if fans.is_empty(): - _clear_selected_fan() + if not connect_backend: return - - var selected_path := "" - if selected_fan: - selected_path = selected_fan.get_dbus_path() - fan_dropdown.clear() - var selected_index := 0 - for index in fans.size(): - var fan = fans[index] - fan_dropdown.add_item(fan.get_name()) - if fan.get_dbus_path() == selected_path: - selected_index = index - fan_dropdown.visible = should_show_selector(fans.size()) - _select_fan(selected_index) - + model.start(power_station.get_fan() if power_station else null) func _select_fan(index: int) -> void: - if index < 0 or index >= fans.size(): + if _syncing: return - var next_fan = fans[index] - if selected_fan != next_fan: - apply_timer.stop() - if selected_fan and selected_fan.updated.is_connected(_sync_view): - selected_fan.updated.disconnect(_sync_view) - _clear_curve_controls() - selected_fan = next_fan - fan_dropdown.select(index) - if not selected_fan.updated.is_connected(_sync_view): - selected_fan.updated.connect(_sync_view) + _selected = index _sync_view() - -func _sync_view() -> void: - if not selected_fan: - return - syncing = true - compatible = supports_api_version(selected_fan.get_api_version()) - var editing := not apply_timer.is_stopped() - if not compatible: - apply_timer.stop() - editing = false - var telemetry_valid: bool = selected_fan.get_telemetry_valid() - if telemetry_valid: - var effective: int = selected_fan.get_effective_percent() - var duty_text: String = "firmware" if effective < 0 else "%d%%" % effective - telemetry_label.text = "%.1f C | %d RPM | %s" % [ - selected_fan.get_temperature_c(), selected_fan.get_rpm(), duty_text] - else: - telemetry_label.text = "Telemetry unavailable" - backend_label.text = "Backend: %s" % _display_backend(selected_fan.get_backend()) - limits_label.text = describe_limits( - selected_fan.get_minimum_percent(), - selected_fan.get_minimum_running_percent(), - selected_fan.get_stop_temperature_c(), - selected_fan.get_restart_temperature_c(), - selected_fan.get_full_speed_temperature_c(), - selected_fan.get_emergency_handoff_temperature_c()) - status_label.text = describe_state({ - "compatible": compatible, - "fault": selected_fan.get_fault(), - "automatic_verified": selected_fan.get_automatic_verified(), - "suspended": selected_fan.get_suspended(), - "mode": selected_fan.get_mode(), - }) - - if not editing: - mode_dropdown.disabled = not compatible - mode_dropdown.select(mode_to_index(selected_fan.get_mode())) - manual_slider.min_value = selected_fan.get_minimum_percent() - manual_slider.value = selected_fan.get_manual_percent() - manual_slider.editable = compatible - manual_slider.visible = selected_fan.get_mode() == "manual" - _populate_presets() - var curve_visible: bool = selected_fan.get_mode() in ["curve", "preset"] - preset_dropdown.visible = curve_visible - curve_section.visible = curve_visible - if curve_visible: - _rebuild_curve() - syncing = false - focus_group.recalculate_focus.call_deferred() - - -func _populate_presets() -> void: - var presets: PackedStringArray = selected_fan.get_presets() - var active: String = selected_fan.get_active_preset() - preset_dropdown.clear() - preset_dropdown.add_item("Custom") - var selected := 0 - for index in presets.size(): - preset_dropdown.add_item(presets[index]) - if presets[index] == active: - selected = index + 1 - preset_dropdown.select(selected) - preset_dropdown.disabled = not compatible or presets.is_empty() - - -func _rebuild_curve() -> void: - var temperatures: PackedFloat64Array = selected_fan.get_curve_temperatures() - var percents: PackedInt32Array = selected_fan.get_curve_percents() - if temperatures.size() != percents.size(): - return - if _curve_matches(temperatures, percents): - return - _clear_curve_controls() - for index in temperatures.size(): - var temperature := SLIDER_SCENE.instantiate() as ValueSlider - temperature.name = "Point%dTemperature" % (index + 1) - temperature.text = "Point %d temperature (C)" % (index + 1) - temperature.min_value = selected_fan.get_minimum_valid_temperature_c() - temperature.max_value = selected_fan.get_full_speed_temperature_c() - temperature.step = 1.0 - temperature.value = temperatures[index] - temperature.editable = compatible - temperature.value_changed.connect(_on_curve_changed) - curve_points.add_child(temperature) - curve_temperature_sliders.append(temperature) - - var percent := SLIDER_SCENE.instantiate() as ValueSlider - percent.name = "Point%dDuty" % (index + 1) - percent.text = "Point %d fan (%%)" % (index + 1) - percent.min_value = selected_fan.get_minimum_percent() - percent.max_value = 100.0 - percent.step = 1.0 - percent.value = percents[index] - percent.editable = compatible - percent.value_changed.connect(_on_curve_changed) - curve_points.add_child(percent) - curve_percent_sliders.append(percent) - - -func _curve_matches(temperatures: PackedFloat64Array, percents: PackedInt32Array) -> bool: - if temperatures.size() != curve_temperature_sliders.size(): - return false - for index in temperatures.size(): - if not is_equal_approx(curve_temperature_sliders[index].value, temperatures[index]): - return false - if int(curve_percent_sliders[index].value) != percents[index]: - return false - return true - - -func _on_fan_selected(index: int) -> void: - _select_fan(index) - - -func _on_mode_selected(index: int) -> void: - if syncing or not compatible or not selected_fan: - return - var successful := false - match index: - 0: - successful = selected_fan.set_automatic() - 1: - successful = selected_fan.set_manual(int(manual_slider.value)) - 2: - if curve_temperature_sliders.size() >= 2: - successful = _send_curve() - else: - var temperatures: PackedFloat64Array = selected_fan.get_curve_temperatures() - var percents: PackedInt32Array = selected_fan.get_curve_percents() - if temperatures.size() >= 2 and temperatures.size() == percents.size(): - successful = selected_fan.set_curve(temperatures, percents) - else: - var initial := initial_curve( - selected_fan.get_restart_temperature_c(), - selected_fan.get_full_speed_temperature_c(), - selected_fan.get_minimum_percent()) - successful = selected_fan.set_curve( - initial.temperatures, initial.percents) - _apply_result(successful) - - -func _on_preset_selected(index: int) -> void: - if syncing or not compatible or not selected_fan: - return - if index == 0: - _apply_result(_send_curve()) - return - var presets: PackedStringArray = selected_fan.get_presets() - if index - 1 < presets.size(): - _apply_result(selected_fan.apply_preset(presets[index - 1])) - - -func _on_manual_changed(_value: float) -> void: - if syncing or not compatible or not selected_fan or selected_fan.get_mode() != "manual": - return - apply_timer.start() - - -func _on_curve_changed(_value: float) -> void: - if syncing or not compatible or not selected_fan: +func _select_mode(index: int) -> void: + if _syncing or index < 0 or index >= _modes.size(): return - preset_dropdown.select(0) - apply_timer.start() + model.edit_fan(model.snapshot.fans[_selected].id, _modes[index], int(manual_slider.value)) + profile_editor.text = JSON.stringify(model.draft, " ") - -func _apply_curve() -> void: - if not compatible or not selected_fan: - return - if selected_fan.get_mode() == "manual": - _apply_result(selected_fan.set_manual(int(manual_slider.value))) +func _set_percent(value: float) -> void: + if _syncing: return - if selected_fan.get_mode() in ["curve", "preset"]: - _apply_result(_send_curve()) - + model.edit_fan(model.snapshot.fans[_selected].id, "manual", int(value)) + profile_editor.text = JSON.stringify(model.draft, " ") -func _send_curve() -> bool: - var temperatures := PackedFloat64Array() - var percents := PackedInt32Array() - for slider in curve_temperature_sliders: - temperatures.append(slider.value) - for slider in curve_percent_sliders: - percents.append(int(slider.value)) - return selected_fan.set_curve(temperatures, percents) +func _use_json() -> void: + if model.set_json(profile_editor.text): + profile_editor.text = JSON.stringify(model.draft, " ") +func _apply() -> void: + if model.set_json(profile_editor.text): + model.apply() -func _apply_result(successful: bool) -> void: - if not successful: - status_label.text = "PowerStation rejected the change: %s" % selected_fan.get_last_error() - return - _sync_view.call_deferred() - +func _automatic() -> void: + model.automatic() + profile_editor.text = JSON.stringify(model.draft, " ") -func _on_service_stopped() -> void: - fans = [] - _clear_selected_fan() - compatible = false - _set_card_available(false) - - -func _clear_selected_fan() -> void: - apply_timer.stop() - if selected_fan and selected_fan.updated.is_connected(_sync_view): - selected_fan.updated.disconnect(_sync_view) - selected_fan = null - _clear_curve_controls() - - -func _clear_curve_controls() -> void: - for child in curve_points.get_children(): - curve_points.remove_child(child) - child.queue_free() - curve_temperature_sliders.clear() - curve_percent_sliders.clear() - - -func _set_card_available(available: bool) -> void: - var card := find_parent("FanControlsCard") as Control - if not card or card.visible == available: +func _sync_view() -> void: + if not is_node_ready(): return - card.visible = available - var viewport := card.get_parent() - if viewport and viewport.has_node("FocusGroup"): - var outer_focus := viewport.get_node("FocusGroup") as FocusGroup - outer_focus.recalculate_focus.call_deferred() - - -static func should_show_card(fan_count: int) -> bool: - return fan_count > 0 - - -static func should_show_selector(fan_count: int) -> bool: - return fan_count > 1 - - -static func supports_api_version(version: int) -> bool: - return version == 1 - - -static func initial_curve( - restart_temperature_c: float, - full_speed_temperature_c: float, - minimum_percent: int, - ) -> Dictionary: - return { - "temperatures": PackedFloat64Array([ - restart_temperature_c, - full_speed_temperature_c, - ]), - "percents": PackedInt32Array([minimum_percent, 100]), - } - - -static func mode_to_index(mode: String) -> int: - match mode: - "manual": - return 1 - "curve", "preset": - return 2 - _: - return 0 - - -static func describe_state(state: Dictionary) -> String: - if not state.get("compatible", false): - return "Incompatible PowerStation fan API. Update PowerStation." - var fault := str(state.get("fault", "")) - if not fault.is_empty(): - if state.get("automatic_verified", false): - return "Automatic fallback: %s" % fault - return "Fan fault: %s" % fault - if state.get("suspended", false): - return "Firmware automatic mode verified for sleep" - if state.get("mode", "automatic") == "automatic": - if state.get("automatic_verified", false): - return "Firmware automatic mode" - return "Waiting for firmware automatic verification" - return "PowerStation is controlling the fan" - - -static func describe_limits( - minimum_percent: int, - minimum_running_percent: int, - stop_temperature_c: float, - restart_temperature_c: float, - full_speed_temperature_c: float, - emergency_handoff_temperature_c: float, - ) -> String: - return "Duty %d-100%% | Running floor %d%% | Stop <= %.0f C / restart > %.0f C\nFull fan %.0f C | Firmware handoff %.0f C" % [ - minimum_percent, - minimum_running_percent, - stop_temperature_c, - restart_temperature_c, - full_speed_temperature_c, - emergency_handoff_temperature_c, - ] - - -static func _display_backend(backend: String) -> String: - match backend: - "software_curve": - return "Software curve" - "hardware_curve": - return "Hardware curve" - _: - return backend.capitalize() + _syncing = true + var state := model.snapshot + var fans: Array = state.get("fans", []) + var available := model.compatible() and not fans.is_empty() + var card := get_parent() as Control + if card and card.name == "FanControlsCard": + card.visible = available or not str(state.get("error", "")).is_empty() + fan_dropdown.clear() + for fan in fans: + fan_dropdown.add_item(str(fan.name)) + _selected = clampi(_selected, 0, maxi(0, fans.size() - 1)) + fan_dropdown.select(_selected) + fan_dropdown.visible = fans.size() > 1 + mode_dropdown.clear() + _modes.clear() + var lines: Array[String] = [] + if available and _selected < model.draft.get("fans", []).size(): + var fan: Dictionary = fans[_selected] + var entry: Dictionary = model.draft.fans[_selected] + # Profiles may list fans in a different order to enumeration. + for candidate in model.draft.fans: + if candidate.fan == fan.id: + entry = candidate + break + for mode in fan.available_modes: + if MODE_LABELS.has(mode): + _modes.append(mode) + mode_dropdown.add_item(MODE_LABELS[mode]) + mode_dropdown.select(_modes.find(entry.mode)) + manual_slider.visible = entry.mode == "manual" + manual_slider.value = entry.get("percent", 60) + var duty := "%d%%" % int(fan.percent) if fan.percent_valid else "percentage unavailable" + lines.append("Current: %s — %s" % [MODE_LABELS.get(fan.mode, fan.mode), duty]) + if not str(fan.fault).is_empty(): + lines.append("Fan fault: " + str(fan.fault)) + else: + manual_slider.visible = false + for sensor in state.get("sensors", []): + lines.append("%s: %.1f C (%s)" % [sensor.name, float(sensor.temperature_c), sensor.id] if sensor.telemetry_valid else "%s: temperature unavailable (%s)" % [sensor.name, sensor.id]) + telemetry_label.text = "\n".join(lines) if not lines.is_empty() else "Fan telemetry unavailable" + profile_label.text = "Current profile: %s\n%s" % [state.get("profile_name", "") if not str(state.get("profile_name", "")).is_empty() else "Firmware Automatic", state.get("profile_path", "")] + status_label.text = model.error if not model.error.is_empty() else str(state.get("error", "Edit a complete profile, then Apply. New service processes start Automatic.")) + mode_dropdown.disabled = not available + manual_slider.editable = available + apply_button.disabled = not available + automatic_button.disabled = not available + $%LoadFileButton.disabled = not available + $%UseJsonButton.disabled = not available + if profile_editor.text.is_empty() and not model.draft.is_empty(): + profile_editor.text = JSON.stringify(model.draft, " ") + _syncing = false + focus_group.recalculate_focus.call_deferred() diff --git a/core/ui/common/quick_bar/fan_controls_menu.tscn b/core/ui/common/quick_bar/fan_controls_menu.tscn index f917a00c8..dfc07f288 100644 --- a/core/ui/common/quick_bar/fan_controls_menu.tscn +++ b/core/ui/common/quick_bar/fan_controls_menu.tscn @@ -14,7 +14,7 @@ script = ExtResource("1_fan") [node name="FocusGroup" parent="." node_paths=PackedStringArray("current_focus") instance=ExtResource("2_focus")] unique_name_in_owner = true -current_focus = NodePath("../ModeDropdown") +current_focus = NodePath("../AutomaticButton") focus_stack = ExtResource("3_stack") [node name="ServiceTimer" type="Timer" parent="."] @@ -22,75 +22,78 @@ unique_name_in_owner = true wait_time = 2.0 autostart = true -[node name="ApplyTimer" type="Timer" parent="."] +[node name="ProfileLabel" type="Label" parent="."] unique_name_in_owner = true -wait_time = 0.5 -one_shot = true - -[node name="FanDropdown" parent="." instance=ExtResource("4_drop")] -unique_name_in_owner = true -visible = false layout_mode = 2 -title = "Fan" -description = "" +autowrap_mode = 3 [node name="TelemetryLabel" type="Label" parent="."] unique_name_in_owner = true layout_mode = 2 -text = "Telemetry unavailable" -horizontal_alignment = 1 +autowrap_mode = 3 -[node name="BackendLabel" type="Label" parent="."] +[node name="StatusLabel" type="Label" parent="."] unique_name_in_owner = true layout_mode = 2 -text = "Backend" -horizontal_alignment = 1 +autowrap_mode = 3 -[node name="StatusLabel" type="Label" parent="."] +[node name="AutomaticButton" type="Button" parent="."] unique_name_in_owner = true -custom_minimum_size = Vector2(100, 0) layout_mode = 2 -text = "Waiting for PowerStation..." -horizontal_alignment = 1 -autowrap_mode = 3 +text = "Firmware Automatic" -[node name="LimitsLabel" type="Label" parent="."] +[node name="FanDropdown" parent="." instance=ExtResource("4_drop")] unique_name_in_owner = true -custom_minimum_size = Vector2(100, 0) layout_mode = 2 -text = "Safety limits" -horizontal_alignment = 1 -autowrap_mode = 3 +title = "Fan" [node name="ModeDropdown" parent="." instance=ExtResource("4_drop")] unique_name_in_owner = true layout_mode = 2 -title = "Mode" -description = "" +title = "Edit mode" +description = "Only modes advertised by PowerStation. Apply sends the complete profile." [node name="ManualSlider" parent="." instance=ExtResource("5_slider")] unique_name_in_owner = true -visible = false layout_mode = 2 -text = "Fan (%)" -value = 50.0 +text = "Manual fan (%)" +min_value = 0.0 +max_value = 100.0 +step = 1.0 +value = 60.0 + +[node name="EditorLabel" type="Label" parent="."] +layout_mode = 2 +text = "Complete profile (JSON)" -[node name="PresetDropdown" parent="." instance=ExtResource("4_drop")] +[node name="ProfileEditor" type="TextEdit" parent="."] unique_name_in_owner = true -visible = false +custom_minimum_size = Vector2(0, 200) layout_mode = 2 -title = "Preset" -description = "Editing a preset creates a custom curve." +wrap_mode = 1 -[node name="CurveSection" type="VBoxContainer" parent="."] +[node name="UseJsonButton" type="Button" parent="."] unique_name_in_owner = true -visible = false layout_mode = 2 +text = "Use edited JSON" -[node name="CurveLabel" type="Label" parent="CurveSection"] +[node name="ApplyButton" type="Button" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Apply and save profile" + +[node name="CurveHelp" type="Label" parent="."] +layout_mode = 2 +autowrap_mode = 3 +text = "Curves use sensor IDs above and temperature/percentage pairs. Native curves require every hardware point. PowerStation validates hardware constraints and owns thermal safety." + +[node name="ProfileFile" type="LineEdit" parent="."] +unique_name_in_owner = true layout_mode = 2 -text = "Fan Curve" +text = "quiet.yaml" +placeholder_text = "Packaged or distro profile filename" -[node name="CurvePoints" type="VBoxContainer" parent="CurveSection"] +[node name="LoadFileButton" type="Button" parent="."] unique_name_in_owner = true layout_mode = 2 +text = "Load and save file profile" diff --git a/core/ui/common/quick_bar/fan_controls_menu_test.gd b/core/ui/common/quick_bar/fan_controls_menu_test.gd index ce336c479..ef046bbc0 100644 --- a/core/ui/common/quick_bar/fan_controls_menu_test.gd +++ b/core/ui/common/quick_bar/fan_controls_menu_test.gd @@ -1,60 +1,28 @@ extends GutTest - -const FanControlsMenu := preload("res://core/ui/common/quick_bar/fan_controls_menu.gd") -const MENU_SCENE := preload("res://core/ui/common/quick_bar/fan_controls_menu.tscn") - - -func test_visibility_rules_cover_no_and_multiple_fans() -> void: - assert_false(FanControlsMenu.should_show_card(0)) - assert_true(FanControlsMenu.should_show_card(1)) - assert_false(FanControlsMenu.should_show_selector(1)) - assert_true(FanControlsMenu.should_show_selector(2)) - - -func test_modes_include_preset_as_editable_curve() -> void: - assert_eq(FanControlsMenu.mode_to_index("automatic"), 0) - assert_eq(FanControlsMenu.mode_to_index("manual"), 1) - assert_eq(FanControlsMenu.mode_to_index("curve"), 2) - assert_eq(FanControlsMenu.mode_to_index("preset"), 2) - - -func test_first_custom_curve_uses_device_limits_without_a_preset() -> void: - var curve := FanControlsMenu.initial_curve(45.0, 95.0, 0) - assert_eq(curve.temperatures, PackedFloat64Array([45.0, 95.0])) - assert_eq(curve.percents, PackedInt32Array([0, 100])) - - -func test_fault_message_reports_verified_automatic_fallback() -> void: - var message := FanControlsMenu.describe_state({ - "compatible": true, - "fault": "temperature sensor disappeared", - "automatic_verified": true, - "suspended": false, - "mode": "automatic", - }) - assert_true(message.begins_with("Automatic fallback:")) - - -func test_limit_summary_includes_zero_duty_hysteresis_and_thermal_handoff() -> void: - var summary := FanControlsMenu.describe_limits(0, 10, 42.0, 45.0, 95.0, 98.0) - assert_true(summary.contains("Duty 0-100%")) - assert_true(summary.contains("Running floor 10%")) - assert_true(summary.contains("Stop <= 42 C / restart > 45 C")) - assert_true(summary.contains("Full fan 95 C")) - assert_true(summary.contains("Firmware handoff 98 C")) - - -func test_future_api_is_rejected_clearly() -> void: - assert_true(FanControlsMenu.supports_api_version(1)) - assert_false(FanControlsMenu.supports_api_version(0)) - assert_false(FanControlsMenu.supports_api_version(2)) - var message := FanControlsMenu.describe_state({"compatible": false}) - assert_true(message.contains("Update PowerStation")) - - -func test_menu_has_controller_focus_group() -> void: - var menu := MENU_SCENE.instantiate() - add_child_autoqfree(menu) - var focus_group := menu.get_node("FocusGroup") as FocusGroup - assert_not_null(focus_group) - assert_eq(focus_group.current_focus.name, "ModeDropdown") +const MENU := preload("res://core/ui/common/quick_bar/fan_controls_menu.tscn") +const Fixtures := preload("res://core/systems/performance/fan_profile_model_test.gd") + +func test_menu_shows_available_modes_and_does_not_display_invalid_duty() -> void: + var backend := Fixtures.Backend.new() + var menu := MENU.instantiate() + menu.connect_backend = false + menu.model.start(backend) + add_child_autofree(menu) + assert_eq(menu.mode_dropdown.option_button.item_count, 3) + assert_string_contains(menu.telemetry_label.text, "percentage unavailable") + assert_string_contains(menu.telemetry_label.text, "CPU: 50.0 C") + assert_false(menu.telemetry_label.text.contains("RPM")) + assert_eq(backend.calls.size(), 0) + +func test_automatic_button_remains_available_without_sensor_telemetry() -> void: + var backend := Fixtures.Backend.new() + backend.state.sensors[0].telemetry_valid = false + var menu := MENU.instantiate() + menu.connect_backend = false + menu.model.start(backend) + add_child_autofree(menu) + assert_false(menu.automatic_button.disabled) + menu.automatic_button.pressed.emit() + assert_eq(backend.calls.size(), 1) + assert_eq(backend.calls[0].profile.fans[0].mode, "automatic") + assert_eq(menu.focus_group.current_focus.name, "AutomaticButton") diff --git a/docs/fans.md b/docs/fans.md new file mode 100644 index 000000000..a371a2a49 --- /dev/null +++ b/docs/fans.md @@ -0,0 +1,87 @@ +# Fan profiles + +The Fan Settings quick-menu card requires PowerStation fan API v2, including +its startup-status and guarded startup-load methods. API v1 clients and +PowerStation releases without this contract cannot be used for these controls. +CPU/GPU controls retain their existing contracts. + +PowerStation enumerates stable fan and sensor IDs. The card shows each fan's +current mode, advertised editable modes and sensor temperatures. A percentage +is displayed only when `PercentValid` is true; firmware Automatic/native curves +can have valid temperature telemetry while duty is unavailable. RPM, raw PWM, +driver settings, ownership and thermal-policy values are not client controls. + +- **Automatic:** firmware controls cooling. +- **Manual:** request a fixed percentage, subject to the service's safety policy. +- **Software curve:** PowerStation interpolates the selected sensors' curve. +- **Firmware curve:** the hardware runs the complete native curve. Its current + duty is unavailable through this API. + +Choose a mode and percentage or edit the complete profile as JSON, then Apply +and save. Each enumerated fan must appear exactly once. Curve entries name +sensors and supply increasing temperature/percentage pairs. Native firmware +curves require the exact hardware point count; the service rejects incomplete +curves and enforces its own safety policy without resampling. Selecting a mode +or editing a draft does not write hardware. Firmware Automatic is a separate +explicit action that remains usable without telemetry or a valid saved draft. + +For example, after substituting the IDs advertised on the current system: + +```json +{ + "name": "My curve", + "fans": [{ + "fan": "ayaneo-3/system", + "mode": "software_curve", + "sensors": ["ayaneo-3/system/cpu"], + "curve": [[45, 20], [65, 40], [85, 70], [95, 100]] + }] +} +``` + +Load and save file profile calls `LoadProfile` with a filename, such as the +packaged `quiet.yaml`. PowerStation resolves only its approved profile +directories; a same-name distro override replaces the whole packaged profile. +A saved file selection stores that reference, so a later explicit or guarded +load uses the current distro version. The JSON editor remains a separate draft; +a file profile's curve is not guessed from telemetry. + +## Preferences and restoration + +OpenGamepadUI owns `user://data/fans/profile.json`, a versioned, bounded private +JSON preference file. Only a successful, owner-pinned service reply permits +saving a selection. Writes are synced and atomically renamed. Save errors are +reported even when the hardware request succeeded. Corrupt, oversized, linked +or insecure existing preferences are preserved and reported, never reset. +PowerStation remains the authority for all profile and hardware validation. + +Every new PowerStation process starts in firmware Automatic. At application +startup, OpenGamepadUI reads native `GetStartupStatus`, checks the kernel boot +identity and durably consumes its per-user boot attempt before reading saved +preferences or sending a restoration request. No saved selection means no +hardware write. `LoadStartupProfile`/`LoadStartupProfileJson` recheck the first +managed invocation and healthy untouched Automatic state under the native +profile transaction lock. A status read cannot authorize a stale later write. + +The client pins the unique D-Bus owner for snapshots and requests. Ineligible +startup, service replacement, faults, invalid telemetry, a previously selected +profile, malformed preferences, failed/uncertain requests and a consumed boot +attempt prevent automatic replay. Refreshing/remounting the menu never restores +anything. Application/service restarts within that boot do not retry. Successful +suspend/resume remains the native service's responsibility. A user can always +select a fresh profile explicitly after reviewing current state. + +The per-user attempt directories are preferences-side evidence, not privileged +ownership. The native transaction guard is authoritative across clients. Do not +delete startup evidence to manufacture eligibility. If repairing corrupt user +preferences manually, preserve the `restore-*` attempt directories. + +## Validation + +Run the `fan_` Rust tests with a private `dbus-daemon`, the focused GUT +`fan_profile_model_test.gd` and `fan_controls_menu_test.gd` suites, and locked +release builds for x86_64 and aarch64 in the pinned project builder. Fake +services/files prove software behavior only. Native GUI interaction, installed +session permissions and physical fan/thermal behavior on this revision require +separate device acceptance. See the PR handoff for exact tested commits and +counts. diff --git a/extensions/Cargo.lock b/extensions/Cargo.lock index cd1a5bfec..0afe7adbc 100644 --- a/extensions/Cargo.lock +++ b/extensions/Cargo.lock @@ -848,6 +848,7 @@ dependencies = [ "log", "nix 0.31.2", "once_cell", + "serde_json", "tokio", "zbus", "zvariant", diff --git a/extensions/core/Cargo.toml b/extensions/core/Cargo.toml index a37e6c4c4..6bcc6dcfc 100644 --- a/extensions/core/Cargo.toml +++ b/extensions/core/Cargo.toml @@ -22,3 +22,4 @@ inotify = "0.11.1" byte-unit = "5.2.0" log = "0.4.29" keyvalues-parser = "0.2.3" +serde_json = "=1.0.149" diff --git a/extensions/core/src/dbus/powerstation.rs b/extensions/core/src/dbus/powerstation.rs index 370a6f541..66b0c112c 100644 --- a/extensions/core/src/dbus/powerstation.rs +++ b/extensions/core/src/dbus/powerstation.rs @@ -4,5 +4,6 @@ pub mod core; pub mod cpu; pub mod fan; pub mod fan_device; +pub mod fan_sensor; pub mod gpu; pub mod tdp; diff --git a/extensions/core/src/dbus/powerstation/fan.rs b/extensions/core/src/dbus/powerstation/fan.rs index 1ba262f9c..9409aa85e 100644 --- a/extensions/core/src/dbus/powerstation/fan.rs +++ b/extensions/core/src/dbus/powerstation/fan.rs @@ -8,6 +8,28 @@ use zbus::proxy; default_path = "/org/shadowblip/Performance/Fan" )] pub trait Fan { + fn load_profile(&self, file: &str) -> zbus::Result<()>; + fn load_profile_json(&self, json: &str) -> zbus::Result<()>; + fn get_startup_status(&self) -> zbus::Result<(String, String, bool)>; + fn load_startup_profile_json( + &self, + boot_id: &str, + invocation_id: &str, + json: &str, + ) -> zbus::Result<()>; + fn load_startup_profile( + &self, + boot_id: &str, + invocation_id: &str, + file: &str, + ) -> zbus::Result<()>; /// Return every fan exposed by PowerStation. fn enumerate_fans(&self) -> zbus::Result>; + fn enumerate_sensors(&self) -> zbus::Result>; + #[zbus(property)] + fn api_version(&self) -> zbus::Result; + #[zbus(property)] + fn current_profile_name(&self) -> zbus::Result; + #[zbus(property)] + fn current_profile_path(&self) -> zbus::Result; } diff --git a/extensions/core/src/dbus/powerstation/fan_device.rs b/extensions/core/src/dbus/powerstation/fan_device.rs index 1c4acbed3..7a373778d 100644 --- a/extensions/core/src/dbus/powerstation/fan_device.rs +++ b/extensions/core/src/dbus/powerstation/fan_device.rs @@ -1,5 +1,4 @@ -//! D-Bus proxy for a PowerStation fan device. - +//! Percentage-only API v2 fan state. Profile writes belong to the collection. use zbus::proxy; #[proxy( @@ -7,13 +6,6 @@ use zbus::proxy; default_service = "org.shadowblip.PowerStation" )] pub trait FanDevice { - fn set_automatic(&self) -> zbus::Result<()>; - fn set_manual(&self, percent: u8) -> zbus::Result<()>; - fn set_curve(&self, points: Vec<(f64, u8)>) -> zbus::Result<()>; - fn apply_preset(&self, name: &str) -> zbus::Result<()>; - - #[zbus(property)] - fn api_version(&self) -> zbus::Result; #[zbus(property)] fn id(&self) -> zbus::Result; #[zbus(property)] @@ -21,61 +13,11 @@ pub trait FanDevice { #[zbus(property)] fn mode(&self) -> zbus::Result; #[zbus(property)] - fn backend(&self) -> zbus::Result; - #[zbus(property)] fn available_modes(&self) -> zbus::Result>; #[zbus(property)] - fn temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn telemetry_valid(&self) -> zbus::Result; - #[zbus(property)] - fn rpm(&self) -> zbus::Result; - #[zbus(property)] - fn effective_percent(&self) -> zbus::Result; + fn percent(&self) -> zbus::Result; #[zbus(property)] - fn manual_percent(&self) -> zbus::Result; - #[zbus(property)] - fn curve(&self) -> zbus::Result>; - #[zbus(property)] - fn presets(&self) -> zbus::Result>; - #[zbus(property)] - fn active_preset(&self) -> zbus::Result; + fn percent_valid(&self) -> zbus::Result; #[zbus(property)] fn fault(&self) -> zbus::Result; - #[zbus(property)] - fn automatic_verified(&self) -> zbus::Result; - #[zbus(property)] - fn controlling(&self) -> zbus::Result; - #[zbus(property)] - fn suspended(&self) -> zbus::Result; - #[zbus(property)] - fn curve_point_count(&self) -> zbus::Result; - #[zbus(property)] - fn minimum_percent(&self) -> zbus::Result; - #[zbus(property)] - fn minimum_running_percent(&self) -> zbus::Result; - #[zbus(property)] - fn pwm_min(&self) -> zbus::Result; - #[zbus(property)] - fn pwm_max(&self) -> zbus::Result; - #[zbus(property)] - fn pwm_readback_tolerance(&self) -> zbus::Result; - #[zbus(property)] - fn stop_temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn restart_temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn stall_rpm(&self) -> zbus::Result; - #[zbus(property)] - fn stall_grace_seconds(&self) -> zbus::Result; - #[zbus(property)] - fn full_speed_temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn emergency_handoff_temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn minimum_valid_temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn maximum_valid_temperature_c(&self) -> zbus::Result; - #[zbus(property)] - fn maximum_valid_rpm(&self) -> zbus::Result; } diff --git a/extensions/core/src/dbus/powerstation/fan_sensor.rs b/extensions/core/src/dbus/powerstation/fan_sensor.rs new file mode 100644 index 000000000..6902dfc0a --- /dev/null +++ b/extensions/core/src/dbus/powerstation/fan_sensor.rs @@ -0,0 +1,16 @@ +use zbus::proxy; + +#[proxy( + interface = "org.shadowblip.Fan.Sensor", + default_service = "org.shadowblip.PowerStation" +)] +pub trait FanSensor { + #[zbus(property)] + fn id(&self) -> zbus::Result; + #[zbus(property)] + fn name(&self) -> zbus::Result; + #[zbus(property)] + fn temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn telemetry_valid(&self) -> zbus::Result; +} diff --git a/extensions/core/src/performance/powerstation.rs b/extensions/core/src/performance/powerstation.rs index 33ad72c00..437e6f57b 100644 --- a/extensions/core/src/performance/powerstation.rs +++ b/extensions/core/src/performance/powerstation.rs @@ -1,7 +1,9 @@ pub mod cpu; pub mod cpu_core; pub mod fan; +mod fan_client; pub mod fan_device; +mod fan_settings; pub mod gpu; pub mod gpu_card; pub mod gpu_connector; diff --git a/extensions/core/src/performance/powerstation/fan.rs b/extensions/core/src/performance/powerstation/fan.rs index c6b518920..290684efa 100644 --- a/extensions/core/src/performance/powerstation/fan.rs +++ b/extensions/core/src/performance/powerstation/fan.rs @@ -1,10 +1,16 @@ use std::collections::HashMap; -use godot::{classes::ResourceLoader, prelude::*}; +use godot::{ + classes::{ProjectSettings, ResourceLoader}, + prelude::*, +}; +use serde_json::{json, Value}; use crate::{dbus::powerstation::fan::FanProxyBlocking, get_dbus_system_blocking}; +use super::{fan_client, fan_settings::FanSettings}; use super::{fan_device::FanDevice, POWERSTATION_BUS}; +use crate::{get_dbus_system, RUNTIME}; /// A collection of fan devices exposed by PowerStation. #[derive(GodotClass)] @@ -14,6 +20,8 @@ pub struct Fan { path: String, proxy: Option>, devices: HashMap>, + last_error: String, + restoration_attempted: bool, } #[godot_api] @@ -35,6 +43,8 @@ impl Fan { proxy, path: path.clone().into(), devices: HashMap::new(), + last_error: String::new(), + restoration_attempted: false, }; instance.refresh_devices(); instance @@ -61,6 +71,145 @@ impl Fan { self.path.as_str().into() } + #[func] + pub fn get_last_error(&self) -> GString { + self.last_error.as_str().into() + } + + /// One owner-pinned snapshot, with no cached properties or raw hardware data. + #[func] + pub fn get_snapshot_json(&mut self) -> GString { + match self.snapshot() { + Ok(snapshot) => snapshot.to_string().as_str().into(), + Err(error) => json!({"available": false, "fans": [], "sensors": [], "error": error}) + .to_string() + .as_str() + .into(), + } + } + + #[func] + pub fn get_saved_selection_json(&mut self) -> GString { + match Self::settings().load() { + Ok(Some(value)) => value.to_string().as_str().into(), + Ok(None) => "{}".into(), + Err(error) => { + self.last_error = error; + "{}".into() + } + } + } + + /// Apply an approved-directory YAML profile and save its reference on success. + #[func] + pub fn load_profile(&mut self, file: GString, owner: GString) -> bool { + self.apply_selection( + json!({"kind": "file", "file": file.to_string()}), + &owner.to_string(), + None, + ) + } + + /// Apply a complete profile, then save only a confirmed selection. + #[func] + pub fn load_profile_json(&mut self, raw: GString, owner: GString) -> bool { + let raw = raw.to_string(); + if raw.len() > fan_client::MAX_PROFILE_BYTES { + self.last_error = "Fan profile exceeds 64 KiB".into(); + return false; + } + match serde_json::from_str::(&raw) { + Ok(profile) => self.apply_selection( + json!({"kind": "json", "profile": profile}), + &owner.to_string(), + None, + ), + Err(error) => { + self.last_error = format!("Invalid fan profile JSON: {error}"); + false + } + } + } + + /// Called once by the application, never by a remounted menu or recovery loop. + #[func] + pub fn restore_saved(&mut self) -> bool { + if self.restoration_attempted { + return false; + } + self.restoration_attempted = true; + let settings = Self::settings(); + let result = RUNTIME.block_on(async { + tokio::time::timeout(fan_client::TIMEOUT, async { + let connection = get_dbus_system().await.map_err(|e| e.to_string())?; + fan_client::restore(&connection, &settings).await + }) + .await + .map_err(|_| { + "Fan restoration timed out; it will not be retried automatically".to_string() + })? + }); + match result { + Ok(()) => { + self.last_error.clear(); + true + } + Err(error) => { + self.last_error = error; + false + } + } + } + + fn snapshot(&self) -> Result { + RUNTIME.block_on(async { + tokio::time::timeout(fan_client::TIMEOUT, async { + let connection = get_dbus_system().await.map_err(|e| e.to_string())?; + fan_client::snapshot(&connection).await + }) + .await + .map_err(|_| "PowerStation fan snapshot timed out".to_string())? + }) + } + + fn settings() -> FanSettings { + let path = ProjectSettings::singleton().globalize_path("user://data/fans"); + FanSettings::new(path.to_string().into()) + } + + fn apply_selection( + &mut self, + selection: Value, + owner: &str, + startup: Option<(&str, &str)>, + ) -> bool { + let result = RUNTIME.block_on(async { + tokio::time::timeout(fan_client::TIMEOUT, async { + let connection = get_dbus_system().await.map_err(|e| e.to_string())?; + fan_client::apply(&connection, owner, &selection, startup).await + }) + .await + .map_err(|_| { + "Fan profile request timed out; it will not be retried automatically".to_string() + })? + }); + if let Err(error) = result { + self.last_error = error; + return false; + } + match Self::settings().save(&selection) { + Ok(()) => { + self.last_error.clear(); + true + } + Err(error) => { + self.last_error = + format!("Profile applied, but preferences could not be saved: {error}"); + false + } + } + } + /// Refresh fan objects after service restart or hardware rediscovery. #[func] pub fn refresh(&mut self) { diff --git a/extensions/core/src/performance/powerstation/fan_client.rs b/extensions/core/src/performance/powerstation/fan_client.rs new file mode 100644 index 000000000..befa5c604 --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_client.rs @@ -0,0 +1,182 @@ +//! Owner-pinned API v2 transactions. Saved preferences never contain hardware policy. +use super::POWERSTATION_BUS; +use crate::dbus::powerstation::{ + fan::FanProxy, fan_device::FanDeviceProxy, fan_sensor::FanSensorProxy, +}; +use serde_json::{json, Value}; +use std::time::Duration; +use zbus::{names::BusName, proxy::CacheProperties, Connection}; + +pub const TIMEOUT: Duration = Duration::from_secs(3); +pub const MAX_PROFILE_BYTES: usize = 64 * 1024; + +pub async fn restore( + connection: &Connection, + settings: &super::fan_settings::FanSettings, +) -> Result<(), String> { + let state = snapshot(connection).await?; + let startup = &state["startup"]; + if state["available"] != true || startup["eligible"] != true { + return Err("Saved fan profile was not restored: startup is not eligible. Select a profile explicitly.".into()); + } + let boot = startup["boot_id"] + .as_str() + .ok_or("Missing startup boot identity")?; + let invocation = startup["invocation_id"] + .as_str() + .ok_or("Missing startup invocation identity")?; + let current_boot = + std::fs::read_to_string("/proc/sys/kernel/random/boot_id").map_err(|e| e.to_string())?; + if boot != current_boot.trim() { + return Err("PowerStation startup belongs to another OS boot".into()); + } + // The fence precedes preferences and hardware writes. Any later error or + // uncertain result remains consumed across application restarts. + if !settings.consume_boot(boot)? { + return Err("Fan restoration was already attempted this boot".into()); + } + let Some(selection) = settings.load()? else { + return Ok(()); + }; + apply( + connection, + state["owner"].as_str().unwrap_or_default(), + &selection, + Some((boot, invocation)), + ) + .await +} + +async fn current_owner(connection: &Connection) -> Result { + let dbus = zbus::fdo::DBusProxy::new(connection) + .await + .map_err(|e| e.to_string())?; + dbus.get_name_owner(BusName::try_from(POWERSTATION_BUS).unwrap()) + .await + .map(|owner| owner.to_string()) + .map_err(|e| e.to_string()) +} + +pub async fn snapshot(connection: &Connection) -> Result { + let owner = current_owner(connection).await?; + let result = async { + let proxy = FanProxy::builder(connection).destination(owner.clone())? + .cache_properties(CacheProperties::No).build().await?; + let version = proxy.api_version().await?; + if version != 2 { return Ok(json!({"available": false, "api_version": version, + "error": "PowerStation fan API v2 is required", "fans": [], "sensors": []})); } + let mut fans = Vec::new(); + for path in proxy.enumerate_fans().await? { + let fan = FanDeviceProxy::builder(connection).destination(owner.clone())?.path(path.clone())? + .cache_properties(CacheProperties::No).build().await?; + fans.push(json!({"path": path.as_str(), "id": fan.id().await?, "name": fan.name().await?, + "mode": fan.mode().await?, "available_modes": fan.available_modes().await?, + "percent": fan.percent().await?, "percent_valid": fan.percent_valid().await?, "fault": fan.fault().await?})); + } + let mut sensors = Vec::new(); + for path in proxy.enumerate_sensors().await? { + let sensor = FanSensorProxy::builder(connection).destination(owner.clone())?.path(path.clone())? + .cache_properties(CacheProperties::No).build().await?; + sensors.push(json!({"path": path.as_str(), "id": sensor.id().await?, "name": sensor.name().await?, + "temperature_c": sensor.temperature_c().await?, "telemetry_valid": sensor.telemetry_valid().await?})); + } + let (boot, invocation, eligible) = proxy.get_startup_status().await?; + let startup = json!({"boot_id": boot, "invocation_id": invocation, "eligible": eligible}); + Ok::<_, zbus::Error>(json!({"available": true, "api_version": version, "owner": owner, + "profile_name": proxy.current_profile_name().await?, "profile_path": proxy.current_profile_path().await?, + "fans": fans, "sensors": sensors, "startup": startup})) + }.await.map_err(|e| e.to_string())?; + if current_owner(connection).await? != result["owner"].as_str().unwrap_or_default() + && result["available"] == true + { + return Err("PowerStation changed during the fan snapshot".into()); + } + Ok(result) +} + +pub fn validate_selection(selection: &Value) -> Result<(), String> { + let object = selection.as_object().ok_or("Invalid saved fan selection")?; + match selection["kind"].as_str() { + Some("file") if object.len() == 2 => { + let file = selection["file"] + .as_str() + .ok_or("Missing profile filename")?; + if file.is_empty() || file.len() > 4096 || file.contains('\0') { + return Err("Invalid profile filename".into()); + } + } + Some("json") if object.len() == 2 => { + let profile = &selection["profile"]; + let fields = profile.as_object().ok_or("Invalid profile")?; + if fields.len() != 2 + || profile["name"].as_str().is_none_or(|s| s.trim().is_empty()) + || profile["fans"] + .as_array() + .is_none_or(|fans| fans.is_empty()) + { + return Err("A complete named fan profile is required".into()); + } + } + _ => return Err("Invalid saved fan selection type".into()), + } + if selection.to_string().len() > MAX_PROFILE_BYTES { + return Err("Fan profile exceeds 64 KiB".into()); + } + Ok(()) +} + +pub async fn apply( + connection: &Connection, + expected_owner: &str, + selection: &Value, + startup: Option<(&str, &str)>, +) -> Result<(), String> { + validate_selection(selection)?; + if expected_owner.is_empty() || current_owner(connection).await? != expected_owner { + return Err("PowerStation changed; refresh before applying a profile".into()); + } + let result = async { + let proxy = FanProxy::builder(connection) + .destination(expected_owner.to_owned())? + .cache_properties(CacheProperties::No) + .build() + .await?; + if proxy.api_version().await? != 2 { + return Err(zbus::Error::Failure( + "PowerStation fan API v2 is required".into(), + )); + } + match (selection["kind"].as_str(), startup) { + (Some("file"), None) => { + proxy + .load_profile(selection["file"].as_str().unwrap()) + .await + } + (Some("file"), Some((boot, invocation))) => { + proxy + .load_startup_profile(boot, invocation, selection["file"].as_str().unwrap()) + .await + } + (_, None) => { + proxy + .load_profile_json(&selection["profile"].to_string()) + .await + } + (_, Some((boot, invocation))) => { + proxy + .load_startup_profile_json(boot, invocation, &selection["profile"].to_string()) + .await + } + } + } + .await; + result.map_err(|e| e.to_string())?; + if current_owner(connection).await? != expected_owner { + return Err("PowerStation changed after the request; application is uncertain and will not be retried".into()); + } + Ok(()) +} + +#[cfg(test)] +#[path = "fan_client_test.rs"] +mod tests; diff --git a/extensions/core/src/performance/powerstation/fan_client_test.rs b/extensions/core/src/performance/powerstation/fan_client_test.rs new file mode 100644 index 000000000..4a7c7d59b --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_client_test.rs @@ -0,0 +1,319 @@ +use super::*; +use crate::performance::powerstation::fan_settings::FanSettings; +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + sync::{Arc, Mutex}, +}; +const FAN: &str = "/org/shadowblip/Performance/Fan/Fan0"; +const SENSOR: &str = "/org/shadowblip/Performance/Fan/Fan0/Sensor0"; +const ROOT: &str = "/org/shadowblip/Performance/Fan"; +const INVOCATION: &str = "11111111111111111111111111111111"; +struct Bus { + child: Child, + address: String, +} +impl Bus { + fn new() -> Self { + let mut child = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut address = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + Self { + child, + address: address.trim().into(), + } + } + async fn connection(&self) -> Connection { + zbus::connection::Builder::address(self.address.as_str()) + .unwrap() + .build() + .await + .unwrap() + } +} +impl Drop for Bus { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +#[derive(Default)] +struct State { + profile: String, + calls: Vec, + eligible: bool, + denied: bool, +} +struct Root { + state: Arc>, + version: u32, +} +#[zbus::interface(name = "org.shadowblip.Fan")] +impl Root { + fn enumerate_fans(&self) -> Vec { + vec![FAN.try_into().unwrap()] + } + fn enumerate_sensors(&self) -> Vec { + vec![SENSOR.try_into().unwrap()] + } + #[zbus(property)] + fn api_version(&self) -> u32 { + self.version + } + #[zbus(property)] + fn current_profile_name(&self) -> String { + self.state.lock().unwrap().profile.clone() + } + #[zbus(property)] + fn current_profile_path(&self) -> String { + String::new() + } + fn get_startup_status(&self) -> (String, String, bool) { + ( + std::fs::read_to_string("/proc/sys/kernel/random/boot_id") + .unwrap() + .trim() + .into(), + INVOCATION.into(), + self.state.lock().unwrap().eligible, + ) + } + fn load_profile_json(&self, json: &str) -> zbus::fdo::Result<()> { + self.load(json, false) + } + fn load_profile(&self, file: &str) -> zbus::fdo::Result<()> { + self.load(file, false) + } + fn load_startup_profile_json( + &self, + _boot: &str, + invocation: &str, + json: &str, + ) -> zbus::fdo::Result<()> { + if invocation != INVOCATION { + return Err(zbus::fdo::Error::Failed("Wrong invocation".into())); + } + self.load(json, true) + } + fn load_startup_profile( + &self, + _boot: &str, + invocation: &str, + file: &str, + ) -> zbus::fdo::Result<()> { + self.load_startup_profile_json(_boot, invocation, file) + } +} +impl Root { + fn load(&self, value: &str, startup: bool) -> zbus::fdo::Result<()> { + let mut state = self.state.lock().unwrap(); + if state.denied || (startup && !state.eligible) { + return Err(zbus::fdo::Error::Failed("Fan request rejected".into())); + } + state.calls.push(value.into()); + state.profile = "Confirmed".into(); + state.eligible = false; + Ok(()) + } +} +struct Device; +#[zbus::interface(name = "org.shadowblip.Fan.Device")] +impl Device { + #[zbus(property)] + fn id(&self) -> &str { + "test/fan" + } + #[zbus(property)] + fn name(&self) -> &str { + "Test fan" + } + #[zbus(property)] + fn mode(&self) -> &str { + "automatic" + } + #[zbus(property)] + fn available_modes(&self) -> Vec<&str> { + vec!["automatic", "manual", "software_curve", "firmware_curve"] + } + #[zbus(property)] + fn percent(&self) -> u8 { + 0 + } + #[zbus(property)] + fn percent_valid(&self) -> bool { + false + } + #[zbus(property)] + fn fault(&self) -> &str { + "" + } +} +struct Sensor; +#[zbus::interface(name = "org.shadowblip.Fan.Sensor")] +impl Sensor { + #[zbus(property)] + fn id(&self) -> &str { + "test/fan/cpu" + } + #[zbus(property)] + fn name(&self) -> &str { + "CPU" + } + #[zbus(property)] + fn temperature_c(&self) -> f64 { + 50.0 + } + #[zbus(property)] + fn telemetry_valid(&self) -> bool { + true + } +} +async fn server(bus: &Bus, state: Arc>, version: u32) -> Connection { + zbus::connection::Builder::address(bus.address.as_str()) + .unwrap() + .serve_at(ROOT, Root { state, version }) + .unwrap() + .serve_at(FAN, Device) + .unwrap() + .serve_at(SENSOR, Sensor) + .unwrap() + .name(POWERSTATION_BUS) + .unwrap() + .build() + .await + .unwrap() +} +fn selection() -> Value { + json!({"kind":"json", "profile":{"name":"Test", "fans":[{"fan":"test/fan", "mode":"manual", "percent":60}]}}) +} +#[tokio::test] +async fn snapshot_exposes_v2_sensors_capabilities_and_explicit_percent_validity() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State { + eligible: true, + ..Default::default() + })); + let _server = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let first = snapshot(&connection).await.unwrap(); + assert_eq!(first["api_version"], 2); + assert_eq!(first["fans"][0]["percent_valid"], false); + assert_eq!(first["sensors"][0]["telemetry_valid"], true); + assert_eq!( + first["fans"][0]["available_modes"] + .as_array() + .unwrap() + .len(), + 4 + ); + assert!(first["fans"][0].get("rpm").is_none()); + state.lock().unwrap().profile = "External".into(); + assert_eq!( + snapshot(&connection).await.unwrap()["profile_name"], + "External", + "no stale property cache" + ); + assert!(state.lock().unwrap().calls.is_empty()); +} +#[tokio::test] +async fn old_and_future_api_versions_never_receive_writes() { + for version in [1, 3] { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State::default())); + let service = server(&bus, state.clone(), version).await; + let connection = bus.connection().await; + assert_eq!(snapshot(&connection).await.unwrap()["available"], false); + assert!(apply( + &connection, + service.unique_name().unwrap().as_str(), + &selection(), + None + ) + .await + .is_err()); + assert!(state.lock().unwrap().calls.is_empty()); + } +} +#[tokio::test] +async fn explicit_profiles_report_errors_and_never_retarget_a_replacement_owner() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State::default())); + let first = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let owner = first.unique_name().unwrap().to_string(); + apply(&connection, &owner, &selection(), None) + .await + .unwrap(); + assert_eq!(state.lock().unwrap().calls.len(), 1); + state.lock().unwrap().denied = true; + assert!(apply(&connection, &owner, &selection(), None) + .await + .unwrap_err() + .contains("rejected")); + first.release_name(POWERSTATION_BUS).await.unwrap(); + let replacement_state = Arc::new(Mutex::new(State::default())); + let _replacement = server(&bus, replacement_state.clone(), 2).await; + assert!(apply(&connection, &owner, &selection(), None) + .await + .is_err()); + assert!(replacement_state.lock().unwrap().calls.is_empty()); + assert_eq!(state.lock().unwrap().calls.len(), 1); +} +#[tokio::test] +async fn saved_restore_consumes_attempt_before_a_denied_request_and_cannot_replay() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State { + eligible: true, + denied: true, + ..Default::default() + })); + let service = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let directory = + std::env::temp_dir().join(format!("ogui-fan-restore-denied-{}", std::process::id())); + let settings = FanSettings::new(directory.clone()); + settings.save(&selection()).unwrap(); + assert!(restore(&connection, &settings).await.is_err()); + state.lock().unwrap().denied = false; + assert!(restore(&connection, &settings) + .await + .unwrap_err() + .contains("already attempted")); + assert!(state.lock().unwrap().calls.is_empty()); + service.release_name(POWERSTATION_BUS).await.unwrap(); + fs_cleanup(&directory); +} +#[tokio::test] +async fn successful_restore_is_once_per_boot_and_later_invocation_stays_automatic() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State { + eligible: true, + ..Default::default() + })); + let service = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let directory = + std::env::temp_dir().join(format!("ogui-fan-restore-success-{}", std::process::id())); + let settings = FanSettings::new(directory.clone()); + settings + .save(&json!({"kind":"file", "file":"quiet.yaml"})) + .unwrap(); + restore(&connection, &settings).await.unwrap(); + assert_eq!(state.lock().unwrap().calls, vec!["quiet.yaml"]); + assert!(restore(&connection, &settings).await.is_err()); + service.release_name(POWERSTATION_BUS).await.unwrap(); + let later = Arc::new(Mutex::new(State::default())); + let _replacement = server(&bus, later.clone(), 2).await; + assert!(restore(&connection, &settings).await.is_err()); + assert!(later.lock().unwrap().calls.is_empty()); + fs_cleanup(&directory); +} +fn fs_cleanup(path: &std::path::Path) { + std::fs::remove_dir_all(path).unwrap(); +} diff --git a/extensions/core/src/performance/powerstation/fan_device.rs b/extensions/core/src/performance/powerstation/fan_device.rs index 72a97f11a..fed150859 100644 --- a/extensions/core/src/performance/powerstation/fan_device.rs +++ b/extensions/core/src/performance/powerstation/fan_device.rs @@ -18,7 +18,7 @@ enum Signal { Updated, } -/// A single PowerStation fan and its atomic control operations. +/// A single PowerStation fan and its API v2 read-only state. #[derive(GodotClass)] #[class(no_init, base=Resource)] pub struct FanDevice { @@ -26,7 +26,6 @@ pub struct FanDevice { dbus_path: String, proxy: Option>, rx: Receiver, - last_error: String, } #[godot_api] @@ -49,7 +48,12 @@ impl FanDevice { FanDeviceProxyBlocking::builder(conn) .path(dbus_path) .ok() - .and_then(|builder| builder.build().ok()) + .and_then(|builder| { + builder + .cache_properties(zbus::proxy::CacheProperties::No) + .build() + .ok() + }) } else { None }; @@ -58,7 +62,6 @@ impl FanDevice { proxy, dbus_path: path.into(), rx, - last_error: String::new(), } }) } @@ -81,19 +84,6 @@ impl FanDevice { self.dbus_path.as_str().into() } - #[func] - pub fn get_last_error(&self) -> GString { - self.last_error.as_str().into() - } - - #[func] - pub fn get_api_version(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.api_version().ok()) - .unwrap_or(0) as i64 - } - #[func] pub fn get_id(&self) -> GString { self.string_property(|p| p.id()) @@ -109,267 +99,31 @@ impl FanDevice { self.string_property(|p| p.mode()) } - #[func] - pub fn get_backend(&self) -> GString { - self.string_property(|p| p.backend()) - } - - #[func] - pub fn get_temperature_c(&self) -> f64 { - self.proxy - .as_ref() - .and_then(|p| p.temperature_c().ok()) - .unwrap_or_default() - } - - #[func] - pub fn get_telemetry_valid(&self) -> bool { - self.proxy - .as_ref() - .and_then(|p| p.telemetry_valid().ok()) - .unwrap_or(false) - } - - #[func] - pub fn get_rpm(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.rpm().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_effective_percent(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.effective_percent().ok()) - .unwrap_or(-1) as i64 - } - - #[func] - pub fn get_manual_percent(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.manual_percent().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_presets(&self) -> PackedStringArray { - self.string_array_property(|p| p.presets()) - } - - #[func] - pub fn get_active_preset(&self) -> GString { - self.string_property(|p| p.active_preset()) - } - #[func] pub fn get_fault(&self) -> GString { self.string_property(|p| p.fault()) } #[func] - pub fn get_automatic_verified(&self) -> bool { - self.proxy - .as_ref() - .and_then(|p| p.automatic_verified().ok()) - .unwrap_or(false) - } - - #[func] - pub fn get_controlling(&self) -> bool { - self.proxy - .as_ref() - .and_then(|p| p.controlling().ok()) - .unwrap_or(false) - } - - #[func] - pub fn get_suspended(&self) -> bool { - self.proxy - .as_ref() - .and_then(|p| p.suspended().ok()) - .unwrap_or(false) - } - - #[func] - pub fn get_curve_temperatures(&self) -> PackedFloat64Array { - let values: Vec = self.curve().into_iter().map(|point| point.0).collect(); - PackedFloat64Array::from(values.as_slice()) - } - - #[func] - pub fn get_curve_percents(&self) -> PackedInt32Array { - let values: Vec = self - .curve() - .into_iter() - .map(|point| i32::from(point.1)) - .collect(); - PackedInt32Array::from(values.as_slice()) - } - - #[func] - pub fn get_curve_point_count(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.curve_point_count().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_minimum_percent(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.minimum_percent().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_minimum_running_percent(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.minimum_running_percent().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_pwm_min(&self) -> i64 { + pub fn get_percent(&self) -> i64 { self.proxy .as_ref() - .and_then(|p| p.pwm_min().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_pwm_max(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.pwm_max().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_pwm_readback_tolerance(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.pwm_readback_tolerance().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_stop_temperature_c(&self) -> f64 { - self.proxy - .as_ref() - .and_then(|p| p.stop_temperature_c().ok()) - .unwrap_or_default() - } - - #[func] - pub fn get_restart_temperature_c(&self) -> f64 { - self.proxy - .as_ref() - .and_then(|p| p.restart_temperature_c().ok()) - .unwrap_or_default() - } - - #[func] - pub fn get_stall_rpm(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.stall_rpm().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_stall_grace_seconds(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.stall_grace_seconds().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn get_full_speed_temperature_c(&self) -> f64 { - self.proxy - .as_ref() - .and_then(|p| p.full_speed_temperature_c().ok()) - .unwrap_or_default() - } - - #[func] - pub fn get_emergency_handoff_temperature_c(&self) -> f64 { - self.proxy - .as_ref() - .and_then(|p| p.emergency_handoff_temperature_c().ok()) + .and_then(|p| p.percent().ok()) .unwrap_or_default() + .into() } #[func] - pub fn get_minimum_valid_temperature_c(&self) -> f64 { + pub fn get_percent_valid(&self) -> bool { self.proxy .as_ref() - .and_then(|p| p.minimum_valid_temperature_c().ok()) - .unwrap_or_default() - } - - #[func] - pub fn get_maximum_valid_temperature_c(&self) -> f64 { - self.proxy - .as_ref() - .and_then(|p| p.maximum_valid_temperature_c().ok()) - .unwrap_or_default() - } - - #[func] - pub fn get_maximum_valid_rpm(&self) -> i64 { - self.proxy - .as_ref() - .and_then(|p| p.maximum_valid_rpm().ok()) - .unwrap_or_default() as i64 - } - - #[func] - pub fn set_automatic(&mut self) -> bool { - self.perform(|proxy| proxy.set_automatic()) - } - - #[func] - pub fn set_manual(&mut self, percent: i64) -> bool { - if !(0..=100).contains(&percent) { - self.last_error = "Fan duty must be between 0 and 100 percent".into(); - return false; - } - self.perform(|proxy| proxy.set_manual(percent as u8)) - } - - /// Apply a complete curve in one D-Bus operation. - #[func] - pub fn set_curve( - &mut self, - temperatures: PackedFloat64Array, - percents: PackedInt32Array, - ) -> bool { - if temperatures.len() != percents.len() || temperatures.len() < 2 { - self.last_error = "A fan curve needs matching temperature and duty arrays".into(); - return false; - } - let mut points = Vec::with_capacity(temperatures.len()); - for index in 0..temperatures.len() { - let percent = percents[index]; - if !(0..=100).contains(&percent) { - self.last_error = "Fan curve duty must be between 0 and 100 percent".into(); - return false; - } - points.push((temperatures[index], percent as u8)); - } - self.perform(|proxy| proxy.set_curve(points)) + .and_then(|p| p.percent_valid().ok()) + .unwrap_or(false) } #[func] - pub fn apply_preset(&mut self, name: GString) -> bool { - let name = name.to_string(); - self.perform(|proxy| proxy.apply_preset(name.as_str())) + pub fn get_available_modes(&self) -> PackedStringArray { + self.string_array_property(|p| p.available_modes()) } pub fn process(&mut self) { @@ -384,13 +138,6 @@ impl FanDevice { } } - fn curve(&self) -> Vec<(f64, u8)> { - self.proxy - .as_ref() - .and_then(|p| p.curve().ok()) - .unwrap_or_default() - } - fn string_property(&self, getter: F) -> GString where F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result, @@ -417,39 +164,6 @@ impl FanDevice { .collect(); PackedStringArray::from(values.as_slice()) } - - fn perform(&mut self, operation: F) -> bool - where - F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result<()>, - { - let Some(proxy) = self.proxy.as_ref() else { - self.last_error = "PowerStation fan service is unavailable".into(); - return false; - }; - match proxy.api_version() { - Ok(1) => {} - Ok(version) => { - self.last_error = format!( - "Incompatible PowerStation fan API version {version}; version 1 is required" - ); - return false; - } - Err(error) => { - self.last_error = format!("Unable to verify the PowerStation fan API: {error}"); - return false; - } - } - match operation(proxy) { - Ok(()) => { - self.last_error.clear(); - true - } - Err(error) => { - self.last_error = error.to_string(); - false - } - } - } } async fn run(tx: Sender, path: String) -> Result<(), RunError> { diff --git a/extensions/core/src/performance/powerstation/fan_settings.rs b/extensions/core/src/performance/powerstation/fan_settings.rs new file mode 100644 index 000000000..5eb0fd283 --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_settings.rs @@ -0,0 +1,213 @@ +//! Per-user preferences and a durable, exclusive once-per-boot attempt fence. +use super::fan_client::{validate_selection, MAX_PROFILE_BYTES}; +use nix::libc; +use serde_json::{json, Value}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, + }, + path::PathBuf, +}; + +pub struct FanSettings { + directory: PathBuf, +} +impl FanSettings { + pub fn new(directory: PathBuf) -> Self { + Self { directory } + } + fn directory(&self) -> Result { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&self.directory) + .map_err(|e| e.to_string())?; + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&self.directory) + .map_err(|e| e.to_string())?; + let metadata = directory.metadata().map_err(|e| e.to_string())?; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o022 != 0 { + return Err("Fan preference directory is not trusted".into()); + } + Ok(directory) + } + fn read(&self, directory: &File) -> Result, String> { + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + c"profile.json".as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(error.to_string()); + } + let file = unsafe { File::from_raw_fd(descriptor) }; + let metadata = file.metadata().map_err(|e| e.to_string())?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.nlink() != 1 + || metadata.mode() & 0o777 != 0o600 + || metadata.len() > MAX_PROFILE_BYTES as u64 + { + return Err("Saved fan preferences are not a bounded private regular file".into()); + } + let mut bytes = Vec::new(); + file.take(MAX_PROFILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > MAX_PROFILE_BYTES { + return Err("Saved fan profile is too large".into()); + } + let data: Value = serde_json::from_slice(&bytes) + .map_err(|_| "Saved fan preferences are corrupt; the file was preserved")?; + if data.as_object().is_none_or(|o| o.len() != 2) || data["version"] != 1 { + return Err("Unsupported saved fan preferences; the file was preserved".into()); + } + validate_selection(&data["selection"])?; + Ok(Some(data["selection"].clone())) + } + pub fn load(&self) -> Result, String> { + if !self.directory.exists() { + return Ok(None); + } + self.read(&self.directory()?) + } + pub fn save(&self, selection: &Value) -> Result<(), String> { + validate_selection(selection)?; + let directory = self.directory()?; + // Preserve malformed existing preferences instead of silently replacing them. + self.read(&directory)?; + let bytes = json!({"version": 1, "selection": selection}).to_string(); + if bytes.len() > MAX_PROFILE_BYTES { + return Err("Saved fan preferences exceed 64 KiB".into()); + } + let name = format!(".profile-{}", std::process::id()); + let path = self.directory.join(&name); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(|e| e.to_string())?; + let result = (|| { + file.write_all(bytes.as_bytes())?; + file.sync_all()?; + fs::rename(&path, self.directory.join("profile.json"))?; + directory.sync_all() + })() + .map_err(|e: std::io::Error| e.to_string()); + let _ = fs::remove_file(&path); + result + } + pub fn consume_boot(&self, boot: &str) -> Result { + if boot.len() != 36 + || !boot.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_hexdigit() && !b.is_ascii_uppercase() + } + }) + { + return Err("Invalid OS boot identity".into()); + } + let directory = self.directory()?; + let name = std::ffi::CString::new(format!("restore-{boot}")).unwrap(); + let first = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) } == 0; + if !first { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(error.to_string()); + } + } + // The exclusive directory itself is the attempt, even if fsync fails. + directory.sync_all().map_err(|e| e.to_string())?; + Ok(first) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + Self(std::env::temp_dir().join(format!( + "ogui-fans-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ))) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + const BOOT: &str = "12345678-1234-1234-1234-123456789abc"; + #[test] + fn preferences_and_boot_attempt_survive_client_restart() { + let fixture = Fixture::new(); + let settings = FanSettings::new(fixture.0.clone()); + assert_eq!(settings.load().unwrap(), None); + let selection = json!({"kind":"file", "file":"quiet.yaml"}); + settings.save(&selection).unwrap(); + assert!(settings.consume_boot(BOOT).unwrap()); + let restarted = FanSettings::new(fixture.0.clone()); + assert_eq!(restarted.load().unwrap(), Some(selection)); + assert!(!restarted.consume_boot(BOOT).unwrap()); + assert_eq!( + fs::metadata(fixture.0.join("profile.json")).unwrap().mode() & 0o777, + 0o600 + ); + } + #[test] + fn malformed_oversized_insecure_and_symlink_preferences_are_preserved() { + let fixture = Fixture::new(); + let settings = FanSettings::new(fixture.0.clone()); + let selection = json!({"kind":"file", "file":"quiet.yaml"}); + settings.save(&selection).unwrap(); + let path = fixture.0.join("profile.json"); + for bytes in [ + "{".to_string(), + "x".repeat(MAX_PROFILE_BYTES + 1), + r#"{"version":2,"selection":{}}"#.into(), + ] { + fs::write(&path, &bytes).unwrap(); + assert!(settings.load().is_err()); + assert!(settings.save(&selection).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), bytes); + } + fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink("missing", &path).unwrap(); + assert!(settings.load().is_err()); + assert!(settings.save(&selection).is_err()); + fs::remove_file(&path).unwrap(); + settings.save(&selection).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(settings.load().is_err()); + fs::set_permissions(&fixture.0, fs::Permissions::from_mode(0o777)).unwrap(); + assert!(settings.consume_boot(BOOT).is_err()); + } + #[test] + fn invalid_identity_cannot_claim_an_attempt() { + let fixture = Fixture::new(); + let settings = FanSettings::new(fixture.0.clone()); + assert!(settings.consume_boot("../../escape").is_err()); + assert!(!fixture.0.exists()); + } +} diff --git a/tests/fan_client_contract_test.gd b/tests/fan_client_contract_test.gd new file mode 100644 index 000000000..b97352d7a --- /dev/null +++ b/tests/fan_client_contract_test.gd @@ -0,0 +1,56 @@ +extends SceneTree +## Run only against the private fake-hardware service described in docs/fans.md. +var checks := 0 +var failures := 0 + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(message) + +func _initialize() -> void: + if OS.get_environment("OGUI_FAN_PRIVATE_FIXTURE") != "1" or not OS.get_environment("DBUS_SYSTEM_BUS_ADDRESS").begins_with("unix:"): + push_error("This test requires an explicitly isolated private D-Bus/fake-sysfs fixture") + quit(2) + return + var profile := {"name": "Restored", "fans": [{"fan": "ayaneo-3/system", "mode": "manual", "percent": 60}]} + var selection := {"kind": "json", "profile": profile} + if OS.get_environment("OGUI_FAN_SAVED_FILE") == "1": + selection = {"kind": "file", "file": "quiet.yaml"} + DirAccess.make_dir_recursive_absolute("user://data/fans") + var saved := FileAccess.open("user://data/fans/profile.json", FileAccess.WRITE) + saved.store_string(JSON.stringify({"version": 1, "selection": selection})) + saved.close() + check(OS.execute("chmod", ["600", ProjectSettings.globalize_path("user://data/fans/profile.json")]) == 0, "private fixture preference permissions") + var instance := PowerStationInstance.new() + var fan := instance.get_fan() + var before: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(before.get("available", false), "API v2 service is available") + if not before.get("available", false): + quit(1) + return + check(before.startup.eligible, "first native invocation is eligible") + check(before.fans.size() == 1 and before.sensors.size() == 1, "fan/sensor discovery") + check(fan.restore_saved(), "guarded restore succeeds through Godot and real native D-Bus") + var restored: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(not restored.startup.eligible, "native eligibility is consumed") + check(restored.fans[0].percent_valid, "software duty has explicit validity") + if selection.kind == "json": + check(restored.profile_name == "Restored" and int(restored.fans[0].percent) == 60, "saved JSON restored as Manual 60") + else: + check(restored.profile_name == "Quiet" and restored.fans[0].mode == "software_curve", "saved file restored through native override-aware loading") + check(not fan.restore_saved(), "the application does not attempt a second restoration") + check(not fan.load_profile_json(" ".repeat(65537), restored.owner), "oversized JSON is rejected before parsing or writing") + check(JSON.parse_string(fan.get_snapshot_json()).profile_name == restored.profile_name, "oversized input preserves the live native profile") + var model := FanProfileModel.new() + model.start(fan) + check(model.set_json('{"name":"Edited","fans":[{"fan":"ayaneo-3/system","mode":"manual","percent":65}]}'), "JSON editor accepts integral percentages") + check(model.apply(), "Godot JSON numeric serialization is accepted by native profile parsing") + var edited: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(edited.profile_name == "Edited" and int(edited.fans[0].percent) == 65, "native state confirms edited profile") + check(model.automatic(), "explicit Automatic remains available") + var automatic: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(automatic.fans[0].mode == "automatic" and not automatic.fans[0].percent_valid, "firmware duty is not invented") + print("FAN CONTRACT: %d assertions, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/fan_client_contract_test.gd.uid b/tests/fan_client_contract_test.gd.uid new file mode 100644 index 000000000..18b693894 --- /dev/null +++ b/tests/fan_client_contract_test.gd.uid @@ -0,0 +1 @@ +uid://bfo7djtkr5ciy