-
-
Notifications
You must be signed in to change notification settings - Fork 6
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.
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
Runs as your normal user:
- Acquires a single-instance lock with
flockon/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. - Runs
ensure_custom_presets_file()andcheck_python_deps()fromflows/setup.py. The latter checks that every package inrequirements.txtis importable (nodmidecodeor other external-binary check anymore, hardware detection reads CPUID sysctls//proc/cpuinfodirectly); a non-Noneresult goes to the TUI asFatalErrorModal. - Runs
needs_setup(); on first run callsinit_config(), otherwisecheck_integrity()fromflows/setup.pyto verifyconfig.iniandcustom.jsonexist with required keys. - 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 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).
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.
| 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.
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.
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.
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.
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.
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_URLon Linux,cfg.MACOS_BACKEND_WIKI_URLon 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.
ZenTune no longer talks to hardware directly. Assets/engine/runner.py::apply_args() is a thin router:
-
shlex.splitthe args string -
sys-*tokens ->_apply_system()->Assets/system/platformctl.py(Linux-only OS integrations) -
nvidia-clocks=...->_apply_nvidia()-> NVML bindings, local to ZenTune - 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 - 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.
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, thenpowerprofilesctl(covers power-profiles-daemon and Fedora's tuned-ppd), thentuned-adm. -
tlp_profile_conflict()checks/etc/tlp.confforPLATFORM_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()readsshared_cpu_listto 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.
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: strget_preset(cpu_type, family, cpu_model, variant="") dispatches in this order:
-
Variant match
_variant_preset(variant)- specific devices like Framework Laptop models -
APU family
_apu_preset(family, cpu_model)- dispatches on model suffix (U/H/HS/HX/G/GE) -
Desktop CPU
_desktop_preset(family, cpu_model) -
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".
- Confirm
zenmasteralready supports the family (zenmaster.runner.is_supported(family)), if not, that needs adding upstream inzenmasterfirst (opcode table, mailbox addresses, codename resolution). - Add the family to
RYZEN_FAMILYinengine/presets.pyat the right position. - Add a branch in
_apu_preset()/_desktop_preset()returning aPresetwith wattage/current/clock values for that family. - Add the label in
get_preset_label().
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_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.
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.
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.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.
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 tagRUNTIME_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.
check_integrity() runs at TUI startup:
- File missing/empty -> run the setup wizard (
needs_setup()returns True) -
Infosection missing or incomplete ->reset_all()(deletesconfig.iniandcustom.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.
detect() runs during setup and from Settings -> Re-detect hardware, and calls straight into zenmaster.hardware, no daemon round-trip, no external binary:
-
zenmaster.hardware.detect()reads/proc/cpuinfoon Linux or the CPUID sysctls (machdep.cpu.*) on macOS, returning aCpuInfowith the CPU name and rawFamily/Model/Steppingintegers. Stored inInfo.CPU/Info.Signature. -
_compute_codename()parses those integers back out of the stored signature string, callszenmaster.hardware.resolve()for(arch, family, type), downgrading AMD families with nozenmaster.runner.is_supported()match toUnknown. - Framework Laptop variant via
_detect_framework_variant()->Info.Variant(Linux only; always empty on macOS, no DMI table to read). -
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).
Built on Textual. Runs as the normal user, talks to the daemon through core/ipc.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: callscheck_macos_backend()(macOS) orcheck_ryzen_smu()(Linux), checks for a stale service path, callsapply_savedif the daemon has no active preset, and compares daemon version againstcfg.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.
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 throughhelpers.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 viacore/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.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).
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.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 _backendsystemd.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 pathssystemd (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.
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:
- Back up
config.iniandcustom.jsoninto the install directory (config.ini.bak,custom.json.bak) - Download the release zip and extract it to
<install dir>/ZenTune_new/ -
sudo mvcurrent app dir aside as<install dir>/src.bak -
sudo mvthe extractedZenTune/folder into place as the new app dir; on failure.bakis moved back - Remove
.bakandZenTune_new/ - Move the config/preset backups into the new
Assets/ - Reinstall
requirements.txtinto the venv (or--userif no venv) - Remove the zip
- 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.
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.
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_BUILDincore/config.pyand the top entry ofChangelog.md.
- 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
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 theresolve()/ codename branch for the new(cpu_family, cpu_model)pair - In ZenTune's
engine/presets.py: add the family toRYZEN_FAMILYat 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 inget_preset_label() - Test on a machine that has the CPU (or a spare one someone else can test on)
# Linux
grep -E "^cpu family|^model" /proc/cpuinfo
# macOS
sysctl machdep.cpu.family machdep.cpu.modelNo build step. Run the TUI directly:
python3 ZenTune/zentune.pyDaemon needs root; for development run it by hand:
sudo python3 ZenTune/Assets/daemon/daemon.pySet 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.
Getting started
Using the app
Internals