Skip to content

Developer Documentation

oxGorou edited this page Jun 14, 2026 · 7 revisions

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.


File structure

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

Two-process design

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.


IPC layer (ipc.py)

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.

IPC commands

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.


Daemon internals (daemon.py)

PowerDaemon manages the ZMQ server loop and three background threads. All shared state is under self._lock.

Background threads

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.

_apply_once(args, mode, *, reason="")

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.

Main loop

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":
        break

Both 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

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.

Startup checks

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 layer (smu.py, runner.py)

smu.py

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_VERSION

runner.py

runner.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:

  1. Splits args with shlex.split
  2. For each token, strips leading -- and splits on =
  3. Routes --sys-* pseudo-args to _apply_system() and --nvidia-clocks to _apply_nvidia()
  4. Otherwise calls runner.lookup(family, name) to get the opcode(s)
  5. Scales the value if needed (skin temp times 256)
  6. Sends via smu.send_mp1() or smu.send_rsmu()
  7. 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 -rgc to 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).


System settings layer (`platformctl.py')

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

platformctl.py

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-power falls back to quiet, and so on, based on platform_profile_choices).
  • set_power_profile() tries sysfs first, then powerprofilesctl (covers power-profiles-daemon and Fedora's tuned-ppd), then plain tuned-adm profile powersave|balanced|throughput-performance.
  • tlp_profile_conflict() checks /etc/tlp.conf for PLATFORM_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_value file (kernel 6.8+).
  • set_asus_eco() carries G-Helper's safety guards: it refuses while the dGPU driver is active (checks nvidia_drm refcnt and amdgpu runtime_status for 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 while dgpu_disable=1 and reports that a reboot is required.
  • _l3_domains() reads /sys/devices/system/cpu/cpu*/cache/index3/shared_cpu_list to 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.

Preset system (presets.py)

Data model

@dataclass
class Preset:
    Eco: str
    Balance: str
    Performance: str
    Extreme: str

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

Selection priority

get_preset(cpu_type, family, cpu_model, raw_cpu, variant) dispatches in this order:

  1. Variant match, _variant_preset(variant). Only for specific device variants like Framework Laptop models.
  2. APU family, _apu_preset(family, cpu_model). Most APUs, dispatching on the model suffix (U/H/HS/HX/G/GE).
  3. Desktop CPU, _desktop_preset(family, cpu_model).
  4. Fallback, _desktop_standard().

Family ordering

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

Adding a built-in preset for a new APU family

  1. Add the family name to RYZEN_FAMILY at the correct position (by release generation).
  2. Add the family to socket mapping in _FAMILY_SOCKET in runner.py (and a command table if it's a new socket).
  3. Add MP1/RSMU register addresses to _MP1 / _RSMU in smu.py if different from the defaults.
  4. Add a branch in _apu_preset() in presets.py returning a Preset with appropriate values.
  5. Add the label in get_preset_label() / _apu_label().
  6. Add the codename resolution in _resolve_codename() in hardware.py.
  7. 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.

Adding a device variant

  1. Add detection logic in _detect_framework_variant() in hardware.py (or add a new detection function).
  2. Add a case in _variant_preset() in presets.py returning the correct Preset.
  3. 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.


Custom presets (custom.py)

Storage format

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 definitions

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)

Arg building

build_args(fields, cpu_type) iterates enabled fields and builds the arg string. Special cases:

  • tctl_temp on APUs also emits --chtc-temp=<same value>
  • oc_clk and oc_volt are emitted twice each (ryzenadj quirk), plus --enable-oc --enable-oc
  • --set-coper uses 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,mem at the end
  • System fields emit their --sys-* arg with the choice index

Section visibility

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

Lifecycle

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 (config.py)

config.py owns the single ConfigParser instance. All other modules import it and use cfg.get() / cfg.set_config() / cfg.save().

API

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.

Config file layout

[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 =

Key constants

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.

Config integrity

check_integrity() in setup.py runs at TUI startup:

  • File missing or empty: run the setup wizard
  • Info section missing or incomplete: calls reset_all(), which deletes config.ini and custom.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.


CPU detection (hardware.py)

detect()

Called during setup and from Settings, Re-detect hardware.

  1. Reads the CPU name (Version field) and Signature from dmidecode -t processor via the IPC client, and stores them in Info.CPU and Info.Signature
  2. Calls _compute_codename(), which:
    • Parses the Family and Model integers 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 determine Amd_Apu, Amd_Desktop_Cpu, Intel or Unknown
    • If the family is AMD but runner.has_smu_support(family) is False, downgrades the type to Unknown
    • Saves Architecture, Family and Type into Info
  3. Detects the Framework Laptop variant via _detect_framework_variant() and saves it to Info.Variant
  4. Calls cfg.save()

CPU family / model to codename

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

CPU type assignment

_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"

Framework Laptop variant detection

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


TUI (ui.py, termui.py)

Menu system

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

Hint alignment

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.

Terminal input (termui.py)

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.

In-place redraws

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.

Color constants

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"   # yellow

clear() uses ANSI escapes rather than spawning the clear binary.

_wrap(text, width)

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.


Service management (service.py)

install_service()

Full setup sequence:

  1. Check systemd availability
  2. sudo -v to cache credentials
  3. Create the venv at /opt/uxtu4linux/venv if missing (python3 -m venv --without-pip)
  4. Bootstrap pip separately with ensurepip --upgrade
  5. Install pyzmq if import zmq fails in the venv
  6. Write the unit file to /etc/systemd/system/uxtu4linux.service via a temp file plus sudo mv
  7. systemctl daemon-reload
  8. systemctl enable, with a warning and the manual command if it fails
  9. systemctl start, same fallback

verify_service_path()

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.

_render_unit()

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.


Version scheme

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.

Beta builds

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.

Update procedure (_do_update)

  1. Copy config.ini and custom.json to the install directory as config.ini.bak / custom.json.bak (/opt/uxtu4linux/ on a standard install)
  2. Download the release zip and extract it to <install dir>/UXTU4Linux_new/
  3. sudo mv the current app directory aside as <app dir>.bak (so /opt/uxtu4linux/src.bak)
  4. sudo mv the new release into place; if this fails, the .bak directory is moved back
  5. Remove the .bak directory and UXTU4Linux_new/
  6. Move the config and preset backups into the new version's Assets/
  7. Remove the downloaded zip
  8. Restart the daemon if the service is running
  9. os.execv to 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.


GitHub Actions

main.yml

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.

beta.yml

Also triggered manually. Workflow:

  1. Collect the last few commits for the notes
  2. Package the current state into a zip
  3. Delete the existing U4L-Beta release and tag
  4. Force-create the tag at HEAD
  5. 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.


Contributing

Code style

  • 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

Adding a new family, checklist

  • Add the family to RYZEN_FAMILY in presets.py at the right position
  • Add the family to socket mapping in _FAMILY_SOCKET in runner.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 / _RSMU in smu.py if different from the defaults
  • Add the _resolve_codename branch in hardware.py with the correct cpu_family and cpu_model values
  • Add the CPU type classification if it's a new desktop family
  • Add the _apu_preset() / _desktop_preset() branch in presets.py
  • Add the label in get_preset_label() in presets.py
  • Test on a machine that has the CPU

Finding cpu_family and cpu_model values

sudo dmidecode -t processor | grep Signature

Output looks like: Signature: Type 0, Family 26, Model 32, Stepping 1

The Family and Model values are what go into _resolve_codename().

Clone this wiki locally