-
-
Notifications
You must be signed in to change notification settings - Fork 6
Developer Documentation
This page covers the internals of UXTU4Linux: how the pieces fit together, what each module does, and what you need to know to make changes or add support for new hardware.
UXTU4Linux/
├── UXTU4Linux.py TUI entry point
└── Assets/
├── config.ini generated on first run
├── custom.json saved custom presets, generated on first run
└── Modules/
├── config.py path constants, config singleton, interval parser
├── hardware.py CPU detection, dmidecode helpers, battery sysfs parser
├── presets.py built-in preset table and family dispatch
├── custom.py custom preset editor, field definitions, JSON I/O
├── power.py preset dispatch, apply_smu(), reapply state machine
├── automations.py automations menu and state management
├── settings.py settings menu, toggle handlers
├── setup.py first-run wizard, config integrity check
├── updater.py version check, download, self-update
├── service.py systemd service install/manage, venv bootstrap
├── daemon.py root daemon, PowerDaemon, ZMQ server loop
├── ipc.py DaemonClient ZMQ REQ/REP wrapper, singleton
├── runner.py SMU command tables per CPU family, apply_args()
├── smu.py low-level ryzen_smu sysfs interface
├── platformctl.py system settings: power profile, ASUS WMI, CCD affinity
├── ui.py menu renderer, all screen functions, color constants
└── termui.py raw terminal input, key byte sequences
The app splits into two separate processes.
TUI (UXTU4Linux.py) runs as your normal user. It handles all menus and interaction. It never touches CPU registers.
Daemon (daemon.py) runs as root via systemd. It's the only process that writes to the CPU. It binds a ZeroMQ REP socket at ipc:///run/uxtu4linux.sock.
The split exists because writing to the SMU requires root. Keeping the user-facing TUI out of root makes the attack surface smaller and the code simpler, since the daemon validates everything before acting on it.
The socket sits at /run/uxtu4linux.sock. The daemon chmods it to 666 at startup so the TUI can connect as an unprivileged user.
DaemonClient wraps a ZeroMQ REQ socket. Send and receive both have a 5-second timeout. On any error (ZMQ exception, JSON parse failure, timeout), _send() closes the socket and sets it to None so the next call creates a fresh connection.
Every public method returns a fallback value when the daemon isn't available. Callers don't need to handle None.
from .ipc import get_client
client = get_client()
if client.ping():
result = client.apply(args="--tctl-temp=90", mode="Balance")get_client() returns a module-level singleton. A threading.Lock around the initialization makes it safe across threads.
| Command | Payload fields | Returns |
|---|---|---|
ping |
none | {"ok": True, "version": "..."} |
apply |
args, mode
|
{"ok": True, "output": "...", "rejected": bool} |
apply_loop |
args, mode, interval, automation
|
{"ok": True} |
stop_loop |
none | {"ok": True} |
status |
none |
mode, args, running_loop, interval, on_ac, automation, last_output, last_rejected, version
|
apply_saved |
none | loads config, applies saved preset |
shutdown |
none |
{"ok": True}, then daemon exits |
dmidecode |
type |
{"ok": True, "output": "..."} |
reload_config |
none | {"ok": True} |
shutdown is handled by the daemon for a clean stop, but DaemonClient has no wrapper for it, under systemd the service is stopped with SIGTERM (the daemon's signal handler), so nothing in the app sends it.
dmidecode is proxied through the daemon because dmidecode -t needs root. The TUI uses client.dmidecode("processor") and so on instead of running it directly. The allowed type strings are whitelisted in _DMI_ALLOWED_TYPES.
PowerDaemon manages the ZMQ server loop and three background threads. All shared state is under self._lock.
Reapply loop (_loop_body), started when apply_loop is received. Calls _apply_once() every N seconds. If automations are enabled, it picks the correct preset for the current AC/battery state on each tick and logs when the preset changes because of a power state transition.
Power monitor (_monitor_body) polls /sys/class/power_supply every 2 seconds. When the AC state changes, it finds the automation preset for the new state and calls _apply_once(). It runs independently from the reapply loop so it can react to power changes even when reapply is off.
Suspend monitor (_suspend_monitor_body) compares CLOCK_BOOTTIME (counts time spent suspended) against time.monotonic() (doesn't). When the gap grows past _SUSPEND_GAP_THRESHOLD_S, the system just resumed. It reads OnResume from config and applies it.
def _apply_once(self, args: str, mode: str, *, reason: str = "") -> tuple[str, bool]:This is the single place CPU settings are written. Returns (output, rejected) where output is the per-command status log and rejected is True if the SMU refused any command.
reason is a short human-readable trigger description. When it's set and the apply succeeded, the daemon logs one line, for example:
Applied preset 'Eco' (power source changed from AC to battery).
Applied preset 'Gaming' (selected in the app).
Applied preset 'Balance' (woke from suspend after ~28m).
Callers pass the reason that matches their trigger: "selected in the app", "restoring saved settings at startup", "starting auto-reapply", "automations switched preset, now on battery", and so on. Steady-state reapply ticks pass no reason and stay at debug level, so the journal isn't flooded.
The method updates self._mode, self._args, self._last_output and self._last_rejected under the lock. These are what the status command reports.
while True:
try:
raw = sock.recv_string()
except zmq.ZMQError as exc:
log.error("ZMQ recv error: %s — shutting down.", exc)
break
resp = self.handle(raw)
try:
sock.send_string(resp)
except zmq.ZMQError as exc:
log.error("ZMQ send error: %s", exc)
if cmd == "shutdown":
breakBoth recv and send are wrapped in try/except zmq.ZMQError. A recv error triggers a clean shutdown. A send error is logged but doesn't crash the loop.
self.handle() dispatches to the matching _cmd_* method based on the "cmd" field in the JSON.
Argument validation happens in runner.py, not in the daemon itself. runner.lookup(family, arg_name) returns the opcode list for a given arg name. If the arg name isn't in the command table for that CPU family, it's reported as not supported on <family> in the output and skipped. The rejected flag is set True only when the SMU hardware returns a non-OK status for a command that was actually sent.
main() exits with an actionable log message when something is missing: ryzen_smu not installed / too old / unsigned under Secure Boot / not loaded, dmidecode missing, Intel CPU, or another daemon instance holding the lock file (/run/uxtu4linux_daemon.lock). These exact strings are documented in the Linux Troubleshooting table; if you change them, update that page too.
smu.py talks to the ryzen_smu kernel module through sysfs at /sys/kernel/ryzen_smu_drv/.
Key paths:
| Path | Purpose |
|---|---|
/sys/kernel/ryzen_smu_drv/ |
Module presence check |
.../drv_version |
Module version string |
.../smn |
System Management Network register interface |
The module also exposes a file-based command interface (rsmu_cmd, mp1_smu_cmd, smu_args) and read-only codename / pm_table nodes, but UXTU4Linux doesn't use them, it drives the SMU entirely through SMN register writes on the smn node.
Sending commands:
The module exposes two command channels:
- MP1: management processor 1, accessed via SMN register writes
- RSMU: register-based SMU, also via SMN
Both use the same protocol: write the argument to the args register, write the command opcode to the message register, poll the response register until it's nonzero. The mailbox register addresses differ per family and live in the _MP1 / _RSMU dicts.
status = smu.send_mp1(family, op_code, value)
status = smu.send_rsmu(family, op_code, value)Return values:
| Constant | Value | Meaning |
|---|---|---|
SMU_OK |
0x01 | Command accepted |
SMU_FAILED |
0xFF | General failure |
SMU_UNKNOWN_CMD |
0xFE | Opcode not recognized |
SMU_REJECTED_PREREQ |
0xFD | Prerequisite not met |
SMU_REJECTED_BUSY |
0xFC | SMU is busy |
All SMN access is serialized under a module-level threading.Lock. The response poll spins tightly for the first 100 reads, then sleeps in 0.1 ms steps with a 1 second deadline.
Minimum version check:
MIN_VERSION = (0, 1, 7)
smu.is_available() # True if /sys/kernel/ryzen_smu_drv/ exists
smu.version_ok() # True if version >= MIN_VERSIONrunner.py maps CPU families to their SMU command tables. Each table entry is a tuple[str, bool, int]: (arg_name, is_mp1, opcode).
is_mp1 = True means the command goes through MP1, False means RSMU.
The tables are kept byte-for-byte in sync with the original UXTU's RyzenSmu.cs, so preset strings behave identically on both.
Family to socket type mapping:
| Family | Socket |
|---|---|
| SummitRidge, PinnacleRidge | AM4_V1 |
| RavenRidge, Picasso, Dali, Pollock, FireFlight | FT5_FP5_AM4 |
| Matisse, Vermeer | AM4_V2 |
| Renoir, Lucienne, Cezanne_Barcelo | FP6_AM4 |
| VanGogh | FF3 |
| Mendocino, Rembrandt, PhoenixPoint, PhoenixPoint2, HawkPoint, HawkPoint2, SonomaValley, StrixPoint, StrixHalo, KrackanPoint, KrackanPoint2 | FT6_FP7_FP8 |
| Raphael, DragonRange, GraniteRidge, FireRange | AM5_V1 |
Key functions:
runner.lookup(family, arg_name) -> list[tuple[bool, int]]
# All (is_mp1, opcode) pairs for the given arg on the given family.
# Returns [] if not supported.
runner.has_smu_support(family) -> bool
# True if there's a command table for this family at all.
runner.apply_args(args_str, family) -> tuple[str, bool]
# Parse and execute a full args string against the SMU.
# Returns (output_log, any_rejected).apply_args is what _apply_once calls in the daemon. It:
- Splits args with
shlex.split - For each token, strips leading
--and splits on= - Routes
--sys-*pseudo-args to_apply_system()and--nvidia-clocksto_apply_nvidia() - Otherwise calls
runner.lookup(family, name)to get the opcode(s) - Scales the value if needed (skin temp times 256)
- Sends via
smu.send_mp1()orsmu.send_rsmu() - Collects per-command status lines; SMU-rejected commands are marked
[!]
NVIDIA GPU handling:
The --nvidia-clocks=max,core,mem pseudo-argument calls _apply_nvidia(), which:
- Uses
nvidia-smi -lgc 0,<max>to cap the GPU clock (or-rgcto reset when max is 4000) - Uses NVML (
libnvidia-ml.so.1) to set core and memory clock offsets
Two NVML code paths are tried: nvmlDeviceSetClockOffsets (newer API) and nvmlDeviceSetGpcClkVfOffset / nvmlDeviceSetMemClkVfOffset (legacy).
Pseudo-args prefixed --sys- are not SMU commands. runner.apply_args() routes them through _apply_system():
| Arg | Backend | Applied by |
|---|---|---|
--sys-power-profile=0|1|2 |
/sys/firmware/acpi/platform_profile, falling back to powerprofilesctl, then tuned-adm
|
daemon |
--sys-asus-mode=0|1|2 |
ASUS throttle_thermal_policy (asus-nb-wmi sysfs or asus-armoury firmware-attributes) |
daemon |
--sys-asus-eco=0|1 |
ASUS dgpu_disable
|
daemon |
--sys-asus-mux=0|1 |
ASUS gpu_mux_mode
|
daemon |
--sys-ccd-affinity=0|1|2 |
systemctl set-property --runtime user.slice AllowedCPUs=... |
daemon |
Runs inside the daemon (root). Highlights:
-
resolve_profile()maps the canonical profile names to whatever the firmware actually offers, using the same synonym table as G-Helper (low-powerfalls back toquiet, and so on, based onplatform_profile_choices). -
set_power_profile()tries sysfs first, thenpowerprofilesctl(covers power-profiles-daemon and Fedora's tuned-ppd), then plaintuned-adm profile powersave|balanced|throughput-performance. -
tlp_profile_conflict()checks/etc/tlp.confforPLATFORM_PROFILE_ON_AC/BAT; if set, TLP would fight over the profile, and the daemon logs a warning. - The ASUS paths each have three candidates: the asus-nb-wmi platform device, the generic platform bus path, and the asus-armoury firmware-attributes
current_valuefile (kernel 6.8+). -
set_asus_eco()carries G-Helper's safety guards: it refuses while the dGPU driver is active (checksnvidia_drmrefcnt and amdgpuruntime_statusfor non-boot AMD GPUs) and while the MUX is in Ultimate mode. After re-enabling the dGPU it triggers a PCI bus rescan. -
set_asus_mux()refuses whiledgpu_disable=1and reports that a reboot is required. -
_l3_domains()reads/sys/devices/system/cpu/cpu*/cache/index3/shared_cpu_listto find CCDs;ccd_affinity_available()only reports true with two or more L3 domains. - Each setter caches its last written value and skips the write if nothing changed, so the reapply loop doesn't hammer sysfs.
@dataclass
class Preset:
Eco: str
Balance: str
Performance: str
Extreme: strEach field is a space-separated string of ryzenadj-style CLI arguments (for example "--tctl-temp=90 --stapm-limit=25000 ..."). These are the same flag names used in the SMU command tables, plus the --sys- pseudo-args.
The preset values are ported verbatim from the original UXTU's PremadePresets.cs, with two deliberate differences: the dead --vrmgfx-current arg is dropped (no reachable family's table contains it), and --Win-Power=N maps to --sys-power-profile=N.
get_preset(cpu_type, family, cpu_model, raw_cpu, variant) dispatches in this order:
-
Variant match,
_variant_preset(variant). Only for specific device variants like Framework Laptop models. -
APU family,
_apu_preset(family, cpu_model). Most APUs, dispatching on the model suffix (U/H/HS/HX/G/GE). -
Desktop CPU,
_desktop_preset(family, cpu_model). -
Fallback,
_desktop_standard().
RYZEN_FAMILY is a list of all known family names in release order:
RYZEN_FAMILY = [
"Unknown", "SummitRidge", "PinnacleRidge", "RavenRidge", "Dali", "Pollock",
"Picasso", "FireFlight", "Matisse", "Renoir", "Lucienne", "VanGogh",
"Mendocino", "Vermeer", "Cezanne_Barcelo", "Rembrandt", "Raphael",
"DragonRange", "PhoenixPoint", "PhoenixPoint2", "HawkPoint", "HawkPoint2",
"SonomaValley", "GraniteRidge", "FireRange", "StrixHalo", "StrixPoint",
"KrackanPoint", "KrackanPoint2",
]_before(family, ref) returns True if family appears earlier in this list than ref. Used in _apu_preset() for conditions like "any family before Matisse."
- Add the family name to
RYZEN_FAMILYat the correct position (by release generation). - Add the family to socket mapping in
_FAMILY_SOCKETinrunner.py(and a command table if it's a new socket). - Add MP1/RSMU register addresses to
_MP1/_RSMUinsmu.pyif different from the defaults. - Add a branch in
_apu_preset()inpresets.pyreturning aPresetwith appropriate values. - Add the label in
get_preset_label()/_apu_label(). - Add the codename resolution in
_resolve_codename()inhardware.py. - Run the test suite. It asserts that every arg in every built-in preset resolves in that family's command table, so a typo'd or unsupported arg fails immediately.
- Add detection logic in
_detect_framework_variant()inhardware.py(or add a new detection function). - Add a
casein_variant_preset()inpresets.pyreturning the correctPreset. - Make sure
get_preset_label()handles the variant string.
Variant strings are stored in config.ini under Info.Variant and re-detected each time hardware detection runs.
Assets/custom.json is a JSON array of preset objects:
[
{
"name": "My Preset",
"tctl_temp": {"enabled": true, "value": 90},
"stapm_limit": {"enabled": true, "value": 25},
"fast_limit": {"enabled": false, "value": 28}
}
]Values are stored in the display unit (W, A, °C, MHz), not in the SMU unit. build_args() applies the conversion (times 1000 for W and A) when generating the arg string. Internally, custom preset names get a _custom_preset suffix to distinguish them from built-in presets; the UI strips it for display.
FIELD_DEFS (APU, 65 fields) and FIELD_DEFS_DT (desktop, 34 fields) are lists of dicts, one per parameter:
{
"key": "stapm_limit", # internal identifier
"label": "STAPM Power Limit", # displayed in editor
"arg": "--stapm-limit", # ryzenadj flag name
"unit": "W",
"default": 28,
"min": 5,
"max": 300,
"step": 1,
"enabled": False, # initial state
"section": 2, # which tab
"hint": "...", # description text (matches OG UXTU's tooltips)
}Special field properties:
| Property | Effect |
|---|---|
scale |
Multiply the value before sending (PBO Scalar: times 100) |
signed_co |
CO value: negatives use the 0x100000 - abs(v) encoding |
ccd, core
|
Per-core CO: CCD index and core index |
choices |
List of string choices; the stored value is an index |
nvidia_only |
Only shown if nvidia-smi is available |
check_arg |
Use this arg name for SMU support lookup instead of arg
|
system_check |
Field belongs to the System section; shown only when _system_supported() confirms the capability (platform profile, ASUS WMI, dual CCD) |
build_args(fields, cpu_type) iterates enabled fields and builds the arg string. Special cases:
-
tctl_tempon APUs also emits--chtc-temp=<same value> -
oc_clkandoc_voltare emitted twice each (ryzenadj quirk), plus--enable-oc --enable-oc -
--set-coperuses a packed encoding: CCD index, core index and CO value combined into one 32-bit integer - NVIDIA fields are collected into a single
--nvidia-clocks=max,core,memat the end - System fields emit their
--sys-*arg with the choice index
_supported_field_keys(family, fields) calls runner.lookup(family, arg) for each SMU field, and _system_supported(kind) for System fields. Only fields that pass are shown.
_active_sections(all_sections, fields, supported_keys) returns only the sections that still have at least one visible field. This controls which tabs appear, and the tab numbers are positional among the visible sections.
When a preset is saved with save_preset(), it checks if that preset is currently active (in User.Mode or automation slots). If it is, it sends apply_saved to the daemon so the new values take effect immediately.
When a preset is deleted with delete_preset(), it clears any config references to it, disables automations if both slots are now empty, and notifies the daemon.
config.py owns the single ConfigParser instance. All other modules import it and use cfg.get() / cfg.set_config() / cfg.save().
from . import config as cfg
# Read
val = cfg.get("Settings", "Time", "3") # fallback is "" if omitted
# Write
cfg.set_config("Settings", "Time", "5")
cfg.save()
# Re-read from disk
cfg.load()
# Check debug mode
if cfg.is_debug():
...Saves are atomic: cfg.atomic_write() writes to a temp file, fsyncs, then os.replace()s it over the target. A half-written config can't happen even if the process dies mid-save.
[User]
mode = Balance
[Settings]
time = 3
reapply = 0
applyonstart = 1
softwareupdate = 1
debug = 0
[Info]
cpu = AMD Ryzen 7 7840U ...
signature = Family 25 Model 116 ...
architecture = Zen 3 - Zen 4
family = HawkPoint
type = Amd_Apu
variant =
[Automations]
enabled = 0
onac =
onbattery =
onresume =cfg.CONFIG_PATH # path to config.ini
cfg.CUSTOM_PRESETS_PATH # pathlib.Path to custom.json
cfg.VENV_DIR # /opt/uxtu4linux/venv
cfg.VENV_PYTHON # /opt/uxtu4linux/venv/bin/python3
cfg.ZMQ_SOCKET_ADDR # ipc:///run/uxtu4linux.sock
cfg.ZMQ_SOCKET_PATH # /run/uxtu4linux.sock
cfg.LOCAL_VERSION # current version string
cfg.DMIDECODE # path to dmidecode binary (set by hardware.py)parse_interval() clamps the reapply interval to [1, 86400] seconds.
check_integrity() in setup.py runs at TUI startup:
- File missing or empty: run the setup wizard
-
Infosection missing or incomplete: callsreset_all(), which deletesconfig.iniandcustom.json, then runs the setup wizard - Other missing keys: fill from defaults and save, no wizard
This means adding new config keys in a future version won't wipe anyone's settings. Missing keys are filled with defaults silently.
REQUIRED in config.py lists the keys that must exist in each section. check_integrity uses it to know what to check.
Called during setup and from Settings, Re-detect hardware.
- Reads the CPU name (
Versionfield) andSignaturefromdmidecode -t processorvia the IPC client, and stores them inInfo.CPUandInfo.Signature - Calls
_compute_codename(), which:- Parses the
FamilyandModelintegers out of the signature string - Calls
_resolve_codename(cpu, cpu_family, cpu_model)to get(arch_string, family_string) - Calls
_cpu_type(family, arch)to determineAmd_Apu,Amd_Desktop_Cpu,IntelorUnknown - If the family is AMD but
runner.has_smu_support(family)is False, downgrades the type toUnknown - Saves
Architecture,FamilyandTypeintoInfo
- Parses the
- Detects the Framework Laptop variant via
_detect_framework_variant()and saves it toInfo.Variant - Calls
cfg.save()
_resolve_codename() maps (cpu_family_int, cpu_model_int) to family names using match/case on cpu_model within each cpu_family block.
| cpu_family | Architecture | Notes |
|---|---|---|
| 23 | Zen 1 - Zen 2 | Most pre-2021 APUs |
| 25 | Zen 3 - Zen 4 | 5000 and 6000/7000 series |
| 26 | Zen 5 - Zen 6 | 7000/8000/9000 series |
Some models within a family need the CPU name to disambiguate. For example, cpu_family=25, cpu_model=97 is DragonRange if the name contains "HX", otherwise Raphael.
_DESKTOP_FAMILIES = {
"SummitRidge", "PinnacleRidge", "Matisse",
"Vermeer", "Raphael", "GraniteRidge",
}
def _cpu_type(family, arch):
if family in _DESKTOP_FAMILIES: return "Amd_Desktop_Cpu"
if arch == "Intel": return "Intel"
if arch == "Unknown": return "Unknown"
return "Amd_Apu"_detect_framework_variant() reads Product Name and Manufacturer from DMI system info. Framework machines are identified by manufacturer name, then the product name is checked for "Laptop 16"/"Laptop 13" with the generation identifiers. The 16-inch with RX 7700S additionally runs lspci and checks for "7700s" in the VGA output.
menu(title, items, *, subtitle="", selected=0, on_toggle=None) renders an arrow-key navigable list and returns the selected index, or -1 on Escape. The parameters after items are keyword-only.
from .ui import menu, MenuItem
items = [
MenuItem("Apply preset", hint="Balance", key="apply"),
MenuItem("─", kind="separator"),
MenuItem("Back", key="back"),
]
choice = menu("Power Management", items)
if choice != -1 and items[choice].key != "back":
...MenuItem fields:
| Field | Default | Meaning |
|---|---|---|
label |
required | Main displayed text |
hint |
"" |
Right-side text, column-aligned across all hint items |
desc |
"" |
Description shown below the list when focused (wrapped to fit) |
key |
None |
Identifier for dispatch; auto-set to a slugified label if not provided |
kind |
"action" |
action, toggle, separator, disabled
|
render_menu computes the visual width of the longest label among all items that have a hint, and pads every hinted item to that width before the → separator, so the arrows line up vertically.
_vlen(s) measures visual length by stripping ANSI escape codes first.
get_key(timeout=None) puts stdin into raw mode, reads up to 4 bytes, and returns the byte sequence. Arrow keys produce 3-byte escape sequences. With a timeout, it returns None when nothing was pressed in time (the hardware info screen uses this to refresh on a timer).
from . import termui
key = termui.get_key()
if key == termui.UP:
row -= 1
elif key == termui.ENTER:
...Exported key constants: UP, DOWN, LEFT, RIGHT, ENTER, ESC.
When sys.stdin is not a TTY, get_key() raises a RuntimeError telling the user to run from an interactive terminal. UXTU4Linux requires a real TTY and does not support piped or non-interactive input.
draw_lines(lines, prev) moves the cursor up prev lines then writes lines in place. It returns the number of physical terminal rows the output occupies (accounting for line wrapping at the current width); pass that as prev on the next call.
from .ui import _R, _B, _D, _G, _Y
_R = "\033[0m" # reset
_B = "\033[1m" # bold
_D = "\033[2m" # dim
_G = "\033[32m" # green
_Y = "\033[33m" # yellowclear() uses ANSI escapes rather than spawning the clear binary.
Wraps text to width characters. Handles multi-paragraph text: blank lines in the input produce blank lines in the output. Used for hint text in menus and the custom preset editor.
Full setup sequence:
- Check systemd availability
-
sudo -vto cache credentials - Create the venv at
/opt/uxtu4linux/venvif missing (python3 -m venv --without-pip) - Bootstrap pip separately with
ensurepip --upgrade - Install
pyzmqifimport zmqfails in the venv - Write the unit file to
/etc/systemd/system/uxtu4linux.servicevia a temp file plussudo mv systemctl daemon-reload-
systemctl enable, with a warning and the manual command if it fails -
systemctl start, same fallback
Called at TUI startup. Reads the existing unit file, finds the ExecStart= line, and compares it to what _render_unit() would generate. If they differ (the app was moved), it rewrites the unit file and runs daemon-reload plus restart.
Generates the systemd unit content. It bakes in the absolute path to _python() (the venv Python, or the current interpreter if the venv doesn't exist) and _daemon_script() (the absolute path of daemon.py next to the running code). That's why the unit file is correct no matter where the app is installed.
Non-systemd systems get a manual daemon-start path instead: the setup wizard (_step_daemon_manual) prints the exact command and polls ping for up to 2 minutes.
No hardware, root or daemon is needed; anything touching those is tested manually.
Versions follow standard MAJOR.MINOR.PATCH semver. Pre-release suffixes (-beta, +build) are stripped before comparison.
_ver_tuple(v) parses each dot-separated segment as an integer:
| Version | Tuple | Rank |
|---|---|---|
0.7.0 |
(0, 7, 0) |
1st |
0.7.1 |
(0, 7, 1) |
2nd |
0.8.0 |
(0, 8, 0) |
3rd |
1.0.0 |
(1, 0, 0) |
4th |
get_latest_version() follows the GitHub releases/latest redirect and validates that the tag starts with a digit (after stripping a leading v), so a redirect to some non-release page isn't treated as a version.
check_updates() tries up to 2 times with a 2-second sleep between attempts before giving up.
get_beta_commit() fetches the U4L-Beta tag from the GitHub API and returns the short SHA (7 characters). show_beta_updater() shows a warning and downloads the U4L-Beta release asset if confirmed.
- Copy
config.iniandcustom.jsonto the install directory asconfig.ini.bak/custom.json.bak(/opt/uxtu4linux/on a standard install) - Download the release zip and extract it to
<install dir>/UXTU4Linux_new/ -
sudo mvthe current app directory aside as<app dir>.bak(so/opt/uxtu4linux/src.bak) -
sudo mvthe new release into place; if this fails, the.bakdirectory is moved back - Remove the
.bakdirectory andUXTU4Linux_new/ - Move the config and preset backups into the new version's
Assets/ - Remove the downloaded zip
- Restart the daemon if the service is running
-
os.execvto relaunch the TUI from the new code
Note that the .bak app directory only survives if the update died between steps 3 and 5. On success the only backups left behind are the two config files in the install directory.
Triggered manually from the Actions tab (workflow_dispatch). It zips UXTU4Linux/ into UXTU4Linux.zip, fetches the release tag, generates release notes (the install one-liner plus the changelog), and creates the GitHub release with the zip attached.
Also triggered manually. Workflow:
- Collect the last few commits for the notes
- Package the current state into a zip
- Delete the existing
U4L-Betarelease and tag - Force-create the tag at HEAD
- Create a pre-release titled "U4L Beta Build" with a warning callout and the recent commits
The U4L-Beta tag always points at the most recent manual trigger. The TUI's force-update-to-beta option downloads from this release's asset.
- Python 3.10+, type hints on all function signatures
- Error handling only at system boundaries (user input, IPC, file I/O)
- No backwards-compatibility shims for removed features
- Daemon log strings and install instructions are quoted in the wiki; keep them in sync
- Add the family to
RYZEN_FAMILYinpresets.pyat the right position - Add the family to socket mapping in
_FAMILY_SOCKETinrunner.py - Add the command table under the socket type in
runner.py(or create a new table for a new socket) - Add MP1/RSMU register addresses to
_MP1/_RSMUinsmu.pyif different from the defaults - Add the
_resolve_codenamebranch inhardware.pywith the correctcpu_familyandcpu_modelvalues - Add the CPU type classification if it's a new desktop family
- Add the
_apu_preset()/_desktop_preset()branch inpresets.py - Add the label in
get_preset_label()inpresets.py - Test on a machine that has the CPU
sudo dmidecode -t processor | grep SignatureOutput looks like: Signature: Type 0, Family 26, Model 32, Stepping 1
The Family and Model values are what go into _resolve_codename().
Getting started
Using the app
Internals