Skip to content

Developer Documentation

Le Khanh Binh edited this page Jun 29, 2026 · 5 revisions

Developer Documentation

Internals of UXTU4Linux: how the pieces fit together, what each module does, and what to touch when adding hardware support.

Third-party deps: pyzmq (IPC), textual (TUI), textual-plotext (Home graphs). Everything else is stdlib.


File structure

Modules are grouped by responsibility under Assets/. Imports are absolute from the Assets package root — from Assets.core import config as cfg.

UXTU4Linux/
├── UXTU4Linux.py               entry point: single-instance lock, integrity check, launch TUI
├── requirements.txt            pyzmq, textual, textual-plotext
└── Assets/
    ├── config.ini              generated on first run
    ├── custom.json             saved custom presets, generated on first run
    ├── adaptive.json           saved Adaptive Mode presets, generated on first run
    ├── amd/
    │   └── smu.py              low-level ryzen_smu sysfs / SMN register interface
    ├── core/
    │   ├── config.py           paths, ConfigParser singleton, interval clamp, REQUIRED schema
    │   ├── hardware.py         CPU detection, dmidecode helpers, Secure Boot, battery sysfs
    │   └── ipc.py              DaemonClient (ZMQ REQ wrapper) + get_client() singleton
    ├── daemon/
    │   ├── daemon.py           PowerDaemon assembly, ZMQ REP loop, main() / startup
    │   ├── util.py             daemon free functions: SMU apply, preset resolution, lock, _on_ac
    │   ├── loops.py            LoopsMixin: reapply / power-state / suspend loops
    │   ├── commands.py         CommandsMixin: the _cmd_* IPC handlers + handle()
    │   ├── adaptive.py         AdaptiveMixin: Adaptive Mode loop and commands
    │   └── service.py          systemd unit install/uninstall/restart, venv bootstrap
    ├── engine/
    │   ├── presets.py          built-in preset tables, family dispatch, RYZEN_FAMILY
    │   ├── adaptive.py         Adaptive Mode control maths (power ramp, CO, iGPU clock)
    │   └── runner.py           SMU command tables per socket, apply_args()
    ├── flows/
    │   ├── setup.py            first-run integrity check, config defaults, reset_all
    │   └── updater.py          version check, stable/beta download, self-update
    ├── system/
    │   ├── sensors.py          hwmon / pm_table / sysfs reads, capabilities()
    │   ├── pmtable.py          ryzen_smu pm_table parsing
    │   ├── nvcheck.py          NVIDIA dGPU presence and ROP check
    │   └── platformctl.py      power profile, ASUS WMI, CCD affinity (runs inside the daemon)
    ├── tuning/
    │   ├── power.py            built-in preset list, apply dispatch to the daemon
    │   ├── custom.py           custom-preset field definitions, JSON I/O, build_args()
    │   ├── adaptivemanager.py  Adaptive Mode preset storage (adaptive.json)
    │   └── automations.py      AC / battery / resume slot state helpers
    └── tui/
        ├── app.py             U4LApp: TabbedContent, key bindings, status refresh loop
        ├── tabs/              one module per tab (homeView, premadeView, customView, adaptiveView, …)
        ├── modals.py          About, Updater, Sudo, confirm, log and hardware-info modals
        ├── wizard.py          first-run SetupWizard (3-step ModalScreen)
        ├── helpers.py         banner, status line, do_apply(), adaptive_running()
        └── app.tcss           Textual CSS stylesheet

Entry point (UXTU4Linux.py)

Runs as your normal user:

  1. Acquires a single-instance lock with flock on /tmp/uxtu4linux_tui.lock — a second TUI exits immediately.
  2. Runs check_binaries() — verifies dmidecode is in PATH; non-None result goes to the TUI as FatalErrorModal.
  3. Runs check_integrity() and init_config() from flows/setup.py to verify config.ini and custom.json exist with required keys.
  4. Checks needs_setup() for the first-run wizard, and service_path_stale() for a moved install.
  5. Launches the Textual app via Assets.tui.app.run(...).

After mount, app.py's _deferred_startup() calls check_ryzen_smu() from core/hardware.py. Returns None immediately when Secure Boot is off (PCI backend, no module check needed). With Secure Boot on, it inspects ryzen_smu and returns an error string if missing, too old, or unsigned. Non-None triggers FatalErrorModal.

The app's run() returns a string result; "reset" triggers reset_all() and a relaunch, "setup-done" continues normally.


Two-process design

Two separate processes:

TUI (Assets/tui/) — runs as your normal user, handles all interaction, never touches CPU registers.

Daemon (Assets/daemon/daemon.py) — runs as root via systemd, the only process that writes to the CPU. Binds a ZeroMQ REP socket at ipc:///run/uxtu4linux.sock.

Split because SMU writes need root — keeping the TUI unprivileged reduces attack surface, and the daemon validates everything before acting. Socket is chmoded 666 at startup so the TUI can connect. Both sides take single-instance locks: /tmp/uxtu4linux_tui.lock (TUI) and /run/uxtu4linux_daemon.lock (daemon).


IPC layer (core/ipc.py)

DaemonClient wraps a ZeroMQ REQ socket. Send and receive both have a 2-second timeout (TIMEOUT_MS = 2_000). On any error (ZMQ exception, JSON parse failure, timeout, missing socket), _send() closes the socket and returns None; next call creates a fresh connection. Every public method returns a fallback value when the daemon isn't available.

from Assets.core.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 guarded by a threading.Lock.

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, backend, adaptive
apply_saved none re-derives all runtime state from config: stops the loop/monitor, then starts the loop, the monitor, a single apply, or goes idle - whichever config implies
reload_config none {"ok": True}
reset_state none stops the loop and clears the in-memory preset (used by Reset all)
dmidecode type {"ok": True, "output": "..."}
adaptive_start preset, optional values {"ok": True, "caps": [...]}
adaptive_stop none {"ok": True, "reverted": bool}

dmidecode is proxied through the daemon because dmidecode -t needs root. Allowed type strings are whitelisted in _DMI_ALLOWED_TYPES in commands.py.

To add an IPC command: add the _cmd_* handler to the right daemon mixin (commands.py for general, adaptive.py for adaptive), add a _dispatch entry in PowerDaemon.__init__, then the matching client method in ipc.py.


Daemon internals (daemon/)

PowerDaemon (daemon.py) is assembled from three mixins — CommandsMixin (commands.py), LoopsMixin (loops.py), AdaptiveMixin (adaptive.py) — over the free functions in util.py. Manages the ZMQ server loop and up to four background threads. All shared state is under self._lock.

Background threads

First three live in LoopsMixin (loops.py); adaptive loop in AdaptiveMixin (adaptive.py).

Reapply loop (_loop_body) — started by apply_loop. Calls _apply_once() every N seconds. With an automation slot set, picks the correct preset for the current AC/battery state each tick and logs on transitions.

Power monitor (_monitor_body) — polls /sys/class/power_supply for AC state changes, applies the matching slot, skips empty slots. Runs independently of the reapply loop.

Suspend monitor (_suspend_monitor_body) — compares CLOCK_BOOTTIME against time.monotonic(). Gap exceeds threshold → system just resumed → reads OnResume from config and applies it. Catches suspend-to-RAM and hibernation with no logind dependency. Logs and disables itself if CLOCK_BOOTTIME isn't available.

Adaptive loop (_adaptive_body) — started by adaptive_start. Each tick: samples sensors, computes dynamic args (engine/adaptive.py) plus ASUS/NVIDIA args, applies them. Reapply loop and power monitor skip their ticks while this runs.

_effective_mode_args() resolves which preset actually applies given the base preset, automation slots, and current power state. On transitions it's called with keep_on_empty=True so an empty slot leaves current settings untouched. _load_saved_preset() reconstructs state from config on startup and apply_saved.

apply_saved is the resync entry point — fully re-derives runtime state from config, so the TUI just writes config and calls apply_saved. Automation is active whenever OnAC or OnBattery is set; no separate flag. Every command path leaves coherent state.

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

The single place CPU settings are written. Returns (output, rejected)output is the per-command status log, rejected is True if the SMU refused anything. When reason is set and the apply succeeded, the daemon logs one line:

Applied preset 'Eco' (power source changed from AC to battery).
Applied preset 'Balance' (woke from suspend after ~28m).

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.

Dispatch and main loop

self._dispatch maps command names to _cmd_* methods. handle(raw) parses the JSON, looks up the command, calls it, returns the JSON response (exceptions become {"ok": False, "error": ...}). The run() loop receives on the REP socket, calls handle(), sends the response, breaks on ZMQ error. SIGTERM/SIGINT stop threads, unlink socket, exit.

Startup checks

main() logs an actionable message for each problem:

  • Secure Boot off → logs Secure Boot disabled — using PCI direct access backend., calls init_pci_backend(). If PCI unavailable → PCI backend unavailable — presets will not be applied.
  • Secure Boot on → checks ryzen_smu; logs version, "too old", "not installed. Required when Secure Boot is enabled.", "not signed for Secure Boot.", "installed but not loaded." as appropriate.
  • dmidecode missing → exits with error
  • Intel CPU → warning, continues (presets won't apply)
  • Another daemon instance holds /run/uxtu4linux_daemon.lock → exits

These exact strings are quoted in the Linux Troubleshooting table — update that page if you change them.


SMU layer (amd/smu.py, engine/runner.py)

smu.py

Two backends, selected at daemon startup based on Secure Boot state:

PCI backend (Secure Boot off)

init_pci_backend() is called when secure_boot_enabled() returns False. Opens /sys/bus/pci/devices/0000:00:00.0/config (northbridge PCI config space) and performs SMN register reads/writes via the NB address/data port pair at 0xB8 / 0xBC. No kernel module needed. active_backend() returns "pci".

ryzen_smu backend (Secure Boot on)

When Secure Boot is on, the daemon uses the ryzen_smu sysfs interface at /sys/kernel/ryzen_smu_drv/:

Path Purpose
/sys/kernel/ryzen_smu_drv/ Module presence check
.../drv_version Module version string
.../smn System Management Network register interface

UXTU4Linux drives the SMU through SMN register writes on the smn node. active_backend() returns "ryzen_smu".

Both backends share the same protocol: two channels, MP1 (management processor) and RSMU (register-based SMU). Both follow write args → write opcode → poll response. Mailbox register addresses differ per family and live in _MP1 / _RSMU.

PM table reading on the PCI backend: read_pm_table_pci(family) sends three RSMU commands (get version → get DRAM address → trigger DMA), then reads the table from the physical address via /dev/mem.

status = smu.send_mp1(family, op_code, value)
status = smu.send_rsmu(family, op_code, value)
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. Poll spins tightly first, then sleeps in small steps with a deadline.

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

Maps CPU families to SMU command tables. _FAMILY_SOCKET maps a family to a socket group; each socket group has a _CMD_* table mapping arg names to (is_mp1, opcode). is_mp1 = True → MP1, False → RSMU. Tables are kept in sync with the original UXTU's RyzenSmu.cs.

Family → socket type:

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
runner.lookup(family, arg_name) -> list[tuple[bool, int]]   # [] if not supported
runner.has_smu_support(family) -> bool
runner.apply_args(args_str, family) -> tuple[str, bool]      # (output_log, any_rejected)

apply_args is what _apply_once calls:

  1. shlex.split the args
  2. Strip --, split on =
  3. Route sys-* tokens to _apply_system()platformctl.py, nvidia-clocks to _apply_nvidia()
  4. Otherwise runner.lookup(family, name) for the opcode
  5. Scale value if needed (skin temp × 256)
  6. Send via smu.send_mp1() / smu.send_rsmu()
  7. Collect per-command status; SMU-rejected commands marked [!]

Unsupported args: not supported on <family>, skipped. rejected flag set only when hardware returns non-OK for a sent command.

NVIDIA: --nvidia-clocks=max,core,mem_apply_nvidia(). Uses nvidia-smi -lgc 0,<max> to cap clock (-rgc to reset when max is 4000), NVML (libnvidia-ml.so.1) for offsets. Tries nvmlDeviceSetClockOffsets (new) then nvmlDeviceSetGpcClkVfOffset / nvmlDeviceSetMemClkVfOffset (legacy).


System settings layer (system/platformctl.py)

Pseudo-args prefixed sys- are not SMU commands. runner.apply_args() routes them through platformctl.py, which runs inside the daemon (root):

Arg Backend
sys-power-profile=0|1|2 /sys/firmware/acpi/platform_profile, falling back to powerprofilesctl, then tuned-adm
sys-asus-mode=0|1|2 ASUS throttle_thermal_policy (asus-nb-wmi sysfs or asus-armoury firmware-attributes)
sys-asus-eco=0|1 ASUS dgpu_disable
sys-asus-mux=0|1 ASUS gpu_mux_mode
sys-ccd-affinity=0|1|2 systemctl set-property --runtime user.slice AllowedCPUs=...
  • resolve_profile() maps canonical profile names to whatever the firmware offers, using the same synonym table as G-Helper (platform_profile_choices).
  • set_power_profile() tries sysfs first, then powerprofilesctl (covers power-profiles-daemon and Fedora's tuned-ppd), then tuned-adm.
  • tlp_profile_conflict() checks /etc/tlp.conf for PLATFORM_PROFILE_ON_AC/BAT; if set, logs a warning that TLP will fight over the profile.
  • ASUS paths try three candidates: asus-nb-wmi platform device, generic platform bus, asus-armoury firmware-attributes (kernel 6.8+).
  • set_asus_eco() carries G-Helper's safety guards (refuses while the dGPU driver is active or MUX is in Ultimate mode) and triggers a PCI bus rescan after re-enabling the dGPU.
  • _l3_domains() reads shared_cpu_list to find CCDs; ccd_affinity_available() is true only with two or more L3 domains.
  • Each setter caches its last written value and skips the write if nothing changed.

Preset system (engine/presets.py)

Each built-in preset is a space-separated string of ryzenadj-style args (e.g. "--tctl-temp=90 --stapm-limit=25000 ..."), ported from the original UXTU's PremadePresets.cs. The dead --vrmgfx-current arg is dropped, and --Win-Power=N maps to --sys-power-profile=N.

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

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

  1. Variant match _variant_preset(variant) - specific devices like Framework Laptop models
  2. APU family _apu_preset(family, cpu_model) - dispatches on model suffix (U/H/HS/HX/G/GE)
  3. Desktop CPU _desktop_preset(family, cpu_model)
  4. Fallback _desktop_standard()

RYZEN_FAMILY lists every known family in release order; _before(family, ref) returns True if family is earlier than ref, used for conditions like "any family before Matisse".

Adding a built-in preset for a new family

  1. Add the family to RYZEN_FAMILY at the right position
  2. Add the family→socket entry 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 defaults
  4. Add a branch in _apu_preset() / _desktop_preset() returning a Preset
  5. Add the label in get_preset_label()
  6. Add the codename resolution in _resolve_codename() in hardware.py

Custom presets (tuning/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}
  }
]

Values are stored in display units (W, A, °C, MHz); 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-ins; the UI strips it for display via display_name().

Field definitions

FIELD_DEFS (APU) and FIELD_DEFS_DT (desktop) are lists of dicts, one per parameter:

{
    "key": "stapm_limit", "label": "STAPM Power Limit", "arg": "--stapm-limit",
    "unit": "W", "default": 28, "min": 5, "max": 300, "step": 1,
    "enabled": False, "section": 2, "hint": "...",
}

Special properties: scale (multiply before sending), signed_co (negative CO uses 0x100000 - abs(v)), ccd/core (per-core CO indices), choices (string list; stored value is an index), nvidia_only, check_arg (alternate arg name for support lookup), system_check (System-section field, shown only when the capability is detected).

Section titles come from APU_SECTION_TITLES / DT_SECTION_TITLES. _supported_field_keys() filters fields by runner.lookup() and _system_supported(); _active_sections() keeps only sections that still have a visible field — that's what drives the editor's collapsible sections.

Arg building (build_args)

Iterates enabled fields. Special cases: tctl_temp on APUs also emits --chtc-temp; oc_clk/oc_volt are emitted twice each plus --enable-oc --enable-oc; --set-coper packs CCD index, core index and CO value into one 32-bit int; NVIDIA fields collapse into a single --nvidia-clocks=max,core,mem; System fields emit their --sys-* arg with the choice index.

Lifecycle

save_preset() checks if the preset is currently active; if so, tells the daemon to apply_saved so the new values take effect immediately. delete_preset() clears config references, disables automations if both AC/battery slots are now empty, and notifies the daemon.


Config (core/config.py)

config.py owns the single ConfigParser instance over Assets/config.ini. Both processes read it; only the TUI writes it.

from Assets.core import config as cfg

val = cfg.get("Settings", "Time", "3")     # fallback "" if omitted
cfg.set_config("Settings", "Time", "5")
cfg.save()                                  # atomic
cfg.load()                                  # re-read from disk
if cfg.is_debug(): ...

Saves are atomic: atomic_write() writes a temp file, fsyncs, then os.replace()s it over the target.

Key constants

cfg.CONFIG_PATH          # <Assets>/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_PATH      # /run/uxtu4linux.sock
cfg.ZMQ_SOCKET_ADDR      # ipc:///run/uxtu4linux.sock
cfg.LOCAL_VERSION        # current version string (e.g. "0.9.0")
cfg.LOCAL_BUILD          # build tag

parse_interval() clamps the reapply interval to [MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS] = [1, 86400]. REQUIRED documents the keys each section must have.

Config integrity (flows/setup.py)

check_integrity() runs at TUI startup:

  • File missing/empty → run the setup wizard (needs_setup() returns True)
  • Info section missing or incomplete → reset_all() (deletes config.ini and custom.json), then wizard
  • Other missing keys → filled from defaults and saved, no wizard

Adding new config keys in a future version won't wipe settings — missing keys are filled silently.


CPU detection (core/hardware.py)

detect() runs during setup and from Settings → Re-detect hardware:

  1. Reads CPU name (Version) and Signature from dmidecode -t processor via IPC, stores in Info.CPU / Info.Signature
  2. _compute_codename() parses Family/Model integers, calls _resolve_codename() for (arch, family), then _cpu_type() for Amd_Apu / Amd_Desktop_Cpu / Intel / Unknown. AMD family with no runner.has_smu_support() → downgraded to Unknown.
  3. Framework Laptop variant via _detect_framework_variant()Info.Variant
  4. cfg.save()
cpu_family Architecture
23 Zen 1 - Zen 2
25 Zen 3 - Zen 4
26 Zen 5 - Zen 6

Some models need the CPU name to disambiguate — family 25 / model 97 is DragonRange if the name contains "HX", otherwise Raphael.

_DESKTOP_FAMILIES = {"SummitRidge", "PinnacleRidge", "Matisse", "Vermeer", "Raphael", "GraniteRidge"}

hardware.py also holds ryzen_smu install/sign/load checks and Secure Boot detection.


TUI (tui/)

Built on Textual. Runs as the normal user, talks to the daemon through core/ipc.py.

App shell (app.py)

U4LApp(App) loads app.tcss and composes a banner, a status line, and a TabbedContent with eight TabPanes: home, power, custom, adaptive, automations, hardware, status, settings. Key bindings: h Home, 17 for the other tabs, ? About, q quit.

  • set_interval(1.0, …) polls daemon status and updates the status line every second; offline daemon shows a one-time warning toast.
  • _deferred_startup() runs in a background thread after mount: calls check_ryzen_smu(), checks for a stale service path, calls apply_saved if the daemon has no active preset, and compares daemon version against cfg.LOCAL_VERSION. If they differ, fires a warning toast.
  • _check_size() hides the banner/tabs and shows a "terminal too small" notice below 50×25, swaps the full banner for a compact wordmark on narrow terminals.
  • On mount: applies saved theme and pushes FatalErrorModal, SetupWizard, StalePathModal, or a startup update prompt depending on state.
  • A background toast-reaper thread wakes the event loop so notifications dismiss on time.

Tabs (tabs/)

One module per tab. Each is a VerticalScroll subclass; app.py imports each directly.

  • HomeTab (homeView.py) — live CPU temp/power/clock/usage graphs (textual-plotext), iGPU graphs on APUs, and navigation buttons.
  • PowerTab (premadeView.py) — four preset buttons (Eco/Balance/Performance/Extreme); clicking applies through helpers.do_apply. Blocked while Adaptive Mode runs.
  • CustomEditor (customView.py) — Saved Presets select, name input, Save/Apply/Duplicate/Delete, collapsible sections of field cards (Switch + Input or Select per parameter).
  • AdaptiveTab (adaptiveView.py) — Save/Duplicate/Delete/Start over collapsible settings; ASUS and NVIDIA sections visible only when that hardware is detected.
  • AutomationsTab (automationsView.py) — three Select slots (Battery Charge / Battery Discharge / System Resume). No enable switch; active whenever a slot is set.
  • HardwareTab (infoView.py) — CPU, memory, cache and battery via client.dmidecode(...).
  • StatusTab (statusView.py) — daemon state, automation slots, adaptive state and SMU output in one panel. Refreshes every second while open, plus a power-source watcher that refreshes on AC/battery flip.
  • SettingsTab (settingsView.py) — general toggles (ApplyOnStart, AutoStartAdaptive, SoftwareUpdate, Debug), reapply controls, default-tab select, daemon service card (Install/repair, Restart, View logs, Uninstall), hardware/reset card.

Modals and wizard (modals.py, wizard.py)

modals.py: AboutModal, UpdaterModal, UpdateProgressModal, SudoModal (in-app password prompt for service.prime_sudo), ConfirmModal, DaemonLogModal, HardwareInfoModal, FatalErrorModal, StalePathModal. wizard.py is the three-step SetupWizard (Welcome → Background daemon → Detect hardware).

Helpers (helpers.py)

do_apply() sends an apply through the daemon, ensure_sudo() runs the SudoModal flow, status_line() / fetch_status() build the header, BANNER / WORDMARK are the ASCII art.


Service management (daemon/service.py)

install_service()      # ensure venv, write unit, daemon-reload, enable, start
uninstall_service()    # stop, disable, remove the unit file, daemon-reload
restart_service()
regenerate_service()   # rewrite the unit and restart (used when the path is stale)
service_path_stale()   # True if the unit's ExecStart no longer matches the current paths
read_logs(lines=200)   # journalctl -u uxtu4linux.service

_ensure_venv() creates /opt/uxtu4linux/venv via python3 -m venv --without-pip, bootstraps pip via ensurepip, installs pyzmq, textual and textual-plotext if they're not importable. _render_unit() bakes in the absolute path to the venv Python and to daemon.py, so the unit stays correct if the app is moved. Unit file is written via temp file + sudo mv. prime_sudo() / sudo_available() handle credential caching for the in-app SudoModal.

verify_service_path() runs at TUI startup: if the install moved and the unit's ExecStart no longer matches, it regenerates and restarts. Non-systemd systems get the manual daemon-start command instead.


Updater (flows/updater.py)

Versions are MAJOR.MINOR.PATCH; pre-release/build suffixes are stripped before comparison. _ver_tuple(v) parses each segment. get_latest_version() follows the GitHub releases/latest redirect and validates the tag. check_updates() retries a few times before giving up.

Beta builds: is_beta_build() / get_beta_commit() work against the rolling U4L-Beta tag; "Switch to beta" downloads that release asset.

_do_update procedure:

  1. Back up config.ini and custom.json into /opt/uxtu4linux/
  2. Download and extract the release zip to <install dir>/UXTU4Linux_new/
  3. sudo mv current app dir aside as /opt/uxtu4linux/src.bak
  4. sudo mv new release into place; on failure .bak is moved back
  5. Remove .bak and UXTU4Linux_new/
  6. Move config/preset backups into the new Assets/
  7. Remove the zip; reinstall requirements.txt into the venv
  8. Restart daemon if the service is running
  9. os.execv to relaunch the TUI from new code

On success the only backups left are the two config files in the install directory.


GitHub Actions

main.yml

Manual (workflow_dispatch). Zips UXTU4Linux/, reads version and notes from Changelog.md, generates release notes (install one-liner + changelog), publishes the GitHub release that install.sh downloads.

beta.yml

Also manual. Packages current state, deletes the existing U4L-Beta release and tag, force-creates the tag at HEAD, publishes a pre-release titled "U4L Beta Build". "Switch to beta" downloads from this release.

Versioning lives in two places that must stay in sync: LOCAL_VERSION / LOCAL_BUILD in core/config.py and the top entry of Changelog.md.


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 engine/presets.py at the right position
  • Add the family→socket entry in _FAMILY_SOCKET in engine/runner.py
  • Add the command table under the socket type in runner.py (or create a new one)
  • Add MP1/RSMU register addresses to _MP1 / _RSMU in amd/smu.py if different from defaults
  • Add the _resolve_codename branch in core/hardware.py with the right cpu_family / cpu_model
  • 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()
  • Test on a machine that has the CPU

Finding cpu_family and cpu_model

sudo dmidecode -t processor | grep Signature

Output: Signature: Type 0, Family 26, Model 32, Stepping 1. Family and Model go into _resolve_codename().

Running from source

No build step. Run the TUI directly:

python3 UXTU4Linux/UXTU4Linux.py

Daemon needs root; for development run it by hand:

sudo python3 UXTU4Linux/Assets/daemon/daemon.py

Set Debug = 1 under [Settings] (or toggle it in Settings) for DEBUG-level daemon logs; picked up on reload_config. The installer puts a release zip at /opt/uxtu4linux/src, so test working-tree changes by running source directly.

Clone this wiki locally