Major refactor with GNOME Builder & Flatpak support

This commit is contained in:
Stephen Horvath
2025-03-20 15:02:18 +10:00
parent ac015ab469
commit 35e325b133
40 changed files with 1432 additions and 851 deletions

7
60-cros_ec_python.rules Normal file
View File

@@ -0,0 +1,7 @@
# CrOS_EC_Python udev rules
# LPC Access
KERNEL=="port", TAG+="uaccess"
# /dev/cros_ec Access
KERNEL=="cros_ec", TAG+="uaccess"

View File

@@ -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.

View File

@@ -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`.

48
au.stevetech.yafi.json Normal file
View File

@@ -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\""
]
}
]
}

View File

@@ -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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="yafi">
<schema id="au.stevetech.yafi" path="/au/stevetech/yafi/">
</schema>
</schemalist>

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>au.stevetech.yafi</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-2.0-or-later</project_license>
<name>Yet Another Framework Interface</name>
<summary>YAFI is another GUI for the Framework Laptop Embedded Controller</summary>
<description>
<p>It is written in Python with a GTK3 theme, and uses the `CrOS_EC_Python` library to communicate with the EC.</p>
</description>
<developer id="au.stevetech">
<name>Stephen Horvath</name>
</developer>
<!-- Required: Should be a link to the upstream homepage for the component -->
<url type="homepage">https://github.com/Steve-Tech/YAFI</url>
<!-- Recommended: It is highly recommended for open-source projects to display the source code repository -->
<url type="vcs-browser">https://github.com/Steve-Tech/YAFI</url>
<!-- Should point to the software's bug tracking system, for users to report new bugs -->
<url type="bugtracker">https://github.com/Steve-Tech/YAFI/issues</url>
<!-- Should link a FAQ page for this software, to answer some of the most-asked questions in detail -->
<!-- URLs of this type should point to a webpage where users can submit or modify translations of the upstream project -->
<!-- <url type="translate">https://example.org/translate</url> -->
<!-- <url type="faq">https://example.org/faq</url> -->
<!-- Should provide a web link to an online user's reference, a software manual or help page -->
<!-- <url type="help">https://example.org/help</url> -->
<!-- URLs of this type should point to a webpage showing information on how to donate to the described software project -->
<!-- <url type="donation">https://example.org/donate</url> -->
<!-- This could for example be an HTTPS URL to an online form or a page describing how to contact the developer -->
<!-- <url type="contact">https://example.org/contact</url> -->
<!-- URLs of this type should point to a webpage showing information on how to contribute to the described software project -->
<!-- <url type="contribute">https://example.org/contribute</url> -->
<translation type="gettext">yafi</translation>
<!-- All graphical applications having a desktop file must have this tag in the MetaInfo.
If this is present, appstreamcli compose will pull icons, keywords and categories from the desktop file. -->
<launchable type="desktop-id">au.stevetech.yafi.desktop</launchable>
<!-- Use the OARS website (https://hughsie.github.io/oars/generate.html) to generate these and make sure to use oars-1.1 -->
<content_rating type="oars-1.1" />
<!-- Applications should set a brand color in both light and dark variants like so -->
<branding>
<color type="primary" scheme_preference="light">#ff00ff</color>
<color type="primary" scheme_preference="dark">#993d3d</color>
</branding>
<screenshots>
<screenshot type="default">
<image>https://github.com/Steve-Tech/YAFI/blob/main/docs/1-thermal.png</image>
<caption>The Thermal page</caption>
</screenshot>
<screenshot>
<image>https://github.com/Steve-Tech/YAFI/blob/main/docs/2-leds.png</image>
<caption>The LED page</caption>
</screenshot>
<screenshot>
<image>https://github.com/Steve-Tech/YAFI/blob/main/docs/3-battery.png</image>
<caption>The Battery page</caption>
</screenshot>
<screenshot>
<image>https://github.com/Steve-Tech/YAFI/blob/main/docs/4-hardware.png</image>
<caption>The Hardware page</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.1.0" date="2025-03-20">
<url type="details">https://github.com/Steve-Tech/YAFI/releases/tag/0.1.0</url>
<description translate="no">
<p>First release</p>
<ul>
<li>Added Thermal page</li>
<li>Added LED page</li>
<li>Added Battery page</li>
<li>Added Hardware page</li>
</ul>
</description>
</release>
</releases>
</component>

View File

@@ -0,0 +1,3 @@
[D-BUS Service]
Name=au.stevetech.yafi
Exec=@bindir@/yafi --gapplication-service

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1755 4883 c-120 -22 -418 -180 -609 -322 -118 -89 -148 -118 -177
-177 -23 -48 -24 -57 -27 -314 -1 -146 -7 -294 -13 -330 -18 -110 -78 -222
-163 -303 -46 -43 -69 -58 -305 -197 -191 -112 -222 -139 -257 -214 -33 -73
-39 -145 -39 -461 0 -297 5 -360 33 -440 25 -73 77 -118 257 -224 88 -51 187
-111 220 -132 123 -79 192 -169 245 -319 8 -21 14 -110 16 -230 8 -403 9 -423
29 -460 32 -61 56 -88 134 -148 234 -181 550 -347 676 -356 75 -5 116 12 306
125 181 108 307 175 330 176 8 1 35 5 61 11 70 14 174 5 251 -23 38 -13 164
-81 281 -149 286 -168 312 -173 494 -90 41 19 75 34 77 34 4 0 201 110 243
135 45 27 147 100 213 153 51 40 83 75 104 113 l30 54 6 310 c5 280 8 315 27
365 58 156 137 245 295 336 246 140 346 205 376 243 35 44 60 106 67 166 2 22
8 74 13 115 9 78 7 442 -4 520 -12 97 -16 116 -27 150 -16 52 -61 113 -105
142 -21 14 -126 78 -233 141 -107 64 -209 128 -225 144 -51 48 -101 115 -128
173 -48 105 -56 163 -56 454 -1 262 -1 270 -25 321 -24 53 -65 100 -124 145
-19 14 -46 34 -60 46 -42 32 -190 128 -257 164 -250 138 -384 177 -476 140
-19 -8 -101 -54 -184 -104 -316 -188 -340 -198 -480 -199 -153 -1 -181 10
-501 201 -178 106 -237 128 -309 115z m984 -848 c149 -19 308 -68 441 -135 68
-34 181 -100 190 -110 3 -3 21 -17 40 -30 52 -37 75 -57 166 -148 150 -151
281 -364 345 -562 33 -102 32 -99 56 -210 18 -86 26 -378 13 -455 -5 -27 -12
-68 -15 -90 -16 -102 -64 -250 -117 -364 -211 -450 -634 -764 -1113 -828 -113
-15 -425 -7 -455 12 -4 2 -22 7 -41 10 -150 26 -404 140 -531 239 -14 12 -41
32 -59 46 -115 85 -291 299 -371 449 -78 148 -144 353 -165 516 -10 76 -10
311 0 390 15 117 58 274 106 390 116 278 347 543 608 696 158 92 386 169 549
184 38 4 70 8 72 9 5 6 229 -2 281 -9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1755 4883 c-120 -22 -418 -180 -609 -322 -118 -89 -148 -118 -177
-177 -23 -48 -24 -57 -27 -314 -1 -146 -7 -294 -13 -330 -18 -110 -78 -222
-163 -303 -46 -43 -69 -58 -305 -197 -191 -112 -222 -139 -257 -214 -33 -73
-39 -145 -39 -461 0 -297 5 -360 33 -440 25 -73 77 -118 257 -224 88 -51 187
-111 220 -132 123 -79 192 -169 245 -319 8 -21 14 -110 16 -230 8 -403 9 -423
29 -460 32 -61 56 -88 134 -148 234 -181 550 -347 676 -356 75 -5 116 12 306
125 181 108 307 175 330 176 8 1 35 5 61 11 70 14 174 5 251 -23 38 -13 164
-81 281 -149 286 -168 312 -173 494 -90 41 19 75 34 77 34 4 0 201 110 243
135 45 27 147 100 213 153 51 40 83 75 104 113 l30 54 6 310 c5 280 8 315 27
365 58 156 137 245 295 336 246 140 346 205 376 243 35 44 60 106 67 166 2 22
8 74 13 115 9 78 7 442 -4 520 -12 97 -16 116 -27 150 -16 52 -61 113 -105
142 -21 14 -126 78 -233 141 -107 64 -209 128 -225 144 -51 48 -101 115 -128
173 -48 105 -56 163 -56 454 -1 262 -1 270 -25 321 -24 53 -65 100 -124 145
-19 14 -46 34 -60 46 -42 32 -190 128 -257 164 -250 138 -384 177 -476 140
-19 -8 -101 -54 -184 -104 -316 -188 -340 -198 -480 -199 -153 -1 -181 10
-501 201 -178 106 -237 128 -309 115z m984 -848 c149 -19 308 -68 441 -135 68
-34 181 -100 190 -110 3 -3 21 -17 40 -30 52 -37 75 -57 166 -148 150 -151
281 -364 345 -562 33 -102 32 -99 56 -210 18 -86 26 -378 13 -455 -5 -27 -12
-68 -15 -90 -16 -102 -64 -250 -117 -364 -211 -450 -634 -764 -1113 -828 -113
-15 -425 -7 -455 12 -4 2 -22 7 -41 10 -150 26 -404 140 -531 239 -14 12 -41
32 -59 46 -115 85 -291 299 -371 449 -78 148 -144 353 -165 516 -10 76 -10
311 0 390 15 117 58 274 106 390 116 278 347 543 608 696 158 92 386 169 549
184 38 4 70 8 72 9 5 6 229 -2 281 -9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

13
data/icons/meson.build Normal file
View File

@@ -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
)

46
data/meson.build Normal file
View File

@@ -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')

BIN
docs/1-thermals.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
docs/2-leds.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/3-battery.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
docs/3a-battery-ext.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
docs/4-hardware.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

20
meson.build Normal file
View File

@@ -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,
)

1
po/LINGUAS Normal file
View File

@@ -0,0 +1 @@
# Please keep this file sorted alphabetically.

16
po/POTFILES.in Normal file
View File

@@ -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

1
po/meson.build Normal file
View File

@@ -0,0 +1 @@
i18n.gettext('test', preset: 'glib')

View File

@@ -1 +1 @@
from .yafi import main
from . import main

View File

@@ -1,2 +1,3 @@
from . import yafi
yafi.main()
from . import main
main.main()

215
yafi/battery.py Normal file
View File

@@ -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}"

119
yafi/hardware.py Normal file
View File

@@ -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

154
yafi/leds.py Normal file
View File

@@ -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)

140
yafi/main.py Normal file
View File

@@ -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)

39
yafi/meson.build Normal file
View File

@@ -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)

145
yafi/thermals.py Normal file
View File

@@ -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

View File

@@ -1,19 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Cambalache 0.94.1 -->
<interface>
<!-- interface-description The About page for YAFI -->
<!-- interface-copyright Steve-Tech -->
<requires lib="libadwaita" version="1.2"/>
<object class="AdwAboutWindow" id="about-root">
<property name="application-icon">application-default-icon</property>
<property name="application-name">Yet Another Framework Interface</property>
<property name="comments">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.</property>
<property name="copyright">Copyright © 2025 Stephen Horvath</property>
<property name="developers">Stephen Horvath (Steve-Tech)</property>
<property name="issue-url">https://github.com/Steve-Tech/YAFI/issues</property>
<property name="license-type">gpl-2-0</property>
<property name="version">0.1</property>
<property name="website">https://github.com/Steve-Tech/YAFI</property>
</object>
</interface>

View File

@@ -5,7 +5,7 @@
<!-- interface-authors Steve-Tech -->
<requires lib="gtk" version="4.12"/>
<requires lib="libadwaita" version="1.6"/>
<object class="GtkBox" id="battery-root">
<template class="BatteryPage" parent="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkListBox">
@@ -18,19 +18,19 @@
</object>
</child>
<child>
<object class="AdwSwitchRow" id="chg-limit-enable">
<object class="AdwSwitchRow" id="chg_limit_enable">
<property name="title">Enable Charge Limiter</property>
</object>
</child>
<child>
<object class="AdwActionRow" id="chg-limit">
<object class="AdwActionRow" id="chg_limit">
<property name="sensitive">False</property>
<property name="subtitle">Limit the maximum charge</property>
<property name="title">Charge Limit</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkScale" id="chg-limit-scale">
<object class="GtkScale" id="chg_limit_scale">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="page-increment">10.0</property>
@@ -48,14 +48,14 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="bat-limit">
<object class="AdwActionRow" id="bat_limit">
<property name="sensitive">False</property>
<property name="subtitle">Limit the minimum charge</property>
<property name="title">Discharge Limit</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkScale" id="bat-limit-scale">
<object class="GtkScale" id="bat_limit_scale">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="page-increment">10.0</property>
@@ -72,7 +72,7 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="chg-limit-override">
<object class="AdwActionRow" id="chg_limit_override">
<property name="sensitive">False</property>
<property name="subtitle">Disables the limiter for one charge cycle</property>
<property name="title">Override Charge Limiter</property>
@@ -82,7 +82,7 @@
<property name="homogeneous">True</property>
<property name="valign">center</property>
<child>
<object class="GtkButton" id="chg-limit-override-btn">
<object class="GtkButton" id="chg_limit_override_btn">
<property name="label">Override</property>
</object>
</child>
@@ -91,7 +91,7 @@
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="bat-ext-group">
<object class="AdwPreferencesGroup" id="bat_ext_group">
<property name="description">Preserve the battery lifespan by gradually lowering battery charge voltage automatically if the system is connected to AC for more than the set day limit.</property>
<property name="margin-bottom">5</property>
<property name="margin-end">5</property>
@@ -99,12 +99,12 @@
<property name="margin-top">5</property>
<property name="title">Battery Extender</property>
<child>
<object class="AdwSwitchRow" id="bat-ext-enable">
<object class="AdwSwitchRow" id="bat_ext_enable">
<property name="title">Enable</property>
</object>
</child>
<child>
<object class="AdwActionRow" id="bat-ext-stage">
<object class="AdwActionRow" id="bat_ext_stage">
<property name="selectable">False</property>
<property name="sensitive">False</property>
<property name="title">Current Stage (0 to 2)</property>
@@ -114,7 +114,7 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="bat-ext-trigger-time">
<object class="AdwActionRow" id="bat_ext_trigger_time">
<property name="selectable">False</property>
<property name="sensitive">False</property>
<property name="title">Time Until Trigger</property>
@@ -124,7 +124,7 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="bat-ext-reset-time">
<object class="AdwActionRow" id="bat_ext_reset_time">
<property name="selectable">False</property>
<property name="sensitive">False</property>
<property name="title">Time Until Reset</property>
@@ -134,7 +134,7 @@
</object>
</child>
<child>
<object class="AdwSpinRow" id="bat-ext-trigger">
<object class="AdwSpinRow" id="bat_ext_trigger">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">1.0</property>
@@ -150,7 +150,7 @@
</object>
</child>
<child>
<object class="AdwSpinRow" id="bat-ext-reset">
<object class="AdwSpinRow" id="bat_ext_reset">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">1.0</property>
@@ -172,5 +172,5 @@
</style>
</object>
</child>
</object>
</template>
</interface>

View File

@@ -5,7 +5,7 @@
<!-- interface-copyright Steve-Tech -->
<requires lib="gtk" version="4.12"/>
<requires lib="libadwaita" version="1.3"/>
<object class="GtkBox" id="hardware-root">
<template class="HardwarePage" parent="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkListBox">
@@ -18,15 +18,15 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="hw-chassis">
<object class="AdwActionRow" id="hw_chassis">
<property name="title">Chassis Open Count</property>
<child>
<object class="GtkLabel" id="hw-chassis-label"/>
<object class="GtkLabel" id="hw_chassis_label"/>
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="hw-priv-cam">
<object class="AdwActionRow" id="hw_priv_cam">
<property name="subtitle">Use Privacy Switch</property>
<property name="title">Camera Enabled</property>
<child>
@@ -35,7 +35,7 @@
<property name="homogeneous">True</property>
<property name="valign">center</property>
<child>
<object class="GtkSwitch" id="hw-priv-cam-sw">
<object class="GtkSwitch" id="hw_priv_cam_sw">
<property name="active">True</property>
<property name="sensitive">False</property>
</object>
@@ -45,7 +45,7 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="hw-priv-mic">
<object class="AdwActionRow" id="hw_priv_mic">
<property name="subtitle">Use Privacy Switch</property>
<property name="title">Microphone Enabled</property>
<child>
@@ -54,7 +54,7 @@
<property name="homogeneous">True</property>
<property name="valign">center</property>
<child>
<object class="GtkSwitch" id="hw-priv-mic-sw">
<object class="GtkSwitch" id="hw_priv_mic_sw">
<property name="active">True</property>
<property name="sensitive">False</property>
</object>
@@ -64,7 +64,7 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="hw-fp-pwr">
<object class="AdwActionRow" id="hw_fp_pwr">
<property name="title">Fingerprint</property>
<property name="visible">False</property>
<child>
@@ -74,12 +74,12 @@
<property name="spacing">5</property>
<property name="valign">center</property>
<child>
<object class="GtkButton" id="hw-fp-pwr-en">
<object class="GtkButton" id="hw_fp_pwr_en">
<property name="label">Enable</property>
</object>
</child>
<child>
<object class="GtkButton" id="hw-fp-pwr-dis">
<object class="GtkButton" id="hw_fp_pwr_dis">
<property name="label">Disable</property>
</object>
</child>
@@ -92,5 +92,5 @@
</style>
</object>
</child>
</object>
</template>
</interface>

View File

@@ -5,7 +5,7 @@
<!-- interface-authors Steve-Tech -->
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.6"/>
<object class="GtkBox" id="leds-root">
<template class="LedsPage" parent="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkListBox">
@@ -18,12 +18,12 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="led-pwr">
<object class="AdwActionRow" id="led_pwr">
<property name="title">Power Button</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkScale" id="led-pwr-scale">
<object class="GtkScale" id="led_pwr_scale">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="page-increment">1.0</property>
@@ -41,12 +41,12 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="led-kbd">
<object class="AdwActionRow" id="led_kbd">
<property name="title">Keyboard Backlight</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkScale" id="led-kbd-scale">
<object class="GtkScale" id="led_kbd_scale">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="page-increment">10.0</property>
@@ -64,7 +64,7 @@
</object>
</child>
<child>
<object class="AdwExpanderRow">
<object class="AdwExpanderRow" id="led_advanced">
<property name="selectable">False</property>
<property name="subtitle">These options break normal functionality</property>
<property name="title">Advanced Options</property>
@@ -77,7 +77,7 @@
<property name="margin-top">5</property>
<property name="title">Power Button LED</property>
<child>
<object class="AdwComboRow" id="led-pwr-colour">
<object class="AdwComboRow" id="led_pwr_colour">
<property name="model">
<object class="GtkStringList">
<items>
@@ -100,7 +100,7 @@
<property name="margin-top">5</property>
<property name="title">Charging Indicators</property>
<child>
<object class="AdwComboRow" id="led-chg-colour">
<object class="AdwComboRow" id="led_chg_colour">
<property name="model">
<object class="GtkStringList">
<items>
@@ -121,5 +121,5 @@
</style>
</object>
</child>
</object>
</template>
</interface>

View File

@@ -3,9 +3,12 @@
<interface>
<!-- interface-description The Thermals page for YAFI -->
<!-- interface-authors Steve-Tech -->
<requires lib="gtk" version="4.0"/>
<requires lib="gtk" version="4.12"/>
<requires lib="libadwaita" version="1.6"/>
<object class="GtkPaned" id="thermals-root">
<template class="ThermalsPage" parent="GtkBox">
<property name="homogeneous">True</property>
<child>
<object class="GtkPaned">
<property name="end-child">
<object class="GtkListBox" id="temperatures">
<child>
@@ -30,7 +33,7 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="fan-rpm">
<object class="AdwActionRow" id="fan_rpm">
<property name="title">Current Speed</property>
<style>
<class name="property"/>
@@ -38,7 +41,7 @@
</object>
</child>
<child>
<object class="AdwComboRow" id="fan-mode">
<object class="AdwComboRow" id="fan_mode">
<property name="model">
<object class="GtkStringList">
<items>
@@ -52,13 +55,13 @@
</object>
</child>
<child>
<object class="AdwActionRow" id="fan-set-percent">
<object class="AdwActionRow" id="fan_set_percent">
<property name="title">Fan Speed</property>
<property name="visible">False</property>
<child>
<object class="GtkBox">
<child>
<object class="GtkScale" id="fan-percent-scale">
<object class="GtkScale" id="fan_percent_scale">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="page-increment">10.0</property>
@@ -76,7 +79,7 @@
</object>
</child>
<child>
<object class="AdwSpinRow" id="fan-set-rpm">
<object class="AdwSpinRow" id="fan_set_rpm">
<property name="adjustment">
<object class="GtkAdjustment">
<property name="page-increment">1000.0</property>
@@ -96,4 +99,6 @@
</object>
</property>
</object>
</child>
</template>
</interface>

View File

@@ -2,15 +2,14 @@
<!DOCTYPE cambalache-project SYSTEM "cambalache-project.dtd">
<cambalache-project version="0.94.0" target_tk="gtk-4.0">
<ui>
(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)
</ui>
<object>
(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)
</object>
<object_property>
(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)
</object_property>
<object_data>
(1,17,"GtkWidget",1,1,None,None,None,None,None,None),

View File

@@ -6,7 +6,7 @@
<!-- interface-authors Steve-Tech -->
<requires lib="gtk" version="4.12"/>
<requires lib="libadwaita" version="1.4"/>
<object class="AdwApplicationWindow" id="root">
<template class="YafiWindow" parent="AdwApplicationWindow">
<property name="default-height">500</property>
<property name="default-width">800</property>
<child>
@@ -58,5 +58,5 @@
</property>
</object>
</child>
</object>
</template>
</interface>

32
yafi/window.py Normal file
View File

@@ -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)

10
yafi/yafi.gresource.xml Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/au/stevetech/yafi">
<file preprocess="xml-stripblanks">ui/yafi.ui</file>
<file preprocess="xml-stripblanks">ui/thermals.ui</file>
<file preprocess="xml-stripblanks">ui/leds.ui</file>
<file preprocess="xml-stripblanks">ui/battery.ui</file>
<file preprocess="xml-stripblanks">ui/hardware.ui</file>
</gresource>
</gresources>

47
yafi/yafi.in Executable file
View File

@@ -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))

View File

@@ -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()