diff --git a/60-cros_ec_python.rules b/60-cros_ec_python.rules new file mode 100644 index 0000000..8fc61f9 --- /dev/null +++ b/60-cros_ec_python.rules @@ -0,0 +1,7 @@ +# CrOS_EC_Python udev rules + +# LPC Access +KERNEL=="port", TAG+="uaccess" + +# /dev/cros_ec Access +KERNEL=="cros_ec", TAG+="uaccess" diff --git a/LICENCE b/COPYING similarity index 99% rename from LICENCE rename to COPYING index d159169..1f963da 100644 --- a/LICENCE +++ b/COPYING @@ -337,3 +337,4 @@ proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. + diff --git a/README.md b/README.md index b26938f..e9b7502 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,38 @@ # Yet Another Framework Interface YAFI is another GUI for the Framework Laptop Embedded Controller. -It is written in Python with a GTK3 theme, and uses the `CrOS_EC_Python` library to communicate with the EC. +It is written in Python with a GTK4 Adwaita theme, and uses the `CrOS_EC_Python` library to communicate with the EC. + +## Features + +### Fan Control and Temperature Monitoring + +![Thermals Page](docs/1-thermals.png) + +### LED Control + +![LEDs Page](docs/2-leds.png) + +### Battery Limiting + +![Battery Page](docs/3-battery.png) + +#### Battery Extender + +![Battery Extender](docs/3a-battery-ext.png) + +### Hardware Info + +![Hardware Page](docs/4-hardware.png) + +## Installation + +### udev Rules (MUST READ) + +To allow YAFI to communicate with the EC, you need to copy the `60-cros_ec_python.rules` file to `/etc/udev/rules.d/` and reload the rules with `sudo udevadm control --reload-rules && sudo udevadm trigger`. + +### Flatpak + +Build and install the Flatpak package with `flatpak-builder --install --user build au.stevetech.yafi.json`. + +You can also create a flatpak bundle with `flatpak-builder --repo=repo build au.stevetech.yafi.json` and install it with `flatpak install --user repo au.stevetech.yafi.flatpak`. diff --git a/au.stevetech.yafi.json b/au.stevetech.yafi.json new file mode 100644 index 0000000..514bc8d --- /dev/null +++ b/au.stevetech.yafi.json @@ -0,0 +1,48 @@ +{ + "id" : "au.stevetech.yafi", + "runtime" : "org.gnome.Platform", + "runtime-version" : "48", + "sdk" : "org.gnome.Sdk", + "command" : "yafi", + "finish-args" : [ + "--device=all", + "--socket=fallback-x11", + "--socket=wayland" + ], + "cleanup" : [ + "/include", + "/lib/pkgconfig", + "/man", + "/share/doc", + "/share/gtk-doc", + "/share/man", + "/share/pkgconfig", + "*.la", + "*.a" + ], + "modules" : [ + { + "name" : "yafi", + "builddir" : true, + "buildsystem" : "meson", + "sources" : [ + { + "type" : "dir", + "path" : "." + } + ] + }, + { + "name": "cros_ec_python", + "buildsystem": "simple", + "build-options": { + "build-args": [ + "--share=network" + ] + }, + "build-commands": [ + "pip3 install --prefix=${FLATPAK_DEST} --no-cache-dir \"cros_ec_python>=0.0.2\"" + ] + } + ] +} diff --git a/data/au.stevetech.yafi.desktop.in b/data/au.stevetech.yafi.desktop.in new file mode 100644 index 0000000..08b8795 --- /dev/null +++ b/data/au.stevetech.yafi.desktop.in @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=YAFI +Exec=yafi +Icon=au.stevetech.yafi +Comment=Yet Another Framework Interface +Terminal=false +Type=Application +Categories=Utility; +Keywords=GTK; +StartupNotify=true +DBusActivatable=true diff --git a/data/au.stevetech.yafi.gschema.xml b/data/au.stevetech.yafi.gschema.xml new file mode 100644 index 0000000..b76213a --- /dev/null +++ b/data/au.stevetech.yafi.gschema.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/data/au.stevetech.yafi.metainfo.xml.in b/data/au.stevetech.yafi.metainfo.xml.in new file mode 100644 index 0000000..02f111d --- /dev/null +++ b/data/au.stevetech.yafi.metainfo.xml.in @@ -0,0 +1,83 @@ + + + au.stevetech.yafi + CC0-1.0 + GPL-2.0-or-later + + Yet Another Framework Interface + YAFI is another GUI for the Framework Laptop Embedded Controller + +

It is written in Python with a GTK3 theme, and uses the `CrOS_EC_Python` library to communicate with the EC.

+
+ + + Stephen Horvath + + + + https://github.com/Steve-Tech/YAFI + + https://github.com/Steve-Tech/YAFI + + https://github.com/Steve-Tech/YAFI/issues + + + + + + + + + + + + + + yafi + + au.stevetech.yafi.desktop + + + + + + #ff00ff + #993d3d + + + + + https://github.com/Steve-Tech/YAFI/blob/main/docs/1-thermal.png + The Thermal page + + + https://github.com/Steve-Tech/YAFI/blob/main/docs/2-leds.png + The LED page + + + https://github.com/Steve-Tech/YAFI/blob/main/docs/3-battery.png + The Battery page + + + https://github.com/Steve-Tech/YAFI/blob/main/docs/4-hardware.png + The Hardware page + + + + + + https://github.com/Steve-Tech/YAFI/releases/tag/0.1.0 + +

First release

+
    +
  • Added Thermal page
  • +
  • Added LED page
  • +
  • Added Battery page
  • +
  • Added Hardware page
  • +
+
+
+
+ +
diff --git a/data/au.stevetech.yafi.service.in b/data/au.stevetech.yafi.service.in new file mode 100644 index 0000000..0e697be --- /dev/null +++ b/data/au.stevetech.yafi.service.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=au.stevetech.yafi +Exec=@bindir@/yafi --gapplication-service diff --git a/data/icons/hicolor/scalable/apps/au.stevetech.yafi.svg b/data/icons/hicolor/scalable/apps/au.stevetech.yafi.svg new file mode 100644 index 0000000..425bb1b --- /dev/null +++ b/data/icons/hicolor/scalable/apps/au.stevetech.yafi.svg @@ -0,0 +1,36 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/data/icons/hicolor/symbolic/apps/au.stevetech.yafi-symbolic.svg b/data/icons/hicolor/symbolic/apps/au.stevetech.yafi-symbolic.svg new file mode 100644 index 0000000..425bb1b --- /dev/null +++ b/data/icons/hicolor/symbolic/apps/au.stevetech.yafi-symbolic.svg @@ -0,0 +1,36 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/data/icons/meson.build b/data/icons/meson.build new file mode 100644 index 0000000..23047e3 --- /dev/null +++ b/data/icons/meson.build @@ -0,0 +1,13 @@ +application_id = 'au.stevetech.yafi' + +scalable_dir = 'hicolor' / 'scalable' / 'apps' +install_data( + scalable_dir / ('@0@.svg').format(application_id), + install_dir: get_option('datadir') / 'icons' / scalable_dir +) + +symbolic_dir = 'hicolor' / 'symbolic' / 'apps' +install_data( + symbolic_dir / ('@0@-symbolic.svg').format(application_id), + install_dir: get_option('datadir') / 'icons' / symbolic_dir +) diff --git a/data/meson.build b/data/meson.build new file mode 100644 index 0000000..a9f156e --- /dev/null +++ b/data/meson.build @@ -0,0 +1,46 @@ +desktop_file = i18n.merge_file( + input: 'au.stevetech.yafi.desktop.in', + output: 'au.stevetech.yafi.desktop', + type: 'desktop', + po_dir: '../po', + install: true, + install_dir: get_option('datadir') / 'applications' +) + +desktop_utils = find_program('desktop-file-validate', required: false) +if desktop_utils.found() + test('Validate desktop file', desktop_utils, args: [desktop_file]) +endif + +appstream_file = i18n.merge_file( + input: 'au.stevetech.yafi.metainfo.xml.in', + output: 'au.stevetech.yafi.metainfo.xml', + po_dir: '../po', + install: true, + install_dir: get_option('datadir') / 'metainfo' +) + +appstreamcli = find_program('appstreamcli', required: false, disabler: true) +test('Validate appstream file', appstreamcli, + args: ['validate', '--no-net', '--explain', appstream_file]) + +install_data('au.stevetech.yafi.gschema.xml', + install_dir: get_option('datadir') / 'glib-2.0' / 'schemas' +) + +compile_schemas = find_program('glib-compile-schemas', required: false, disabler: true) +test('Validate schema file', + compile_schemas, + args: ['--strict', '--dry-run', meson.current_source_dir()]) + + +service_conf = configuration_data() +service_conf.set('bindir', get_option('prefix') / get_option('bindir')) +configure_file( + input: 'au.stevetech.yafi.service.in', + output: 'au.stevetech.yafi.service', + configuration: service_conf, + install_dir: get_option('datadir') / 'dbus-1' / 'services' +) + +subdir('icons') diff --git a/docs/1-thermals.png b/docs/1-thermals.png new file mode 100755 index 0000000..8d3d721 Binary files /dev/null and b/docs/1-thermals.png differ diff --git a/docs/2-leds.png b/docs/2-leds.png new file mode 100755 index 0000000..df97535 Binary files /dev/null and b/docs/2-leds.png differ diff --git a/docs/3-battery.png b/docs/3-battery.png new file mode 100755 index 0000000..06e3bd6 Binary files /dev/null and b/docs/3-battery.png differ diff --git a/docs/3a-battery-ext.png b/docs/3a-battery-ext.png new file mode 100755 index 0000000..f6ff4dc Binary files /dev/null and b/docs/3a-battery-ext.png differ diff --git a/docs/4-hardware.png b/docs/4-hardware.png new file mode 100755 index 0000000..69f4d25 Binary files /dev/null and b/docs/4-hardware.png differ diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..dbd137c --- /dev/null +++ b/meson.build @@ -0,0 +1,20 @@ +project('yafi', + version: '0.1.0', + meson_version: '>= 1.0.0', + default_options: [ 'warning_level=2', 'werror=false', ], +) + +i18n = import('i18n') +gnome = import('gnome') + + + + +subdir('data') +subdir('yafi') + +gnome.post_install( + glib_compile_schemas: true, + gtk_update_icon_cache: true, + update_desktop_database: true, +) diff --git a/po/LINGUAS b/po/LINGUAS new file mode 100644 index 0000000..61b6fb7 --- /dev/null +++ b/po/LINGUAS @@ -0,0 +1 @@ +# Please keep this file sorted alphabetically. diff --git a/po/POTFILES.in b/po/POTFILES.in new file mode 100644 index 0000000..e05288a --- /dev/null +++ b/po/POTFILES.in @@ -0,0 +1,16 @@ +# List of source files containing translatable strings. +# Please keep this file sorted alphabetically. +data/au.stevetech.test.desktop.in +data/au.stevetech.test.metainfo.xml.in +data/au.stevetech.test.gschema.xml +yafi/main.py +yafi/window.py +yafi/thermals.py +yafi/leds.py +yafi/battery.py +yafi/hardware.py +yafi/ui/window.ui +yafi/ui/thermals.ui +yafi/ui/leds.ui +yafi/ui/battery.ui +yafi/ui/hardware.ui diff --git a/po/meson.build b/po/meson.build new file mode 100644 index 0000000..ba5c169 --- /dev/null +++ b/po/meson.build @@ -0,0 +1 @@ +i18n.gettext('test', preset: 'glib') diff --git a/yafi/__init__.py b/yafi/__init__.py index 017ec0c..12a7e52 100644 --- a/yafi/__init__.py +++ b/yafi/__init__.py @@ -1 +1 @@ -from .yafi import main +from . import main diff --git a/yafi/__main__.py b/yafi/__main__.py index cadf9c1..1648817 100644 --- a/yafi/__main__.py +++ b/yafi/__main__.py @@ -1,2 +1,3 @@ -from . import yafi -yafi.main() +from . import main + +main.main() \ No newline at end of file diff --git a/yafi/battery.py b/yafi/battery.py new file mode 100644 index 0000000..ac7fbd4 --- /dev/null +++ b/yafi/battery.py @@ -0,0 +1,215 @@ +# battery.py +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from gi.repository import Adw +from gi.repository import Gtk +from gi.repository import GLib + +import cros_ec_python.commands as ec_commands +import cros_ec_python.exceptions as ec_exceptions + +@Gtk.Template(resource_path='/au/stevetech/yafi/ui/battery.ui') +class BatteryPage(Gtk.Box): + __gtype_name__ = 'BatteryPage' + + chg_limit_enable = Gtk.Template.Child() + chg_limit = Gtk.Template.Child() + chg_limit_scale = Gtk.Template.Child() + bat_limit = Gtk.Template.Child() + bat_limit_scale = Gtk.Template.Child() + chg_limit_override = Gtk.Template.Child() + chg_limit_override_btn = Gtk.Template.Child() + + bat_ext_group = Gtk.Template.Child() + bat_ext_enable = Gtk.Template.Child() + bat_ext_stage = Gtk.Template.Child() + bat_ext_trigger_time = Gtk.Template.Child() + bat_ext_reset_time = Gtk.Template.Child() + bat_ext_trigger = Gtk.Template.Child() + bat_ext_reset = Gtk.Template.Child() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def setup(self, app): + # Charge limiter + try: + ec_limit = ec_commands.framework_laptop.get_charge_limit(app.cros_ec) + ec_limit_enabled = ec_limit != (0, 0) + self.chg_limit_enable.set_active(ec_limit_enabled) + if ec_limit_enabled: + self.chg_limit_scale.set_value(ec_limit[0]) + self.bat_limit_scale.set_value(ec_limit[1]) + self.chg_limit.set_sensitive(True) + self.bat_limit.set_sensitive(True) + self.chg_limit_override.set_sensitive(True) + + def handle_chg_limit_change(min, max): + ec_commands.framework_laptop.set_charge_limit( + app.cros_ec, int(min), int(max) + ) + + def handle_chg_limit_enable(switch): + active = switch.get_active() + if active: + handle_chg_limit_change( + self.chg_limit_scale.get_value(), self.bat_limit_scale.get_value() + ) + else: + ec_commands.framework_laptop.disable_charge_limit(app.cros_ec) + + self.chg_limit.set_sensitive(active) + self.bat_limit.set_sensitive(active) + self.chg_limit_override.set_sensitive(active) + + self.chg_limit_enable.connect( + "notify::active", lambda switch, _: handle_chg_limit_enable(switch) + ) + self.chg_limit_scale.connect( + "value-changed", + lambda scale: handle_chg_limit_change( + scale.get_value(), self.bat_limit_scale.get_value() + ), + ) + self.bat_limit_scale.connect( + "value-changed", + lambda scale: handle_chg_limit_change( + self.chg_limit_scale.get_value(), scale.get_value() + ), + ) + + self.chg_limit_override_btn.connect( + "clicked", + lambda _: ec_commands.framework_laptop.override_charge_limit( + app.cros_ec + ), + ) + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append(ec_commands.framework_laptop.EC_CMD_CHARGE_LIMIT) + self.chg_limit_enable.set_sensitive(False) + else: + raise e + + # Battery Extender + try: + ec_extender = ec_commands.framework_laptop.get_battery_extender( + app.cros_ec + ) + self.bat_ext_enable.set_active(not ec_extender["disable"]) + self.bat_ext_stage.set_sensitive(not ec_extender["disable"]) + self.bat_ext_trigger_time.set_sensitive(not ec_extender["disable"]) + self.bat_ext_reset_time.set_sensitive(not ec_extender["disable"]) + self.bat_ext_trigger.set_sensitive(not ec_extender["disable"]) + self.bat_ext_reset.set_sensitive(not ec_extender["disable"]) + + self.bat_ext_stage.set_subtitle(str(ec_extender["current_stage"])) + self.bat_ext_trigger_time.set_subtitle( + format_timedelta(ec_extender["trigger_timedelta"]) + ) + self.bat_ext_reset_time.set_subtitle( + format_timedelta(ec_extender["reset_timedelta"]) + ) + self.bat_ext_trigger.set_value(ec_extender["trigger_days"]) + self.bat_ext_reset.set_value(ec_extender["reset_minutes"]) + + def handle_extender_enable(switch): + active = switch.get_active() + ec_commands.framework_laptop.set_battery_extender( + app.cros_ec, + not active, + int(self.bat_ext_trigger.get_value()), + int(self.bat_ext_reset.get_value()), + ) + self.bat_ext_stage.set_sensitive(active) + self.bat_ext_trigger_time.set_sensitive(active) + self.bat_ext_reset_time.set_sensitive(active) + self.bat_ext_trigger.set_sensitive(active) + self.bat_ext_reset.set_sensitive(active) + + self.bat_ext_enable.connect( + "notify::active", lambda switch, _: handle_extender_enable(switch) + ) + self.bat_ext_trigger.connect( + "notify::value", + lambda scale, _: ec_commands.framework_laptop.set_battery_extender( + app.cros_ec, + not self.bat_ext_enable.get_active(), + int(scale.get_value()), + int(self.bat_ext_reset.get_value()), + ), + ) + self.bat_ext_reset.connect( + "notify::value", + lambda scale, _: ec_commands.framework_laptop.set_battery_extender( + app.cros_ec, + not self.bat_ext_enable.get_active(), + int(self.bat_ext_trigger.get_value()), + int(scale.get_value()), + ), + ) + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append( + ec_commands.framework_laptop.EC_CMD_BATTERY_EXTENDER + ) + self.bat_ext_group.set_visible(False) + else: + raise e + + # Schedule _update_battery to run every second + GLib.timeout_add_seconds( + 1, + self._update_battery, + app + ) + + def _update_battery(self, app): + if ec_commands.framework_laptop.EC_CMD_BATTERY_EXTENDER in app.no_support: + return False + + try: + ec_extender = ec_commands.framework_laptop.get_battery_extender( + app.cros_ec + ) + + self.bat_ext_stage.set_subtitle(str(ec_extender["current_stage"])) + self.bat_ext_trigger_time.set_subtitle( + format_timedelta(ec_extender["trigger_timedelta"]) + ) + self.bat_ext_reset_time.set_subtitle( + format_timedelta(ec_extender["reset_timedelta"]) + ) + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append( + ec_commands.framework_laptop.EC_CMD_BATTERY_EXTENDER + ) + return False + else: + raise e + + return app.current_page == 2 + +def format_timedelta(timedelta): + days = f"{timedelta.days} days, " if timedelta.days else "" + hours, remainder = divmod(timedelta.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return days + f"{hours}:{minutes:02}:{seconds:02}" diff --git a/yafi/hardware.py b/yafi/hardware.py new file mode 100644 index 0000000..cb7816c --- /dev/null +++ b/yafi/hardware.py @@ -0,0 +1,119 @@ +# hardware.py +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from gi.repository import Adw +from gi.repository import Gtk +from gi.repository import GLib + +import cros_ec_python.commands as ec_commands +import cros_ec_python.exceptions as ec_exceptions + +@Gtk.Template(resource_path='/au/stevetech/yafi/ui/hardware.ui') +class HardwarePage(Gtk.Box): + __gtype_name__ = 'HardwarePage' + + hw_chassis = Gtk.Template.Child() + hw_chassis_label = Gtk.Template.Child() + + hw_priv_cam = Gtk.Template.Child() + hw_priv_cam_sw = Gtk.Template.Child() + hw_priv_mic = Gtk.Template.Child() + hw_priv_mic_sw = Gtk.Template.Child() + + hw_fp_pwr = Gtk.Template.Child() + hw_fp_pwr_en = Gtk.Template.Child() + hw_fp_pwr_dis = Gtk.Template.Child() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def setup(self, app): + # Fingerprint Power (Untested) + if ec_commands.general.get_cmd_versions( + app.cros_ec, ec_commands.framework_laptop.EC_CMD_FP_CONTROL + ): + self.hw_fp_pwr_en.connect( + "clicked", + lambda _: ec_commands.framework_laptop.fp_control(app.cros_ec, True), + ) + + self.hw_fp_pwr_dis.connect( + "clicked", + lambda _: ec_commands.framework_laptop.fp_control(app.cros_ec, False), + ) + self.hw_fp_pwr.set_visible(True) + else: + self.hw_fp_pwr.set_visible(False) + + # Schedule _update_hardware to run every second + GLib.timeout_add_seconds(1, self._update_hardware, app) + + def _update_hardware(self, app): + success = False + # Chassis + if not ec_commands.framework_laptop.EC_CMD_CHASSIS_INTRUSION in app.no_support: + try: + ec_chassis = ec_commands.framework_laptop.get_chassis_intrusion( + app.cros_ec + ) + + self.hw_chassis_label.set_label(str(ec_chassis["total_open_count"])) + + ec_chassis_open = ec_commands.framework_laptop.get_chassis_open_check( + app.cros_ec + ) + self.hw_chassis.set_subtitle( + "Currently " + ("Open" if ec_chassis_open else "Closed") + ) + + success = True + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append( + ec_commands.framework_laptop.EC_CMD_CHASSIS_INTRUSION + ) + self.hw_chassis.set_visible(False) + else: + raise e + + # Privacy Switches + if ( + not ec_commands.framework_laptop.EC_CMD_PRIVACY_SWITCHES_CHECK_MODE + in app.no_support + ): + try: + ec_privacy = ec_commands.framework_laptop.get_privacy_switches( + app.cros_ec + ) + self.hw_priv_cam_sw.set_active(ec_privacy["camera"]) + self.hw_priv_mic_sw.set_active(ec_privacy["microphone"]) + + success = True + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append( + ec_commands.framework_laptop.EC_CMD_PRIVACY_SWITCHES_CHECK_MODE + ) + self.hw_priv_cam.set_visible(False) + self.hw_priv_mic.set_visible(False) + else: + raise e + + return app.current_page == 3 and success diff --git a/yafi/leds.py b/yafi/leds.py new file mode 100644 index 0000000..94c71ab --- /dev/null +++ b/yafi/leds.py @@ -0,0 +1,154 @@ +# leds.py +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from gi.repository import Adw +from gi.repository import Gtk + +import cros_ec_python.commands as ec_commands +import cros_ec_python.exceptions as ec_exceptions + +@Gtk.Template(resource_path='/au/stevetech/yafi/ui/leds.ui') +class LedsPage(Gtk.Box): + __gtype_name__ = 'LedsPage' + + led_pwr = Gtk.Template.Child() + led_pwr_scale = Gtk.Template.Child() + + led_kbd = Gtk.Template.Child() + led_kbd_scale = Gtk.Template.Child() + + led_advanced = Gtk.Template.Child() + + led_pwr_colour = Gtk.Template.Child() + + led_chg_colour = Gtk.Template.Child() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def setup(self, app): + # Power LED + try: + + def handle_led_pwr(scale): + value = int(abs(scale.get_value() - 2)) + ec_commands.framework_laptop.set_fp_led_level(app.cros_ec, value) + self.led_pwr.set_subtitle(["High", "Medium", "Low"][value]) + + current_fp_level = ec_commands.framework_laptop.get_fp_led_level( + app.cros_ec + ).value + self.led_pwr_scale.set_value(abs(current_fp_level - 2)) + self.led_pwr.set_subtitle(["High", "Medium", "Low"][current_fp_level]) + self.led_pwr_scale.connect("value-changed", handle_led_pwr) + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append(ec_commands.framework_laptop.EC_CMD_FP_LED_LEVEL) + self.led_pwr.set_visible(False) + else: + raise e + + # Keyboard backlight + if ec_commands.general.get_cmd_versions( + app.cros_ec, ec_commands.pwm.EC_CMD_PWM_SET_KEYBOARD_BACKLIGHT + ): + + def handle_led_kbd(scale): + value = int(scale.get_value()) + ec_commands.pwm.pwm_set_keyboard_backlight(app.cros_ec, value) + self.led_kbd.set_subtitle(f"{value} %") + + current_kb_level = ec_commands.pwm.pwm_get_keyboard_backlight(app.cros_ec)[ + "percent" + ] + self.led_kbd_scale.set_value(current_kb_level) + self.led_kbd.set_subtitle(f"{current_kb_level} %") + self.led_kbd_scale.connect("value-changed", handle_led_kbd) + else: + self.led_kbd.set_visible(False) + + # Advanced options + if ec_commands.general.get_cmd_versions( + app.cros_ec, ec_commands.leds.EC_CMD_LED_CONTROL + ): + + # Advanced: Power LED + led_pwr_colour_strings = self.led_pwr_colour.get_model() + + all_colours = ["Red", "Green", "Blue", "Yellow", "White", "Amber"] + + def add_colours(strings, led_id): + supported_colours = ec_commands.leds.led_control_get_max_values( + app.cros_ec, led_id + ) + for i, colour in enumerate(all_colours): + if supported_colours[i]: + strings.append(colour) + + add_colours( + led_pwr_colour_strings, ec_commands.leds.EcLedId.EC_LED_ID_POWER_LED + ) + + def handle_led_colour(combobox, led_id): + colour = combobox.get_selected() - 2 + match colour: + case -2: # Auto + ec_commands.leds.led_control_set_auto(app.cros_ec, led_id) + case -1: # Off + ec_commands.leds.led_control( + app.cros_ec, + led_id, + 0, + [0] * ec_commands.leds.EcLedColors.EC_LED_COLOR_COUNT.value, + ) + case _: # Colour + colour_idx = all_colours.index( + combobox.get_selected_item().get_string() + ) + ec_commands.leds.led_control_set_color( + app.cros_ec, + led_id, + 100, + ec_commands.leds.EcLedColors(colour_idx), + ) + + self.led_pwr_colour.connect( + "notify::selected", + lambda combo, _: handle_led_colour( + combo, ec_commands.leds.EcLedId.EC_LED_ID_POWER_LED + ), + ) + + # Advanced: Charging LED + led_chg_colour_strings = self.led_chg_colour.get_model() + + add_colours( + led_chg_colour_strings, + ec_commands.leds.EcLedId.EC_LED_ID_BATTERY_LED, + ) + + self.led_chg_colour.connect( + "notify::selected", + lambda combo, _: handle_led_colour( + combo, ec_commands.leds.EcLedId.EC_LED_ID_BATTERY_LED + ), + ) + else: + self.led_advanced.set_visible(False) diff --git a/yafi/main.py b/yafi/main.py new file mode 100644 index 0000000..18b0adf --- /dev/null +++ b/yafi/main.py @@ -0,0 +1,140 @@ +# main.py +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import sys +import traceback +import gi +import os +from gi.repository import Gio + +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') + +from gi.repository import Gtk, Gio, Adw +from .window import YafiWindow +from .thermals import ThermalsPage +from .leds import LedsPage +from .battery import BatteryPage +from .hardware import HardwarePage + +from cros_ec_python import get_cros_ec + +class YafiApplication(Adw.Application): + """The main application singleton class.""" + + def __init__(self): + super().__init__(application_id='au.stevetech.yafi', + flags=Gio.ApplicationFlags.DEFAULT_FLAGS, + resource_base_path='/au/stevetech/yafi') + + self.current_page = 0 + self.no_support = [] + self.cros_ec = None + self.win = None + + def change_page(self, content, page): + page.setup(self) + while content_child := content.get_last_child(): + content.remove(content_child) + content.append(page) + + def do_activate(self): + """Called when the application is activated. + + We raise the application's main window, creating it if + necessary. + """ + self.win = self.props.active_window + if not self.win: + self.win = YafiWindow(application=self) + + try: + self.cros_ec = get_cros_ec() + pass + except Exception as e: + traceback.print_exc() + + message = ( + str(e) + + "\n\n" + + "This application only supports Framework Laptops.\n" + + "If you are using a Framework Laptop, there are additional troubleshooting steps in the README." + ) + self.show_error("EC Initalisation Error", message) + self.win.present() + return + + self.change_page(self.win.content, ThermalsPage()) + + pages = ( + ("Thermals", ThermalsPage()), + ("LEDs", LedsPage()), + ("Battery", BatteryPage()), + ("Hardware", HardwarePage()), + ("About", None), + ) + + # Build the navbar + for page in pages: + row = Gtk.ListBoxRow() + row.set_child(Gtk.Label(label=page[0])) + self.win.navbar.append(row) + + def switch_page(page): + # About page is a special case + if pages[page][1]: + self.current_page = page + self.change_page(self.win.content, pages[page][1]) + else: + self.on_about_action() + + self.win.navbar.connect("row-activated", lambda box, row: switch_page(row.get_index())) + + self.win.present() + + def on_about_action(self, *args): + """Callback for the app.about action.""" + about = Adw.AboutDialog( + application_icon="au.stevetech.yafi", + application_name="Yet Another Framework Interface", + comments="YAFI is another GUI for the Framework Laptop Embedded Controller.\n" + + "It is written in Python with a GTK3 theme, and uses the `CrOS_EC_Python` library to communicate with the EC.", + copyright="© 2025 Stephen Horvath", + developer_name="Stephen Horvath", + developers=["Stephen Horvath"], + issue_url="https://github.com/Steve-Tech/YAFI/issues", + license_type=Gtk.License.GPL_2_0, + version="0.1.0", + website="https://github.com/Steve-Tech/YAFI", + ) + about.add_acknowledgement_section(None, ["Framework Computer Inc. https://frame.work/"]) + about.present(self.props.active_window) + + def show_error(self, heading, message): + dialog = Adw.AlertDialog(heading=heading, body=message) + dialog.add_response("exit", "Exit") + dialog.connect("response", lambda d, r: self.win.destroy()) + dialog.present(self.win) + + +def main(version): + """The application's entry point.""" + app = YafiApplication() + return app.run(sys.argv) diff --git a/yafi/meson.build b/yafi/meson.build new file mode 100644 index 0000000..42f300e --- /dev/null +++ b/yafi/meson.build @@ -0,0 +1,39 @@ +pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name() +moduledir = pkgdatadir / 'yafi' +gnome = import('gnome') + +gnome.compile_resources('yafi', + 'yafi.gresource.xml', + gresource_bundle: true, + install: true, + install_dir: pkgdatadir, +) + +python = import('python') + +conf = configuration_data() +conf.set('PYTHON', python.find_installation('python3').full_path()) +conf.set('VERSION', meson.project_version()) +conf.set('localedir', get_option('prefix') / get_option('localedir')) +conf.set('pkgdatadir', pkgdatadir) + +configure_file( + input: 'yafi.in', + output: 'yafi', + configuration: conf, + install: true, + install_dir: get_option('bindir'), + install_mode: 'r-xr-xr-x' +) + +yafi_sources = [ + '__init__.py', + 'main.py', + 'window.py', + 'thermals.py', + 'leds.py', + 'battery.py', + 'hardware.py', +] + +install_data(yafi_sources, install_dir: moduledir) diff --git a/yafi/thermals.py b/yafi/thermals.py new file mode 100644 index 0000000..ca54600 --- /dev/null +++ b/yafi/thermals.py @@ -0,0 +1,145 @@ +# thermals.py +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from gi.repository import Adw +from gi.repository import Gtk +from gi.repository import GLib + +import cros_ec_python.commands as ec_commands +import cros_ec_python.exceptions as ec_exceptions + +@Gtk.Template(resource_path='/au/stevetech/yafi/ui/thermals.ui') +class ThermalsPage(Gtk.Box): + __gtype_name__ = 'ThermalsPage' + + fan_rpm = Gtk.Template.Child() + fan_mode = Gtk.Template.Child() + fan_set_rpm = Gtk.Template.Child() + fan_set_percent = Gtk.Template.Child() + fan_percent_scale = Gtk.Template.Child() + + temperatures = Gtk.Template.Child() + temp_items = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def setup(self, app): + # Don't let the user change the fans if they can't get back to auto + if ec_commands.general.get_cmd_versions( + app.cros_ec, ec_commands.thermal.EC_CMD_THERMAL_AUTO_FAN_CTRL + ): + + def handle_fan_mode(mode): + match mode: + case 0: # Auto + self.fan_set_rpm.set_visible(False) + self.fan_set_percent.set_visible(False) + ec_commands.thermal.thermal_auto_fan_ctrl(app.cros_ec) + case 1: # Percent + self.fan_set_rpm.set_visible(False) + self.fan_set_percent.set_visible(True) + case 2: # RPM + self.fan_set_rpm.set_visible(True) + self.fan_set_percent.set_visible(False) + + self.fan_mode.connect( + "notify::selected", + lambda combo, _: handle_fan_mode(combo.get_selected()), + ) + + if ec_commands.general.get_cmd_versions( + app.cros_ec, ec_commands.pwm.EC_CMD_PWM_SET_FAN_DUTY + ): + + def handle_fan_percent(scale): + percent = int(scale.get_value()) + ec_commands.pwm.pwm_set_fan_duty(app.cros_ec, percent) + self.fan_set_percent.set_subtitle(f"{percent} %") + + self.fan_percent_scale.connect("value-changed", handle_fan_percent) + else: + self.fan_set_percent.set_sensitive(False) + + if ec_commands.general.get_cmd_versions( + app.cros_ec, ec_commands.pwm.EC_CMD_PWM_SET_FAN_TARGET_RPM + ): + + def handle_fan_rpm(entry): + rpm = int(entry.get_text()) + ec_commands.pwm.pwm_set_fan_rpm(app.cros_ec, rpm) + + self.fan_set_rpm.connect( + "notify::text", lambda entry, _: handle_fan_rpm(entry) + ) + else: + self.fan_set_rpm.set_sensitive(False) + else: + self.fan_mode.set_sensitive(False) + + # Temperature sensors + try: + ec_temp_sensors = ec_commands.thermal.get_temp_sensors(app.cros_ec) + except ec_exceptions.ECError as e: + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + # Generate some labels if the command is not supported + ec_temp_sensors = {} + temps = ec_commands.memmap.get_temps(app.cros_ec) + for i, temp in enumerate(temps): + ec_temp_sensors[f"Sensor {i}"] = (temp, None) + else: + raise e + + for key, value in ec_temp_sensors.items(): + new_row = Adw.ActionRow(title=key, subtitle=f"{value[0]}°C") + new_row.add_css_class("property") + self.temperatures.append(new_row) + self.temp_items.append(new_row) + + self._update_thermals(app) + + # Schedule _update_thermals to run every second + GLib.timeout_add_seconds(1, self._update_thermals, app) + + def _update_thermals(self, app): + # memmap reads should always be supported + ec_fans = ec_commands.memmap.get_fans(app.cros_ec) + self.fan_rpm.set_subtitle(f"{ec_fans[0]} RPM") + + ec_temp_sensors = ec_commands.memmap.get_temps(app.cros_ec) + # The temp sensors disappear sometimes, so we need to handle that + for i in range(min(len(self.temp_items), len(ec_temp_sensors))): + self.temp_items[i].set_subtitle(f"{ec_temp_sensors[i]}°C") + + # Check if this has already failed and skip if it has + if not ec_commands.pwm.EC_CMD_PWM_GET_FAN_TARGET_RPM in app.no_support: + try: + ec_target_rpm = ec_commands.pwm.pwm_get_fan_rpm(app.cros_ec) + self.fan_set_rpm.set_subtitle(f"{ec_target_rpm} RPM") + except ec_exceptions.ECError as e: + # If the command is not supported, we can ignore it + if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: + app.no_support.append(ec_commands.pwm.EC_CMD_PWM_GET_FAN_TARGET_RPM) + self.fan_set_rpm.set_subtitle("") + else: + # If it's another error, we should raise it + raise e + + return app.current_page == 0 diff --git a/yafi/ui/about.ui b/yafi/ui/about.ui deleted file mode 100644 index b3a4f03..0000000 --- a/yafi/ui/about.ui +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - application-default-icon - Yet Another Framework Interface - YAFI is another GUI for the Framework Laptop Embedded Controller. -It is written in Python with a GTK3 theme, and uses the `CrOS_EC_Python` library to communicate with the EC. - Copyright © 2025 Stephen Horvath - Stephen Horvath (Steve-Tech) - https://github.com/Steve-Tech/YAFI/issues - gpl-2-0 - 0.1 - https://github.com/Steve-Tech/YAFI - - diff --git a/yafi/ui/battery.ui b/yafi/ui/battery.ui index b432c46..9680064 100644 --- a/yafi/ui/battery.ui +++ b/yafi/ui/battery.ui @@ -5,7 +5,7 @@ - + diff --git a/yafi/ui/hardware.ui b/yafi/ui/hardware.ui index 3fb13c7..48f4ea7 100644 --- a/yafi/ui/hardware.ui +++ b/yafi/ui/hardware.ui @@ -5,7 +5,7 @@ - + diff --git a/yafi/ui/leds.ui b/yafi/ui/leds.ui index 550e99f..968b95f 100644 --- a/yafi/ui/leds.ui +++ b/yafi/ui/leds.ui @@ -5,7 +5,7 @@ - + diff --git a/yafi/ui/thermals.ui b/yafi/ui/thermals.ui index 95edcd9..c006752 100644 --- a/yafi/ui/thermals.ui +++ b/yafi/ui/thermals.ui @@ -3,97 +3,102 @@ - + - - - - - - False - Temperatures - - - - - - - vertical - - - 10 - 10 + diff --git a/yafi/ui/yafi.cmb b/yafi/ui/yafi.cmb index 55b8a3b..4293ac3 100644 --- a/yafi/ui/yafi.cmb +++ b/yafi/ui/yafi.cmb @@ -2,15 +2,14 @@ - (1,None,"yafi.ui","yafi.ui","YAFI is another GUI for the Framework Laptop Embedded Controller.",None,"Steve-Tech",None,None,None,None), - (2,None,None,"thermals.ui","The Thermals page for YAFI",None,"Steve-Tech",None,None,None,None), - (3,None,None,"leds.ui","The LEDs page for YAFI",None,"Steve-Tech",None,None,None,None), - (4,None,None,"battery.ui","The Battery page for YAFI",None,"Steve-Tech",None,None,None,None), - (5,None,None,"hardware.ui","The Hardware page for YAFI","Steve-Tech",None,None,None,None,None), - (6,None,None,"about.ui","The About page for YAFI","Steve-Tech",None,None,None,None,None) + (1,1,"yafi.ui","yafi.ui","YAFI is another GUI for the Framework Laptop Embedded Controller.",None,"Steve-Tech",None,None,None,None), + (2,17,None,"thermals.ui","The Thermals page for YAFI",None,"Steve-Tech",None,None,None,None), + (3,4,None,"leds.ui","The LEDs page for YAFI",None,"Steve-Tech",None,None,None,None), + (4,1,None,"battery.ui","The Battery page for YAFI",None,"Steve-Tech",None,None,None,None), + (5,1,None,"hardware.ui","The Hardware page for YAFI","Steve-Tech",None,None,None,None,None) - (1,1,"AdwApplicationWindow","root",None,None,None,None,0,None,None), + (1,1,"AdwApplicationWindow","YafiWindow",None,None,None,None,0,None,None), (1,2,"AdwNavigationSplitView",None,1,None,None,None,0,None,None), (1,6,"AdwNavigationPage",None,2,None,None,None,0,None,None), (1,7,"AdwNavigationPage",None,2,None,None,None,1,None,None), @@ -22,79 +21,79 @@ (1,17,"GtkListBox","navbar",16,None,None,None,0,None,None), (1,52,"GtkBox","content",53,None,None,None,0,None,None), (1,53,"GtkScrolledWindow",None,11,None,None,None,1,None,None), - (2,2,"GtkPaned","thermals-root",None,None,None,None,1,None,None), + (2,2,"GtkPaned",None,17,None,None,None,0,None,None), (2,3,"GtkListBox","temperatures",2,None,None,None,1,None,None), (2,4,"AdwActionRow",None,3,None,None,None,0,None,None), (2,5,"GtkBox",None,2,None,None,None,0,None,None), (2,6,"GtkListBox",None,5,None,None,None,0,None,None), (2,7,"AdwActionRow",None,6,None,None,None,0,None,None), - (2,8,"AdwActionRow","fan-rpm",6,None,None,None,1,None,None), - (2,9,"AdwComboRow","fan-mode",6,None,None,None,2,None,None), + (2,8,"AdwActionRow","fan_rpm",6,None,None,None,1,None,None), + (2,9,"AdwComboRow","fan_mode",6,None,None,None,2,None,None), (2,10,"GtkStringList",None,9,None,None,None,0,None,None), - (2,11,"AdwActionRow","fan-set-percent",6,None,None,None,3,None,None), + (2,11,"AdwActionRow","fan_set_percent",6,None,None,None,3,None,None), (2,12,"GtkBox",None,11,None,None,None,0,None,None), - (2,13,"GtkScale","fan-percent-scale",12,None,None,None,0,None,None), + (2,13,"GtkScale","fan_percent_scale",12,None,None,None,0,None,None), (2,14,"GtkAdjustment",None,13,None,None,None,0,None,None), - (2,15,"AdwSpinRow","fan-set-rpm",6,None,None,None,4,None,None), + (2,15,"AdwSpinRow","fan_set_rpm",6,None,None,None,4,None,None), (2,16,"GtkAdjustment",None,15,None,None,None,0,None,None), - (3,4,"GtkBox","leds-root",None,None,None,None,1,None,None), + (2,17,"GtkBox","ThermalsPage",None,None,None,None,1,None,None), + (3,4,"GtkBox","LedsPage",None,None,None,None,1,None,None), (3,5,"GtkListBox",None,4,None,None,None,0,None,None), (3,6,"AdwActionRow",None,5,None,None,None,0,None,None), - (3,10,"AdwActionRow","led-pwr",5,None,None,None,1,None,None), + (3,10,"AdwActionRow","led_pwr",5,None,None,None,1,None,None), (3,11,"GtkBox",None,10,None,None,None,0,None,None), - (3,12,"GtkScale","led-pwr-scale",11,None,None,None,0,None,None), + (3,12,"GtkScale","led_pwr_scale",11,None,None,None,0,None,None), (3,13,"GtkAdjustment",None,12,None,None,None,0,None,None), - (3,14,"AdwActionRow","led-kbd",5,None,None,None,2,None,None), + (3,14,"AdwActionRow","led_kbd",5,None,None,None,2,None,None), (3,15,"GtkBox",None,14,None,None,None,0,None,None), - (3,16,"GtkScale","led-kbd-scale",15,None,None,None,0,None,None), + (3,16,"GtkScale","led_kbd_scale",15,None,None,None,0,None,None), (3,17,"GtkAdjustment",None,16,None,None,None,0,None,None), - (3,18,"AdwExpanderRow",None,5,None,None,None,3,None,None), - (3,23,"AdwComboRow","led-pwr-colour",31,None,None,None,0,None,None), + (3,18,"AdwExpanderRow","led_advanced",5,None,None,None,3,None,None), + (3,23,"AdwComboRow","led_pwr_colour",31,None,None,None,0,None,None), (3,24,"GtkStringList",None,23,None,None,None,0,None,None), - (3,25,"AdwComboRow","led-chg-colour",32,None,None,None,0,None,None), + (3,25,"AdwComboRow","led_chg_colour",32,None,None,None,0,None,None), (3,26,"GtkStringList",None,25,None,None,None,0,None,None), (3,31,"AdwPreferencesGroup",None,18,None,None,None,0,None,None), (3,32,"AdwPreferencesGroup",None,18,None,None,None,1,None,None), - (4,1,"GtkBox","battery-root",None,None,None,None,0,None,None), + (4,1,"GtkBox","BatteryPage",None,None,None,None,0,None,None), (4,2,"GtkListBox",None,1,None,None,None,0,None,None), (4,3,"AdwActionRow",None,2,None,None,None,0,None,None), - (4,4,"AdwActionRow","chg-limit",2,None,None,None,2,None,None), + (4,4,"AdwActionRow","chg_limit",2,None,None,None,2,None,None), (4,5,"GtkBox",None,4,None,None,None,0,None,None), - (4,6,"GtkScale","chg-limit-scale",5,None,None,None,0,None,None), + (4,6,"GtkScale","chg_limit_scale",5,None,None,None,0,None,None), (4,7,"GtkAdjustment",None,6,None,None,None,0,None,None), - (4,13,"AdwPreferencesGroup","bat-ext-group",2,None,None,None,5,None,None), - (4,27,"AdwSpinRow","bat-ext-trigger",13,None,None,None,5,None,None), + (4,13,"AdwPreferencesGroup","bat_ext_group",2,None,None,None,5,None,None), + (4,27,"AdwSpinRow","bat_ext_trigger",13,None,None,None,5,None,None), (4,28,"GtkAdjustment",None,27,None,None,None,0,None,None), - (4,29,"AdwSpinRow","bat-ext-reset",13,None,None,None,6,None,None), + (4,29,"AdwSpinRow","bat_ext_reset",13,None,None,None,6,None,None), (4,30,"GtkAdjustment",None,29,None,None,None,0,None,None), - (4,31,"AdwActionRow","bat-ext-stage",13,None,None,None,2,None,None), - (4,32,"AdwSwitchRow","bat-ext-enable",13,None,None,None,1,None,None), - (4,33,"AdwActionRow","bat-limit",2,None,None,None,3,None,None), + (4,31,"AdwActionRow","bat_ext_stage",13,None,None,None,2,None,None), + (4,32,"AdwSwitchRow","bat_ext_enable",13,None,None,None,1,None,None), + (4,33,"AdwActionRow","bat_limit",2,None,None,None,3,None,None), (4,34,"GtkBox",None,33,None,None,None,0,None,None), - (4,35,"GtkScale","bat-limit-scale",34,None,None,None,0,None,None), + (4,35,"GtkScale","bat_limit_scale",34,None,None,None,0,None,None), (4,36,"GtkAdjustment",None,35,None,None,None,0,None,None), - (4,37,"AdwActionRow","chg-limit-override",2,None,None,None,4,None,None), + (4,37,"AdwActionRow","chg_limit_override",2,None,None,None,4,None,None), (4,38,"GtkBox",None,37,None,None,None,0,None,None), - (4,39,"GtkButton","chg-limit-override-btn",38,None,None,None,0,None,None), - (4,40,"AdwSwitchRow","chg-limit-enable",2,None,None,None,1,None,None), - (4,41,"AdwActionRow","bat-ext-trigger-time",13,None,None,None,3,None,None), - (4,42,"AdwActionRow","bat-ext-reset-time",13,None,None,None,4,None,None), - (5,1,"GtkBox","hardware-root",None,None,None,None,0,None,None), + (4,39,"GtkButton","chg_limit_override_btn",38,None,None,None,0,None,None), + (4,40,"AdwSwitchRow","chg_limit_enable",2,None,None,None,1,None,None), + (4,41,"AdwActionRow","bat_ext_trigger_time",13,None,None,None,3,None,None), + (4,42,"AdwActionRow","bat_ext_reset_time",13,None,None,None,4,None,None), + (5,1,"GtkBox","HardwarePage",None,None,None,None,0,None,None), (5,2,"GtkListBox",None,1,None,None,None,0,None,None), (5,3,"AdwActionRow",None,2,None,None,None,0,None,None), - (5,4,"AdwActionRow","hw-chassis",2,None,None,None,1,None,None), - (5,5,"GtkLabel","hw-chassis-label",4,None,None,None,0,None,None), - (5,6,"AdwActionRow","hw-priv-cam",2,None,None,None,2,None,None), - (5,9,"AdwActionRow","hw-priv-mic",2,None,None,None,3,None,None), - (5,11,"GtkSwitch","hw-priv-mic-sw",13,None,None,None,0,None,None), + (5,4,"AdwActionRow","hw_chassis",2,None,None,None,1,None,None), + (5,5,"GtkLabel","hw_chassis_label",4,None,None,None,0,None,None), + (5,6,"AdwActionRow","hw_priv_cam",2,None,None,None,2,None,None), + (5,9,"AdwActionRow","hw_priv_mic",2,None,None,None,3,None,None), + (5,11,"GtkSwitch","hw_priv_mic_sw",13,None,None,None,0,None,None), (5,13,"GtkBox",None,9,None,None,None,0,None,None), (5,14,"GtkBox",None,6,None,None,None,0,None,None), - (5,15,"GtkSwitch","hw-priv-cam-sw",14,None,None,None,0,None,None), - (5,16,"AdwActionRow","hw-fp-pwr",2,None,None,None,4,None,None), + (5,15,"GtkSwitch","hw_priv_cam_sw",14,None,None,None,0,None,None), + (5,16,"AdwActionRow","hw_fp_pwr",2,None,None,None,4,None,None), (5,17,"GtkBox",None,16,None,None,None,0,None,None), - (5,18,"GtkButton","hw-fp-pwr-en",17,None,None,None,0,None,None), - (5,19,"GtkButton","hw-fp-pwr-dis",17,None,None,None,1,None,None), - (6,1,"AdwAboutWindow","about-root",None,None,None,None,0,None,None) + (5,18,"GtkButton","hw_fp_pwr_en",17,None,None,None,0,None,None), + (5,19,"GtkButton","hw_fp_pwr_dis",17,None,None,None,1,None,None) (1,1,"GtkWindow","default-height","500",None,None,None,None,None,None,None,None,None), @@ -132,6 +131,7 @@ (2,16,"GtkAdjustment","page-increment","1000.0",0,None,None,None,None,None,None,None,None), (2,16,"GtkAdjustment","step-increment","100.0",0,None,None,None,None,None,None,None,None), (2,16,"GtkAdjustment","upper","65535.0",0,None,None,None,None,None,None,None,None), + (2,17,"GtkBox","homogeneous","True",None,None,None,None,None,None,None,None,None), (3,4,"GtkOrientable","orientation","vertical",0,None,None,None,None,None,None,None,None), (3,5,"GtkWidget","margin-end","10",0,None,None,None,None,None,None,None,None), (3,5,"GtkWidget","margin-start","10",0,None,None,None,None,None,None,None,None), @@ -265,16 +265,7 @@ (5,17,"GtkWidget","halign","end",0,None,None,None,None,None,None,None,None), (5,17,"GtkWidget","valign","center",0,None,None,None,None,None,None,None,None), (5,18,"GtkButton","label","Enable",None,None,None,None,None,None,None,None,None), - (5,19,"GtkButton","label","Disable",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","application-icon","application-default-icon",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","application-name","Yet Another Framework Interface",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","comments","YAFI is another GUI for the Framework Laptop Embedded Controller.\nIt is written in Python with a GTK3 theme, and uses the `CrOS_EC_Python` library to communicate with the EC.",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","copyright","Copyright © 2025 Stephen Horvath",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","developers","Stephen Horvath (Steve-Tech)",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","issue-url","https://github.com/Steve-Tech/YAFI/issues",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","license-type","gpl-2-0",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","version","0.1",None,None,None,None,None,None,None,None,None), - (6,1,"AdwAboutWindow","website","https://github.com/Steve-Tech/YAFI",None,None,None,None,None,None,None,None,None) + (5,19,"GtkButton","label","Disable",None,None,None,None,None,None,None,None,None) (1,17,"GtkWidget",1,1,None,None,None,None,None,None), diff --git a/yafi/ui/yafi.ui b/yafi/ui/yafi.ui index c7b23b8..809d191 100644 --- a/yafi/ui/yafi.ui +++ b/yafi/ui/yafi.ui @@ -6,7 +6,7 @@ - + diff --git a/yafi/window.py b/yafi/window.py new file mode 100644 index 0000000..a6dd1c1 --- /dev/null +++ b/yafi/window.py @@ -0,0 +1,32 @@ +# window.py +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from gi.repository import Adw +from gi.repository import Gtk + +@Gtk.Template(resource_path='/au/stevetech/yafi/ui/yafi.ui') +class YafiWindow(Adw.ApplicationWindow): + __gtype_name__ = 'YafiWindow' + + content = Gtk.Template.Child() + navbar = Gtk.Template.Child() + + def __init__(self, **kwargs): + super().__init__(**kwargs) diff --git a/yafi/yafi.gresource.xml b/yafi/yafi.gresource.xml new file mode 100644 index 0000000..90915f0 --- /dev/null +++ b/yafi/yafi.gresource.xml @@ -0,0 +1,10 @@ + + + + ui/yafi.ui + ui/thermals.ui + ui/leds.ui + ui/battery.ui + ui/hardware.ui + + diff --git a/yafi/yafi.in b/yafi/yafi.in new file mode 100755 index 0000000..4a76788 --- /dev/null +++ b/yafi/yafi.in @@ -0,0 +1,47 @@ +#!@PYTHON@ + +# yafi.in +# +# Copyright 2025 Stephen Horvath +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import os +import sys +import signal +import locale +import gettext + +VERSION = '@VERSION@' +pkgdatadir = '@pkgdatadir@' +localedir = '@localedir@' + +sys.path.insert(1, pkgdatadir) +signal.signal(signal.SIGINT, signal.SIG_DFL) +locale.bindtextdomain('yafi', localedir) +locale.textdomain('yafi') +gettext.install('yafi', localedir) + +if __name__ == '__main__': + import gi + + from gi.repository import Gio + resource = Gio.Resource.load(os.path.join(pkgdatadir, 'yafi.gresource')) + resource._register() + + from yafi import main + sys.exit(main.main(VERSION)) diff --git a/yafi/yafi.py b/yafi/yafi.py deleted file mode 100644 index 22da5ed..0000000 --- a/yafi/yafi.py +++ /dev/null @@ -1,659 +0,0 @@ -import sys -import os -import gi -import traceback - -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw, GLib - -from cros_ec_python import get_cros_ec -import cros_ec_python.commands as ec_commands -import cros_ec_python.exceptions as ec_exceptions - - -class YAFI(Adw.Application): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.script_dir = os.path.dirname(os.path.abspath(__file__)) - self.no_support = [] - - try: - self.cros_ec = get_cros_ec() - - self.connect("activate", self.on_activate) - except Exception as e: - traceback.print_exc() - - message = ( - str(e) + "\n\n" + "This application only supports Framework Laptops.\n" + - "If you are using a Framework Laptop, there are additional troubleshooting steps in the README." - ) - - dialog = Adw.AlertDialog(heading="EC Initalisation Error", body=message) - dialog.add_response("exit", "Exit") - dialog.connect("response", lambda d, r: self.win.destroy()) - - self.connect( - "activate", - lambda app: self.minimal_activate( - app, lambda: dialog.present(self.win) - ), - ) - - def _change_page(self, builder, page): - content = builder.get_object("content") - while content_child := content.get_last_child(): - content.remove(content_child) - content.append(page) - - def _update_thermals(self, fan_rpm, temp_items, fan_rpm_target): - # memmap reads should always be supported - ec_fans = ec_commands.memmap.get_fans(self.cros_ec) - fan_rpm.set_subtitle(f"{ec_fans[0]} RPM") - - ec_temp_sensors = ec_commands.memmap.get_temps(self.cros_ec) - # The temp sensors disappear sometimes, so we need to handle that - for i in range(min(len(temp_items), len(ec_temp_sensors))): - temp_items[i].set_subtitle(f"{ec_temp_sensors[i]}°C") - - # Check if this has already failed and skip if it has - if not ec_commands.pwm.EC_CMD_PWM_GET_FAN_TARGET_RPM in self.no_support: - try: - ec_target_rpm = ec_commands.pwm.pwm_get_fan_rpm(self.cros_ec) - fan_rpm_target.set_subtitle(f"{ec_target_rpm} RPM") - except ec_exceptions.ECError as e: - # If the command is not supported, we can ignore it - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append( - ec_commands.pwm.EC_CMD_PWM_GET_FAN_TARGET_RPM - ) - fan_rpm_target.set_subtitle("") - else: - # If it's another error, we should raise it - raise e - - return self.current_page == 0 - - def _thermals_page(self, builder): - # Load the thermals.ui file - thermals_builder = Gtk.Builder() - thermals_builder.add_from_file(os.path.join(self.script_dir, "ui/thermals.ui")) - - # Get the root widget from the thermals.ui file - thermals_root = thermals_builder.get_object("thermals-root") - - self._change_page(builder, thermals_root) - - # Fan control - fan_rpm = thermals_builder.get_object("fan-rpm") - fan_mode = thermals_builder.get_object("fan-mode") - fan_set_rpm = thermals_builder.get_object("fan-set-rpm") - fan_set_percent = thermals_builder.get_object("fan-set-percent") - fan_percent_scale = thermals_builder.get_object("fan-percent-scale") - - # Don't let the user change the fans if they can't get back to auto - if ec_commands.general.get_cmd_versions( - self.cros_ec, ec_commands.thermal.EC_CMD_THERMAL_AUTO_FAN_CTRL - ): - - def handle_fan_mode(mode): - match mode: - case 0: # Auto - fan_set_rpm.set_visible(False) - fan_set_percent.set_visible(False) - ec_commands.thermal.thermal_auto_fan_ctrl(self.cros_ec) - case 1: # Percent - fan_set_rpm.set_visible(False) - fan_set_percent.set_visible(True) - case 2: # RPM - fan_set_rpm.set_visible(True) - fan_set_percent.set_visible(False) - - fan_mode.connect( - "notify::selected", - lambda combo, _: handle_fan_mode(combo.get_selected()), - ) - - if ec_commands.general.get_cmd_versions( - self.cros_ec, ec_commands.pwm.EC_CMD_PWM_SET_FAN_DUTY - ): - - def handle_fan_percent(scale): - percent = int(scale.get_value()) - ec_commands.pwm.pwm_set_fan_duty(self.cros_ec, percent) - fan_set_percent.set_subtitle(f"{percent} %") - - fan_percent_scale.connect("value-changed", handle_fan_percent) - else: - fan_set_percent.set_sensitive(False) - - if ec_commands.general.get_cmd_versions( - self.cros_ec, ec_commands.pwm.EC_CMD_PWM_SET_FAN_TARGET_RPM - ): - - def handle_fan_rpm(entry): - rpm = int(entry.get_text()) - ec_commands.pwm.pwm_set_fan_rpm(self.cros_ec, rpm) - - fan_set_rpm.connect( - "notify::text", lambda entry, _: handle_fan_rpm(entry) - ) - else: - fan_set_rpm.set_sensitive(False) - else: - fan_mode.set_sensitive(False) - - # Temperature sensors - temperatures = thermals_builder.get_object("temperatures") - temp_items = [] - try: - ec_temp_sensors = ec_commands.thermal.get_temp_sensors(self.cros_ec) - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - # Generate some labels if the command is not supported - ec_temp_sensors = {} - temps = ec_commands.memmap.get_temps(self.cros_ec) - for i, temp in enumerate(temps): - ec_temp_sensors[f"Sensor {i}"] = (temp, None) - else: - raise e - - for key, value in ec_temp_sensors.items(): - new_row = Adw.ActionRow(title=key, subtitle=f"{value[0]}°C") - new_row.add_css_class("property") - temperatures.append(new_row) - temp_items.append(new_row) - - self._update_thermals(fan_rpm, temp_items, fan_set_rpm) - - # Schedule _update_thermals to run every second - GLib.timeout_add_seconds( - 1, self._update_thermals, fan_rpm, temp_items, fan_set_rpm - ) - - def _leds_page(self, builder): - # Load the leds.ui file - leds_builder = Gtk.Builder() - leds_builder.add_from_file(os.path.join(self.script_dir, "ui/leds.ui")) - - # Get the root widget from the leds.ui file - leds_root = leds_builder.get_object("leds-root") - - self._change_page(builder, leds_root) - - # Power LED - led_pwr = leds_builder.get_object("led-pwr") - led_pwr_scale = leds_builder.get_object("led-pwr-scale") - - try: - - def handle_led_pwr(scale): - value = int(abs(scale.get_value() - 2)) - ec_commands.framework_laptop.set_fp_led_level(self.cros_ec, value) - led_pwr.set_subtitle(["High", "Medium", "Low"][value]) - - current_fp_level = ec_commands.framework_laptop.get_fp_led_level( - self.cros_ec - ).value - led_pwr_scale.set_value(abs(current_fp_level - 2)) - led_pwr.set_subtitle(["High", "Medium", "Low"][current_fp_level]) - led_pwr_scale.connect("value-changed", handle_led_pwr) - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append(ec_commands.framework_laptop.EC_CMD_FP_LED_LEVEL) - led_pwr.set_visible(False) - else: - raise e - - # Keyboard backlight - led_kbd = leds_builder.get_object("led-kbd") - led_kbd_scale = leds_builder.get_object("led-kbd-scale") - - if ec_commands.general.get_cmd_versions( - self.cros_ec, ec_commands.pwm.EC_CMD_PWM_SET_KEYBOARD_BACKLIGHT - ): - - def handle_led_kbd(scale): - value = int(scale.get_value()) - ec_commands.pwm.pwm_set_keyboard_backlight(self.cros_ec, value) - led_kbd.set_subtitle(f"{value} %") - - current_kb_level = ec_commands.pwm.pwm_get_keyboard_backlight(self.cros_ec)[ - "percent" - ] - led_kbd_scale.set_value(current_kb_level) - led_kbd.set_subtitle(f"{current_kb_level} %") - led_kbd_scale.connect("value-changed", handle_led_kbd) - else: - led_kbd.set_visible(False) - - # Advanced options - if ec_commands.general.get_cmd_versions( - self.cros_ec, ec_commands.leds.EC_CMD_LED_CONTROL - ): - - # Advanced: Power LED - led_pwr_colour = leds_builder.get_object("led-pwr-colour") - led_pwr_colour_strings = led_pwr_colour.get_model() - - all_colours = ["Red", "Green", "Blue", "Yellow", "White", "Amber"] - - def add_colours(strings, led_id): - supported_colours = ec_commands.leds.led_control_get_max_values( - self.cros_ec, led_id - ) - for i, colour in enumerate(all_colours): - if supported_colours[i]: - strings.append(colour) - - add_colours( - led_pwr_colour_strings, ec_commands.leds.EcLedId.EC_LED_ID_POWER_LED - ) - - def handle_led_colour(combobox, led_id): - colour = combobox.get_selected() - 2 - match colour: - case -2: # Auto - ec_commands.leds.led_control_set_auto(self.cros_ec, led_id) - case -1: # Off - ec_commands.leds.led_control( - self.cros_ec, - led_id, - 0, - [0] * ec_commands.leds.EcLedColors.EC_LED_COLOR_COUNT.value, - ) - case _: # Colour - colour_idx = all_colours.index( - combobox.get_selected_item().get_string() - ) - ec_commands.leds.led_control_set_color( - self.cros_ec, - led_id, - 100, - ec_commands.leds.EcLedColors(colour_idx), - ) - - led_pwr_colour.connect( - "notify::selected", - lambda combo, _: handle_led_colour( - combo, ec_commands.leds.EcLedId.EC_LED_ID_POWER_LED - ), - ) - - # Advanced: Charging LED - led_charge_colour = leds_builder.get_object("led-chg-colour") - led_charge_colour_strings = led_charge_colour.get_model() - - add_colours( - led_charge_colour_strings, - ec_commands.leds.EcLedId.EC_LED_ID_BATTERY_LED, - ) - - led_charge_colour.connect( - "notify::selected", - lambda combo, _: handle_led_colour( - combo, ec_commands.leds.EcLedId.EC_LED_ID_BATTERY_LED - ), - ) - else: - leds_builder.get_object("led-advanced").set_visible(False) - - def _format_timedelta(self, timedelta): - days = f"{timedelta.days} days, " if timedelta.days else "" - hours, remainder = divmod(timedelta.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - return days + f"{hours}:{minutes:02}:{seconds:02}" - - def _update_battery(self, bat_ext_stage, bat_ext_trigger_time, bat_ext_reset_time): - if ec_commands.framework_laptop.EC_CMD_BATTERY_EXTENDER in self.no_support: - return False - - try: - ec_extender = ec_commands.framework_laptop.get_battery_extender( - self.cros_ec - ) - - bat_ext_stage.set_subtitle(str(ec_extender["current_stage"])) - bat_ext_trigger_time.set_subtitle( - self._format_timedelta(ec_extender["trigger_timedelta"]) - ) - bat_ext_reset_time.set_subtitle( - self._format_timedelta(ec_extender["reset_timedelta"]) - ) - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append( - ec_commands.framework_laptop.EC_CMD_BATTERY_EXTENDER - ) - return False - else: - raise e - - return self.current_page == 2 - - def _battery_page(self, builder): - # Load the battery.ui file - battery_builder = Gtk.Builder() - battery_builder.add_from_file(os.path.join(self.script_dir, "ui/battery.ui")) - - # Get the root widget from the battery.ui file - battery_root = battery_builder.get_object("battery-root") - - self._change_page(builder, battery_root) - - # Charge limiter - chg_limit_enable = battery_builder.get_object("chg-limit-enable") - chg_limit = battery_builder.get_object("chg-limit") - chg_limit_scale = battery_builder.get_object("chg-limit-scale") - bat_limit = battery_builder.get_object("bat-limit") - bat_limit_scale = battery_builder.get_object("bat-limit-scale") - chg_limit_override = battery_builder.get_object("chg-limit-override") - chg_limit_override_btn = battery_builder.get_object("chg-limit-override-btn") - - try: - ec_limit = ec_commands.framework_laptop.get_charge_limit(self.cros_ec) - ec_limit_enabled = ec_limit != (0, 0) - chg_limit_enable.set_active(ec_limit_enabled) - if ec_limit_enabled: - chg_limit_scale.set_value(ec_limit[0]) - bat_limit_scale.set_value(ec_limit[1]) - chg_limit.set_sensitive(True) - bat_limit.set_sensitive(True) - chg_limit_override.set_sensitive(True) - - def handle_chg_limit_change(min, max): - ec_commands.framework_laptop.set_charge_limit( - self.cros_ec, int(min), int(max) - ) - - def handle_chg_limit_enable(switch): - active = switch.get_active() - if active: - handle_chg_limit_change( - chg_limit_scale.get_value(), bat_limit_scale.get_value() - ) - else: - ec_commands.framework_laptop.disable_charge_limit(self.cros_ec) - - chg_limit.set_sensitive(active) - bat_limit.set_sensitive(active) - chg_limit_override.set_sensitive(active) - - chg_limit_enable.connect( - "notify::active", lambda switch, _: handle_chg_limit_enable(switch) - ) - chg_limit_scale.connect( - "value-changed", - lambda scale: handle_chg_limit_change( - scale.get_value(), bat_limit_scale.get_value() - ), - ) - bat_limit_scale.connect( - "value-changed", - lambda scale: handle_chg_limit_change( - chg_limit_scale.get_value(), scale.get_value() - ), - ) - - chg_limit_override_btn.connect( - "clicked", - lambda _: ec_commands.framework_laptop.override_charge_limit( - self.cros_ec - ), - ) - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append(ec_commands.framework_laptop.EC_CMD_CHARGE_LIMIT) - chg_limit_enable.set_sensitive(False) - else: - raise e - - # Battery Extender - bat_ext_group = battery_builder.get_object("bat-ext-group") - bat_ext_enable = battery_builder.get_object("bat-ext-enable") - bat_ext_stage = battery_builder.get_object("bat-ext-stage") - bat_ext_trigger_time = battery_builder.get_object("bat-ext-trigger-time") - bat_ext_reset_time = battery_builder.get_object("bat-ext-reset-time") - bat_ext_trigger = battery_builder.get_object("bat-ext-trigger") - bat_ext_reset = battery_builder.get_object("bat-ext-reset") - - try: - ec_extender = ec_commands.framework_laptop.get_battery_extender( - self.cros_ec - ) - bat_ext_enable.set_active(not ec_extender["disable"]) - bat_ext_stage.set_sensitive(not ec_extender["disable"]) - bat_ext_trigger_time.set_sensitive(not ec_extender["disable"]) - bat_ext_reset_time.set_sensitive(not ec_extender["disable"]) - bat_ext_trigger.set_sensitive(not ec_extender["disable"]) - bat_ext_reset.set_sensitive(not ec_extender["disable"]) - - bat_ext_stage.set_subtitle(str(ec_extender["current_stage"])) - bat_ext_trigger_time.set_subtitle( - self._format_timedelta(ec_extender["trigger_timedelta"]) - ) - bat_ext_reset_time.set_subtitle( - self._format_timedelta(ec_extender["reset_timedelta"]) - ) - bat_ext_trigger.set_value(ec_extender["trigger_days"]) - bat_ext_reset.set_value(ec_extender["reset_minutes"]) - - def handle_extender_enable(switch): - active = switch.get_active() - ec_commands.framework_laptop.set_battery_extender( - self.cros_ec, - not active, - int(bat_ext_trigger.get_value()), - int(bat_ext_reset.get_value()), - ) - bat_ext_stage.set_sensitive(active) - bat_ext_trigger_time.set_sensitive(active) - bat_ext_reset_time.set_sensitive(active) - bat_ext_trigger.set_sensitive(active) - bat_ext_reset.set_sensitive(active) - - bat_ext_enable.connect( - "notify::active", lambda switch, _: handle_extender_enable(switch) - ) - bat_ext_trigger.connect( - "notify::value", - lambda scale, _: ec_commands.framework_laptop.set_battery_extender( - self.cros_ec, - not bat_ext_enable.get_active(), - int(scale.get_value()), - int(bat_ext_reset.get_value()), - ), - ) - bat_ext_reset.connect( - "notify::value", - lambda scale, _: ec_commands.framework_laptop.set_battery_extender( - self.cros_ec, - not bat_ext_enable.get_active(), - int(bat_ext_trigger.get_value()), - int(scale.get_value()), - ), - ) - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append( - ec_commands.framework_laptop.EC_CMD_BATTERY_EXTENDER - ) - bat_ext_group.set_visible(False) - else: - raise e - - # Schedule _update_battery to run every second - GLib.timeout_add_seconds( - 1, - self._update_battery, - bat_ext_stage, - bat_ext_trigger_time, - bat_ext_reset_time, - ) - - def _update_hardware(self, hardware_builder): - success = False - # Chassis - if not ec_commands.framework_laptop.EC_CMD_CHASSIS_INTRUSION in self.no_support: - hw_chassis = hardware_builder.get_object("hw-chassis") - hw_chassis_label = hardware_builder.get_object("hw-chassis-label") - try: - ec_chassis = ec_commands.framework_laptop.get_chassis_intrusion( - self.cros_ec - ) - - hw_chassis_label.set_label(str(ec_chassis["total_open_count"])) - - ec_chassis_open = ec_commands.framework_laptop.get_chassis_open_check( - self.cros_ec - ) - hw_chassis.set_subtitle( - "Currently " + ("Open" if ec_chassis_open else "Closed") - ) - - success = True - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append( - ec_commands.framework_laptop.EC_CMD_CHASSIS_INTRUSION - ) - hw_chassis.set_visible(False) - else: - raise e - - # Privacy Switches - if ( - not ec_commands.framework_laptop.EC_CMD_PRIVACY_SWITCHES_CHECK_MODE - in self.no_support - ): - hw_priv_cam = hardware_builder.get_object("hw-priv-cam") - hw_priv_mic = hardware_builder.get_object("hw-priv-mic") - hw_priv_cam_sw = hardware_builder.get_object("hw-priv-cam-sw") - hw_priv_mic_sw = hardware_builder.get_object("hw-priv-mic-sw") - - try: - ec_privacy = ec_commands.framework_laptop.get_privacy_switches( - self.cros_ec - ) - hw_priv_cam_sw.set_active(ec_privacy["camera"]) - hw_priv_mic_sw.set_active(ec_privacy["microphone"]) - - success = True - except ec_exceptions.ECError as e: - if e.ec_status == ec_exceptions.EcStatus.EC_RES_INVALID_COMMAND: - self.no_support.append( - ec_commands.framework_laptop.EC_CMD_PRIVACY_SWITCHES_CHECK_MODE - ) - hw_priv_cam.set_visible(False) - hw_priv_mic.set_visible(False) - else: - raise e - - return self.current_page == 3 and success - - def _hardware_page(self, builder): - # Load the hardware.ui file - hardware_builder = Gtk.Builder() - hardware_builder.add_from_file(os.path.join(self.script_dir, "ui/hardware.ui")) - - # Get the root widget from the hardware.ui file - hardware_root = hardware_builder.get_object("hardware-root") - - self._change_page(builder, hardware_root) - - self._update_hardware(hardware_builder) - - # Fingerprint Power - hw_fp_pwr = hardware_builder.get_object("hw-fp-pwr") - if ec_commands.general.get_cmd_versions( - self.cros_ec, ec_commands.framework_laptop.EC_CMD_FP_CONTROL - ): - hw_fp_pwr_en = hardware_builder.get_object("hw-fp-pwr-en") - hw_fp_pwr_dis = hardware_builder.get_object("hw-fp-pwr-dis") - - hw_fp_pwr_en.connect( - "clicked", - lambda _: ec_commands.framework_laptop.fp_control(self.cros_ec, True), - ) - - hw_fp_pwr_dis.connect( - "clicked", - lambda _: ec_commands.framework_laptop.fp_control(self.cros_ec, False), - ) - hw_fp_pwr.set_visible(True) - else: - hw_fp_pwr.set_visible(False) - - # Schedule _update_hardware to run every second - GLib.timeout_add_seconds(1, self._update_hardware, hardware_builder) - - def _about_page(self, app_builder): - # Open About dialog - builder = Gtk.Builder() - builder.add_from_file(os.path.join(self.script_dir, "ui/about.ui")) - - about = builder.get_object("about-root") - about.set_modal(True) - about.set_transient_for(self.win) - - # Reset the selection in the navbar - navbar = app_builder.get_object("navbar") - about.connect( - "close-request", - lambda _: navbar.select_row(navbar.get_row_at_index(self.current_page)), - ) - - about.present() - - def minimal_activate(self, app, callback): - builder = Gtk.Builder() - builder.add_from_file(os.path.join(self.script_dir, "ui/yafi.ui")) - - self.win = builder.get_object("root") - self.win.set_application(self) - self.win.present() - - callback() - - def on_activate(self, app): - builder = Gtk.Builder() - builder.add_from_file(os.path.join(self.script_dir, "ui/yafi.ui")) - - self.win = builder.get_object("root") - self.win.set_application(self) - - self.current_page = 0 - self._thermals_page(builder) - - pages = ( - ("Thermals", self._thermals_page), - ("LEDs", self._leds_page), - ("Battery", self._battery_page), - ("Hardware", self._hardware_page), - ("About", self._about_page), - ) - - # Build the navbar - navbar = builder.get_object("navbar") - for page in pages: - row = Gtk.ListBoxRow() - row.set_child(Gtk.Label(label=page[0])) - navbar.append(row) - - def switch_page(page): - # About page is a special case - if page != len(pages) - 1: - self.current_page = page - pages[page][1](builder) - - navbar.connect("row-activated", lambda box, row: switch_page(row.get_index())) - - self.win.present() - -def main(): - app = YAFI(application_id="au.stevetech.yafi") - app.run(sys.argv) - -if __name__ == "__main__": - main()