Skip to content

Developer Documentation

Le Khanh Binh edited this page Jul 4, 2026 · 1 revision

Developer Documentation

Internals of ZenTune: 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), zenmaster (SMU access and CPU detection, a separate pure-Python package). Everything else is stdlib.

A note on architecture: the low-level SMU opcode tables, mailbox protocol, and per-platform hardware access (PCI direct / ryzen_smu on Linux, PawnIO on Windows, DirectHW / IOPCIBridge on macOS) used to live inside this repo. They've since moved into zenmaster, a standalone package ZenTune depends on. ZenTune's own code is now the TUI, the daemon plumbing, preset tables, and Linux-only OS integrations (ASUS WMI, power profiles, CCD affinity) that don't belong in a hardware-access library.


File structure

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

ZenTune/
├── zentune.py                  entry point: single-instance lock, dependency check, launch TUI
├── requirements.txt            pyzmq, textual, textual-plotext, zenmaster>=1.0.0
└── 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
    ├── core/
    │   ├── config.py           paths, ConfigParser singleton, interval clamp, REQUIRED schema
    │   ├── platform.py         IS_LINUX / IS_MACOS / RUNTIME_DIR
    │   ├── powerstate.py       on_ac() - power_supply sysfs on Linux, pmset on macOS
    │   ├── hardware.py         CPU detection via zenmaster, ryzen_smu/macOS backend checks
    │   └── 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 (Linux only)
    │   ├── service.py          sudo helpers, venv bootstrap, dispatches to launchd.py/systemd.py
    │   ├── systemd.py          systemd unit install/uninstall/restart (Linux)
    │   └── launchd.py          launchd plist install/uninstall/restart (macOS)
    ├── engine/
    │   ├── presets.py          built-in preset tables (wattages etc.), family dispatch, RYZEN_FAMILY
    │   ├── adaptive.py         Adaptive Mode control maths (power ramp, CO, iGPU clock)
    │   └── runner.py           routes sys-*/nvidia-clocks locally, everything else to zenmaster.apply
    ├── 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()
    │   ├── nvcheck.py          NVIDIA dGPU presence and ROP check
    │   ├── nvml.py             NVML bindings shared by nvcheck and runner's NVIDIA apply path
    │   └── platformctl.py      power profile, ASUS WMI, CCD affinity (Linux only, 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()
    │   ├── adaptivepreset.py   Adaptive Mode preset storage (adaptive.json)
    │   └── automations.py      AC / battery / resume slot state helpers
    └── tui/
        ├── app.py             ZenTuneApp: 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 (zentune.py)

Runs as your normal user:

  1. Acquires a single-instance lock with flock on /tmp/zentune_tui.lock (Linux and macOS both use /tmp) - a second TUI exits immediately, showing a "ZenTune is already running." error screen instead of a silent failure.
  2. Runs ensure_custom_presets_file() and check_python_deps() from flows/setup.py. The latter checks that every package in requirements.txt is importable (no dmidecode or other external-binary check anymore, hardware detection reads CPUID sysctls//proc/cpuinfo directly); a non-None result goes to the TUI as FatalErrorModal.
  3. Runs needs_setup(); on first run calls init_config(), otherwise check_integrity() from flows/setup.py to verify config.ini and custom.json exist with required keys.
  4. Launches the Textual app via Assets.tui.app.run(...).

After mount, app.py's _deferred_startup() calls check_macos_backend() on macOS or check_ryzen_smu() on Linux, both in core/hardware.py. On Linux, check_ryzen_smu() 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, unsigned, or not loaded. On macOS, check_macos_backend() returns None if DirectHW is loaded, or if the kext-free IOPCIBridge path is usable (debug=0x144 boot-arg present); otherwise it returns install instructions linking cfg.MACOS_BACKEND_WIKI_URL. Either way, a non-None result triggers FatalErrorModal.

The app's run() returns a string result; "setup-done" / "relaunch" re-exec the process with os.execv, "reset" calls reset_all() (and tells the daemon to reset_state first, if reachable) before re-execing.


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 (Linux) or launchd (macOS), the only process that writes to the CPU. Binds a ZeroMQ REP socket at cfg.ZMQ_SOCKET_ADDR (ipc:///run/zentune.sock on Linux, ipc:///var/run/zentune.sock on macOS - core/platform.py's RUNTIME_DIR picks the right root).

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/zentune_tui.lock (TUI) and {RUNTIME_DIR}/zentune_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)
adaptive_start preset, optional values {"ok": True, "caps": [...]}
adaptive_stop none {"ok": True, "reverted": bool}

There's no dmidecode command; CPU detection runs directly in the TUI process through zenmaster.hardware, no root needed, so there's nothing to proxy through the daemon.

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, Linux only).

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 core.powerstate.on_ac() for AC state changes (power_supply sysfs on Linux, pmset -g batt on macOS), applies the matching slot, skips empty slots. Runs independently of the reapply loop.

Suspend monitor (_suspend_monitor_body) - compares _clock_boottime() (CLOCK_BOOTTIME on Linux, wall-clock time.time() on macOS, both keep advancing through sleep) against time.monotonic() (which doesn't). Gap exceeds threshold -> system just resumed -> reads OnResume from config and applies it. Catches suspend-to-RAM and hibernation with no logind dependency, and works the same way on both platforms. Logs and disables itself if CLOCK_BOOTTIME isn't available on Linux.

Adaptive loop (_adaptive_body, Linux only) - 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 log 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, using zenmaster.smu's cross-platform ensure_backend() / unavailable_reason():

  • Backend ready -> logs {driver} driver ready (version {version}).
  • Linux, Secure Boot off, PCI usable -> Secure Boot disabled, using PCI direct access backend.
  • Backend unavailable -> logs the platform's unavailable_reason() text plus an install-guide link: cfg.RYZEN_SMU_WIKI_URL on Linux, cfg.MACOS_BACKEND_WIKI_URL on macOS
  • Intel CPU -> warning, continues (presets won't apply)
  • Another daemon instance holds {RUNTIME_DIR}/zentune_daemon.lock -> exits

These exact strings are quoted in Linux Troubleshooting and macOS Troubleshooting - update those pages if you change them.


SMU layer (external zenmaster package)

ZenTune no longer talks to hardware directly. Assets/engine/runner.py::apply_args() is a thin router:

  1. shlex.split the args string
  2. sys-* tokens -> _apply_system() -> Assets/system/platformctl.py (Linux-only OS integrations)
  3. nvidia-clocks=... -> _apply_nvidia() -> NVML bindings, local to ZenTune
  4. Everything else -> zenmaster.apply.apply(args_str, family), which does the actual opcode lookup, mailbox send, and per-family scaling (e.g. skin-temp × 256), and returns per-command results
  5. Results are formatted into the human-readable output string the Status tab and daemon logs show, marking SMU-rejected commands [!]
from zenmaster.apply import apply
from zenmaster.smu import status_name

results, rejected = apply("--tctl-temp=90 --stapm-limit=25000", family)

Everything that used to be documented here, the MP1/RSMU mailbox protocol, per-family socket groups and command tables, the PCI-direct vs. ryzen_smu backend selection on Linux, PawnIO on Windows, and DirectHW/IOPCIBridge on macOS, now lives in the zenmaster package (a separate repo/CLAUDE.md). Read that project's own documentation before touching hardware-access code; ZenTune only consumes its public API (zenmaster.apply, zenmaster.smu, zenmaster.hardware, zenmaster.runner).

SMU_OK / SMU_FAILED / etc. and status_name() also come from zenmaster.smu, used only for formatting here.

NVIDIA: --nvidia-clocks=max,core,mem[,power] stays local to ZenTune (_apply_nvidia/_nvml_apply in runner.py). Uses NVML (libnvidia-ml.so.1) directly via ctypes: nvmlDeviceSetGpuLockedClocks/nvmlDeviceResetGpuLockedClocks for the clock cap, nvmlDeviceSetPowerManagementLimit for the power limit, and nvmlDeviceSetClockOffsets (falling back to the legacy nvmlDeviceSetGpcClkVfOffset/nvmlDeviceSetMemClkVfOffset pair) for core/mem offsets.


System settings layer (system/platformctl.py)

Linux only. 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.

On macOS, none of these paths exist, so every field in the Custom Preset Editor's System section is hidden automatically, there's no macOS branch to maintain here.


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. This table is ZenTune's own and is separate from zenmaster's opcode/mailbox tables, it defines values, not which SMU commands exist for a family.

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

get_preset(cpu_type, family, cpu_model, 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. Confirm zenmaster already supports the family (zenmaster.runner.is_supported(family)), if not, that needs adding upstream in zenmaster first (opcode table, mailbox addresses, codename resolution).
  2. Add the family to RYZEN_FAMILY in engine/presets.py at the right position.
  3. Add a branch in _apu_preset() / _desktop_preset() returning a Preset with wattage/current/clock values for that family.
  4. Add the label in get_preset_label().

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 against zenmaster.runner.lookup()), system_check (System-section field, shown only when the capability is detected, always false on macOS).

Section titles come from APU_SECTION_TITLES / DT_SECTION_TITLES. _supported_field_keys() filters fields by zenmaster.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/zentune/venv
cfg.VENV_PYTHON             # /opt/zentune/venv/bin/python3
cfg.ZMQ_SOCKET_PATH         # {RUNTIME_DIR}/zentune.sock
cfg.ZMQ_SOCKET_ADDR         # ipc://{RUNTIME_DIR}/zentune.sock
cfg.RYZEN_SMU_WIKI_URL      # Linux ryzen_smu install guide
cfg.MACOS_BACKEND_WIKI_URL  # macOS DirectHW/IOPCIBridge install guide
cfg.LOCAL_VERSION           # current version string (e.g. "2.0.0")
cfg.LOCAL_BUILD             # build tag

RUNTIME_DIR (from core/platform.py) is /run on Linux, /var/run on macOS. 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, and calls straight into zenmaster.hardware, no daemon round-trip, no external binary:

  1. zenmaster.hardware.detect() reads /proc/cpuinfo on Linux or the CPUID sysctls (machdep.cpu.*) on macOS, returning a CpuInfo with the CPU name and raw Family/Model/Stepping integers. Stored in Info.CPU / Info.Signature.
  2. _compute_codename() parses those integers back out of the stored signature string, calls zenmaster.hardware.resolve() for (arch, family, type), downgrading AMD families with no zenmaster.runner.is_supported() match to Unknown.
  3. Framework Laptop variant via _detect_framework_variant() -> Info.Variant (Linux only; always empty on macOS, no DMI table to read).
  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 the two backend-availability checks used at TUI startup: check_ryzen_smu() (Linux, reads zenmaster.smu.secure_boot_enabled() and module_status(), both unprivileged) and check_macos_backend() (macOS, checks zenmaster.directhw.is_loaded() or zenmaster.iokit.is_available() plus the debug=0x144 boot-arg, also unprivileged, no root needed for either check even though the daemon's actual SMU access does need root).


TUI (tui/)

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

App shell (app.py)

ZenTuneApp(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, 1-7 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_macos_backend() (macOS) or check_ryzen_smu() (Linux), 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 50x25, 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. Tab and Settings toggle both hidden entirely on macOS.
  • 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 info, read directly via core/hardware.py (no daemon round-trip).
  • 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, systemd.py, launchd.py)

service.py holds the shared, platform-agnostic pieces (sudo_available(), prime_sudo(), sudo_run(), sudo_write_file(), ensure_venv(), daemon_script(), python_bin(), manual_start_command(), wait_for_daemon()), then dispatches to the platform backend at import time:

from Assets.core import platform as plat
if plat.IS_MACOS:
    from Assets.daemon import launchd as _backend
else:
    from Assets.daemon import systemd as _backend

systemd.py and launchd.py both expose the same five-function surface, re-exported through service.py:

install_service()      # ensure venv, write unit/plist, enable, start
uninstall_service()    # stop, disable, remove the unit/plist file
restart_service()
regenerate_service()   # rewrite the unit/plist and restart (used when the path is stale)
service_path_stale()   # True if ExecStart / ProgramArguments no longer matches the current paths

systemd (systemd.py): unit at /etc/systemd/system/zentune.service, ExecStart={python_bin()} {daemon_script()}, logs via journald (Restart=on-failure, RestartSec=5). Written via temp file + sudo mv, then daemon-reload + enable --now.

launchd (launchd.py): plist at /Library/LaunchDaemons/com.horizonunix.zentune.plist, label com.horizonunix.zentune, KeepAlive.SuccessfulExit = false (relaunch on crash, same intent as systemd's Restart=on-failure). StandardOutPath/StandardErrorPath both point at /var/log/zentune.log; read_logs() tails that file directly since there's no journald equivalent. Managed with launchctl bootstrap/bootout/kickstart -k against the system/ domain.

_ensure_venv() (in service.py) creates cfg.VENV_DIR via python3 -m venv --without-pip, bootstraps pip via ensurepip, installs the packages from requirements.txt if they're not importable.

service_path_stale() runs at TUI startup (verify_service_path()-equivalent logic in app.py's _deferred_startup()): if the install moved and the unit's/plist's baked-in paths no longer match, StalePathModal prompts for sudo and calls regenerate_service() automatically. Systems with neither systemd nor launchd (rare, e.g. some non-systemd Linux distros) get manual_start_command() shown instead, both in the wizard and in Settings.


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() / beta_available() work against the rolling ZenTune-Beta tag; "Switch to beta" downloads that release's ZenTune.zip asset.

perform_update() procedure:

  1. Back up config.ini and custom.json into the install directory (config.ini.bak, custom.json.bak)
  2. Download the release zip and extract it to <install dir>/ZenTune_new/
  3. sudo mv current app dir aside as <install dir>/src.bak
  4. sudo mv the extracted ZenTune/ folder into place as the new app dir; on failure .bak is moved back
  5. Remove .bak and ZenTune_new/
  6. Move the config/preset backups into the new Assets/
  7. Reinstall requirements.txt into the venv (or --user if no venv)
  8. Remove the zip
  9. Restart the daemon if the service is running

The caller (the Updater modal) re-execs the TUI (os.execv) once perform_update() returns {"ok": True}. On success the only backups left are the two config files in the install directory.


GitHub Actions

main.yml

Manual (workflow_dispatch). Reads the version tag from the top entry of Changelog.md (repo root), zips ZenTune/ into ZenTune.zip, and, for versions in the 2.0.0-2.2.0 range, also assembles a legacy migration relay (migration/uxtu4linux_relay/ renamed to UXTU4Linux/ and zipped) for v1.1.0 users still on the old in-app updater. Generates release notes from the changelog section and publishes the GitHub release that install.sh downloads.

beta.yml

Also manual. Zips current state, deletes the existing ZenTune-Beta release and tag, force-creates the tag at HEAD, publishes a pre-release titled "ZenTune 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 CPU family - checklist

Opcode-level support (whether the SMU understands a command at all) lives in zenmaster, not here. To add a new family:

  • In zenmaster: add the family's socket group, command table, and mailbox register addresses (see that project's own docs)
  • In zenmaster: add the resolve() / codename branch for the new (cpu_family, cpu_model) pair
  • In ZenTune's engine/presets.py: add the family to RYZEN_FAMILY at the right position
  • In ZenTune's engine/presets.py: add the _apu_preset() / _desktop_preset() branch returning wattage/current/clock values for the new family
  • In ZenTune's engine/presets.py: add the label in get_preset_label()
  • Test on a machine that has the CPU (or a spare one someone else can test on)

Finding cpu_family and cpu_model

# Linux
grep -E "^cpu family|^model" /proc/cpuinfo

# macOS
sysctl machdep.cpu.family machdep.cpu.model

Running from source

No build step. Run the TUI directly:

python3 ZenTune/zentune.py

Daemon needs root; for development run it by hand:

sudo python3 ZenTune/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/zentune/src, so test working-tree changes by running source directly.

Clone this wiki locally