diff --git a/README.md b/README.md index 9ed2e9b..53c4b8c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ are experimental. See orchard's The desktop finds the hardware at login. On the iPad 6 and iPad 7 (A10) it draws on the GPU, and Firefox plays H.264 video, YouTube included, through the -hardware video decoder. On every other device it draws in software, as before. +hardware video decoder. On G5P devices it uses a matched private Zink/EGL/GBM/GLES and Vulkan stack from the same Orchard Mesa commit. Firefox selects hardware WebRender and WebGL only in that G5P session. Other devices keep the software default. To start an A10 device on the software desktop once, boot with `modprobe.blacklist=powervr`. diff --git a/desktop/build b/desktop/build index 79b10e0..0978886 100755 --- a/desktop/build +++ b/desktop/build @@ -22,7 +22,8 @@ FILES = { 'libva_v4l2_request_source': INPUTS / 'libva-v4l2_request-1.2-2-gcac6ece.tar.gz', } # Source-built on 64-bit boards only: the A10 GPU's Vulkan driver and the VA-API video bridge. -HARDWARE_PACKAGES = {'vulkan-powervr-orchard.pkg.tar.zst': ('gpu_recipe_sha256', 'packages/vulkan-powervr-orchard'), +HARDWARE_PACKAGES = {'mesa-g5p-orchard.pkg.tar.zst': ('g5p_recipe_sha256', 'packages/mesa-g5p-orchard'), + 'vulkan-powervr-orchard.pkg.tar.zst': ('gpu_recipe_sha256', 'packages/vulkan-powervr-orchard'), 'libva-v4l2-request.pkg.tar.zst': ('video_bridge_recipe_sha256', 'packages/libva-v4l2-request')} # Prebuilt on 64-bit boards: packages published by their own repositories, # downloaded at build time and pinned by SHA-256 in arm64/inputs.json. diff --git a/desktop/build-sources b/desktop/build-sources index bd60047..10b0d9a 100755 --- a/desktop/build-sources +++ b/desktop/build-sources @@ -162,6 +162,16 @@ PKGDEST="$build/result" PKGEXT='.pkg.tar.zst' makepkg --noconfirm --nodeps > "$b package=$(ls "$build"/result/vulkan-powervr-orchard-*-$OMARCHY_ARCH.pkg.tar.zst) bsdtar -tf "$package" usr/lib/libvulkan_powervr_mesa.so usr/share/vulkan/icd.d/powervr_mesa_icd.json >/dev/null cp "$package" "$ORCHARD_WORK/result/vulkan-powervr-orchard.pkg.tar.zst" +''' + if args.architecture == 'aarch64': + script += r''' +mkdir -p "$build/g5p" +cp "$ORCHARD_RECIPE"/packages/mesa-g5p-orchard/{PKGBUILD,gpu-probe.c} "$build/g5p/" +cd "$build/g5p" +PKGDEST="$build/result" PKGEXT='.pkg.tar.zst' makepkg --noconfirm --nodeps +package=$(ls "$build"/result/mesa-g5p-orchard-*-$OMARCHY_ARCH.pkg.tar.zst) +bsdtar -tf "$package" opt/orchard/g5p/share/drirc.d/00-mesa-defaults.conf opt/orchard/g5p/libexec/omarchy-g5p-probe >/dev/null +cp "$package" "$ORCHARD_WORK/result/mesa-g5p-orchard.pkg.tar.zst" ''' buildenv.run(image, ['bash', '-euc', script], [(work, '/work', False), (HERE, '/recipe', True)], @@ -173,7 +183,7 @@ cp "$package" "$ORCHARD_WORK/result/vulkan-powervr-orchard.pkg.tar.zst" if args.architecture == 'armv7h': names.append('omacalc.pkg.tar.zst') else: - names += ['vulkan-powervr-orchard.pkg.tar.zst', 'libva-v4l2-request.pkg.tar.zst'] + names += ['mesa-g5p-orchard.pkg.tar.zst', 'vulkan-powervr-orchard.pkg.tar.zst', 'libva-v4l2-request.pkg.tar.zst'] for name in names[1:]: check_package(built / name, args.architecture) files = {name: sha(built / name) for name in names} @@ -187,6 +197,7 @@ cp "$package" "$ORCHARD_WORK/result/vulkan-powervr-orchard.pkg.tar.zst" if args.architecture == 'armv7h': meta['omacalc_recipe_sha256'] = recipe_sha(HERE / 'packages/omacalc') else: + meta['g5p_recipe_sha256'] = recipe_sha(HERE / 'packages/mesa-g5p-orchard') meta['gpu_recipe_sha256'] = recipe_sha(gpu) meta['video_bridge_recipe_sha256'] = recipe_sha(bridge) (built / 'manifest.json').write_text(json.dumps(meta, indent=2) + '\n') diff --git a/desktop/configuration/auto-brightness b/desktop/configuration/auto-brightness new file mode 100755 index 0000000..fb0e74f --- /dev/null +++ b/desktop/configuration/auto-brightness @@ -0,0 +1,862 @@ +#!/usr/bin/env python3 +"""Ambient-light auto-brightness for omarchy-a. + +Uses the ALS curve, smoothing, hysteresis, ramp and manual pause policy tested +on the iPad 5. Reads generic IIO lux or iio-sensor-proxy; no board calibration +is embedded. Off by default. omarchy-auto-brightness on|off|toggle|enabled|status +controls it; --list and --probe [seconds] diagnose without backlight writes. +Configuration: ~/.config/omarchy/als-brightness.conf (same keys as the port). +The power daemon serializes automatic brightness with screen-off/sleep/wake. +""" + +import argparse +import glob +import json +import math +import os +import pty +import re +import select +import shlex +import shutil +import signal +import socket +import stat +import statistics +import subprocess +import sys +import time + +IIO_ROOT = "/sys/bus/iio/devices" +BACKLIGHT_ROOT = "/sys/class/backlight" +TICK = 0.1 +RAMP_TICK = 1.0 / 30 # loop period while a ramp runs: small, frequent steps look like a fade +RAMP_GAMMA = 2.2 # ramp in perceived brightness (raw ~ perceived ** gamma), not raw PWM +RAMP_MIN_SECS, RAMP_MAX_SECS = 0.3, 3.0 +HOLD_SETTLE = 1.0 # let the power daemon finish restoring brightness after wake +# Panel tuning: the initial brightness curve read too dark on Orchard; lifted ~10-20 points at indoor levels. +DEFAULT_CURVE = "0:15, 5:25, 20:38, 80:58, 250:78, 700:94, 1500:100" +DEFAULTS = { + "enabled": False, + "backlight": "", + "curve": DEFAULT_CURVE, + "min_pct": 5.0, + "max_pct": 100.0, + "lux_scale": 1.0, + "smooth_secs": 3.0, # darkening: slow, so a passing shadow does not dim the panel + "smooth_up_secs": 0.4, # brightening: fast, like iOS (a flashlight should not take seconds) + "hysteresis": 0.20, + "min_pct_step": 2.0, + "min_interval": 2.0, + "min_interval_up": 0.25, + "ramp_pct_per_s": 40.0, + "override_secs": 30.0, + "poll_hz": 4.0, +} + + +def log(*args): + print("omarchy-auto-brightness:", *args, file=sys.stderr, flush=True) + + +# --- configuration and state --------------------------------------------------- +def config_home(): + return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + + +def state_path(): + base = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") + return os.path.join(base, "omarchy", "display") + + +def runtime_dir(): + return os.path.join(os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "omarchy") + + +def status_path(): + return os.path.join(os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "omarchy-auto-brightness.json") + + +def parse_curve(text): + points = [] + for part in text.replace(";", ",").split(","): + part = part.strip() + if not part: + continue + lux, pct = part.split(":") + points.append((float(lux), float(pct))) + points.sort() + if len(points) < 2 or len({p[0] for p in points}) != len(points): + raise ValueError("needs at least two points with different lux") + return points + + +def load_conf(): + conf = dict(DEFAULTS) + try: + with open(os.path.join(config_home(), "omarchy", "als-brightness.conf")) as f: + lines = f.read().splitlines() + except OSError: + lines = [] + for line in lines: + line = line.split("#", 1)[0].strip() + # several "key = value" pairs may share a line only in the docs; the + # file takes one per line + if "=" not in line: + continue + key, value = (part.strip() for part in line.split("=", 1)) + if key not in DEFAULTS: + log(f"als-brightness.conf: unknown key {key}") + elif key == "enabled": + conf[key] = value.lower() in ("on", "yes", "true", "1") + elif key in ("backlight", "curve"): + conf[key] = value + else: + try: + conf[key] = float(value) + except ValueError: + log(f"als-brightness.conf: {key} = {value}: not a number") + try: + conf["curve_points"] = parse_curve(conf["curve"]) + except ValueError as e: + log(f"als-brightness.conf: curve = {conf['curve']}: {e}; using the default") + conf["curve_points"] = parse_curve(DEFAULT_CURVE) + conf["min_pct"] = max(0.0, min(conf["min_pct"], 100.0)) + conf["max_pct"] = max(conf["min_pct"], min(conf["max_pct"], 100.0)) + return conf + + +def read_state(): + values = {} + try: + with open(state_path()) as f: + for line in f: + m = re.match(r"\s*([\w]+)\s*=\s*(\S+)", line) + if m: + values[m.group(1)] = m.group(2) + except OSError: + pass + return values + + +def set_state(key, value): + """Rewrite the state file with one key changed, the others kept (ipad-rotate + and ipad-tablet keep their own keys in it).""" + path = state_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + with open(path) as f: + lines = [ln for ln in f.read().splitlines() if not re.match(rf"\s*{re.escape(key)}\s*=", ln)] + except OSError: + lines = [] + lines.append(f"{key}={value}") + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, "w") as f: + f.write("\n".join(lines) + "\n") + os.replace(tmp, path) + + +FORCE_ENABLED = False # --probe runs whatever the switch says + + +def is_enabled(conf): + if FORCE_ENABLED: + return True + value = read_state().get("auto_brightness") + if value in ("on", "off"): + return value == "on" + return conf["enabled"] + + +# --- the policy (no I/O, so the tests can drive it) ------------------------------- +def curve_pct(points, lux): + """Brightness percent for a lux level: linear in log(1 + lux) between the + points, flat beyond the ends.""" + if lux <= points[0][0]: + return points[0][1] + if lux >= points[-1][0]: + return points[-1][1] + for (x0, y0), (x1, y1) in zip(points, points[1:]): + if x0 <= lux <= x1: + a, b, v = math.log1p(x0), math.log1p(x1), math.log1p(lux) + return y0 + (y1 - y0) * (v - a) / (b - a) + return points[-1][1] + + +class Smoother: + """Median of the last few readings, then a first-order filter in log(1 + lux).""" + + def __init__(self, tau, window=3, tau_up=None): + self.tau, self.window = max(tau, 0.0), window + self.tau_up = self.tau if tau_up is None else max(tau_up, 0.0) + self.readings = [] + self.level = None # log(1 + lux) + self.time = None + + def feed(self, lux, now): + self.readings = (self.readings + [max(lux, 0.0)])[-self.window:] + target = math.log1p(statistics.median(self.readings)) + tau = self.tau_up if self.level is not None and target > self.level else self.tau + if self.level is None or tau == 0: + self.level = target + else: + alpha = 1.0 - math.exp(-max(now - self.time, 0.0) / tau) + self.level += alpha * (target - self.level) + self.time = now + + @property + def lux(self): + return None if self.level is None else math.expm1(self.level) + + +class Policy: + """When to move and to what.""" + + def __init__(self, conf): + self.conf = conf + self.reset() + + def reset(self): + self.ref_level = None # log(1 + lux) at the last move + self.last_move = -1e9 + + def goal(self, lux): + pct = curve_pct(self.conf["curve_points"], lux) + return max(self.conf["min_pct"], min(pct, self.conf["max_pct"])) + + def decide(self, smoothed, now, current_pct): + """The percent to ramp to, or None to stay.""" + goal = self.goal(smoothed.lux) + if self.ref_level is None: + # First move after starting, a pause or a manual change: go now. + if abs(goal - current_pct) < 1.0: + self.ref_level = smoothed.level + return None + else: + if abs(smoothed.level - self.ref_level) < math.log1p(self.conf["hysteresis"]): + return None + if abs(goal - current_pct) < self.conf["min_pct_step"]: + return None + interval = self.conf["min_interval_up"] if goal > current_pct else self.conf["min_interval"] + if now - self.last_move < interval: + return None + self.ref_level = smoothed.level + self.last_move = now + return goal + + +# --- the backlight ------------------------------------------------------------------ +def read_number(path, default=None): + try: + with open(path) as f: + return float(f.read().strip()) + except (OSError, ValueError): + return default + + +def backlight_root(): + return os.environ.get("IPAD_ALS_BACKLIGHT_ROOT") or BACKLIGHT_ROOT + + +def find_backlight(name=""): + root = backlight_root() + if name: + return name if os.path.exists(os.path.join(root, name, "brightness")) else None + for path in sorted(glob.glob(os.path.join(root, "*"))): + if os.path.exists(os.path.join(path, "brightness")) and os.path.exists(os.path.join(path, "max_brightness")): + return os.path.basename(path) + return None + + +class Backlight: + def __init__(self, name): + self.name = name + self.dir = os.path.join(backlight_root(), name) + self.max = int(read_number(os.path.join(self.dir, "max_brightness"), 0)) + self.failed = False + + def read(self): + value = read_number(os.path.join(self.dir, "brightness")) + return None if value is None else int(value) + + def pct(self, raw): + return 100.0 * raw / self.max if self.max else 0.0 + + def raw_for(self, pct): + return max(1, min(self.max, int(round(pct * self.max / 100.0)))) + + def write(self, raw, expected=None): + # Compare-and-set is performed by powerd in its event loop, so an + # automatic ramp cannot race its dark/suspend transition or a manual edit. + current = self.read() + if current is None or current <= 0 or (expected is not None and current != expected): + return False + reply = power_request(f"brightness {self.name} {raw} {current}") + ok = reply == "ok" + if not ok and not self.failed and reply != "held": + log("powerd brightness request failed:", reply or "unavailable") + self.failed = not ok + return ok + + +# --- sensor sources: poll(timeout) -> [lux, ...] ----------------------------------------- +def find_iio_light(root): + """The first IIO device with in_illuminance_input (or raw and scale).""" + for dev in sorted(glob.glob(os.path.join(root, "iio:device*"))): + if os.path.exists(os.path.join(dev, "in_illuminance_input")) or \ + os.path.exists(os.path.join(dev, "in_illuminance_raw")): + return dev + return None + + +def read_iio_lux(dev): + value = read_number(os.path.join(dev, "in_illuminance_input")) + if value is not None: + return value + raw = read_number(os.path.join(dev, "in_illuminance_raw")) + if raw is None: + return None + scale = read_number(os.path.join(dev, "in_illuminance_scale"), 1.0) + offset = read_number(os.path.join(dev, "in_illuminance_offset"), 0.0) + return (raw + offset) * scale + + +class IioSource: + kind = "iio" + + def __init__(self, root, hz): + self.root = root + self.interval = 1.0 / max(hz, 0.5) + self.due = 0.0 + self.warned = False + + def poll(self, timeout): + wait = max(0.0, min(timeout, self.due - time.monotonic())) + time.sleep(wait) + if time.monotonic() < self.due: + return [] + self.due = time.monotonic() + self.interval + dev = find_iio_light(self.root) + lux = read_iio_lux(dev) if dev else None + if lux is None: + if not self.warned: + log("no readable IIO light sensor under", self.root) + self.warned = True + return [] + self.warned = False + return [lux] + + def close(self): + pass + + +class LineSource: + """Lux readings, one number per line, from a FIFO or a file (--mock).""" + + kind = "mock" + + def __init__(self, path): + self.path = path + self.buf = b"" + self.fd = None + self.offset = 0 + self.pending = [] + if not os.path.exists(path): + os.mkfifo(path, 0o600) + self.fifo = stat.S_ISFIFO(os.stat(path).st_mode) + if self.fifo: + self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK) + else: + with open(path) as f: + self.pending = [v for v in map(self.parse, f.read().splitlines()) if v is not None][-1:] + self.offset = os.path.getsize(path) + + @staticmethod + def parse(line): + line = line.split("#", 1)[0].strip() + try: + return float(line) + except ValueError: + return None + + def poll(self, timeout): + out = list(self.pending) + self.pending = [] + if self.fifo: + ready, _, _ = select.select([self.fd], [], [], timeout) + if ready: + self.buf += os.read(self.fd, 4096) + *lines, self.buf = self.buf.split(b"\n") + out += [self.parse(ln.decode(errors="replace")) for ln in lines] + else: + time.sleep(min(timeout, 0.2)) + try: + size = os.path.getsize(self.path) + except OSError: + size = self.offset + if size < self.offset: + self.offset = 0 + if size > self.offset: + with open(self.path, "rb") as f: + f.seek(self.offset) + data = f.read() + self.offset += len(data) + out += [self.parse(ln.decode(errors="replace")) for ln in data.splitlines()] + return [v for v in out if v is not None] + + def close(self): + if self.fd is not None: + os.close(self.fd) + + +class ProxySource: + """iio-sensor-proxy through monitor-sensor, on a pty so its output isn't + block-buffered. It prints the level when it changes, so the daemon holds + the last value. Restarts monitor-sensor if it exits.""" + + kind = "proxy" + CHANGED = re.compile(r"Light changed:\s*([-+0-9.eE]+)\s*\((\w+)\)") + HAS = re.compile(r"Has ambient light sensor.*?value:\s*([-+0-9.eE]+),\s*unit:\s*(\w+)", re.I) + + def __init__(self): + self.proc = None + self.master = None + self.buf = b"" + self.retry_at = 0.0 + + @staticmethod + def command(): + override = os.environ.get("IPAD_ALS_MONITOR_CMD") + if override: + return shlex.split(override) + cmd = ["monitor-sensor"] + try: + help_text = subprocess.run(cmd + ["--help"], capture_output=True, text=True, timeout=5) + if "--light" in help_text.stdout + help_text.stderr: + cmd.append("--light") + except (OSError, subprocess.TimeoutExpired): + pass + return cmd + + def start(self): + cmd = self.command() + master, slave = pty.openpty() + self.proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=slave, stderr=slave, close_fds=True) + os.close(slave) + self.master = master + self.buf = b"" + log("started", " ".join(cmd)) + + def stop(self): + if self.proc: + self.proc.terminate() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + if self.master is not None: + os.close(self.master) + self.proc = self.master = None + + def poll(self, timeout): + if self.proc is None: + if time.monotonic() < self.retry_at: + time.sleep(timeout) + return [] + try: + self.start() + except OSError as e: + log("cannot start monitor-sensor:", e) + self.retry_at = time.monotonic() + 5 + return [] + ready, _, _ = select.select([self.master], [], [], timeout) + if not ready: + if self.proc.poll() is not None: + self.exited() + return [] + try: + data = os.read(self.master, 4096) + except OSError: # EIO: the child closed the pty + data = b"" + if not data: + self.exited() + return [] + self.buf += data + *lines, self.buf = self.buf.split(b"\n") + out = [] + for raw in lines: + text = raw.decode(errors="replace").strip() + m = self.CHANGED.search(text) or self.HAS.search(text) + if m: + if m.group(2).lower() != "lux": + log(f"iio-sensor-proxy reports {m.group(2)}, not lux: ignoring") + continue + out.append(float(m.group(1))) + elif text: + log("monitor-sensor:", text) + return out + + def exited(self): + log("monitor-sensor exited; retrying in 3 s") + self.stop() + self.retry_at = time.monotonic() + 3 + + def close(self): + self.stop() + + +def proxy_has_light(): + try: + out = subprocess.run( + ["busctl", "--system", "get-property", "net.hadess.SensorProxy", "/net/hadess/SensorProxy", + "net.hadess.SensorProxy", "HasAmbientLight"], + capture_output=True, text=True, timeout=10).stdout.strip() + except (OSError, subprocess.TimeoutExpired): + return False + return out == "b true" + + +def have_monitor_sensor(): + return bool(os.environ.get("IPAD_ALS_MONITOR_CMD")) or shutil.which("monitor-sensor") is not None + + +def make_source(args, conf): + if args.mock: + return LineSource(args.mock) + root = args.mock_iio or IIO_ROOT + if args.mock_iio: + return IioSource(root, conf["poll_hz"]) + if args.source == "proxy": + return ProxySource() + if args.source == "iio": + return IioSource(root, conf["poll_hz"]) + announced = False + while True: + if have_monitor_sensor() and proxy_has_light(): + return ProxySource() + if find_iio_light(root): + return IioSource(root, conf["poll_hz"]) + if args.probe is not None: + log("no ambient light sensor: iio-sensor-proxy has none and there is no IIO device") + sys.exit(1) + if not announced: + log("no ambient light sensor yet (iio-sensor-proxy has none, no IIO device); waiting") + announced = True + time.sleep(10) + + +def list_sensors(args, conf): + print("iio-sensor-proxy HasAmbientLight:", proxy_has_light() if shutil.which("busctl") else "no busctl") + print("monitor-sensor:", "found" if have_monitor_sensor() else "missing") + dev = find_iio_light(args.mock_iio or IIO_ROOT) + print("IIO light sensor:", dev or "none") + if dev: + print(" lux:", read_iio_lux(dev)) + name = find_backlight(conf["backlight"]) + print("backlight:", name or "none") + if name: + bl = Backlight(name) + raw = bl.read() + print(f" brightness {raw} of {bl.max}" + ("" if raw is None else f" ({bl.pct(raw):.0f} %)")) + print("powerd:", power_request("als-state") or "unavailable") + print("auto-brightness:", "on" if is_enabled(conf) else "off") + + +# --- holds: other tools that own the backlight for a while ----------------------------- +def power_request(request): + path = os.environ.get("IPAD_ALS_POWER_SOCKET", "/run/omarchy-powerd.sock") + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer: + peer.settimeout(0.5) + peer.connect(path) + peer.sendall((request + "\n").encode()) + return peer.recv(64).decode().strip() + except (OSError, UnicodeError): + return "" + + +def holds(): + # No answer is a hold too: never infer that an unavailable power daemon + # permits the automatic controller to light the panel. + return [] if power_request("als-state") == "lit" else ["powerd"] + + +# --- the daemon ------------------------------------------------------------------------- +class Daemon: + def __init__(self, conf, source, backlight, dry, debug): + self.conf, self.source, self.backlight, self.dry, self.debug = conf, source, backlight, dry, debug + self.smoother = Smoother(conf["smooth_secs"], tau_up=conf["smooth_up_secs"]) + self.policy = Policy(conf) + self.latest = None # the last lux reading + self.next_sample = 0.0 + self.last_written = None # raw value we last set; a different reading is someone else's + self.goal_raw = None + self.next_step = 0.0 + self.override_until = 0.0 + self.hold = [] + self.settle_until = 0.0 + self.active = False + self.moves = 0 + self.stop = False + self.shown = None + + # -- helpers + def tolerance(self): + return max(2, int(round(self.backlight.max * 0.01))) + + def pause(self, why): + """Forget what we were doing, so that what comes next starts from the + backlight as it is.""" + self.last_written = None + self.goal_raw = None + self.policy.reset() + if self.active: + log("paused:", why) + self.active = False + + def tick(self, now): + conf = self.conf + if not is_enabled(conf): + self.pause("off") + self.write_status(now, enabled=False) + return + hold = [] if self.dry else holds() + if hold != self.hold: + if hold: + log("holding for", ", ".join(hold)) + elif self.hold: + self.settle_until = now + HOLD_SETTLE + self.hold = hold + if hold or now < self.settle_until: + self.pause("hold") + self.write_status(now, enabled=True) + return + raw = self.backlight.read() + if raw is None: + return + # A change we didn't make: a brightness key, the menu. + if self.last_written is not None and abs(raw - self.last_written) > self.tolerance(): + self.override_until = now + conf["override_secs"] + log(f"brightness changed by hand ({self.backlight.pct(self.last_written):.0f} -> " + f"{self.backlight.pct(raw):.0f} %): paused for {conf['override_secs']:.0f} s") + self.pause("manual change") + if now < self.override_until: + self.write_status(now, enabled=True, override=self.override_until - now) + return + if not self.active: + self.active = True + log("auto-brightness on") + # sample + if self.latest is not None and now >= self.next_sample: + self.next_sample = now + 1.0 / max(conf["poll_hz"], 0.5) + self.smoother.feed(self.latest * conf["lux_scale"], now) + goal = self.policy.decide(self.smoother, now, self.backlight.pct(raw)) + if self.debug: + log("lux %.1f smoothed %.1f -> %s" % + (self.latest * conf["lux_scale"], self.smoother.lux, + "%.0f %%" % goal if goal is not None else "stay")) + if goal is not None: + self.goal_raw = self.backlight.raw_for(goal) + self.start_ramp(raw, now) + log("lux %.0f: brightness %.0f %% -> %.0f %% over %.2f s" % + (self.smoother.lux, self.backlight.pct(raw), goal, self.ramp_secs)) + # ramp + if self.goal_raw is not None: + self.step(raw, now) + self.write_status(now, enabled=True) + + # Perceived brightness in [0, 1]: equal steps here look equal on the panel. + def perceived(self, raw): + return (max(raw, 0) / self.backlight.max) ** (1.0 / RAMP_GAMMA) if self.backlight.max else 0.0 + + def raw_from_perceived(self, p): + return max(1, min(self.backlight.max, int(round(self.backlight.max * max(p, 0.0) ** RAMP_GAMMA)))) + + def start_ramp(self, raw, now): + """Fade from the current level to goal_raw: the duration follows the perceived + change (ramp_pct_per_s in perceived percent), eased in and out.""" + self.ramp_from, self.ramp_start = raw, now + delta = abs(self.perceived(self.goal_raw) - self.perceived(raw)) * 100.0 + speed = max(self.conf["ramp_pct_per_s"], 1.0) + self.ramp_secs = max(RAMP_MIN_SECS, min(delta / speed, RAMP_MAX_SECS)) + + def step(self, raw, now): + t = min(1.0, (now - self.ramp_start) / self.ramp_secs) if self.ramp_secs > 0 else 1.0 + if t >= 1.0: + new = self.goal_raw + else: + eased = t * t * (3.0 - 2.0 * t) # smoothstep: no jolt at the start or the end + p0, p1 = self.perceived(self.ramp_from), self.perceived(self.goal_raw) + new = self.raw_from_perceived(p0 + (p1 - p0) * eased) + if new == raw: + if t >= 1.0: + self.goal_raw = None + self.last_written = raw + return + if self.dry: + log(f"would set brightness {new} of {self.backlight.max} (dry run)") + self.goal_raw = None + return + if self.backlight.write(new, expected=raw): + self.last_written = new + self.moves += 1 + if new == self.goal_raw: + self.goal_raw = None + else: + self.goal_raw = None # a manual change or a dark transition: decide again later + + def ramping(self): + return self.goal_raw is not None + + def write_status(self, now, enabled, override=0.0): + if self.shown is not None and now - self.shown < 1.0: + return + self.shown = now + raw = self.backlight.read() + data = { + "pid": os.getpid(), + "source": self.source.kind, + "enabled": enabled, + "backlight": self.backlight.name, + "brightness_pct": None if raw is None else round(self.backlight.pct(raw), 1), + "lux": self.latest, + "smoothed_lux": None if self.smoother.lux is None else round(self.smoother.lux, 1), + "hold": self.hold, + "override_secs_left": round(override, 1), + "moves": self.moves, + "time": time.time(), + } + tmp = status_path() + ".tmp" + try: + with open(tmp, "w") as f: + json.dump(data, f) + os.replace(tmp, status_path()) + except OSError: + pass + + def run(self, seconds=None): + end = time.monotonic() + seconds if seconds else None + log(f"source {self.source.kind}, backlight {self.backlight.name} (max {self.backlight.max})" + + (", dry run" if self.dry else "") + ("" if is_enabled(self.conf) else "; off until switched on")) + compositor = os.environ.get("LABWC_PID") + while not self.stop and (end is None or time.monotonic() < end): + if compositor: + try: + os.kill(int(compositor), 0) + except (OSError, ValueError): + break + if not is_enabled(self.conf): + self.tick(time.monotonic()) + time.sleep(1) # off means no 10Hz backlight/status work + continue + for lux in self.source.poll(RAMP_TICK if self.ramping() else TICK): + self.latest = lux + self.tick(time.monotonic()) + self.source.close() + try: + os.unlink(status_path()) + except OSError: + pass + + +# --- commands ------------------------------------------------------------------------------- +def running_pid(): + try: + with open(status_path()) as f: + pid = json.load(f)["pid"] + os.kill(pid, 0) + return pid + except (OSError, ValueError, KeyError): + return None + + +def cmd_switch(action, conf): + now = is_enabled(conf) + want = (not now) if action == "toggle" else action == "on" + set_state("auto_brightness", "on" if want else "off") + print("auto-brightness", "on" if want else "off") + if want and not running_pid(): + print("note: the daemon isn't running (no ambient light sensor, or not logged in yet)", file=sys.stderr) + return 0 + + +def cmd_status(conf): + pid = running_pid() + print("auto-brightness:", "on" if is_enabled(conf) else "off", "| daemon:", f"running (pid {pid})" if pid else "not running") + if pid: + try: + with open(status_path()) as f: + print(f.read()) + except OSError: + pass + return 0 if pid else 1 + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("command", nargs="?", default="daemon", + choices=("daemon", "on", "off", "toggle", "status", "enabled", "available")) + parser.add_argument("--source", choices=("auto", "proxy", "iio"), default=None) + parser.add_argument("--mock") + parser.add_argument("--mock-iio") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--probe", nargs="?", const=20, type=float) + parser.add_argument("--list", action="store_true") + parser.add_argument("--debug", action="store_true") + parser.add_argument("-h", "--help", action="store_true") + args = parser.parse_args() + if args.help: + print(__doc__.strip("\n")) + return 0 + conf = load_conf() + if args.source is None: + args.source = os.environ.get("IPAD_ALS_SOURCE", "auto") + if args.list: + list_sensors(args, conf) + return 0 + if args.command in ("on", "off", "toggle"): + return cmd_switch(args.command, conf) + if args.command == "status": + return cmd_status(conf) + if args.command == "enabled": + return 0 if is_enabled(conf) else 1 + if args.command == "available": + return 0 if find_iio_light(args.mock_iio or IIO_ROOT) and find_backlight(conf["backlight"]) else 1 + + if args.probe is not None: + global FORCE_ENABLED + args.dry_run = args.debug = True + FORCE_ENABLED = True + name = find_backlight(conf["backlight"]) + if not name: + log("no backlight under", backlight_root()) + if args.probe is not None: + return 1 + while not name: # the panel may appear after the compositor starts + compositor = os.environ.get("LABWC_PID") + if compositor: + try: + os.kill(int(compositor), 0) + except (OSError, ValueError): + return 0 + time.sleep(1) + name = find_backlight(conf["backlight"]) + source = make_source(args, conf) + daemon = Daemon(conf, source, Backlight(name), args.dry_run, args.debug) + def stop(*_): + daemon.stop = True + + def reload(*_): + daemon.conf.update(load_conf()) + daemon.smoother.tau = daemon.conf["smooth_secs"] + daemon.smoother.tau_up = daemon.conf["smooth_up_secs"] + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGHUP, reload) + daemon.run(args.probe) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/desktop/configuration/firefox.cfg b/desktop/configuration/firefox.cfg index abfd06b..9e8d53f 100644 --- a/desktop/configuration/firefox.cfg +++ b/desktop/configuration/firefox.cfg @@ -13,3 +13,31 @@ if (getenv("MOZ_DISABLE_RDD_SANDBOX") == "1" && getenv("LIBVA_DRIVER_NAME") == " defaultPref("media.av1.enabled", false); defaultPref("media.mediasource.vp9.enabled", false); } + +// Match the explicit hardware arm used by the qualified G5P desktop runner. +// The private libraries alone do not select that arm of Firefox's policy. +if (getenv("OMARCHY_G5P") == "1") { + defaultPref("gfx.webrender.all", true); + defaultPref("gfx.webrender.software", false); + defaultPref("gfx.webrender.force-disabled", false); + defaultPref("layers.acceleration.force-enabled", true); + defaultPref("webgl.force-enabled", true); + defaultPref("webgl.disabled", false); +} + +// Small dual-core systems use bounded caches and fewer spare/content processes. +if (getenv("OMARCHY_LOW_MEMORY") == "1") { + defaultPref("dom.ipc.processCount", 2); + defaultPref("dom.ipc.processCount.webIsolated", 1); + defaultPref("dom.ipc.processPrelaunch.enabled", false); + defaultPref("browser.cache.memory.capacity", 32768); // KiB; network cache only + defaultPref("browser.cache.disk.capacity", 131072); // KiB on NVMe, not RAM + defaultPref("browser.cache.disk.smart_size.enabled", false); + defaultPref("browser.sessionhistory.max_total_viewers", 1); + defaultPref("browser.tabs.unloadOnLowMemory", true); + // Optional research arm: restarting with OMARCHY_RAM2_NO_FISSION=1 trades + // site isolation for fewer content processes. Keep isolation by default. + if (getenv("OMARCHY_RAM2_NO_FISSION") == "1") { + defaultPref("fission.autostart", false); + } +} diff --git a/desktop/configuration/first-theme b/desktop/configuration/first-theme old mode 100644 new mode 100755 diff --git a/desktop/configuration/labwc-autostart b/desktop/configuration/labwc-autostart old mode 100644 new mode 100755 index 6216c9f..6c8f168 --- a/desktop/configuration/labwc-autostart +++ b/desktop/configuration/labwc-autostart @@ -11,8 +11,12 @@ rm -f "$XDG_RUNTIME_DIR/omarchy-overview-open" /usr/local/libexec/arch-ipad-rotate-output > "$XDG_RUNTIME_DIR/arch-ipad-rotation.log" 2>&1 & # Let user services launched after the compositor see its actual Wayland socket. systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE > "$XDG_RUNTIME_DIR/arch-ipad-user-env.log" 2>&1 +# Sensor policy follows the compositor lifetime; it stays off until enabled. +/usr/local/libexec/arch-ipad-auto-brightness > "$XDG_RUNTIME_DIR/arch-ipad-auto-brightness.log" 2>&1 & # Keep the theme-coloured keyboard resident but hidden until the user taps Show. export OMARCHY_PATH="$HOME/.local/share/omarchy-ipad" printf '0\n' > "$XDG_RUNTIME_DIR/omarchy-keyboard-visible" "$OMARCHY_PATH/bin/omarchy-keyboard" run > "$XDG_RUNTIME_DIR/omarchy-keyboard.log" 2>&1 & qs -p "$OMARCHY_PATH/shell/shell.qml" > "$XDG_RUNTIME_DIR/omarchy-shell.log" 2>&1 & + +# Observe touch without grabbing; actions are native labwc and honor lock/power. diff --git a/desktop/configuration/login-session b/desktop/configuration/login-session old mode 100644 new mode 100755 index eee9f14..a807fb3 --- a/desktop/configuration/login-session +++ b/desktop/configuration/login-session @@ -19,6 +19,14 @@ export WLR_RENDERER=pixman QT_QUICK_BACKEND=software QT_QPA_PLATFORM=wayland # Match pinned upstream Omarchy's GTK-backed Qt platform theme. export QT_QPA_PLATFORMTHEME=gtk3 export OMARCHY_TOUCH_MODE=1 +# Actual RAM/CPU, independent of board or GPU. Clear a previous login's policy. +unset OMARCHY_LOW_MEMORY +if (( $(awk '/^MemTotal:/ {print $2}' /proc/meminfo) <= 2097152 && $(getconf _NPROCESSORS_ONLN) <= 2 )); then + export OMARCHY_LOW_MEMORY=1 +fi +systemctl --user unset-environment OMARCHY_LOW_MEMORY +if [[ ${OMARCHY_LOW_MEMORY:-} == 1 ]]; then systemctl --user import-environment OMARCHY_LOW_MEMORY; fi +export LP_NUM_THREADS=${LP_NUM_THREADS:-2} export LIBGL_ALWAYS_SOFTWARE=1 LIBSEAT_BACKEND=seatd MOZ_ENABLE_WAYLAND=1 # A board with a GPU this image has a Vulkan driver for (the A10's, kernel # driver powervr): Labwc draws on wlroots' GLES2 renderer and applications on @@ -40,6 +48,57 @@ if [[ -n $gpu && -e /usr/share/vulkan/icd.d/powervr_mesa_icd.json ]]; then fi done fi +# G5P uses a private matched GL/ICD stack; A10 above keeps Arch GL. +# No Zink override here. That stack's Mesa already routes each device: the +# G5P render node to Zink, and the ADBE card, which has no render node, to +# kmsro. kmsro allocates every scanout buffer as an ADBE dumb buffer and +# imports it into Zink. ADBE scans out only physically contiguous memory. +# MESA_LOADER_DRIVER_OVERRIDE=zink would also send the ADBE card to Zink, +# which then allocates Labwc's output buffers in G5P memory that ADBE cannot +# import, and Labwc is left without an output. +for node in /sys/class/drm/renderD*; do + driver=$(basename "$(readlink -f "$node/device/driver")") + if [[ $driver != apple-g5p && $driver != apple-g5p-drm ]]; then continue; fi + prefix=/opt/orchard/g5p + probe=$prefix/libexec/omarchy-g5p-probe + if [[ ! -e $prefix/share/vulkan/icd.d/powervr_mesa_icd.aarch64.json || ! -e $prefix/lib/libEGL_mesa.so.0 || ! -x $probe ]]; then + echo 'G5P private GL/ICD package missing; retaining software desktop' >&2 + break + fi + kms= + for card in /sys/class/drm/card*; do + if [[ ${card##*/} =~ ^card[0-9]+$ && $(basename "$(readlink -f "$card/device/driver")") == apple-adbe ]]; then + kms=/dev/dri/${card##*/} + fi + done + if [[ -z $kms ]]; then + echo 'No ADBE display card; retaining software desktop' >&2 + break + fi + g5p_stack=("LD_LIBRARY_PATH=$prefix/lib" "GBM_BACKENDS_PATH=$prefix/lib/gbm" GBM_BACKEND=dri + "LIBGL_DRIVERS_PATH=$prefix/lib/dri" + "__EGL_VENDOR_LIBRARY_FILENAMES=$prefix/share/glvnd/egl_vendor.d/50_mesa.json" + "VK_DRIVER_FILES=$prefix/share/vulkan/icd.d/powervr_mesa_icd.aarch64.json" + "VK_ICD_FILENAMES=$prefix/share/vulkan/icd.d/powervr_mesa_icd.aarch64.json" + PVR_I_WANT_A_BROKEN_VULKAN_DRIVER=1) + # Leave software only once this stack renders on G5P and draws into a + # buffer allocated the way Labwc's output buffers are (GBM on ADBE). The + # probe never modesets and needs no DRM master. + if ! env -u LIBGL_ALWAYS_SOFTWARE -u MESA_LOADER_DRIVER_OVERRIDE -u GALLIUM_DRIVER "${g5p_stack[@]}" \ + timeout -k 2 12 "$probe" "/dev/dri/${node##*/}" "$kms" >"$XDG_RUNTIME_DIR/omarchy-g5p-probe.log" 2>&1; then + echo "G5P probe failed (see $XDG_RUNTIME_DIR/omarchy-g5p-probe.log); retaining software desktop" >&2 + break + fi + export OMARCHY_G5P=1 "${g5p_stack[@]}" + export WLR_RENDERER=gles2 WLR_BACKENDS=drm,libinput + export WLR_DRM_DEVICES=$kms WLR_RENDER_DRM_DEVICE=/dev/dri/${node##*/} + # Client buffers are G5P memory, which ADBE cannot scan out: direct + # scanout would only fail its import every frame, and its scanout + # feedback would ask clients for linear buffers. ADBE has no cursor plane. + export WLR_SCENE_DISABLE_DIRECT_SCANOUT=1 WLR_NO_HARDWARE_CURSORS=1 + unset LIBGL_ALWAYS_SOFTWARE MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER + break +done # A hardware video decoder (kernel driver apple-vxd, H.264): VA-API # applications reach it through libva-v4l2-request. Firefox uses it only when # it draws on the GPU, and only with its media decoding sandbox off, which @@ -55,6 +114,19 @@ export PATH=/usr/local/bin:/usr/bin # Services such as PipeWire and WirePlumber use the same user manager/bus as # Quickshell. Do not spawn a separate dbus-run-session instance. systemctl --user import-environment DBUS_SESSION_BUS_ADDRESS XDG_SESSION_TYPE XDG_CURRENT_DESKTOP +# D-Bus/user-manager launched applications need the same G5P stack as labwc. +# Clear the previous login's private selection before importing this one. +g5p_env=(OMARCHY_G5P LD_LIBRARY_PATH GBM_BACKENDS_PATH GBM_BACKEND LIBGL_DRIVERS_PATH + __EGL_VENDOR_LIBRARY_FILENAMES VK_DRIVER_FILES VK_ICD_FILENAMES GALLIUM_DRIVER + MESA_LOADER_DRIVER_OVERRIDE PVR_I_WANT_A_BROKEN_VULKAN_DRIVER WLR_RENDERER + WLR_RENDER_DRM_DEVICE WLR_DRM_DEVICES WLR_BACKENDS WLR_SCENE_DISABLE_DIRECT_SCANOUT + WLR_NO_HARDWARE_CURSORS) +systemctl --user unset-environment "${g5p_env[@]}" +if [[ ${OMARCHY_G5P:-} == 1 ]]; then + g5p_set=() + for name in "${g5p_env[@]}"; do if [[ -v $name ]]; then g5p_set+=("$name"); fi; done + systemctl --user import-environment "${g5p_set[@]}" +fi if [[ -n ${QT_QPA_PLATFORMTHEME:-} ]]; then systemctl --user import-environment QT_QPA_PLATFORMTHEME; fi /usr/local/libexec/arch-ipad-first-theme # labwc -m reads labwc/rc.xml from each XDG config dir, then ~/.config/labwc/rc.xml. diff --git a/desktop/configuration/rotate-output b/desktop/configuration/rotate-output old mode 100644 new mode 100755 diff --git a/desktop/configuration/scale-output b/desktop/configuration/scale-output old mode 100644 new mode 100755 index 599e57b..53ad3b0 --- a/desktop/configuration/scale-output +++ b/desktop/configuration/scale-output @@ -9,4 +9,11 @@ if (( ${#outputs[@]} != 1 )) || ! grep -q ' Enabled: yes' <<< "$state" || echo 'Native single-panel mode not established; left scale unchanged' >&2 exit 0 fi -wlr-randr --output "${outputs[0]}" --scale 2 +# The user's choice from the Display panel (omarchy-display-scale), else 2. +scale=2 +saved=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy-ipad/display-scale +if [[ -r $saved ]]; then + value=$(<"$saved") + [[ $value =~ ^(1|1\.25|1\.5|1\.75|2|2\.25|2\.5|3)$ ]] && scale=$value +fi +wlr-randr --output "${outputs[0]}" --scale "$scale" diff --git a/desktop/configuration/touch-output b/desktop/configuration/touch-output old mode 100644 new mode 100755 diff --git a/desktop/install-toolchain b/desktop/install-toolchain index 314c50e..4fcc7b5 100644 --- a/desktop/install-toolchain +++ b/desktop/install-toolchain @@ -23,7 +23,7 @@ packages=(base-devel wayland libxkbcommon pango scdoc pkgconf patch python pytho # vulkan-powervr-orchard (packages/vulkan-powervr-orchard): the Mesa fork's Vulkan # driver; LLVM, Clang and libclc compile its internal shaders at build time. bison flex expat zlib zstd libelf spirv-tools python-mako python-yaml python-packaging python-ply - llvm clang libclc spirv-llvm-translator + llvm clang libclc spirv-llvm-translator libx11 libxcb libxshmfence libxrandr xorgproto # libva-v4l2-request (packages/libva-v4l2-request) libva) pacman-download -Sy --noconfirm --needed "${packages[@]}" diff --git a/desktop/packages/mesa-g5p-orchard/PKGBUILD b/desktop/packages/mesa-g5p-orchard/PKGBUILD new file mode 100644 index 0000000..0b1bd18 --- /dev/null +++ b/desktop/packages/mesa-g5p-orchard/PKGBUILD @@ -0,0 +1,56 @@ +# Private GL + ICD from ONE source revision. Keep Arch mesa/libglvnd and A10's ICD. +pkgname=mesa-g5p-orchard +# Move to the merged OrchardLinux/mesa commit once the G5P driver is merged there. +_commit=1f24e522a3eb8134c12b0ca4eb8c830375fd5c31 +pkgver=27.0.0.dev.1f24e522a3e +pkgrel=1 +pkgdesc='Matched G5P Zink, EGL, GBM, GLES and Vulkan driver for Omarchy A' +arch=('aarch64') +url=https://github.com/OrchardLinux/mesa +license=('MIT') +options=('!debug' '!lto') +depends=('llvm-libs' 'mesa' 'libglvnd' 'vulkan-icd-loader' 'libdrm' 'wayland' 'expat' 'zlib' 'zstd' 'libdisplay-info' 'spirv-tools' 'systemd-libs' 'libx11' 'libxcb' 'libxshmfence' 'libxrandr') +makedepends=('git' 'meson' 'ninja' 'bison' 'flex' 'python-mako' 'python-packaging' 'python-ply' 'python-yaml' 'clang' 'llvm' 'libclc' 'spirv-llvm-translator' 'glslang' 'vulkan-headers' 'wayland-protocols' 'libelf' 'xorgproto') +# All files are private: no provides/conflicts for the system GL dispatchers. +# gpu-probe.c: the login session's check before it leaves the software desktop. +source=("mesa::git+${url}.git#commit=${_commit}" gpu-probe.c) +sha256sums=('SKIP' # Immutable git commit checked again before configuration. + 'c31626333a0c246e3e3fe2554326f9e5b526ef07caa5105b70fc07d9276be94e') + +build() { + test "$(git -C mesa rev-parse HEAD)" = "$_commit" + export MESA_GIT_SHA1_OVERRIDE="$_commit" + meson setup mesa build --prefix=/opt/orchard/g5p --libdir=lib \ + --buildtype=release --wrap-mode=nodownload --auto-features=disabled \ + -Db_lto=false -Dplatforms=wayland,x11 -Dgallium-drivers=zink \ + -Dvulkan-manifest-per-architecture=true -Dvulkan-drivers=imagination -Dimagination-g5p-experimental=true \ + -Degl=enabled -Dgbm=enabled -Dgles1=disabled -Dgles2=enabled \ + -Dopengl=true -Dglx=dri -Dglvnd=enabled -Dllvm=enabled -Dshared-llvm=enabled \ + -Dzlib=enabled -Dspirv-tools=enabled -Dexpat=enabled -Dshader-cache=enabled -Dxmlconfig=enabled -Dzstd=enabled \ + -Dvideo-codecs= -Dtools= -Dvulkan-layers= -Dbuild-tests=false + # Build the installed stack, not Mesa's uninstalled developer executables. + mapfile -t targets < <(meson introspect --targets build | python -c ' +import json, os, sys +for target in json.load(sys.stdin): + if target.get("installed"): + for name in target["filename"]: + print(os.path.relpath(name, "build"))') + (( ${#targets[@]} > 0 )) + ninja -C build -j4 "${targets[@]}" + # GLES2 on G5P drawing into a GBM-on-ADBE buffer, linked to this libgbm. + cc -O2 -Wall -Wextra gpu-probe.c -o omarchy-g5p-probe -Imesa/src/gbm/main \ + $(pkg-config --cflags egl glesv2) build/src/gbm/libgbm.so.1.0.0 $(pkg-config --libs egl glesv2) \ + -Wl,-rpath,/opt/orchard/g5p/lib +} + +package() { + DESTDIR="$pkgdir" meson install -C build --no-rebuild + install -Dm755 omarchy-g5p-probe "$pkgdir/opt/orchard/g5p/libexec/omarchy-g5p-probe" + # GLVND must name our EGL vendor even when a child sanitizes its library path. + sed -i 's#"library_path" : "libEGL_mesa.so.0"#"library_path" : "/opt/orchard/g5p/lib/libEGL_mesa.so.0"#' \ + "$pkgdir/opt/orchard/g5p/share/glvnd/egl_vendor.d/50_mesa.json" + # The Vulkan JSON is also generated with the private absolute prefix. + test -s "$pkgdir/opt/orchard/g5p/share/drirc.d/00-mesa-defaults.conf" + grep -q 'device="g5p"' "$pkgdir/opt/orchard/g5p/share/drirc.d/00-mesa-defaults.conf" + install -Dm644 mesa/docs/license.rst "$pkgdir/usr/share/licenses/$pkgname/license.rst" +} diff --git a/desktop/packages/mesa-g5p-orchard/gpu-probe.c b/desktop/packages/mesa-g5p-orchard/gpu-probe.c new file mode 100644 index 0000000..5239a8e --- /dev/null +++ b/desktop/packages/mesa-g5p-orchard/gpu-probe.c @@ -0,0 +1,69 @@ +/* SPDX-License-Identifier: MIT + * Generated-by: LLM + * Bounded by the caller. Exercise the same GBM/KMS allocation used by stock + * wlroots, import it on the render GPU, clear and read it back without modesetting. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "GPU-PROBE failed: %s\n", #x); return 1; } } while (0) +int main(int argc, char **argv) +{ + CHECK(argc == 3); + int render = open(argv[1], O_RDWR | O_CLOEXEC); + int kms = open(argv[2], O_RDWR | O_CLOEXEC); + CHECK(render >= 0 && kms >= 0); + struct gbm_device *gpu = gbm_create_device(render), *display = gbm_create_device(kms); + CHECK(gpu && display); + EGLDisplay dpy = eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, gpu, NULL); + CHECK(dpy != EGL_NO_DISPLAY && eglInitialize(dpy, NULL, NULL)); + CHECK(eglBindAPI(EGL_OPENGL_ES_API)); + EGLConfig cfg; + EGLint count; + const EGLint attrs[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_NONE}; + CHECK(eglChooseConfig(dpy, attrs, &cfg, 1, &count) && count == 1); + const EGLint ca[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE}; + EGLContext ctx = eglCreateContext(dpy, cfg, EGL_NO_CONTEXT, ca); + CHECK(ctx != EGL_NO_CONTEXT && eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx)); + const char *renderer = (const char *)glGetString(GL_RENDERER); + CHECK(renderer && strstr(renderer, "zink") && strstr(renderer, "PowerVR")); + struct gbm_bo *bo = gbm_bo_create(display, 32, 32, GBM_FORMAT_XRGB8888, + GBM_BO_USE_SCANOUT | GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR); + CHECK(bo); + int dma = gbm_bo_get_fd(bo); + CHECK(dma >= 0); + const EGLint ia[] = {EGL_WIDTH, 32, EGL_HEIGHT, 32, + EGL_LINUX_DRM_FOURCC_EXT, GBM_FORMAT_XRGB8888, + EGL_DMA_BUF_PLANE0_FD_EXT, dma, EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0, + EGL_DMA_BUF_PLANE0_PITCH_EXT, (EGLint)gbm_bo_get_stride(bo), EGL_NONE}; + PFNEGLCREATEIMAGEKHRPROC create = (void *)eglGetProcAddress("eglCreateImageKHR"); + PFNEGLDESTROYIMAGEKHRPROC destroy = (void *)eglGetProcAddress("eglDestroyImageKHR"); + PFNGLEGLIMAGETARGETRENDERBUFFERSTORAGEOESPROC storage = + (void *)eglGetProcAddress("glEGLImageTargetRenderbufferStorageOES"); + CHECK(create && destroy && storage); + EGLImageKHR image = create(dpy, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, NULL, ia); + CHECK(image != EGL_NO_IMAGE_KHR); + GLuint rb, fb; + glGenRenderbuffers(1, &rb); glBindRenderbuffer(GL_RENDERBUFFER, rb); + storage(GL_RENDERBUFFER, image); + glGenFramebuffers(1, &fb); glBindFramebuffer(GL_FRAMEBUFFER, fb); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rb); + CHECK(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE); + glViewport(0, 0, 32, 32); glClearColor(1, 0, 0, 1); glClear(GL_COLOR_BUFFER_BIT); + unsigned char pixel[4] = {0}; + glReadPixels(16, 16, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); glFinish(); + CHECK(glGetError() == GL_NO_ERROR && pixel[0] > 250 && pixel[1] < 5 && pixel[2] < 5); + printf("GPU-PROBE PASS renderer=%s render=%s kms=%s\n", renderer, argv[1], argv[2]); + glDeleteFramebuffers(1, &fb); glDeleteRenderbuffers(1, &rb); destroy(dpy, image); + close(dma); gbm_bo_destroy(bo); + eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + eglDestroyContext(dpy, ctx); eglTerminate(dpy); + gbm_device_destroy(display); gbm_device_destroy(gpu); close(kms); close(render); + return 0; +} diff --git a/desktop/shell/bin/omarchy b/desktop/shell/bin/omarchy old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-auto-brightness b/desktop/shell/bin/omarchy-auto-brightness new file mode 100755 index 0000000..9fb3b61 --- /dev/null +++ b/desktop/shell/bin/omarchy-auto-brightness @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/local/libexec/arch-ipad-auto-brightness "$@" diff --git a/desktop/shell/bin/omarchy-battery-status b/desktop/shell/bin/omarchy-battery-status old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-brightness-display b/desktop/shell/bin/omarchy-brightness-display old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-display-scale b/desktop/shell/bin/omarchy-display-scale new file mode 100755 index 0000000..f54bd36 --- /dev/null +++ b/desktop/shell/bin/omarchy-display-scale @@ -0,0 +1,21 @@ +#!/bin/sh +# Show or set the panel's display scale. The choice persists: the session's +# arch-ipad-scale-output applies it again at the next login. +# omarchy-display-scale prints the current scale, e.g. 2 +# omarchy-display-scale 1.75 applies and saves 1.75 +set -eu +allowed='1 1.25 1.5 1.75 2 2.25 2.5 3' +state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy-ipad +output=$(wlr-randr | awk '/^[^[:space:]]/ {print $1; exit}') +[ -n "$output" ] || { echo 'omarchy-display-scale: no output' >&2; exit 1; } +if [ $# -eq 0 ]; then + wlr-randr | awk '/^[^[:space:]]/ {seen = 1} seen && $1 == "Scale:" {print $2 + 0; exit}' + exit 0 +fi +case " $allowed " in + *" $1 "*) ;; + *) echo "omarchy-display-scale: unsupported scale $1 (one of: $allowed)" >&2; exit 2 ;; +esac +wlr-randr --output "$output" --scale "$1" +mkdir -p "$state_dir" +printf '%s\n' "$1" > "$state_dir/display-scale" diff --git a/desktop/shell/bin/omarchy-greeter-theme-publish b/desktop/shell/bin/omarchy-greeter-theme-publish old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-keyboard b/desktop/shell/bin/omarchy-keyboard old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-launch-about b/desktop/shell/bin/omarchy-launch-about old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-picker-process b/desktop/shell/bin/omarchy-picker-process old mode 100644 new mode 100755 diff --git a/desktop/shell/bin/omarchy-rotate b/desktop/shell/bin/omarchy-rotate new file mode 100755 index 0000000..512d377 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotate @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Manual rotation uses the same output protocol as auto-rotation; holds auto until unlock.""" +import json, os, subprocess, sys +from pathlib import Path +mode = sys.argv[1] if len(sys.argv)>1 else 'status' +outputs = json.loads(subprocess.check_output(['wlr-randr', '--json'], text=True)) +panel = next((o for o in outputs if o['name'] == 'eDP-1'), None) +if panel is None: + panel = next(o for o in outputs if o.get('enabled')) +current = panel['transform'] +turns = ['normal', '90', '180', '270'] +if mode == 'status': + print(current); raise SystemExit(0) +if mode == 'next': mode = turns[(turns.index(current)+1)%4] +mode = {'portrait':'normal','landscape':'90','portrait-flipped':'180','landscape-flipped':'270'}.get(mode,mode) +if mode not in turns: raise SystemExit('Usage: omarchy-rotate status|next|normal|90|180|270') +lock = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'omarchy/rotation-lock' +was_locked = lock.exists() +lock.parent.mkdir(parents=True,exist_ok=True); lock.touch() +try: + subprocess.run(['wlr-randr','--output',panel['name'],'--transform',mode],check=True,timeout=5) +except (OSError, subprocess.SubprocessError): + if not was_locked: lock.unlink(missing_ok=True) + raise diff --git a/desktop/shell/bin/omarchy-theme-set b/desktop/shell/bin/omarchy-theme-set old mode 100644 new mode 100755 diff --git a/desktop/shell/config/omarchy/shell.json b/desktop/shell/config/omarchy/shell.json index c75d48e..803f965 100644 --- a/desktop/shell/config/omarchy/shell.json +++ b/desktop/shell/config/omarchy/shell.json @@ -5,12 +5,11 @@ "position": "top", "background": "opaque", "layout": { - "left": [{ "id": "omarchy.menu" }], + "left": [{ "id": "omarchy.menu" }, { "id": "ipadlinux.keyboard" }], "centerBegin": [{ "id": "omarchy.indicators", "items": ["StayAwake"] }], "center": [{ "id": "omarchy.clock", "format": "HH:mm" }], "centerEnd": [], "right": [ - { "id": "ipadlinux.keyboard", "narrow": "left" }, { "id": "omarchy.audio", "narrow": false }, { "id": "omarchy.bluetooth", "narrow": false }, { "id": "omarchy.network", "narrow": "left" }, diff --git a/desktop/shell/default/i18n/en/shell.json b/desktop/shell/default/i18n/en/shell.json index 87b6864..7580a2f 100644 --- a/desktop/shell/default/i18n/en/shell.json +++ b/desktop/shell/default/i18n/en/shell.json @@ -326,6 +326,8 @@ "other": "%1 selected" }, "monitor.brightness": "BRIGHTNESS", + "monitor.autoBrightness": "Auto-brightness", + "monitor.autoBrightnessHint": "Follow the light sensor. Moving the slider pauses it for 30 s.", "monitor.display": "Display", "monitor.displayFocused": "%1 · focused", "monitor.displays": "DISPLAYS", diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index de7b289..bd34558 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -35,10 +35,11 @@ // System "system.screensaver": {"icon":"󱄄","label":"Screensaver","action":"omarchy-launch-screensaver force"}, "system.lock": {"icon":"","label":"Lock","action":"omarchy-system-lock"}, - "system.suspend": {"icon":"󰒲","label":"Suspend","when":"! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled","action":"systemctl suspend"}, + "system.suspend": {"icon":"󰒲","label":"Suspend","when":"! omarchy-toggle-enabled suspend-off && /usr/local/bin/ipadlinux-suspend-ready","action":"/usr/local/bin/ipadlinux-suspend-ready && systemctl suspend"}, "system.hibernate": {"icon":"󰤁","label":"Hibernate","when":"omarchy-hibernation-available && ! omarchy-sleep-disabled","action":"systemctl hibernate"}, "system.logout": {"icon":"󰍃","label":"Logout","action":"omarchy-system logout"}, // Orchard: a reboot does not come back into Linux. The device's own boot chain starts its own system; Linux returns through DFU. + "system.restart": {"icon":"󰜉","label":"Restart","action":"orchard-restart"}, "system.reboot": {"icon":"󰜉","label":"Reboot (leaves Linux)","action":"omarchy-system reboot"}, "system.shutdown": {"icon":"󰐥","label":"Shutdown","action":"omarchy-system shutdown"}, @@ -184,6 +185,9 @@ "trigger.tests.disk-speedtest": {"icon":"󰋊","label":"Disk Speed Test","action":"omarchy-shell shell summon omarchy.disk-speedtest"}, // Setup + "setup.tablet": {"icon":"󰦧","label":"Tablet"}, + "setup.tablet.rotate": {"icon":"󰑢","label":"Rotate Once and Lock","action":"omarchy-rotate next"}, + "setup.tablet.auto-brightness": {"icon":"󰃠","label":"Auto Brightness","when":"omarchy-auto-brightness available","checked":"omarchy-auto-brightness enabled","action":"omarchy-auto-brightness toggle"}, "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, diff --git a/desktop/shell/labwc-rc.xml b/desktop/shell/labwc-rc.xml index 512d7f5..f51cb28 100644 --- a/desktop/shell/labwc-rc.xml +++ b/desktop/shell/labwc-rc.xml @@ -123,6 +123,18 @@ + + + + + + + + + + + diff --git a/desktop/shell/shell/plugins/panels/monitor/Panel.qml b/desktop/shell/shell/plugins/panels/monitor/Panel.qml index 481b225..8aa2541 100644 --- a/desktop/shell/shell/plugins/panels/monitor/Panel.qml +++ b/desktop/shell/shell/plugins/panels/monitor/Panel.qml @@ -15,6 +15,13 @@ Panel { property bool brightnessAvailable: false property int brightnessPercent: 0 property int queuedPercent: -1 + // Ambient-light auto-brightness (omarchy-auto-brightness); hidden when the tool is absent. + property bool autoAvailable: false + property bool autoOn: false + // Display scale (omarchy-display-scale); saved and reapplied at login. + readonly property var scaleOptions: ["1.5", "1.75", "2", "2.25", "2.5"] + property string scaleValue: "" + // The shell's idle service owns Stay Awake (state file, bar indicator, IPC). readonly property var idleService: bar?.shell?.firstPartyServiceFor("omarchy.idle") readonly property bool stayAwake: idleService ? idleService.stayAwake === true : false @@ -23,8 +30,24 @@ Panel { readonly property string rotationHelper: (Quickshell.env("OMARCHY_PATH") || Quickshell.env("HOME") + "/.local/share/omarchy-ipad") + "/bin/omarchy-rotation-lock" + function setAuto(on) { + if (!autoAvailable || autoWriteProc.running) return + root.autoOn = on // optimistic: the knob moves at once + autoWriteProc.command = ["omarchy-auto-brightness", on ? "on" : "off"] + autoWriteProc.running = true + } + + function setScale(value) { + if (scaleWriteProc.running || value === root.scaleValue) return + root.scaleValue = value + scaleWriteProc.command = ["omarchy-display-scale", value] + scaleWriteProc.running = true + } + function refresh() { if (!readProc.running && !writeProc.running) readProc.running = true + if (!autoReadProc.running && !autoWriteProc.running) autoReadProc.running = true + if (!scaleReadProc.running && !scaleWriteProc.running) scaleReadProc.running = true if (!rotationRead.running && !rotationWrite.running) rotationRead.running = true } function setBrightness(value) { @@ -85,11 +108,47 @@ Panel { } } } + + Process { + id: autoReadProc + command: ["omarchy-auto-brightness", "status"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var m = /^auto-brightness:\s*(on|off)/m.exec(String(text || "")) + root.autoAvailable = m !== null + if (m) root.autoOn = m[1] === "on" + } + } + } Process { id: rotationWrite onExited: function(exitCode, exitStatus) { root.refresh() } } + Process { + id: autoWriteProc + stdout: StdioCollector { waitForEnd: true } + onRunningChanged: if (!running) root.refresh() + } + + Process { + id: scaleReadProc + command: ["omarchy-display-scale"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var v = parseFloat(String(text || "").trim()) + root.scaleValue = isNaN(v) ? "" : String(v) + } + } + } + Process { + id: scaleWriteProc + stdout: StdioCollector { waitForEnd: true } + onRunningChanged: if (!running) root.refresh() + } + BarIconButton { id: button anchors.fill: parent @@ -182,6 +241,33 @@ Panel { onMoved: function(v) { root.setBrightness(v) } onReleased: function(v) { root.setBrightness(v) } } + Toggle { + width: parent.width + visible: root.autoAvailable + label: Commons.I18n.t("monitor.autoBrightness") + description: Commons.I18n.t("monitor.autoBrightnessHint") + checked: root.autoOn + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + onClicked: root.setAuto(!root.autoOn) + } + Text { + width: parent.width + visible: root.scaleValue !== "" + text: Commons.I18n.t("monitor.scale") + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + textFormat: Text.PlainText + } + ButtonGroup { + visible: root.scaleValue !== "" + options: root.scaleOptions + value: root.scaleValue + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + onChanged: function(v) { root.setScale(v) } + } } } } diff --git a/desktop/shell/shell/plugins/services/idle/Service.qml b/desktop/shell/shell/plugins/services/idle/Service.qml index 9f0511c..a4e384b 100644 --- a/desktop/shell/shell/plugins/services/idle/Service.qml +++ b/desktop/shell/shell/plugins/services/idle/Service.qml @@ -285,6 +285,19 @@ Item { onTriggered: if (root.screensaverEnabled) root.launchScreensaver() } + // Dim before the 5-minute lock. The power daemon serializes dim/ALS/off; + // input restores only if a manual brightness change has not superseded dim. + IdleMonitor { + id: dimMonitor + timeout: 180 + enabled: Quickshell.env("OMARCHY_TOUCH_MODE") === "1" && root.idleEnabled && root.lockEnabled + onIsIdleChanged: { + dimProcess.command = ["/usr/libexec/omarchy-powerctl", isIdle ? "dim" : "undim"] + dimProcess.running = true + } + } + Process { id: dimProcess } + Timer { id: lockTimer repeat: false diff --git a/desktop/stage.py b/desktop/stage.py index 1cf4f79..b62541e 100644 --- a/desktop/stage.py +++ b/desktop/stage.py @@ -101,6 +101,7 @@ def main(): PORT / "fastfetch/about-compact.txt": config / "omarchy/branding/about-compact.txt", CONFIG / "first-theme": target / "usr/local/libexec/arch-ipad-first-theme", CONFIG / "scale-output": target / "usr/local/libexec/arch-ipad-scale-output", + CONFIG / "auto-brightness": target / "usr/local/libexec/arch-ipad-auto-brightness", CONFIG / "rotate-output": target / "usr/local/libexec/arch-ipad-rotate-output", CONFIG / "touch-output": target / "usr/local/libexec/arch-ipad-touch-output", CONFIG / "login-session": target / "usr/local/libexec/arch-ipad-login-session", @@ -123,16 +124,11 @@ def main(): copy(src, dst) # Branding is static; kernel, memory and CPU information use live probes. assert "VM prototype" not in (config / "fastfetch/config.jsonc").read_text() - # Offer Suspend only when the kernel-side charger PM contract is ready. - menu = source / "default/omarchy/omarchy-menu.jsonc" - original_menu = menu.read_text() - stock_guard = '"when":"! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled"' - assert original_menu.count(stock_guard) == 1 - menu.write_text(original_menu.replace(stock_guard, '"when":"command -v ipadlinux-suspend-ready >/dev/null && ipadlinux-suspend-ready"')) (config / "labwc/autostart").chmod(0o755) (target / "usr/local/libexec/arch-ipad-first-theme").chmod(0o755) (target / "usr/local/libexec/arch-ipad-scale-output").chmod(0o755) (target / "usr/local/libexec/arch-ipad-rotate-output").chmod(0o755) + (target / "usr/local/libexec/arch-ipad-auto-brightness").chmod(0o755) (target / "usr/local/libexec/arch-ipad-touch-output").chmod(0o755) (target / "usr/local/libexec/arch-ipad-login-session").chmod(0o755) # The base overlay preserves directory modes. Set the target layout diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index f43bfde..90bf3b4 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -4,8 +4,8 @@ of the screen, the lock request and sleep. Its state machine, the `Machine` class, has no I/O and no clocks; `tests/test_powerd.py` walks every reachable state with every input and checks the invariants below. Whether a board may -suspend at all is not decided here or by device data: every board may (see -orchard `docs/bring-up/suspend.md`); this model decides when. +suspend at all is decided by the kernel's suspend admission; this model decides +when. Where the kernel refuses, boards retain dim, blank and wake. ## Requirements @@ -222,3 +222,47 @@ when `user.slice` thaws. `omarchy-tablet-power` (one process per press, state in brightness and files, a fixed window after resume), `omarchy-screen-toggle` and `omarchy-greeter-wake` (a second and third writer of the backlight). + +## Suspend readiness + +The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both +fresh roots and public-root refreshes. It reads the kernel's admission query, +`/sys/power/suspend_ready` (Orchard's suspend topic), on every board: `ready` +exits 0; `not-ready ` exits 1 naming the refusing provider device +or precheck. The kernel runs every admission provider's non-reserving check +(GPU, video decoder) and every early precheck (PCI/NVMe, AOP, USB) for the +current `mem_sleep` state, takes no reservation, and answers +`not-ready transition` while a transition runs. A missing, unreadable or +malformed answer refuses: a kernel without the query cannot show that a suspend +would be admitted. There is no board list and no feature-state lookup. Refusal +returns 1 with a reason on stderr. Arguments are rejected with status 2. It +never writes sysfs, unbinds drivers, runs a PM test or requests suspend. The +power daemon logs refusal reasons. The answer is advisory; the real suspend +reserves and checks again. + +The source menu used by both desktop staging and `release/public_root.py` uses +this helper for visibility and again before its suspend action. Powerd uses it +for every sleep request (Power, idle and closing the Smart Cover); a refusal +leaves the screen dark until touch, Home, Power or opening the cover wakes it, +without retrying sleep. Logind ignores lid, idle, suspend and power keys; +keyboard or direct logind/systemctl requests still pass the same helper in +`systemd-suspend.service`'s `ExecStartPre`. Hibernation, hybrid sleep and +suspend-then-hibernate remain disabled and their targets masked. No static +suspend mask or board-specific override is installed: the kernel's admission is +the only gate. + +Device verification on a rebuilt image: + +```sh +cat /sys/power/suspend_ready +timeout -k 5 10 /usr/local/bin/ipadlinux-suspend-ready +``` + +The helper's status must match the kernel's answer: 0 for `ready`, 1 with the +same refusal named for `not-ready`. While it refuses, `systemctl suspend` must +refuse too, the journal must name the kernel's refusal rather than a suspend +attempt, suspend success counters must not advance, and the final session menu +must omit Suspend. Use `timeout -k 5 5 omarchy-powerctl idle`, closing the +Smart Cover, and a short Power press separately; while suspend is refused each +must blank without sleep, and touch, Power or opening the cover must wake. +Dim/undim must still restore the previous brightness. These are pending device checks, not host-test results. diff --git a/docs/tablet-sensors.md b/docs/tablet-sensors.md new file mode 100644 index 0000000..cdd13fe --- /dev/null +++ b/docs/tablet-sensors.md @@ -0,0 +1,25 @@ +# Tablet light and orientation + +Orientation already comes from iio-sensor-proxy and the kernel's accelerometer mount matrix. Labwc maps touch to the panel, so display and touch rotate together. This remains the native orientation path; no extra board matrix or second sensor reader is installed. + +The Tablet submenu under Setup adds Rotation Lock. `omarchy-rotation-lock on|off|toggle|status` uses the same lock as the orientation helper. While locked the helper keeps watching; after unlock it applies the last settled pose even without a new sensor event. Undefined/flat poses stay ignored and the existing half-second settling remains. The helper exits with labwc. + +Auto Brightness uses the existing tablet ALS curve, smoothing, hysteresis, ramp and manual-change pause policy. It is **off by default** because panel/light calibration still needs device validation. Enable it in Setup → Tablet → Auto Brightness, or: + +```sh +omarchy-auto-brightness on +omarchy-auto-brightness status +omarchy-auto-brightness off +``` + +It reads generic IIO lux or iio-sensor-proxy. Missing sensors are harmless; the menu action appears when an IIO light sensor and panel exist. Its process follows the compositor lifetime. Logs are in `$XDG_RUNTIME_DIR/arch-ipad-auto-brightness.log`; `--list` shows discovery and `--probe 20` computes a dry run without backlight writes. + +User preferences live in `~/.config/omarchy/als-brightness.conf` and the switch is saved in `~/.local/state/omarchy/display`. Optional configuration keys are `enabled`, `backlight`, `curve`, `min_pct`, `max_pct`, `lux_scale`, `smooth_secs`, `hysteresis`, `min_pct_step`, `min_interval`, `ramp_pct_per_s`, `override_secs`, and `poll_hz`. The default curve is `0:10, 5:18, 20:30, 80:50, 250:70, 700:90, 1500:100` (lux:percent). Readings pass through a five-sample median and log-lux smoothing. Manual changes pause automatic adjustment for 30 seconds. + +Automatic writes use omarchy-powerd's control socket, serialized with its existing screen/sleep/wake state machine. The request names the panel and supplies the expected old raw value. Powerd accepts only positive, in-range levels for that panel while lit and rejects stale values. A dark panel, unavailable power daemon or manual edit therefore stops a ramp. Wake still restores the level through powerd before automatic adjustment resumes. No brightnessctl helper, root user daemon or passwordless brightness sudo rule is added. + +Both fresh desktop staging and the public-root builder include the helper, CLI commands, native rotation helper and updated menu. A running install needs those files plus the updated power daemon; restart omarchy-powerd and log into a fresh labwc session. Preserve local autostart/menu edits when applying by hand. The power daemon remains the sole owner of screen-off and sleep. + +Host checks cover the retained light policy, switch state, missing power daemon, stale-value/dark-panel refusal, manual pause, actual socket writes, rotation debounce/lock/unlock and compositor exit, plus staging and public-root inclusion. Physical lux response, all four orientation directions, touch mapping and power-button/idle/sleep coexistence still need a device run. An AOP-disabled warm kernel cannot supply rotation data; cold boot through the matching kernel/m1n1 restores it. + +Proposed changelog entry (not added): “Tablet: optional ambient-light brightness and rotation lock, with screen-off and sleep still owned by the power daemon.” diff --git a/docs/warm-restart.md b/docs/warm-restart.md new file mode 100644 index 0000000..ee54277 --- /dev/null +++ b/docs/warm-restart.md @@ -0,0 +1,33 @@ +# Warm restart + +The menu stays **Restart** on every device. Warm restart is used where the +system has what it needs, and `orchard-kexec supported` answers that from the +mechanisms themselves: the running kernel's kexec support +(`/sys/kernel/kexec_loaded`), kexec-tools, and the kernel's tethered reset guard +described below. There is no board list and no feature-state lookup. Today only +J71t's kernel describes the tethered boot on its watchdog, so only J71t warm +restarts; other boards (including J71b) use normal reboot, silently. + +Where those exist, Restart and `orchard-restart` load the selected BLS default +before systemd kexec. The matching initrd retains the running pristine DT and +boot ID. Canonically equal pristine trees use the file loader; changed trees +merge runtime fixups with the legacy loader. No silent fallback after failure. +The plan records `pristine_dt_changed` for the integration checker. + +Require `/sys/kernel/reboot/mode` plus `apple,tethered-boot` on an enabled +`apple,wdt` node, or the older `apple_wdt` `cold_restart` parameter. Reboot sysfs +alone is insufficient. Loading validates; dispatch selects warm (old: N). +Incoming guarded trees use reboot=warm; old targets also get the old parameter. +Health, status and diagnostics report the same contract. CPU lifecycle/kexec +requires the resident PSCI loader with `psci=1 psci_reset=0`. + +The final kexec hook requires GPU quiesce success before dispatch. Missing image +halts safely. Reboot (leaves Linux) retains its ordinary action: a separate reboot +hook selects cold (old: Y) and leaves systemd's shutdown command intact. +`orchard-kexec ios` explicitly exits too. Unguarded kernels still reboot normally +but cannot warm restart. Poweroff is unchanged. + +Public-root assembly enables provenance/load/health units; incremental overlays +must enable those after installing files. The pacman hook refreshes images after +updates. Refresh is never part of shutdown because it needs mounted /boot. +Host tests do not qualify hardware; J71t and J71b device tests remain required. diff --git a/release/public_root.py b/release/public_root.py index 63a2d35..56eb695 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -42,6 +42,14 @@ def desktop_replacements(): replacements['home/tablet/.config/labwc/rc.xml'] = (PROJECT / 'desktop/shell/labwc-rc.xml').read_bytes() replacements['home/tablet/.config/labwc/autostart'] = (PROJECT / 'desktop/configuration/labwc-autostart').read_bytes() replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-output').read_bytes() + replacements['usr/local/libexec/arch-ipad-scale-output'] = (PROJECT / 'desktop/configuration/scale-output').read_bytes() + replacements[prefix + 'shell/plugins/panels/monitor/Panel.qml'] = (PROJECT / 'desktop/shell/shell/plugins/panels/monitor/Panel.qml').read_bytes() + replacements[prefix + 'default/i18n/en/shell.json'] = (PROJECT / 'desktop/shell/default/i18n/en/shell.json').read_bytes() + replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_bytes() + replacements[prefix + 'bin/omarchy-menu-images'] = (PROJECT / 'desktop/shell/bin/omarchy-menu-images').read_bytes() + replacements[prefix + 'bin/omarchy-picker-process'] = (PROJECT / 'desktop/shell/bin/omarchy-picker-process').read_bytes() + replacements[prefix + 'shell/picker.qml'] = (PROJECT / 'desktop/shell/shell/picker.qml').read_bytes() + replacements[prefix + 'shell/plugins/image-picker/ImagePicker.qml'] = (PROJECT / 'desktop/shell/shell/plugins/image-picker/ImagePicker.qml').read_bytes() return replacements @@ -129,20 +137,29 @@ def labwc_files(directory, architecture='aarch64'): def desktop_additions(): - name = 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml' - return {name: ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644), - 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-rotation-lock': - ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755)} + prefix = 'home/tablet/.local/share/omarchy-ipad/' + return { + 'home/tablet/.config/omarchy-ipad/shell.json': + ((PROJECT / 'desktop/shell/config/omarchy/shell.json').read_bytes(), 0o644), + 'usr/local/bin/omarchy-rotate': ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-rotate': ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755), + 'usr/local/bin/omarchy-rotation-lock': ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755), + 'usr/local/libexec/arch-ipad-auto-brightness': ((PROJECT / 'desktop/configuration/auto-brightness').read_bytes(), 0o755), + 'usr/local/bin/omarchy-auto-brightness': ((PROJECT / 'desktop/shell/bin/omarchy-auto-brightness').read_bytes(), 0o755), + 'usr/local/bin/omarchy-display-scale': ((PROJECT / 'desktop/shell/bin/omarchy-display-scale').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-rotation-lock': ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644), + } def system_files(): additions = {} for path in sorted(SYSTEM.rglob('*')): - if path.is_file() and not path.is_symlink(): + if path.is_file() and not path.is_symlink() and '__pycache__' not in path.parts: name = path.relative_to(SYSTEM).as_posix() data = path.read_bytes() mode = 0o440 if name.startswith('etc/sudoers.d/') else ( - 0o755 if name.startswith(('usr/libexec/', 'etc/NetworkManager/dispatcher.d/')) else 0o644) + 0o755 if name.startswith(('usr/libexec/', 'usr/bin/', 'usr/local/bin/', 'etc/NetworkManager/dispatcher.d/')) or name in ('usr/lib/orchard-kexec/quiesce-gpu', 'usr/lib/orchard-kexec/fdt-merge') else 0o644) additions[name] = (data, mode) return additions @@ -245,6 +262,11 @@ def add(member, data=None): data = group_membership(data, shadow=member.name == 'etc/gshadow') if member.name in replacements: data = replacements[member.name] + # A base made by older staging code may lack executable bits. + if (member.name.startswith(('usr/local/libexec/', 'usr/local/bin/', + 'home/tablet/.local/share/omarchy-ipad/bin/')) + or member.name.endswith('/labwc/autostart')): + member.mode = 0o755 if member.name == 'arch-ipad-ui-manifest.json': manifest = json.loads(data) for name, replacement in replacements.items(): @@ -281,6 +303,8 @@ def add_parents(name): wants = [('multi-user.target.wants/' + unit, unit) for unit in ('omarchy-pacman-init.service', 'orchard-usb-network.service', 'orchard-ssh.service', 'omarchy-powerd.service', 'omarchy-wifi-country.service')] + wants += [('multi-user.target.wants/' + unit, unit) for unit in + ('orchard-kexec-provenance.service', 'orchard-kexec-load.service', 'orchard-kexec-refresh.path', 'orchard-kexec-health.service')] wants.append(('getty.target.wants/serial-getty@ttyGS0.service', 'serial-getty@.service')) # BindsTo stops the USB network when usb0 goes; usb0's return starts it again wants.append(('sys-subsystem-net-devices-usb0.device.wants/orchard-usb-network.service', diff --git a/system/assemble-rootfs b/system/assemble-rootfs index d4a9534..0270c57 100755 --- a/system/assemble-rootfs +++ b/system/assemble-rootfs @@ -33,7 +33,7 @@ local_packages=("$desktop/packages/omafox.pkg.tar.xz" "$desktop/packages/wlroots [[ $architecture == aarch64 ]] || local_packages+=("$desktop/packages/omacalc.pkg.tar.zst") # 64-bit boards: the A10 GPU's Vulkan driver and the VA-API bridge to the # hardware video decoders. Both sit unused on a board without that hardware. -[[ $architecture != aarch64 ]] || local_packages+=("$desktop/packages/vulkan-powervr-orchard.pkg.tar.zst" "$desktop/packages/libva-v4l2-request.pkg.tar.zst") +[[ $architecture != aarch64 ]] || local_packages+=("$desktop/packages/mesa-g5p-orchard.pkg.tar.zst" "$desktop/packages/vulkan-powervr-orchard.pkg.tar.zst" "$desktop/packages/libva-v4l2-request.pkg.tar.zst") # 64-bit boards: Omactivity, the touch task manager, as its repository publishes it. [[ $architecture != aarch64 ]] || local_packages+=("$desktop/packages/omactivity.pkg.tar.zst") # 64-bit boards: Files, upstream Omarchy's file manager (Folio) with the touch @@ -87,6 +87,11 @@ pacman --config "$config" --root "$root" --gpgdir "$gpgdir" -S --noconfirm "${re for package in "${local_packages[@]}"; do pacman --config "$config" --root "$root" --gpgdir "$gpgdir" --noconfirm -U "$package" done +# J71t and A10 use the same stateless VXD bridge. Refuse an image that +# contains the package receipt but lacks the actual VA-API driver. +if [[ $architecture == aarch64 ]]; then + [[ -s "$root/usr/lib/dri/v4l2_request_drv_video.so" ]] || { echo 'Missing VXD VA-API bridge' >&2; exit 1; } +fi useradd --root "$root" --create-home --uid 1000 --user-group --shell /bin/bash tablet # Native login needs seatd's socket and DRM/input device access (render: the # GPU, video: the hardware video decoder, also over SSH); wheel @@ -99,7 +104,8 @@ usermod --root "$root" --append --groups seat,video,input greeter cp -a --no-preserve=ownership "$desktop/payload/." "$root/" chown -R 1000:1000 "$root/home/tablet" install -d "$root/etc/systemd/system" -# Suspend stays possible; orchard allows it per board. Hibernation never is. +# Suspend is gated at runtime by the platform registry and kernel admission. +# Hibernation never is supported. for unit in hibernate hybrid-sleep suspend-then-hibernate; do ln -s /dev/null "$root/etc/systemd/system/$unit.target" done diff --git a/system/overlay/etc/polkit-1/rules.d/61-orchard-kexec.rules b/system/overlay/etc/polkit-1/rules.d/61-orchard-kexec.rules new file mode 100644 index 0000000..52bf37e --- /dev/null +++ b/system/overlay/etc/polkit-1/rules.d/61-orchard-kexec.rules @@ -0,0 +1,7 @@ +// The local active session may request warm restart through the load unit. +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "orchard-kexec-load.service" && + action.lookup("verb") == "start" && subject.local && subject.active && + subject.isInGroup("wheel")) return polkit.Result.YES; +}); diff --git a/system/overlay/etc/sysctl.d/20-omarchy-a-console.conf b/system/overlay/etc/sysctl.d/20-omarchy-a-console.conf new file mode 100644 index 0000000..30da760 --- /dev/null +++ b/system/overlay/etc/sysctl.d/20-omarchy-a-console.conf @@ -0,0 +1,2 @@ +# Keep errors visible; full messages remain in the kernel journal. +kernel.printk = 3 4 1 7 diff --git a/system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf b/system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf new file mode 100644 index 0000000..2fed5c5 --- /dev/null +++ b/system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf @@ -0,0 +1,5 @@ +# Tested on J71t; favor compressed swap over filesystem reclaim. +vm.swappiness = 180 +vm.page-cluster = 0 +vm.watermark_scale_factor = 125 +vm.watermark_boost_factor = 0 diff --git a/system/overlay/etc/systemd/journald.conf.d/90-omarchy-a-memory.conf b/system/overlay/etc/systemd/journald.conf.d/90-omarchy-a-memory.conf new file mode 100644 index 0000000..24f4d15 --- /dev/null +++ b/system/overlay/etc/systemd/journald.conf.d/90-omarchy-a-memory.conf @@ -0,0 +1,4 @@ +[Journal] +RuntimeMaxUse=8M +RuntimeKeepFree=32M +SystemMaxUse=64M diff --git a/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf b/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf index 4c3908a..61a2812 100644 --- a/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf +++ b/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf @@ -3,3 +3,7 @@ HandlePowerKey=ignore HandleSuspendKey=ignore HandleHibernateKey=ignore IdleAction=ignore + +HandleLidSwitch=ignore +HandleLidSwitchExternalPower=ignore +HandleLidSwitchDocked=ignore diff --git a/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf b/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf index 01e716c..9e0b786 100644 --- a/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf +++ b/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf @@ -1,5 +1,5 @@ -# No hibernation: nothing restores an image on these devices. Every board may -# suspend; do not set AllowSuspend here. +# No hibernation: nothing restores an image on these devices. Suspend is +# checked at runtime by systemd-suspend.service, against the kernel's admission. [Sleep] AllowHibernation=no AllowHybridSleep=no diff --git a/system/overlay/etc/systemd/system.conf.d/90-omarchy-a-quiet.conf b/system/overlay/etc/systemd/system.conf.d/90-omarchy-a-quiet.conf new file mode 100644 index 0000000..711a7c4 --- /dev/null +++ b/system/overlay/etc/systemd/system.conf.d/90-omarchy-a-quiet.conf @@ -0,0 +1,2 @@ +[Manager] +ShowStatus=error diff --git a/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf new file mode 100644 index 0000000..4c9384e --- /dev/null +++ b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf @@ -0,0 +1,4 @@ +# Enforce the same kernel admission check for direct systemctl/logind +# requests and keyboard bindings, even without a desktop or powerd running. +[Service] +ExecStartPre=/usr/local/bin/ipadlinux-suspend-ready diff --git a/system/overlay/etc/systemd/zram-generator.conf.d/99-omarchy-a.conf b/system/overlay/etc/systemd/zram-generator.conf.d/99-omarchy-a.conf new file mode 100644 index 0000000..4956c78 --- /dev/null +++ b/system/overlay/etc/systemd/zram-generator.conf.d/99-omarchy-a.conf @@ -0,0 +1,3 @@ +[zram0] +zram-size = ram +compression-algorithm = zstd diff --git a/system/overlay/etc/wireplumber/wireplumber.conf.d/60-omarchy-a-volume.conf b/system/overlay/etc/wireplumber/wireplumber.conf.d/60-omarchy-a-volume.conf index 3e4d0ae..60c0f19 100644 --- a/system/overlay/etc/wireplumber/wireplumber.conf.d/60-omarchy-a-volume.conf +++ b/system/overlay/etc/wireplumber/wireplumber.conf.d/60-omarchy-a-volume.conf @@ -1,7 +1,5 @@ -# The speakers' amplifier has no volume control of its own, so PipeWire's is -# the only one: start a new output at 10% (on the cubic scale the shell and -# wpctl show; WirePlumber stores it linear, 0.1^3), not WirePlumber's 40%. -# The last volume set is kept as usual after that. +# New outputs start at 50% on the cubic scale shown by wpctl and the shell. +# WirePlumber stores linear gain: 0.5^3 = 0.125. Saved volumes still win. wireplumber.settings = { - device.routes.default-sink-volume = 0.001 + device.routes.default-sink-volume = 0.125 } diff --git a/system/overlay/etc/wireplumber/wireplumber.conf.d/61-omarchy-a-bluetooth-codecs.conf b/system/overlay/etc/wireplumber/wireplumber.conf.d/61-omarchy-a-bluetooth-codecs.conf new file mode 100644 index 0000000..8ed1733 --- /dev/null +++ b/system/overlay/etc/wireplumber/wireplumber.conf.d/61-omarchy-a-bluetooth-codecs.conf @@ -0,0 +1,2 @@ +# Radio codec policy is generated per user by omarchy-radio-policy. +# Empty vendor file also clears the former global codec restriction on upgrade. diff --git a/system/overlay/usr/bin/orchard-kexec b/system/overlay/usr/bin/orchard-kexec new file mode 100755 index 0000000..d9b70bb --- /dev/null +++ b/system/overlay/usr/bin/orchard-kexec @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/python3 -I /usr/lib/orchard-kexec/cli.py "$@" diff --git a/system/overlay/usr/bin/orchard-restart b/system/overlay/usr/bin/orchard-restart new file mode 100755 index 0000000..5a6688e --- /dev/null +++ b/system/overlay/usr/bin/orchard-restart @@ -0,0 +1,11 @@ +#!/bin/sh +# Warm restart only where the kernel's kexec support and reset guard exist. +/usr/bin/orchard-kexec supported || exec systemctl reboot +# Preflight while the desktop and notification service still exist. +if ! systemctl start orchard-kexec-load.service; then + message='Warm restart refused. Linux is still running. Check journalctl -u orchard-kexec-load; no hardware reset was requested.' + echo "$message" >&2 + command -v notify-send >/dev/null && notify-send -u critical 'Restart unavailable' "$message" + exit 1 +fi +exec systemctl kexec diff --git a/system/overlay/usr/lib/orchard-kexec/cli.py b/system/overlay/usr/lib/orchard-kexec/cli.py new file mode 100644 index 0000000..9901f2d --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/cli.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Warm restart for tether-booted Orchard systems. No host is needed.""" +import argparse +import fcntl +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import device as d +import diagnostics +import reset + + +def warm_supported(): + """Whether this system has what a warm restart uses: the running kernel's + kexec support, kexec-tools and the kernel's tethered reset guard. Without + them Restart is an ordinary reboot. The guard is the board's own: only a + kernel whose watchdog describes the tethered boot provides it.""" + try: + if not Path('/sys/kernel/kexec_loaded').exists() or shutil.which('kexec') is None: + return False + reset.backend() + except (OSError, ValueError): + return False + return True + + +def load(stage): + plan=json.loads((stage/'plan.json').read_text()) + if Path('/proc/sys/kernel/random/boot_id').read_text().strip()!=plan['boot_id']: + raise ValueError('boot changed after preparation') + if plan.get('loader') == 'file': + args=['kexec','-s','-l',str(stage/'Image')] + elif plan.get('loader') == 'legacy': + args=['kexec','-c','-l',str(stage/'Image'),'--dtb='+str(stage/'merged.dtb')] + else: + raise ValueError('missing or invalid loader; prepare the warm entry again') + args+=['--command-line='+plan['options']] + if plan['initrd']: args+=['--initrd='+str(stage/'initrd')] + d.run(*args) + if Path('/sys/kernel/kexec_loaded').read_text().strip()!='1': + raise ValueError('kexec did not load an image') + # The kernel owns copies of the segments now; retain only DT provenance. + for name in ('Image','initrd','merged.dtb'): + (stage/name).unlink(missing_ok=True) + (d.RUN/'developer-loaded').write_text(plan['boot_id']+'\n') + # Arm the shutdown dispatch here, at load time. The refresh unit no longer runs in + # the reboot/kexec transaction (a unit needing /boot conflicts with umount.target), + # so nothing else writes this marker. Without it, dispatch refused and *halted* + # every warm restart (device journal 2026-11-08: six hops, none jumped). + (d.RUN/'restart-ready').write_text(plan['boot_id']+'\n') + return plan + + +def unload(): + """Invalidate provenance before touching the kernel's armed image.""" + for name in ('restart-ready', 'developer-loaded', 'loaded.json'): + (d.RUN/name).unlink(missing_ok=True) + d.run('kexec', '-u') + + +def cold_reset(reason=None): + """Best-effort cold escape; one failed step must not suppress reboot.""" + def note(message): + try: + print(message, file=sys.stderr) + except OSError: + pass + try: + Path('/dev/kmsg').write_text('<0>' + message + '\n') + except OSError: + pass + if reason: + note(f'Warm restart aborted: {reason}; unloading kexec and cold-resetting to iOS.') + try: + reset.select(warm=False) + except (ValueError, OSError, subprocess.SubprocessError) as exc: + note(f'Cold escape: guard selection failed: {exc}; using reboot mode ABI') + try: + mode = Path('/sys/kernel/reboot/mode') + mode.write_text('cold\n') + if mode.read_text().strip() != 'cold': + raise ValueError('cold mode did not read back') + except (ValueError, OSError) as mode_error: + note(f'Cold escape: cold mode unavailable: {mode_error}; arming native watchdog') + try: + if Path('/sys/class/watchdog/watchdog0/identity').read_text().strip() != 'Apple SoC Watchdog': + raise ValueError('native Apple watchdog unavailable') + fd = os.open('/dev/watchdog0', os.O_WRONLY | os.O_CLOEXEC) + try: + import struct + fcntl.ioctl(fd, 0xc0045706, struct.pack('I', 1)) # WDIOC_SETTIMEOUT + os.write(fd, b'\0') # Keep armed: never write the magic-close byte. + finally: + os.close(fd) + note('Cold escape: native watchdog armed; no warm reboot requested') + return True + except (ValueError, OSError) as watchdog_error: + note(f'Cold escape: native watchdog failed: {watchdog_error}; refusing warm reset') + return False + try: + unload() + except (ValueError, OSError, subprocess.SubprocessError) as exc: + note(f'Cold escape: unload kexec failed: {exc}') + try: + d.run('systemctl', '--force', 'reboot') + return True + except (ValueError, OSError, subprocess.SubprocessError) as exc: + note(f'Cold escape: reboot request failed: {exc}; trying emergency restart') + try: + # Also try the standard ABI if guard discovery or read-back failed. + Path('/sys/kernel/reboot/mode').write_text('cold\n') + except OSError as exc: + note(f'Cold escape: emergency cold selection failed: {exc}') + try: + Path('/proc/sysrq-trigger').write_text('b') + except OSError as exc: + note(f'Cold escape: emergency restart failed: {exc}') + # A returning emergency restart is failure: never trigger SuccessAction=kexec. + return False + + +def select_default(entry): + """Change selection synchronously; never leave the previous image armed.""" + entries = [(p, d.fields(p)) for p in sorted((d.BOOT/'loader/entries').glob('*.conf'))] + path, _ = d.select_entry(entries, entry) + loader = d.BOOT/'loader/loader.conf' + lines = loader.read_text().splitlines() if loader.exists() else [] + lines = [line for line in lines if not line.split() or line.split()[0] != 'default'] + unload() + # Write only after unload succeeds. On prepare failure the new default remains + # selected, with no stale image; refresh can retry while /boot is available. + temporary = loader.with_name('loader.conf.orchard-new') + temporary.write_text('\n'.join(lines + ['default ' + path.name]) + '\n') + temporary.replace(loader) + try: + return prepare_installed('default') + except BaseException: + unload() + raise + + +def prepare_reboot(): + """Shutdown-only: no /boot, systemctl call, dispatch, or halt.""" + if Path('/sys/kernel/kexec_loaded').read_text().strip() == '1': + reset.select(warm=False) + else: + try: + reset.backend() + except ValueError: + return + reset.select(warm=False) + + +def snapshot(): + """Persist early-initrd snapshot, never derive an old DT from updated /boot.""" + boot=Path('/proc/sys/kernel/random/boot_id').read_text().strip() + if not (d.RUN/'pristine.dtb').is_file() or (d.RUN/'boot-id').read_text().strip()!=boot: + raise ValueError('matching initrd did not retain pristine DT; cold boot the new bundle first') + target=Path('/var/lib/orchard-kexec/boots')/boot + target.mkdir(parents=True,exist_ok=True) + shutil.copyfile(d.RUN/'pristine.dtb',target/'pristine.dtb') + return target + + +def prepare_installed(entry): + reset.backend() + snapshot() + stage=Path(tempfile.mkdtemp(prefix='orchard-warm.',dir='/var/tmp')) + shutil.copyfile(Path(__file__).with_name('fdt-merge'),stage/'fdt-merge') + try: + plan=d.prepare(stage,False,entry_name=entry) + load(stage) + (d.RUN/'developer-loaded').unlink(missing_ok=True) + except BaseException: + shutil.rmtree(stage) + raise + d.RUN.mkdir(exist_ok=True,parents=True) + (d.RUN/'loaded.json').write_text(json.dumps(plan,indent=2)+'\n') + return plan + + +def main(): + ap=argparse.ArgumentParser(description=__doc__) + sub=ap.add_subparsers(dest='action',required=True) + for action in ('load','reboot','select'): + p=sub.add_parser(action);p.add_argument('entry',nargs='?',default='default') + for action in ('snapshot','refresh','dispatch','ios','cold','status','update','prepare-reboot','supported'): + sub.add_parser(action) + p=sub.add_parser('stage',help='developer wrapper: staged matching bundle, same engine') + p.add_argument('directory',type=Path);p.add_argument('operation',choices=('prepare','load','diagnose','bisect')) + p.add_argument('--profile',choices=d.PROFILES,default='normal') + p.add_argument('--entry',default='default',help='installed BLS entry for a staged host test') + p.add_argument('--replace-modules',action='store_true');p.add_argument('--no-aop',action='store_true') + p.add_argument('--pristine-dtb');p.add_argument('--remote-mac');p.add_argument('--port',type=int,default=6666) + p.add_argument('--outgoing-no-ans-handoff',action='store_true');p.add_argument('--outgoing-quiesce-gpu',action='store_true') + p.add_argument('--outgoing-offline-cpu1',action='store_true') + a=ap.parse_args() + if a.action == 'supported': + raise SystemExit(0 if warm_supported() else 1) + if a.action == 'prepare-reboot': + prepare_reboot() + return + if a.action in ('load', 'refresh', 'reboot', 'update', 'dispatch', 'select'): + qualified = warm_supported() + if not qualified: + if a.action == 'dispatch': + if cold_reset('this system has no kexec support or reset guard for a warm restart') is False: + raise SystemExit(1) + elif a.action == 'reboot': + d.run('systemctl', 'reboot') + return + if os.geteuid()!=0: raise ValueError('run with sudo') + d.RUN.mkdir(parents=True,exist_ok=True) + if a.action == 'dispatch' and (d.RUN/'gpu-quiesce-failed').exists(): + if cold_reset('GPU td-kexec failed or timed out') is False: + raise SystemExit(1) + return + with (d.RUN/'lock').open('w') as lock: + try: + fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) + except OSError: + if a.action != 'dispatch': + raise + if cold_reset('shutdown dispatch lock unavailable') is False: + raise SystemExit(1) + return + if a.action=='stage': + stage=a.directory.resolve() + if not __import__('re').fullmatch(r'/var/tmp/orchard-warm\.[A-Za-z0-9]+',str(stage)): + raise ValueError('invalid stage') + if a.operation=='prepare': result=d.prepare(stage,a.replace_modules,a.no_aop,a.pristine_dtb,a.profile,entry_name=a.entry) + elif a.operation=='load': result=load(stage) + elif a.operation=='diagnose': result=diagnostics.netconsole(a.remote_mac,a.port) + else: result=diagnostics.bisect(a.outgoing_no_ans_handoff,a.outgoing_quiesce_gpu,a.outgoing_offline_cpu1) + elif a.action=='snapshot': result=str(snapshot()) + elif a.action=='status': + print('Reset guard: ' + reset.status()) + print((d.RUN/'loaded.json').read_text() if (d.RUN/'loaded.json').exists() else 'No warm image loaded') + return + elif a.action=='cold': + # Preserve normal reboot on systems without a tethered guard. + try: reset.backend() + except ValueError: return + reset.select(warm=False) + return + elif a.action=='ios': + # Explicit escape hatch only; not used by the Restart action. + if cold_reset() is False: + raise SystemExit(1) + return + elif a.action=='select': + result=select_default(a.entry) + elif a.action=='refresh': + # Selection and package changes always mean the current default. + # Invalidate the old image first; failed preparation cannot leave it armed. + unload() + try: + prepare_installed('default') + except BaseException: + unload() + raise + return + elif a.action=='dispatch': + if (d.RUN/'gpu-quiesce-failed').exists(): + if cold_reset('GPU td-kexec failed or timed out') is False: + raise SystemExit(1) + return + # At this point /boot may already be unmounted. Never load here. + try: + if (Path('/sys/kernel/kexec_loaded').read_text().strip() != '1' or + (d.RUN/'restart-ready').read_text().strip() != + Path('/proc/sys/kernel/random/boot_id').read_text().strip()): + raise ValueError('warm image was not prepared before shutdown') + d.guard_outgoing('normal', arm=True) + d.run('systemctl','--force','kexec') + except (ValueError,OSError,subprocess.SubprocessError) as exc: + if cold_reset(f'warm dispatch refused: {exc}') is False: + raise SystemExit(1) + return + else: + # Always reload here: packages may have replaced even same-path BLS assets. + try: result=prepare_installed(getattr(a,'entry','default')) + except (ValueError,OSError,subprocess.SubprocessError): + # Never leave an old loaded kernel advertised after a failed update. + subprocess.run(['kexec','-u'],timeout=30,check=False) + raise + if getattr(a,'entry','default') != 'default': + (d.RUN/'developer-loaded').write_text(result['boot_id']+'\n') + if a.action=='reboot': d.run('systemctl','kexec') + print(json.dumps(result,indent=2)) + +if __name__=='__main__': + try: main() + except (ValueError,OSError,subprocess.SubprocessError) as exc: + sys.exit(f'Warm restart refused: {exc}. Linux is still running; no hardware reset was requested.') diff --git a/system/overlay/usr/lib/orchard-kexec/device.py b/system/overlay/usr/lib/orchard-kexec/device.py new file mode 100644 index 0000000..7464f03 --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/device.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Device half of warm-reboot.sh. Uploaded and called explicitly; no import side effects.""" +import argparse +import reset +from functools import cmp_to_key +import fnmatch +import gzip +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys + +BOOT = Path('/boot') +RUN = Path('/run/orchard-kexec') + +def run(*args, **kw): + return subprocess.run(args, check=True, timeout=kw.pop('timeout', 60), **kw) + +def fields(path): + values = {} + for line in path.read_text().splitlines(): + parts = line.strip().split(None, 1) + if len(parts) == 2 and not parts[0].startswith('#'): + values.setdefault(parts[0].lower(), []).append(parts[1].strip()) + return values + +def value(entry, key, default=''): + return entry.get(key, [default])[-1] + +def version_cmp(a, b): + # Same ordering as Orchard m1n1 bls.rs (strverscmp_improved). + def cmp(x, y): return (x > y) - (x < y) + if not a or not b: + return cmp(a, b) + while True: + a = re.sub(r'^[^a-zA-Z0-9~^.-]+', '', a) + b = re.sub(r'^[^a-zA-Z0-9~^.-]+', '', b) + for separator in '~': + x, y = a.startswith(separator), b.startswith(separator) + if x != y: return -1 if x else 1 + if x: a, b = a[1:], b[1:] + if not a or not b: return cmp(a, b) + for separator in '-^.': + x, y = a.startswith(separator), b.startswith(separator) + if x != y: return -1 if x else 1 + if x: a, b = a[1:], b[1:] + numeric = a[:1].isdigit() or b[:1].isdigit() + pattern = r'^\d*' if numeric else r'^[a-zA-Z]*' + x, y = re.match(pattern, a)[0], re.match(pattern, b)[0] + if numeric: + sx, sy = x.lstrip('0'), y.lstrip('0') + r = cmp(bool(x), bool(y)) or cmp(len(sx), len(sy)) or cmp(sx, sy) + else: + r = cmp(x, y) + if r: return r + a, b = a[len(x):], b[len(y):] + +def entry_cmp(a, b): + pa, ea = a; pb, eb = b + ka, kb = value(ea, 'sort-key'), value(eb, 'sort-key') + if bool(ka) != bool(kb): return -1 if ka else 1 + if ka: + for x, y in ((ka, kb), (value(ea, 'machine-id'), value(eb, 'machine-id'))): + if x != y: return (x > y) - (x < y) + r = version_cmp(value(eb, 'version'), value(ea, 'version')) + if r: return r + return version_cmp(pb.stem, pa.stem) + +def boot_path(name): + if not name or '..' in Path(name).parts: + raise ValueError(f'invalid BLS path: {name!r}') + p = (BOOT / name.lstrip('/')).resolve() + if not p.is_relative_to(BOOT.resolve()) or not p.is_file(): + raise ValueError(f'missing or escaping BLS path: {name}') + return p + +def release_of(path): + data = path.read_bytes() + if data.startswith(b'\x1f\x8b'): data = gzip.decompress(data) + if data[56:60] != b'ARM\x64': raise ValueError(f'not an arm64 Image: {path}') + m = re.search(rb'Linux version ([^\s]+) ', data) + if not m: raise ValueError(f'no kernel release in {path}') + return m[1].decode() + +def select_entry(entries, name='default'): + if name != 'default': + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+-]*', name): + raise ValueError('invalid BLS entry name') + found = [(p, e) for p, e in entries if p.name == name or p.stem == name] + if len(found) != 1: raise ValueError('missing or ambiguous BLS entry') + return found[0] + loader = BOOT / 'loader/loader.conf' + pattern = value(fields(loader), 'default') if loader.exists() else '' + if pattern.startswith('@'): raise ValueError(f'unsupported loader default {pattern}') + candidates = [(p, e) for p, e in entries if not pattern or + fnmatch.fnmatchcase(p.name, pattern) or fnmatch.fnmatchcase(p.stem, pattern)] + if not candidates: raise ValueError('no matching default BLS entry') + return sorted(candidates, key=cmp_to_key(entry_cmp))[0] + +PROFILES = ('normal', 'safe', 'smp', 'display', 'gpu-bind', 'gpu', 'full') + +def warm_options(options, stage, no_aop=False, profile='normal'): + # Keep root and ordinary BLS options; replace warm provenance and AOP policy. + options = re.sub(r'(?:^|\s)orchard_warm.dtb=\S+', '', options).strip() + if profile not in PROFILES: + raise ValueError('unknown warm profile') + # Retain info-level checkpoints in ramoops; fbcon=map:9 keeps the console + # off this panel. Lower loglevel would also discard the pstore breadcrumbs. + forced = {'panic': '0', 'reboot': 'warm', 'loglevel': '7', + 'fbcon': 'map:9', 'vt.global_cursor_default': '0'} + if profile != 'normal': + forced.update(nr_cpus='1' if profile == 'safe' else '2', + **{'a9_gpu_drm.enable': '1' if profile in ('gpu-bind', 'gpu', 'full') else '0', + 'a9_gpu_drm.autoboot': '1' if profile in ('gpu', 'full') else '0', + 'systemd.unit': 'multi-user.target'}) + options = ' '.join(t for t in options.split() if t.split('=', 1)[0] not in forced + and not t.startswith('apple_wdt.cold_restart=') + and t not in ('quiet', 'ignore_loglevel', 'debug')) + options += ' ' + ' '.join(f'{k}={v}' for k, v in forced.items()) + if no_aop or profile in ('safe', 'smp', 'display', 'gpu-bind', 'gpu'): + blocked = [] + for m in re.finditer(r'(?:^|\s)module_blacklist=(\S+)', options): + blocked.extend(m[1].split(',')) + blocked.append('apple_aop') + options = re.sub(r'(?:^|\s)module_blacklist=\S+', '', options).strip() + options += ' module_blacklist=' + ','.join(dict.fromkeys(blocked)) + options = ' '.join(t for t in options.split() if not t.startswith('crash_kexec_post_notifiers')) + options += f' crash_kexec_post_notifiers orchard_warm.dtb={stage}/pristine.dtb' + return options + +def profile_dtb(source, dest, profile, merger): + """Save the *effective pristine* DT, so diagnostic status is not inherited. + + Editing only the merged tree would look like a loader fixup on the next hop + and silently keep ADBE disabled even when the next profile enables it. + """ + loader = importlib.machinery.SourceFileLoader('orchard_fdt', str(merger)) + spec = importlib.util.spec_from_loader(loader.name, loader) + fdt = importlib.util.module_from_spec(spec) + loader.exec_module(fdt) + root, reservations, cpu = fdt.parse(source.read_bytes()) + disabled = [] + if profile in ('safe', 'smp'): + for path, node in fdt.index(root).items(): + if b'apple,s8000-display-pipe' in node.props.get('compatible', b'').split(b'\0'): + node.props['status'] = b'disabled\0' + disabled.append(path) + if len(disabled) != 1: + raise ValueError('warm profile requires exactly one J71t ADBE node') + dest.write_bytes(fdt.serialize(root, reservations, cpu)) + return disabled + +def guard_outgoing(profile, arm=False): + reset.backend() # Refuse unguarded kernels for every profile. + # A running firmware/userspace watchdog is separate from the reset fallback. + for state in Path('/sys/class/watchdog').glob('watchdog*/state'): + if state.read_text().strip() != 'inactive': + raise ValueError('active watchdog; stop its owner before a warm test') + result = reset.select(warm=True) if arm else reset.status() + Path('/proc/sys/kernel/panic').write_text('0\n') + return 'outgoing ' + result + +def check_psci(possible, tree=Path('/proc/device-tree')): + if possible == '0': + return # nr_cpus=1 diagnostic cold boot; maxcpus=1 is insufficient. + method = tree / 'psci/method' + cpus = [cpu for cpu in (tree / 'cpus').glob('cpu@*') + if not (cpu / 'status').exists() or + (cpu / 'status').read_bytes().rstrip(b'\0') in (b'okay', b'ok')] + if (not method.exists() or method.read_bytes().rstrip(b'\0') != b'smc' or + not cpus or any(not (cpu / 'enable-method').exists() or + (cpu / 'enable-method').read_bytes().rstrip(b'\0') != b'psci' + for cpu in cpus)): + raise ValueError('SMP kexec needs PSCI m1n1 (method=smc, CPU enable-method=psci). ' + 'Cold boot with that loader or nr_cpus=1 first.') + + +def prepare(stage, replace, no_aop=False, pristine_dtb=None, profile='normal', entry_name='default'): + for tool in ('kexec', 'depmod'): + if not shutil.which(tool): raise ValueError(f'missing {tool}; sudo pacman -S kexec-tools kmod (needs network)') + possible = Path('/sys/devices/system/cpu/possible').read_text().strip() + check_psci(possible) + entries = [(p, fields(p)) for p in sorted((BOOT / 'loader/entries').glob('*.conf'))] + path, entry = select_entry(entries, entry_name) + if any(k in entry for k in ('efi', 'uki', 'devicetree-overlay')) or value(entry, 'architecture', 'aa64') != 'aa64': + raise ValueError(f'unsupported BLS entry: {path}') + options = value(entry, 'options') + extra = stage / 'extra-options' + if extra.exists(): + text = extra.read_text() + if any(ord(c) < 32 for c in text): raise ValueError('invalid extra options') + options = (options + ' ' + text).strip() + if not re.search(r'(^|\s)root=PARTUUID=\S+', options): raise ValueError('BLS options lack root=PARTUUID') + target_kernel = boot_path(value(entry, 'linux')) + target_dtb = boot_path(value(entry, 'devicetree')) + current_release = run('uname', '-r', capture_output=True, text=True).stdout.strip() + cmdline = Path('/proc/cmdline').read_text().strip() + custom = (stage / 'kernel.release').exists() + if custom: + release = (stage / 'kernel.release').read_text().strip() + if not re.fullmatch(r'[A-Za-z0-9._+-]+', release): raise ValueError('invalid kernel.release') + if release == current_release and not replace: + raise ValueError(f'refusing to overwrite running modules {release}; use --replace-modules explicitly') + target_kernel, target_dtb = stage / 'Image.gz', stage / 'dtb' + if release_of(target_kernel) != release: raise ValueError('kernel.release disagrees with Image') + else: + release = release_of(target_kernel) + # Resolve the exact pristine DT of the running cold/warm entry; never pass a raw build DT to kexec. + marker = re.search(r'(?:^|\s)orchard_warm.dtb=(\S+)', cmdline) + if (RUN/'pristine.dtb').is_file(): + if (RUN/'boot-id').read_text().strip() != Path('/proc/sys/kernel/random/boot_id').read_text().strip(): + raise ValueError('stale running pristine DT snapshot') + old = RUN/'pristine.dtb' + elif marker: + old = Path(marker[1]) + if not re.fullmatch(r'/var/tmp/orchard-warm\.[A-Za-z0-9]+/pristine.dtb', str(old)): + raise ValueError('invalid running DT provenance marker') + else: + root = re.search(r'(?:^|\s)root=(\S+)', cmdline) + matches = [] + for _, e in entries: + if not root or f'root={root[1]}' not in value(e, 'options').split(): continue + if release_of(boot_path(value(e, 'linux'))) == current_release: + matches.append(boot_path(value(e, 'devicetree'))) + if matches and len({p.read_bytes() for p in matches}) == 1: + old = matches[0] + elif pristine_dtb: + # Explicit operator choice for the ambiguous case (entries sharing one kernel.release). + old = Path(pristine_dtb) + if not re.fullmatch(r'(/boot|/var/tmp/oi-backup-[A-Za-z0-9-]+)/[A-Za-z0-9._/+-]+', str(old)) \ + or not old.is_file() or old.read_bytes()[:4] != b'\xd0\x0d\xfe\xed': + raise ValueError('--pristine-dtb is not a device tree under /boot or /var/tmp/oi-backup-*') + else: + raise ValueError('running pristine DT is ambiguous or absent; keep its exact cold-boot BLS entry ' + 'installed, or pass --pristine-dtb') + disabled = profile_dtb(target_dtb, stage / 'pristine.dtb', profile, stage / 'fdt-merge') + # Canonicalize both pristine trees: reserialization alone is not a DT change. + merger = importlib.machinery.SourceFileLoader('orchard_compare_fdt', str(stage / 'fdt-merge')).load_module() + def canonical(path): + tree, reservations, cpu = merger.parse(path.read_bytes()) + return ({name: node.props for name, node in merger.index(tree).items()}, + sorted(reservations), cpu) + loader = 'file' if canonical(old) == canonical(stage / 'pristine.dtb') else 'legacy' + if loader == 'legacy': + run('python3', str(stage / 'fdt-merge'), '--old', str(old), '--running', '/sys/firmware/fdt', + '--new', str(stage / 'pristine.dtb'), '-o', str(stage / 'merged.dtb')) + # file_load copies the kernel's live FDT and adds linux,booted-from-kexec + # in drivers/of/kexec.c. Never fall back silently after a failed file load. + data = target_kernel.read_bytes() + (stage / 'Image').write_bytes(gzip.decompress(data) if data.startswith(b'\x1f\x8b') else data) + initrds = [boot_path(p) for p in entry.get('initrd', [])] + if custom and not (stage / 'initrd').is_file(): + raise ValueError('custom bundle requires its matching initrd; refusing BLS fallback') + if not initrds and not custom: + raise ValueError('installed BLS entry requires an initrd') + if initrds and not custom: + with (stage / 'initrd').open('wb') as out: + for p in initrds: + with p.open('rb') as src: shutil.copyfileobj(src, out) + if custom: + source = stage / 'lib/modules' / release + if not source.is_dir(): raise ValueError('missing uploaded modules') + # Preserve the installed BLS kernel/initrd/dtb assets within this directory. + shutil.copytree(source, Path('/usr/lib/modules') / release, dirs_exist_ok=True) + run('depmod', '-a', release) + options = warm_options(options, stage, no_aop, profile) + target_tree, _, _ = merger.parse((stage / 'pristine.dtb').read_bytes()) + if not any('apple,tethered-boot' in node.props for node in merger.index(target_tree).values()): + options += ' apple_wdt.cold_restart=0' + guard = guard_outgoing(profile) + plan = dict(entry=path.name, release=release, boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip(), + options=options, initrd=(stage / 'initrd').is_file(), stage=str(stage), + profile=profile, loader=loader, pristine_dt_changed=(loader == 'legacy'), disabled_nodes=disabled, reset_guard=guard, + notes=['AOP explicitly disabled.' if no_aop else 'AOP reload requires a certified outgoing quiesce; untested states refuse.', + 'G5P uses mode 3 on kexec; warm peripheral parity still needs Orchard device validation.']) + (stage / 'plan.json').write_text(json.dumps(plan)) + return plan diff --git a/system/overlay/usr/lib/orchard-kexec/diagnostics.py b/system/overlay/usr/lib/orchard-kexec/diagnostics.py new file mode 100644 index 0000000..6ed5a13 --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/diagnostics.py @@ -0,0 +1,69 @@ +"""Optional outgoing capture and reversible bisects; no import side effects.""" +import json +import reset +from pathlib import Path +import re +import subprocess + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30).stdout + + +def netconsole(remote_mac, port=6666): + if not re.fullmatch(r'(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', remote_mac): + raise ValueError('invalid Mac ethernet address') + if not 1024 <= port <= 65535: + raise ValueError('invalid UDP port') + route = json.loads(run('ip', '-j', 'route', 'get', '172.16.42.2')) + if len(route) != 1 or route[0].get('dev') != 'usb0' or route[0].get('prefsrc') != '172.16.42.1': + raise ValueError('netconsole requires usb0 172.16.42.1 -> 172.16.42.2') + run('modprobe', 'netconsole') + if not Path('/sys/kernel/config/netconsole').exists(): + run('mount', '-t', 'configfs', 'none', '/sys/kernel/config') + target = Path('/sys/kernel/config/netconsole/orchard-warm') + target.mkdir(exist_ok=True) + # A new target starts disabled, and writing 0 to it fails with EINVAL. + if (target/'enabled').read_text().strip() not in ('0', ''): + (target/'enabled').write_text('0\n') + for key, value in dict(dev_name='usb0', local_ip='172.16.42.1', local_port='6665', + remote_ip='172.16.42.2', remote_port=str(port), remote_mac=remote_mac).items(): + (target/key).write_text(value+'\n') + (target/'enabled').write_text('1\n') + # Only for this diagnostic run: INFO/DEBUG shutdown records reach netconsole + # and ramoops. fbcon=map:9 keeps them off the panel; this is not a GPU fix. + values = {} + for path, value in [('/proc/sys/kernel/printk', '8'), + ('/sys/module/kernel/parameters/initcall_debug', 'Y'), + ('/sys/module/printk/parameters/ignore_loglevel', 'Y')]: + p=Path(path) + if p.exists(): + values[path]=p.read_text().strip() + if path.endswith('/printk'): + value=' '.join([value]+values[path].split()[1:]) + p.write_text(value+'\n') + marker='orchard-warm netconsole ready boot='+Path('/proc/sys/kernel/random/boot_id').read_text().strip() + Path('/dev/kmsg').write_text('<6>'+marker+'\n') + return dict(marker=marker, target=str(target), usb_mac=Path('/sys/class/net/usb0/address').read_text().strip(), + remote_mac=remote_mac, previous=values) + + +def bisect(no_ans=False, quiesce_gpu=False, offline_cpu1=False): + result={"reset_guard": reset.status()} + if no_ans: + p=Path('/sys/module/pcie_apple_h9p/parameters/ans_keep_power') + result['ans_keep_power_before']=p.read_text().strip() + # This changes outgoing policy only. A paired cold baseline is still + # required to compare the full boot history. Do not unbind live NVMe. + p.write_text('N\n');result['ans_keep_power_after']=p.read_text().strip() + if quiesce_gpu: + # Lab's production td-kexec path; leave clients stopped and driver bound. + # Unbinding G5P/AOP would enter remove paths and is not a warm equivalent. + run('systemctl','stop','greetd.service') + p=Path('/sys/bus/platform/devices/201000000.gpu/command') + p.write_text('td-kexec\n');result['gpu']='td-kexec (one-shot; no more GPU work this boot)' + if offline_cpu1: + p=Path('/sys/devices/system/cpu/cpu1/online') + if p.exists(): + p.write_text('0\n');result['cpu1_online']=p.read_text().strip() + return result diff --git a/system/overlay/usr/lib/orchard-kexec/fdt-merge b/system/overlay/usr/lib/orchard-kexec/fdt-merge new file mode 100755 index 0000000..b6106b5 --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/fdt-merge @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Carry m1n1's runtime devicetree fixups over to a new kernel's dtb. + +A warm (kexec) boot has no m1n1 to prepare the devicetree. The running FDT +(/sys/firmware/fdt) is m1n1's output: the pristine dtb the running kernel was +built with, plus everything m1n1 filled in at boot (memory, reserved-memory, +framebuffer, MAC addresses, calibration, CPU enable-method, /psci, chosen...). +For a kernel whose dtb changed, this computes + + merged = new + (running - old) + +node by node and property by property, where old is the running kernel's +pristine dtb and new the next kernel's. A property both sides changed +differently is a conflict. Phandles are normalized by node path before comparison, with binding-aware +specifier parsing. Numeric arguments are never treated as references. Unknown +reference layouts, dangling references and genuine conflicting edits fail closed. + +usage: fdt-merge --old OLD.dtb --running /sys/firmware/fdt --new NEW.dtb -o OUT.dtb + fdt-merge --diff A.dtb B.dtb +""" + +import argparse +import copy +import struct +import sys + +FDT_MAGIC = 0xD00DFEED +BEGIN_NODE, END_NODE, PROP, NOP, END = 1, 2, 3, 4, 9 + +# A binding describes reference positions, not arbitrary cells equal to IDs. +PHANDLE_ARRAYS = { + "clocks": "#clock-cells", "assigned-clocks": "#clock-cells", + "assigned-clock-parents": "#clock-cells", "resets": "#reset-cells", + "power-domains": "#power-domain-cells", "performance-domains": "#performance-domain-cells", + "dmas": "#dma-cells", "mboxes": "#mbox-cells", "phys": "#phy-cells", + "iommus": "#iommu-cells", "interrupts-extended": "#interrupt-cells", + "pwms": "#pwm-cells", "io-channels": "#io-channel-cells", + "interconnects": "#interconnect-cells", "thermal-sensors": "#thermal-sensor-cells", + "sound-dai": "#sound-dai-cells", "cooling-device": "#cooling-cells", + "msi-parent": "#msi-cells", +} +PHANDLE_LISTS = { + "memory-region", "interrupt-parent", "interrupt-affinity", "cpus", + "next-level-cache", "operating-points-v2", "nvmem-cells", "remote-endpoint", + "apple,sart", "apple,sio", "apple,nvme-hmb", + "apple,observe-domains", "apple,spi0-domain", +} + + +def is_phandle_prop(name): + return (name in PHANDLE_ARRAYS or name in PHANDLE_LISTS or + name in {"phandle", "linux,phandle", "iommu-addresses", "msi-map", "iommu-map", "gpio-ranges", "interrupt-map"} or + name.startswith("pinctrl-") and name[8:].isdigit() or + name.endswith(("-supply", "-parent", "-gpios")) or name == "gpios") + + +def normalize_phandles(roots): + """Use a single path-based namespace for all three inputs, on private copies. + + Provider cell counts come from each original input. In particular, an + argument whose numeric value happens to equal a phandle stays unchanged. + Unknown layouts on an m1n1 delta are refused below when renumbering occurs. + """ + trees = [index(r) for r in roots] + by_id = [] + for tree in trees: + ids = {} + for path, node in tree.items(): + vals = [node.props[n] for n in ("phandle", "linux,phandle") if n in node.props] + if not vals: + continue + if any(len(v) != 4 for v in vals) or len(set(vals)) != 1: + raise ValueError(f"{path}: invalid or inconsistent phandle") + ph = int.from_bytes(vals[0], "big") + if ph in (0, 0xffffffff) or ph in ids: + raise ValueError(f"{path}: duplicate or invalid phandle {ph:#x}") + ids[ph] = path + by_id.append(ids) + paths = sorted(set().union(*(set(ids.values()) for ids in by_id))) + # Preserve the new DT's numbering; only runtime-only paths need fresh IDs. + canonical = {p: ph for ph, p in by_id[-1].items()} + next_id = max(canonical.values(), default=0) + 1 + for p in paths: + if p not in canonical: + canonical[p] = next_id + next_id += 1 + for tree, ids in zip(trees, by_id): + for path, node in tree.items(): + for name, val in list(node.props.items()): + if not is_phandle_prop(name): + continue + if len(val) % 4: + raise ValueError(f"{path}:{name}: invalid cell length") + if name == "interrupt-map": + raise ValueError(f"{path}:{name}: unsupported phandle layout") + cells = list(struct.unpack(f">{len(val)//4}I", val)) + if name in ("phandle", "linux,phandle"): + cells[0] = canonical[path] + else: + def ref(pos): + if pos >= len(cells) or cells[pos] not in ids: + raise ValueError(f"{path}:{name}: dangling phandle at cell {pos}") + target = ids[cells[pos]] + cells[pos] = canonical[target] + return tree[target] + + def count(provider, prop, default=None): + v = provider.props.get(prop) + if v is None and default is not None: + return default + if v is None or len(v) != 4: + raise ValueError(f"{path}:{name}: provider missing {prop}") + n = int.from_bytes(v, "big") + if n > 64: + raise ValueError(f"{path}:{name}: unreasonable {prop}") + return n + + pos = 0 + while pos < len(cells): + if name == "gpio-ranges": + ref(pos) + pos += 4 + elif name in ("msi-map", "iommu-map"): + # rid-base, provider, provider specifier, length. + # Orchard's A9 DART takes five cells per specifier. + provider = ref(pos + 1) + key = "#iommu-cells" if name == "iommu-map" else "#msi-cells" + pos += 3 + count(provider, key, 1) + elif name == "iommu-addresses": + provider = ref(pos) + pos += 1 + count(provider, "#address-cells", 2) + count(provider, "#size-cells", 1) + elif name in PHANDLE_ARRAYS or name == "gpios" or name.endswith("-gpios"): + # Empty array slots are legal (e.g. assigned-clock-parents). + if cells[pos] == 0: + pos += 1 + continue + provider = ref(pos) + key = PHANDLE_ARRAYS.get(name, "#gpio-cells") + pos += 1 + count(provider, key, 0 if name == "msi-parent" else None) + else: + ref(pos) + pos += 1 + if pos > len(cells): + raise ValueError(f"{path}:{name}: truncated specifier") + node.props[name] = struct.pack(f">{len(cells)}I", *cells) + return trees + + +class Node: + def __init__(self, name): + self.name = name + self.props = {} # insertion-ordered + self.children = {} + + +def parse(blob): + (magic, totalsize, off_struct, off_strings, off_rsv, version, _last, boot_cpuid, + size_strings, size_struct) = struct.unpack_from(">10I", blob, 0) + if magic != FDT_MAGIC: + raise ValueError("not a flattened devicetree") + rsv = [] + off = off_rsv + while True: + addr, size = struct.unpack_from(">QQ", blob, off) + off += 16 + if addr == 0 and size == 0: + break + rsv.append((addr, size)) + + def string(nameoff): + start = off_strings + nameoff + return blob[start:blob.index(b"\0", start)].decode() + + off = off_struct + stack = [] + root = None + while True: + (tok,) = struct.unpack_from(">I", blob, off) + off += 4 + if tok == BEGIN_NODE: + end = blob.index(b"\0", off) + node = Node(blob[off:end].decode()) + off = (end + 4) & ~3 + if stack: + stack[-1].children[node.name] = node + else: + root = node + stack.append(node) + elif tok == END_NODE: + stack.pop() + elif tok == PROP: + length, nameoff = struct.unpack_from(">II", blob, off) + off += 8 + stack[-1].props[string(nameoff)] = bytes(blob[off:off + length]) + off = (off + length + 3) & ~3 + elif tok == NOP: + pass + elif tok == END: + break + else: + raise ValueError(f"bad FDT token {tok:#x} at {off - 4:#x}") + return root, rsv, boot_cpuid + + +def serialize(root, rsv, boot_cpuid): + strings = bytearray() + stroff = {} + st = bytearray() + + def add_string(s): + if s not in stroff: + stroff[s] = len(strings) + strings.extend(s.encode() + b"\0") + return stroff[s] + + def pad(): + while len(st) % 4: + st.append(0) + + def emit(node): + st.extend(struct.pack(">I", BEGIN_NODE)) + st.extend(node.name.encode() + b"\0") + pad() + for name, val in node.props.items(): + st.extend(struct.pack(">III", PROP, len(val), add_string(name))) + st.extend(val) + pad() + for child in node.children.values(): + emit(child) + st.extend(struct.pack(">I", END_NODE)) + + emit(root) + st.extend(struct.pack(">I", END)) + rsvmap = b"".join(struct.pack(">QQ", a, s) for a, s in rsv) + struct.pack(">QQ", 0, 0) + off_rsv = 40 + off_struct = off_rsv + len(rsvmap) + off_strings = off_struct + len(st) + total = off_strings + len(strings) + hdr = struct.pack(">10I", FDT_MAGIC, total, off_struct, off_strings, off_rsv, 17, 16, + boot_cpuid, len(strings), len(st)) + return hdr + rsvmap + bytes(st) + bytes(strings) + + +def walk(node, path=None): + p = "/" if path is None else path.rstrip("/") + "/" + node.name + yield p, node + for child in node.children.values(): + yield from walk(child, p) + + +def index(root): + return dict(walk(root)) + + +def phandle_of(node): + v = node.props.get("phandle") or node.props.get("linux,phandle") + return struct.unpack(">I", v)[0] if v and len(v) == 4 else None + + +def parent_path(path): + return "/" if path.count("/") == 1 else path.rsplit("/", 1)[0] + + +def merge(old, running, new): + """Apply running-old onto new (all Node trees, new is modified).""" + # Never mutate the provenance inputs; callers can reuse them for another load. + old, running = copy.deepcopy(old), copy.deepcopy(running) + try: + io, ir, inew = normalize_phandles((old, running, new)) + except ValueError as e: + return [str(e)], [], [] + conflicts, delta_props, notes = [], [], [] + + # Nodes m1n1 removed (e.g. pruned CPUs) + for path in io: + if path not in ir and path in inew and parent_path(path) in inew: + if serialize(inew[path], [], 0) != serialize(io[path], [], 0): + conflicts.append(f"{path}: removed by m1n1, changed by the new dtb") + continue + parent = inew[parent_path(path)] + parent.children.pop(inew[path].name, None) + notes.append(f"- {path}") + inew = index(new) + + for path, rnode in ir.items(): + onode = io.get(path) + nnode = inew.get(path) + if onode is None: + # Node m1n1 added: copy it in whole if new lacks it + if nnode is None: + parent = inew.get(parent_path(path)) + if parent is None: + continue # its parent was added too and brought it along + parent.children[rnode.name] = rnode + inew = index(new) + notes.append(f"+ {path}") + for subpath, subnode in walk(rnode, parent_path(path)): + delta_props.extend((subpath, n) for n in subnode.props) + continue + onode = Node(rnode.name) # new has it already: merge props against empty + if nnode is None: + if onode.props != rnode.props: + conflicts.append(f"{path}: fixed up by m1n1 but removed from the new dtb") + continue + for name, rval in rnode.props.items(): + oval = onode.props.get(name) + if oval == rval: + continue + nval = nnode.props.get(name) + if nval is not None and nval != oval and nval != rval: + conflicts.append(f"{path}:{name}: changed by m1n1 and by the new dtb") + continue + nnode.props[name] = rval + delta_props.append((path, name)) + for name in onode.props: + if name not in rnode.props and name in nnode.props: + if nnode.props[name] != onode.props[name]: + conflicts.append(f"{path}:{name}: removed by m1n1, changed by the new dtb") + continue + del nnode.props[name] + delta_props.append((path, name)) + + # References carried from m1n1 must still resolve after pruning/new edits. + try: + normalize_phandles((copy.deepcopy(new),)) + except ValueError as e: + conflicts.append(str(e)) + seen = {} + for p, n in walk(new): + ph = phandle_of(n) + if ph is not None: + if ph in seen: + conflicts.append(f"phandle {ph:#x} used by {seen[ph]} and {p}") + seen[ph] = p + return conflicts, notes, delta_props + + +def load(path): + with open(path, "rb") as f: + return parse(f.read()) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--old") + ap.add_argument("--running") + ap.add_argument("--new") + ap.add_argument("-o", "--output") + ap.add_argument("--diff", nargs=2, metavar=("A", "B")) + a = ap.parse_args() + + if a.diff: + (ra, _, _), (rb, _, _) = load(a.diff[0]), load(a.diff[1]) + ia, ib = index(ra), index(rb) + for p in sorted(set(ia) | set(ib)): + if p not in ia: + print(f"+ {p}") + elif p not in ib: + print(f"- {p}") + else: + for n in sorted(set(ia[p].props) | set(ib[p].props)): + if ia[p].props.get(n) != ib[p].props.get(n): + print(f"~ {p}:{n}") + return 0 + + if not (a.old and a.running and a.new and a.output): + ap.error("--old, --running, --new and -o are required") + old, old_rsv, _ = load(a.old) + running, rsv, cpuid = load(a.running) + new, new_rsv, _ = load(a.new) + conflicts, notes, delta = merge(old, running, new) + for n in notes: + print(n, file=sys.stderr) + if conflicts: + for c in conflicts: + print(f"conflict: {c}", file=sys.stderr) + return 1 + # Reservations come from the running FDT: m1n1's image, stacks and its + # devicetree buffer. The old initrd's is stale: kexec places a new one. + chosen = running.children.get("chosen") + if chosen: + cells = [chosen.props.get(k) for k in ("linux,initrd-start", "linux,initrd-end")] + if all(cells): + start, end = (int.from_bytes(c, "big") for c in cells) + rsv = [r for r in rsv if r[0] != start or r[1] not in + (end - start, (end - start + 0x3fff) & ~0x3fff)] + # Preserve firmware reservations and any genuinely new static reservation. + rsv = list(dict.fromkeys(rsv + [r for r in new_rsv if r not in old_rsv])) + chosen = new.children.get("chosen") + if chosen: + for name in ("linux,initrd-start", "linux,initrd-end", "linux,usable-memory-range", + "linux,elfcorehdr", "kaslr-seed", "rng-seed", "linux,kho-fdt", + "linux,kho-scratch", "linux,ima-kexec-buffer"): + chosen.props.pop(name, None) + chosen.props["linux,booted-from-kexec"] = b"" + with open(a.output, "wb") as f: + f.write(serialize(new, rsv, cpuid)) + print(f"merged {len(delta)} m1n1 properties into {a.output}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/system/overlay/usr/lib/orchard-kexec/health.py b/system/overlay/usr/lib/orchard-kexec/health.py new file mode 100644 index 0000000..38c5eed --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/health.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Post-boot health gate: refuse to call a boot usable while NVMe I/O crawls. + +Usage: kexec-health-gate.py [--expect-boot-id UUID] [--min-uptime 120] + [--min-observe 60] [--interval 10] ... +OMARCHY_SSH overrides the SSH wrapper (scripts/omarchy/ssh.sh). + +Each probe runs one bounded SSH command that times `sudo -n true`, reads +16 KiB from /dev/nvme0n1 with O_DIRECT (a real NVMe command, not the page +cache), and compares the nvme0q IRQ count before and after. It also checks +the boot's own dmesg for NVMe timeout lines. The gate passes only after +uptime >= --min-uptime and --min-observe seconds of clean probes. Exit 0 = +healthy, 1 = unhealthy (last line "HEALTH FAIL: ..."), 2 = usage. +Background: docs/boot/ds-kexec-nvme-crawl.md. +""" +import argparse +import os +from pathlib import Path +import re +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +MARK = 'linux-a9-health-probe' +# One SSH round trip. Every step that can block on the disk has a device-side +# timeout; the Mac-side subprocess timeout covers a hung SSH or D-state hang. +PROBE = r''' +export LC_ALL=C +ms() { echo $(( $(date +%s%N) / 1000000 )); } +echo "boot_id=$(cat /proc/sys/kernel/random/boot_id)" +echo "uptime=$(cut -d' ' -f1 /proc/uptime)" +echo "load=$(cut -d' ' -f1 /proc/loadavg)" +echo "irq_before=$(grep -m1 'nvme0q' /proc/interrupts)" +t=$(ms); timeout 8 sudo -n true; rc=$?; echo "sudo_rc=$rc"; echo "sudo_ms=$(( $(ms) - t ))" +skip=$(( (RANDOM * 32768 + RANDOM) % 262144 )) +t=$(ms) +timeout 8 sudo -n dd if=/dev/nvme0n1 of=/dev/null bs=4096 count=4 skip=$skip iflag=direct status=none +rc=$?; echo "read_rc=$rc"; echo "read_ms=$(( $(ms) - t ))" +echo "irq_after=$(grep -m1 'nvme0q' /proc/interrupts)" +log=$(timeout 8 sudo -n dmesg); echo "dmesg_rc=$?" +printf '%s\n' "$log" | grep -v -E 'omarchy-rd|AOP-PREV' | + grep -E 'completion polled|Apple ANS: timeout|I/O tag .* timeout|drained every CQ|IRQ rescue|MSI resumed|nvme.*(reset controller|disable controller|Disabling device)' | + tail -n 40 | sed 's/^/dmesg: /' +echo "done=1" +''' +# NVMe trouble in this boot's kernel log. Lines replayed from earlier boots +# (pstore/ramoops, "[omarchy-rd]: ...") are not this boot and are ignored. +# Rescue drains also log "drained every CQ"; "IRQ rescue every N ms" is the +# opt-in rescue's bring-up line and is not a failure. +BAD = re.compile(r'completion polled|Apple ANS: timeout|I/O tag .* timeout|' + r'drained every CQ|reset controller|disable controller|' + r'Disabling device') +REPLAY = re.compile(r'omarchy-rd|AOP-PREV') + + +def irq_total(line): + """Sum the per-CPU counts of a /proc/interrupts line; None if absent.""" + fields = line.split() + if not fields or not fields[0].endswith(':'): + return None + total = 0 + seen = False + for f in fields[1:]: + if not f.isdigit(): + break + total += int(f) + seen = True + return total if seen else None + + +def parse(text): + probe = {'dmesg': []} + for line in text.splitlines(): + if line.startswith('dmesg: '): + probe['dmesg'].append(line[7:]) + elif '=' in line: + key, value = line.split('=', 1) + probe[key.strip()] = value.strip() + return probe + + +def judge(probe, args, boot_id=None): + """Return a failure reason, or None if this probe is healthy.""" + if probe.get('done') != '1': + return 'probe output incomplete' + if boot_id and probe.get('boot_id') != boot_id: + return f'boot_id changed: {boot_id} -> {probe.get("boot_id")}' + if probe.get('dmesg_rc') != '0': + return f'sudo -n dmesg failed rc={probe.get("dmesg_rc")}' + native = [l for l in probe['dmesg'] if BAD.search(l) and not REPLAY.search(l)] + if native: + return f'{len(native)} NVMe timeout/recovery line(s) in this boot, first: {native[0][:200]}' + try: + sudo_rc, sudo_ms = int(probe['sudo_rc']), int(probe['sudo_ms']) + read_rc, read_ms = int(probe['read_rc']), int(probe['read_ms']) + except (KeyError, ValueError): + return 'probe output unparseable' + if sudo_rc: + return f'sudo -n true failed rc={sudo_rc} after {sudo_ms} ms' + if sudo_ms > args.sudo_max * 1000: + return f'sudo -n true took {sudo_ms} ms (> {args.sudo_max} s)' + if read_rc: + return f'direct NVMe read failed rc={read_rc} after {read_ms} ms' + if read_ms > args.read_max * 1000: + return f'direct NVMe read took {read_ms} ms (> {args.read_max} s)' + before = irq_total(probe.get('irq_before', '')) + after = irq_total(probe.get('irq_after', '')) + if before is None or after is None: + return 'no nvme0q line in /proc/interrupts' + if after <= before: + return (f'nvme0q IRQ count stayed at {after} across a direct read: ' + 'the completion was polled, not signalled (lost MSI)') + return None + + +def probe_once(args): + try: + r = subprocess.run(['bash', '-s', '--', MARK], input=PROBE, text=True, + capture_output=True, timeout=args.ssh_timeout) + except subprocess.TimeoutExpired: + return None, f'local probe hung > {args.ssh_timeout} s' + if r.returncode != 0 and 'done=1' not in r.stdout: + return None, f'local probe failed rc={r.returncode}: {r.stderr.strip()[:200]}' + return parse(r.stdout), None + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--expect-boot-id', help='fail if the device is not on this boot') + ap.add_argument('--min-uptime', type=float, default=120, + help='keep probing until the boot is this old (s)') + ap.add_argument('--min-observe', type=float, default=60, + help='and for at least this long (s)') + ap.add_argument('--interval', type=float, default=10, help='between probes (s)') + ap.add_argument('--sudo-max', type=float, default=5, help='max sudo -n true (s)') + ap.add_argument('--read-max', type=float, default=3, help='max direct read (s)') + ap.add_argument('--ssh-timeout', type=float, default=30, help='per probe (s)') + ap.add_argument('--max-probes', type=int, default=60, help='hard bound') + ap.add_argument('--quick', action='store_true', + help='two probes 2 s apart, no uptime floor (before each test step)') + args = ap.parse_args(argv) + if args.quick: + args.min_uptime, args.min_observe, args.interval = 0, 0, 2 + if min(args.min_uptime, args.min_observe, args.interval) < 0 or args.max_probes < 1: + ap.error('negative timing or no probes') + + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import reset + print('Reset guard: ' + reset.status(), file=sys.stderr) + start = time.monotonic() + boot_id = args.expect_boot_id + passed = 0 + retried = False + for n in range(1, args.max_probes + 1): + probe, err = probe_once(args) + if err is None: + err = judge(probe, args, boot_id) + boot_id = boot_id or probe.get('boot_id') + elapsed = time.monotonic() - start + if err and err.startswith('SSH probe') and not retried: + # One immediate retry: a USB-link blip is not a crawl; two are. + retried = True + print(f'health: probe {n} +{elapsed:.0f}s {err}; retrying once', file=sys.stderr) + continue + if err: + print(f'HEALTH FAIL: {err} (probe {n}, +{elapsed:.0f}s, ' + f'{passed} clean probes before)', file=sys.stderr) + return 1 + retried = False + passed += 1 + uptime = float(probe.get('uptime', '0') or 0) + print(f'health: probe {n} +{elapsed:.0f}s uptime {uptime:.0f}s load {probe.get("load")} ' + f'sudo {probe["sudo_ms"]}ms read {probe["read_ms"]}ms nvme irq ' + f'{irq_total(probe["irq_before"])}->{irq_total(probe["irq_after"])} ok', + file=sys.stderr) + if uptime >= args.min_uptime and elapsed >= args.min_observe and passed >= 2: + print(f'HEALTH PASS: {passed} clean probes over {elapsed:.0f}s, uptime {uptime:.0f}s, ' + f'boot_id {boot_id}') + return 0 + time.sleep(args.interval) + print(f'HEALTH FAIL: no verdict after {args.max_probes} probes', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/system/overlay/usr/lib/orchard-kexec/quiesce-gpu b/system/overlay/usr/lib/orchard-kexec/quiesce-gpu new file mode 100755 index 0000000..3049001 --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/quiesce-gpu @@ -0,0 +1,28 @@ +#!/bin/sh +# Warm restart GPU handover: by the final shutdown +# step every GPU client (compositor, greeter, sessions) is stopped, so ask the G5P +# firmware to quiesce (td-kexec) before the next kernel takes it over. Without this, +# a warm restart from a running desktop hangs in the incoming kernel's GPU warm start +# (device journal 2026-11-08: "origin=warm-uncycled ... cmd8-consumed", then silence). +# Matched by driver, not by board: without the G5P driver's handover command +# there is nothing to quiesce, and nothing is armed. +set -- /sys/bus/platform/drivers/apple-g5p-drm/*/command +[ -e "$1" ] || exit 0 +# Arm failure before touching sysfs so interruption/timeout is also a failure. +# dispatch consumes this receipt; success alone removes it. +mkdir -p /run/orchard-kexec || exit 1 +printf '%s\n' 'GPU handover pending or failed' > /run/orchard-kexec/gpu-quiesce-failed || exit 1 +failed=0 +for command in /sys/bus/platform/drivers/apple-g5p-drm/*/command; do + [ -e "$command" ] || continue + if echo td-kexec > "$command"; then + echo "<6>orchard-kexec: GPU handover td-kexec ${command%/command}" > /dev/kmsg + else + failed=1 + echo "<3>orchard-kexec: GPU handover td-kexec failed ${command%/command}" > /dev/kmsg + fi +done +if [ "$failed" -eq 0 ]; then + rm -f /run/orchard-kexec/gpu-quiesce-failed +fi +exit "$failed" diff --git a/system/overlay/usr/lib/orchard-kexec/reset.py b/system/overlay/usr/lib/orchard-kexec/reset.py new file mode 100644 index 0000000..271996b --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/reset.py @@ -0,0 +1,33 @@ +"""Reset guard contract shared by installed and diagnostic warm handovers.""" +from pathlib import Path + + +def backend(): + mode = Path('/sys/kernel/reboot/mode') + # The standard ABI alone exists on unguarded kernels too. Require the + # watchdog's binding, not an unrelated property elsewhere in the tree. + for node in Path('/proc/device-tree').rglob('apple,tethered-boot'): + compatible = node.parent / 'compatible' + status = node.parent / 'status' + if (compatible.exists() and b'apple,wdt' in compatible.read_bytes().split(b'\0') + and (not status.exists() or status.read_bytes().rstrip(b'\0') in (b'ok', b'okay')) + and mode.exists()): + return mode, 'reboot-mode' + old = Path('/sys/module/apple_wdt/parameters/cold_restart') + if old.exists(): + return old, 'cold_restart' + raise ValueError('running kernel lacks the cold reset guard') + + +def select(warm=True): + knob, abi = backend() + value = ('warm' if warm else 'cold') if abi == 'reboot-mode' else ('N' if warm else 'Y') + knob.write_text(value + '\n') + if knob.read_text().strip() != value: + raise ValueError('could not select guarded reset mode') + return f'{abi}={value}' + + +def status(): + knob, abi = backend() + return f'{abi}={knob.read_text().strip()}' diff --git a/system/overlay/usr/lib/systemd/system/orchard-kexec-health.service b/system/overlay/usr/lib/systemd/system/orchard-kexec-health.service new file mode 100644 index 0000000..e3732a5 --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/orchard-kexec-health.service @@ -0,0 +1,13 @@ +[Unit] +Description=Verify Orchard warm boot storage now and across the NVMe late window +After=local-fs.target orchard-kexec-provenance.service +ConditionPathExists=/dev/nvme0n1 + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 -I /usr/lib/orchard-kexec/health.py --quick +ExecStart=/usr/bin/python3 -I /usr/lib/orchard-kexec/health.py --min-uptime 120 --min-observe 60 +TimeoutStartSec=700 + +[Install] +WantedBy=multi-user.target diff --git a/system/overlay/usr/lib/systemd/system/orchard-kexec-load.service b/system/overlay/usr/lib/systemd/system/orchard-kexec-load.service new file mode 100644 index 0000000..08e0ab1 --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/orchard-kexec-load.service @@ -0,0 +1,12 @@ +[Unit] +Description=Prepare warm restart into the default BLS entry +After=local-fs.target orchard-kexec-provenance.service +Before=systemd-user-sessions.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/orchard-kexec load default +TimeoutStartSec=180 + +[Install] +WantedBy=multi-user.target diff --git a/system/overlay/usr/lib/systemd/system/orchard-kexec-provenance.service b/system/overlay/usr/lib/systemd/system/orchard-kexec-provenance.service new file mode 100644 index 0000000..10f0976 --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/orchard-kexec-provenance.service @@ -0,0 +1,13 @@ +[Unit] +Description=Retain the running Orchard pristine device tree +After=local-fs.target +Before=orchard-kexec-load.service orchard-kexec-health.service systemd-user-sessions.service +ConditionPathExists=/run/orchard-kexec/pristine.dtb + +[Service] +Type=oneshot +ExecStart=/usr/bin/orchard-kexec snapshot +TimeoutStartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.path b/system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.path new file mode 100644 index 0000000..24720f2 --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.path @@ -0,0 +1,12 @@ +[Unit] +Description=Refresh the warm image when the selected boot entry changes +# Stop before unmount; the refresh service never participates in shutdown. +After=local-fs.target + +[Path] +PathChanged=/boot/loader/loader.conf +PathChanged=/boot/loader/entries +Unit=orchard-kexec-refresh.service + +[Install] +WantedBy=multi-user.target diff --git a/system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.service b/system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.service new file mode 100644 index 0000000..05529ec --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.service @@ -0,0 +1,15 @@ +[Unit] +Description=Load the latest selected BLS entry for the next Linux restart +# Not part of the reboot/kexec transaction: a unit started during shutdown that needs /boot +# conflicts with umount.target (systemd orders the mount's stop before any start, and +# refuses the transaction). The image is kept current at boot (orchard-kexec-load), +# after package updates (95-orchard-kexec.hook), on BLS selection changes +# (orchard-kexec-refresh.path) and by orchard-restart's preflight. +# Refresh replaces even an explicit developer image with the selected default. +After=local-fs.target +RequiresMountsFor=/boot /var/tmp + +[Service] +Type=oneshot +ExecStart=/usr/bin/orchard-kexec refresh +TimeoutStartSec=180 diff --git a/system/overlay/usr/lib/systemd/system/systemd-kexec.service.d/50-orchard-warm.conf b/system/overlay/usr/lib/systemd/system/systemd-kexec.service.d/50-orchard-warm.conf new file mode 100644 index 0000000..0ad61d9 --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/systemd-kexec.service.d/50-orchard-warm.conf @@ -0,0 +1,12 @@ +[Unit] +# Last resort if dispatch cannot run or its emergency escape returns. +FailureAction=none +# Dispatch requests kexec or cold reboot itself; never override a cold escape. +SuccessAction=none + +[Service] +ExecStart= +# Every GPU client is stopped by now: hand the GPU over before the jump. +ExecStartPre=-/usr/bin/timeout --kill-after=1 10 /usr/lib/orchard-kexec/quiesce-gpu +ExecStart=/usr/bin/orchard-kexec dispatch +TimeoutStartSec=infinity diff --git a/system/overlay/usr/lib/systemd/system/systemd-reboot.service.d/50-orchard-cold.conf b/system/overlay/usr/lib/systemd/system/systemd-reboot.service.d/50-orchard-cold.conf new file mode 100644 index 0000000..d67239a --- /dev/null +++ b/system/overlay/usr/lib/systemd/system/systemd-reboot.service.d/50-orchard-cold.conf @@ -0,0 +1,6 @@ +[Service] +# reboot.target uses RB_AUTOBOOT even with a loaded image: always select cold. +# Image-backed warm hops use the separate kexec dispatch transaction. +# The kernel notifier is the fallback if either best-effort pre-step fails. +ExecStartPre=-/usr/bin/timeout --kill-after=1 10 /usr/lib/orchard-kexec/quiesce-gpu +ExecStartPre=-/usr/bin/timeout --kill-after=1 5 /usr/bin/orchard-kexec prepare-reboot diff --git a/system/overlay/usr/lib/systemd/user/wireplumber.service.d/60-radio-policy.conf b/system/overlay/usr/lib/systemd/user/wireplumber.service.d/60-radio-policy.conf new file mode 100644 index 0000000..e6ada3b --- /dev/null +++ b/system/overlay/usr/lib/systemd/user/wireplumber.service.d/60-radio-policy.conf @@ -0,0 +1,2 @@ +[Service] +ExecStartPre=/usr/libexec/omarchy-radio-policy diff --git a/system/overlay/usr/lib/systemd/zram-generator.conf b/system/overlay/usr/lib/systemd/zram-generator.conf index 3e05338..0e12495 100644 --- a/system/overlay/usr/lib/systemd/zram-generator.conf +++ b/system/overlay/usr/lib/systemd/zram-generator.conf @@ -1,5 +1,4 @@ -# 2 GB of RAM and no swap: Firefox plus the shell can exhaust memory and stall -# the session. Compressed swap in RAM only; nothing is written to storage. +# Device-qualified A9 policy: compressed swap equal to RAM, no disk swap. [zram0] -zram-size = ram / 2 +zram-size = ram compression-algorithm = zstd diff --git a/system/overlay/usr/lib/tmpfiles.d/omarchy-a-memory.conf b/system/overlay/usr/lib/tmpfiles.d/omarchy-a-memory.conf new file mode 100644 index 0000000..472688a --- /dev/null +++ b/system/overlay/usr/lib/tmpfiles.d/omarchy-a-memory.conf @@ -0,0 +1,3 @@ +# Avoid compressing pages twice in front of zram; built-in hid_apple ignores modprobe. +w- /sys/module/zswap/parameters/enabled - - - - N +w- /sys/module/hid_apple/parameters/fnmode - - - - 2 diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index f414789..30219dc 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -382,6 +382,17 @@ class Buttons: return 'power' if short else None +def platform_suspend_ready(): + helper = '/usr/local/bin/ipadlinux-suspend-ready' + if not Path(helper).exists(): + log('no suspend: missing ' + helper) + return False + result = command(helper) + if result.returncode: + log('no suspend: ' + (result.stderr.strip() or 'platform readiness refused')) + return result.returncode == 0 + + def command(*argv, timeout=5, **options): return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, **options) @@ -414,7 +425,6 @@ def as_user(user, *argv, timeout=5): class Daemon: def __init__(self): self.machine = Machine() - self.buttons = Buttons() self.cover = Cover() self.covers = {} self.events = queue.Queue() @@ -428,8 +438,11 @@ class Daemon: self.saved = None self.counters = None self.sleep_started = None + self.buttons = Buttons() self.ticks = 0 self.cancel = threading.Event() + self.dim_saved = None + self.dim_level = None self.dark = threading.Event() self.session_jobs = queue.Queue() self.inhibitor = None @@ -557,13 +570,22 @@ class Daemon: getattr(self, 'do_' + action.replace('-', '_'))(before) self.dark.set() if self.machine.state in (SETTLING, DARK) else self.dark.clear() + def undim(self): + panel = self.backlight() + if self.dim_saved is not None and panel: + current = int((panel / 'brightness').read_text()) + if self.machine.state == ON and current == self.dim_level: + (panel / 'brightness').write_text(f'{self.dim_saved}\n') + self.dim_saved = self.dim_level = None + def do_dark(self, _): panel = self.backlight() if panel: current = int((panel / 'brightness').read_text()) if current > 0 and self.fade is None: - self.saved = current - self.remember_brightness(current) + self.saved = self.dim_saved or current + self.remember_brightness(self.saved) + self.dim_saved = self.dim_level = None self.start_fade(panel, 0) def start_fade(self, panel, target): @@ -700,6 +722,12 @@ class Daemon: def request_suspend(self): try: + # Shared with the final menu and systemd's direct suspend guard: + # the kernel's non-reserving admission query. + if not platform_suspend_ready(): + log('no suspend: platform readiness contract refused') + self.post('suspend-refused') + return answer = command('busctl', 'call', 'org.freedesktop.login1', '/org/freedesktop/login1', 'org.freedesktop.login1.Manager', 'CanSuspend').stdout.strip() if answer != 's "yes"': @@ -722,6 +750,8 @@ class Daemon: try: user = session_user() if job == 'lock': + if user is not None: + as_user(user, 'omarchy-keyboard', 'hide') self.post('lock-ok' if user is None or self.lock_session(user) else 'lock-failed') elif user is not None and job == 'woke': as_user(user, 'omarchy-shell', '-q', 'lock', 'woke') @@ -781,6 +811,34 @@ class Daemon: connection.sendall(answer.encode() + b'\n') elif request == 'status': connection.sendall(self.machine.state.encode() + b'\n') + elif request.startswith('brightness '): + parts = request.split() + panel = self.backlight() + reply = b'held\n' + if (len(parts) == 4 and panel and parts[1] == panel.name and + parts[2].isascii() and parts[2].isdigit() and + parts[3].isascii() and parts[3].isdigit()): + level, expected = int(parts[2]), int(parts[3]) + current = int((panel / 'brightness').read_text()) + maximum = int((panel / 'max_brightness').read_text()) + if self.machine.state == ON and self.fade is None and self.dim_saved is None and current > 0 and current == expected and 1 <= level <= maximum: + (panel / 'brightness').write_text(f'{level}\n') + reply = b'ok\n' + connection.sendall(reply) + elif request == 'als-state': + connection.sendall(b'lit\n' if self.machine.state == ON and self.fade is None and self.dim_saved is None else b'dark\n') + elif request == 'dim': + panel = self.backlight() + if self.machine.state == ON and self.dim_saved is None and panel: + current = int((panel / 'brightness').read_text()) + level = max(1, int((panel / 'max_brightness').read_text()) // 10) + if current > level: + self.dim_saved, self.dim_level = current, level + (panel / 'brightness').write_text(f'{level}\n') + connection.sendall(b'ok\n') + elif request == 'undim': + self.undim() + connection.sendall(b'ok\n') elif request in ('idle', 'wake', 'lock'): self.feed(request) connection.sendall(b'ok\n') diff --git a/system/overlay/usr/libexec/omarchy-radio-policy b/system/overlay/usr/libexec/omarchy-radio-policy new file mode 100755 index 0000000..ae83127 --- /dev/null +++ b/system/overlay/usr/libexec/omarchy-radio-policy @@ -0,0 +1,13 @@ +#!/bin/sh +# Per-login generated config: BCM4355 shares 2.4 GHz, expensive codecs cost CPU. +set -eu +conf=${XDG_CONFIG_HOME:-$HOME/.config}/wireplumber/wireplumber.conf.d/61-omarchy-a-radio.conf +rm -f "$conf" +if [ "$(getconf _NPROCESSORS_ONLN)" -gt 2 ]; then exit 0; fi +for node in $(find /sys/firmware/devicetree/base/ -name compatible 2>/dev/null); do + [ -r "$node" ] || continue + tr '\000' '\n' < "$node" | grep -Eq '^brcm,bcm4355c[01]-bt$' || continue + mkdir -p "${conf%/*}" + printf 'monitor.bluez.properties = { bluez5.codecs = [ aac sbc_xq sbc ] }\n' > "$conf" + break +done diff --git a/system/overlay/usr/local/bin/ipadlinux-suspend-ready b/system/overlay/usr/local/bin/ipadlinux-suspend-ready new file mode 100755 index 0000000..7e3e2fd --- /dev/null +++ b/system/overlay/usr/local/bin/ipadlinux-suspend-ready @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Read-only suspend policy probe; 0 permits a request, 1 refuses, 2 is usage. + +This never performs a suspend or reserves admission. The kernel remains the +final authority at the actual transition. The answer comes from the kernel's +own admission query, /sys/power/suspend_ready ("ready", or +"not-ready "), on every board. A kernel without that query +cannot show that its drivers and platform prechecks would admit a suspend, so +suspend is refused there; PM callback presence, a loaded module and +power/state's "freeze" are not proof of admission. +""" +from pathlib import Path +import sys + +READINESS = Path('/sys/power/suspend_ready') + + +def admission(): + """The kernel's readiness answer; None when it reports ready.""" + try: + report = READINESS.read_text(encoding='ascii').strip() + except FileNotFoundError: + return ('the kernel has no suspend readiness query (/sys/power/suspend_ready); ' + 'driver admission and platform prechecks cannot be verified') + except (OSError, UnicodeDecodeError) as error: + return f'cannot read kernel suspend readiness: {error}' + if report == 'ready': + return None + words = report.split() + if len(words) == 3 and words[0] == 'not-ready': + return f'kernel admission refused by {words[1]} ({words[2]})' + return f'malformed kernel suspend readiness: {report!r}' + + +def main(argv=None): + if argv is None: + argv = sys.argv[1:] + if argv: + print('Usage: ipadlinux-suspend-ready', file=sys.stderr) + return 2 + reason = admission() + if reason: + print(f'Suspend unavailable: {reason}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/system/overlay/usr/share/libalpm/hooks/95-orchard-kexec.hook b/system/overlay/usr/share/libalpm/hooks/95-orchard-kexec.hook new file mode 100644 index 0000000..8699a0d --- /dev/null +++ b/system/overlay/usr/share/libalpm/hooks/95-orchard-kexec.hook @@ -0,0 +1,16 @@ +[Trigger] +Operation = Install +Operation = Upgrade +Operation = Remove +Type = Path +Target = boot/loader/entries/* +Target = boot/loader/loader.conf +Target = boot/* +Target = usr/lib/modules/*/vmlinuz +Target = usr/lib/modules/*/initrd +Target = usr/lib/modules/*/dtb/* + +[Action] +Description = Prepare warm restart with the updated default BLS entry +When = PostTransaction +Exec = /usr/bin/orchard-kexec update diff --git a/system/packages.txt b/system/packages.txt index 90aa534..605d8a8 100644 --- a/system/packages.txt +++ b/system/packages.txt @@ -42,8 +42,12 @@ xdg-desktop-portal-wlr pipewire pipewire-pulse wireplumber +rtkit socat jq pango cairo wayland + +kexec-tools +libnotify diff --git a/tests/test_auto_brightness.py b/tests/test_auto_brightness.py new file mode 100644 index 0000000..a27f61f --- /dev/null +++ b/tests/test_auto_brightness.py @@ -0,0 +1,111 @@ +"""Ported ALS policy and power-daemon cooperation, with no hardware access.""" +import importlib.machinery +import importlib.util +import math +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +PROJECT = Path(__file__).resolve().parents[1] +loader = importlib.machinery.SourceFileLoader('auto_brightness', str(PROJECT/'desktop/configuration/auto-brightness')) +spec = importlib.util.spec_from_loader(loader.name, loader) +a = importlib.util.module_from_spec(spec); loader.exec_module(a) + +class AutoBrightness(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup) + self.root = Path(temp.name); self.panel = self.root/'backlight/panel'; self.panel.mkdir(parents=True) + (self.panel/'max_brightness').write_text('1000\n'); self.set_raw(500) + self.env = patch.dict(os.environ, {'XDG_CONFIG_HOME':str(self.root/'config'), + 'XDG_STATE_HOME':str(self.root/'state'), 'XDG_RUNTIME_DIR':str(self.root), + 'IPAD_ALS_BACKLIGHT_ROOT':str(self.root/'backlight')}) + self.env.start(); self.addCleanup(self.env.stop) + self.conf = a.load_conf() + + def set_raw(self, value): (self.panel/'brightness').write_text(str(value)+'\n') + + def test_default_off_and_persisted_switch(self): + self.assertFalse(a.is_enabled(self.conf)) + a.set_state('auto_brightness','on'); self.assertTrue(a.is_enabled(self.conf)) + a.set_state('auto_brightness','off'); self.assertFalse(a.is_enabled(self.conf)) + + def test_curve_and_median_ignore_one_spike(self): + points = a.parse_curve(a.DEFAULT_CURVE) + self.assertEqual(a.curve_pct(points, 80), 58) + self.assertEqual(a.curve_pct(points, 100000), 100) + smooth = a.Smoother(0) + for i, lux in enumerate((20,20,20,20000,20)): smooth.feed(lux,float(i)) + self.assertAlmostEqual(smooth.lux,20) + + def test_brightening_is_fast_and_darkening_slow(self): + # Hands-on test 2026-11-08: a flashlight took seconds to register, then moved in steps. + up=a.Smoother(3.0,tau_up=0.4); down=a.Smoother(3.0,tau_up=0.4) + for t in (0.0,0.25,0.5): up.feed(20,t); down.feed(2000,t) + for t in (0.75,1.0,1.25,1.5): up.feed(2000,t); down.feed(20,t) + self.assertGreater(up.lux,800) # most of the way within ~1 s of a flashlight + self.assertGreater(down.lux,300) # a shadow does not dim at once + policy=a.Policy(self.conf); policy.ref_level=0.0; policy.last_move=10.0 + class S: level=math.log1p(1000); lux=1000 + self.assertIsNotNone(policy.decide(S,10.3,30.0)) # brighter: 0.25 s spacing + policy.last_move=10.0 + class D: level=math.log1p(0.5); lux=0.5 + policy.ref_level=math.log1p(1000) + self.assertIsNone(policy.decide(D,10.3,90.0)) # darker: keeps the 2 s spacing + + def test_powerd_missing_or_dark_is_a_hold(self): + for reply in ('','dark'): + with patch.object(a,'power_request',return_value=reply): self.assertEqual(a.holds(),['powerd']) + with patch.object(a,'power_request',return_value='lit'): self.assertEqual(a.holds(),[]) + + def test_automatic_write_is_compare_and_set_through_powerd(self): + panel=a.Backlight('panel') + with patch.object(a,'power_request',return_value='ok') as request: + self.assertTrue(panel.write(600,expected=500)) + request.assert_called_once_with('brightness panel 600 500') + for raw in (0,501): + self.set_raw(raw) + with patch.object(a,'power_request') as request: + self.assertFalse(panel.write(600,expected=500)); request.assert_not_called() + + def test_hysteresis_and_manual_override(self): + panel=a.Backlight('panel'); self.conf['enabled']=True + daemon=a.Daemon(self.conf,type('Source',(),{'kind':'test'})(),panel,False,False) + daemon.last_written=500; self.set_raw(300); daemon.latest=1500 + with patch.object(a,'holds',return_value=[]), patch.object(panel,'write') as write: + daemon.tick(10.0) + self.assertGreater(daemon.override_until,10.0); write.assert_not_called() + + def test_ramp_is_a_smooth_perceptual_fade(self): + panel=a.Backlight('panel'); self.conf['enabled']=True + daemon=a.Daemon(self.conf,type('Source',(),{'kind':'test'})(),panel,False,False) + self.set_raw(100); daemon.goal_raw=800; daemon.start_ramp(100,0.0) + self.assertGreaterEqual(daemon.ramp_secs,a.RAMP_MIN_SECS); self.assertLessEqual(daemon.ramp_secs,a.RAMP_MAX_SECS) + written=[] + def fake_write(new,expected=None): + self.assertEqual(expected,int((self.panel/'brightness').read_text())); self.set_raw(new); written.append(new); return True + with patch.object(panel,'write',side_effect=fake_write): + t=0.0 + while daemon.ramping() and t<10: + daemon.step(int((self.panel/'brightness').read_text()),t); t+=a.RAMP_TICK + self.assertEqual(written[-1],800); self.assertFalse(daemon.ramping()) + self.assertEqual(written,sorted(written)) # monotonic, no overshoot + self.assertGreater(len(written),10) # a fade of many small steps, not a few jumps + p=[daemon.perceived(w) for w in [100]+written] + steps=[b-c for c,b in zip(p,p[1:])] + self.assertLess(max(steps),0.08) # no single step brighter than ~8% perceived + self.assertLess(steps[0],max(steps)) # eased in: starts gentler than the middle + + def test_public_root_and_fresh_staging_both_include_helpers(self): + from release.public_root import desktop_additions, desktop_replacements + additions=desktop_additions(); replacements=desktop_replacements() + self.assertEqual(additions['usr/local/libexec/arch-ipad-auto-brightness'][1],0o755) + for command in ('omarchy-auto-brightness','omarchy-rotation-lock'): + self.assertIn('usr/local/bin/'+command,additions) + self.assertIn('usr/local/libexec/arch-ipad-rotate-output',replacements) + stage=(PROJECT/'desktop/stage.py').read_text() + self.assertIn('arch-ipad-auto-brightness',stage) + self.assertIn('arch-ipad-auto-brightness',(PROJECT/'desktop/configuration/labwc-autostart').read_text()) + +if __name__=='__main__': unittest.main() diff --git a/tests/test_gpu_video_session.py b/tests/test_gpu_video_session.py index d431997..02638ae 100644 --- a/tests/test_gpu_video_session.py +++ b/tests/test_gpu_video_session.py @@ -81,5 +81,95 @@ def test_firefox_defaults_follow_the_session(self): self.assertEqual(config.count('Pref('), body.count('defaultPref(')) +class G5PSession(unittest.TestCase): + """The iPad 5: the private stack, a passing probe and the ADBE card decide the GPU desktop.""" + G5P = {'drm/renderD128': ('soc/gpu', 'apple-g5p-drm'), 'drm/card0': ('soc/gpu', 'apple-g5p-drm'), + 'drm/card1': ('soc/display', 'apple-adbe'), 'drm/card1-eDP-1': ('soc/display/drm/card1', 'none')} + PREFIX = '/opt/orchard/g5p' + + def run_session(self, devices, probe_rc=0, stack=True, inherited=''): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for name, (device, driver) in devices.items(): + node = root / 'sys/class' / name + node.mkdir(parents=True) + target = root / 'sys/devices' / device + (target / 'driver-dir' / driver).mkdir(parents=True, exist_ok=True) + if not (target / 'driver').exists(): + (target / 'driver').symlink_to(target / 'driver-dir' / driver) + (node / 'device').symlink_to(target) + prefix = root / self.PREFIX.lstrip('/') + if stack: + for path in ('share/vulkan/icd.d/powervr_mesa_icd.aarch64.json', 'lib/libEGL_mesa.so.0'): + (prefix / path).parent.mkdir(parents=True, exist_ok=True) + (prefix / path).touch() + probe = prefix / 'libexec/omarchy-g5p-probe' + probe.parent.mkdir(parents=True) + probe.write_text('#!/bin/sh\necho "$@" >"$FAKE/probe-args"\n' + 'env | sort >"$FAKE/probe-env"\nexit %d\n' % probe_rc) + probe.chmod(0o755) + bin_dir = root / 'bin' + bin_dir.mkdir() + (bin_dir / 'timeout').write_text('#!/bin/sh\nshift 3\nexec "$@"\n') # timeout -k 2 12 cmd... + (bin_dir / 'timeout').chmod(0o755) + (root / 'run').mkdir() + block = detection().replace(self.PREFIX, '"$FAKE"' + self.PREFIX) + script = ('set -euo pipefail\nexport WLR_RENDERER=pixman LIBGL_ALWAYS_SOFTWARE=1\n' + inherited + + block + '\nenv | grep -E "^(WLR_|MESA_|GALLIUM_|PVR_|LIBGL_|OMARCHY_G5P|VK_|GBM_)" | sort') + env = {'PATH': f'{bin_dir}:{os.environ["PATH"]}', 'FAKE': str(root), 'XDG_RUNTIME_DIR': str(root / 'run')} + result = subprocess.run(['bash', '-c', script], env=env, text=True, capture_output=True, check=True) + exported = dict(line.split('=', 1) for line in result.stdout.splitlines()) + args = (root / 'probe-args').read_text().split() if (root / 'probe-args').exists() else None + probe_env = (root / 'probe-env').read_text().splitlines() if (root / 'probe-env').exists() else [] + return exported, args, probe_env, result.stderr + + SOFTWARE = {'WLR_RENDERER': 'pixman', 'LIBGL_ALWAYS_SOFTWARE': '1'} + + def test_gpu_desktop_after_a_passing_probe(self): + env, args, probe_env, _ = self.run_session(self.G5P) + self.assertEqual(args, ['/dev/dri/renderD128', '/dev/dri/card1']) + self.assertEqual(env['OMARCHY_G5P'], '1') + self.assertEqual(env['WLR_RENDERER'], 'gles2') + self.assertEqual(env['WLR_BACKENDS'], 'drm,libinput') + self.assertEqual(env['WLR_DRM_DEVICES'], '/dev/dri/card1') + self.assertEqual(env['WLR_RENDER_DRM_DEVICE'], '/dev/dri/renderD128') + self.assertEqual(env['WLR_SCENE_DISABLE_DIRECT_SCANOUT'], '1') + self.assertTrue(env['VK_DRIVER_FILES'].endswith(self.PREFIX + '/share/vulkan/icd.d/powervr_mesa_icd.aarch64.json')) + # Mesa's own routing (kmsro for ADBE, Zink for G5P): no override for Labwc or its clients. + for name in ('MESA_LOADER_DRIVER_OVERRIDE', 'GALLIUM_DRIVER', 'LIBGL_ALWAYS_SOFTWARE'): + self.assertNotIn(name, env) + # The probe ran with the stack Labwc gets, and without an override. + self.assertTrue(any(line.startswith('VK_DRIVER_FILES=') for line in probe_env)) + self.assertFalse(any(line.startswith(('MESA_LOADER_DRIVER_OVERRIDE=', 'GALLIUM_DRIVER=', + 'LIBGL_ALWAYS_SOFTWARE=')) for line in probe_env)) + + def test_inherited_override_is_dropped(self): + env, _, probe_env, _ = self.run_session( + self.G5P, inherited='export MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink\n') + self.assertNotIn('MESA_LOADER_DRIVER_OVERRIDE', env) + self.assertNotIn('GALLIUM_DRIVER', env) + self.assertFalse(any(line.startswith('MESA_LOADER_DRIVER_OVERRIDE=') for line in probe_env)) + + def test_failed_probe_keeps_the_software_desktop(self): + env, args, _, stderr = self.run_session(self.G5P, probe_rc=1) + self.assertEqual(args, ['/dev/dri/renderD128', '/dev/dri/card1']) + self.assertEqual(env, self.SOFTWARE) + self.assertIn('G5P probe failed', stderr) + + def test_missing_stack_or_display_keeps_the_software_desktop(self): + env, args, _, _ = self.run_session(self.G5P, stack=False) + self.assertEqual((env, args), (self.SOFTWARE, None)) + no_display = {k: v for k, v in self.G5P.items() if not k.startswith('drm/card1')} + env, args, _, _ = self.run_session(no_display) + self.assertEqual((env, args), (self.SOFTWARE, None)) + + def test_display_card_is_chosen_by_driver(self): + # A later card that is not ADBE (another KMS-less device) must not win. + devices = {**self.G5P, 'drm/card2': ('soc/other', 'other-drm')} + env, args, _, _ = self.run_session(devices) + self.assertEqual(args[1], '/dev/dri/card1') + self.assertEqual(env['WLR_DRM_DEVICES'], '/dev/dri/card1') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_greeter_staging.py b/tests/test_greeter_staging.py index 4aa427c..f846c8e 100644 --- a/tests/test_greeter_staging.py +++ b/tests/test_greeter_staging.py @@ -66,6 +66,10 @@ def test_trusted_ui_fallback_theme_and_tmpfiles_are_staged(self): self.assertEqual((greeter / 'plugins/lock/LockView.qml').read_bytes(), (stage.PORT / 'shell/plugins/lock/LockView.qml').read_bytes()) self.assertTrue((greeter / 'Commons/Color.qml').is_file()) + menu = 'default/omarchy/omarchy-menu.jsonc' + staged_menu = target / 'home/tablet/.local/share/omarchy-ipad' / menu + self.assertEqual(staged_menu.read_bytes(), (stage.PORT / menu).read_bytes()) + self.assertIn('/usr/local/bin/ipadlinux-suspend-ready', staged_menu.read_text()) jade = stage.HERE / 'themes/jade-horse' self.assertEqual((greeter / 'default-theme/background').read_bytes(), (jade / 'backgrounds/01-mountain-horses.webp').read_bytes()) diff --git a/tests/test_menu_shutdown.py b/tests/test_menu_shutdown.py index 5e4133b..3088831 100644 --- a/tests/test_menu_shutdown.py +++ b/tests/test_menu_shutdown.py @@ -62,6 +62,15 @@ def test_menu_keeps_upstreams_calls(self): ('lock', 'omarchy-system-lock')): self.assertEqual(menu_action(name), action) + def test_final_and_staged_menu_share_the_suspend_gate(self): + from release.public_root import desktop_replacements + name = 'home/tablet/.local/share/omarchy-ipad/default/omarchy/omarchy-menu.jsonc' + self.assertEqual(desktop_replacements()[name], MENU.read_bytes()) + entry = json.loads(re.search(r'"system\.suspend":\s*(\{[^\n]+\})', MENU.read_text()).group(1)) + self.assertIn('/usr/local/bin/ipadlinux-suspend-ready', entry['when']) + self.assertNotIn('omarchy-sleep-disabled', entry['when']) + self.assertEqual(entry['action'], '/usr/local/bin/ipadlinux-suspend-ready && systemctl suspend') + def test_reboot_says_it_leaves_linux(self): # A reboot does not come back into Linux; one entry, no other system's name. entries = re.findall(r'"system\.reboot[^"]*":\s*(\{[^\n]+\})', MENU.read_text()) diff --git a/tests/test_no_orchard_imports.py b/tests/test_no_orchard_imports.py index a05637e..f8a76f8 100644 --- a/tests/test_no_orchard_imports.py +++ b/tests/test_no_orchard_imports.py @@ -17,7 +17,7 @@ def python_files(): continue if path.suffix == '.py': yield path - elif not path.suffix and path.read_bytes()[:64].startswith(b'#!') and b'python' in path.read_bytes()[:64]: + elif not path.suffix and path.read_bytes().split(b'\n', 1)[0].startswith(b'#!') and b'python' in path.read_bytes().split(b'\n', 1)[0]: yield path diff --git a/tests/test_powerd.py b/tests/test_powerd.py index d234266..5b4577a 100644 --- a/tests/test_powerd.py +++ b/tests/test_powerd.py @@ -591,7 +591,7 @@ def setUp(self): ('STATS', self.stats), ('BACKLIGHTS', self.work / 'backlight'), ('BRIGHTNESS', self.remembered), ('WAKEUP_COUNT', self.work / 'wakeup_count'), ('SOCKET', str(self.work / 'ctl')), - ('session_user', lambda: None), ('command', self.fake_command), ('log', lambda *_: None)): + ('platform_suspend_ready', lambda: True), ('session_user', lambda: None), ('command', self.fake_command), ('log', lambda *_: None)): patcher = patch.object(powerd, target, value) patcher.start() self.addCleanup(patcher.stop) @@ -660,6 +660,62 @@ def ask(self, request): connection.sendall(request.encode() + b'\n') return connection.recv(64).decode().strip() + def test_home_socket_reports_combo_after_screenshot_chord(self): + when = time.monotonic() + event = powerd.INPUT_EVENT.pack + os.write(self.key_write, b''.join(event(int(when+i/10), int((when+i/10)%1*1e6), 1, code, down) + for i, (code, down) in enumerate(((116, 1), (172, 1), (172, 0), (116, 0))))) + self.wait(lambda: self.daemon.buttons.home_in_combo) + self.assertEqual(self.ask('home'), 'combo') + self.assertEqual(self.ask('status'), 'ON') + + def test_idle_dim_restore_and_manual_brightness_supersedes_dim(self): + self.assertEqual(self.ask('dim'), 'ok') + self.assertEqual(self.brightness(), '300') + self.assertEqual(self.ask('als-state'), 'dark') + self.assertEqual(self.ask('brightness panel 600 300'), 'held') + self.ask('undim'); self.assertEqual(self.brightness(), '1563') + self.ask('dim') + (self.panel / 'brightness').write_text('700\n') + self.ask('undim'); self.assertEqual(self.brightness(), '700') + + def test_unqualified_power_idle_and_cover_lock_stay_dark_and_can_wake(self): + with patch.object(powerd, 'platform_suspend_ready', return_value=False): + for entry in ('power', 'idle', 'cover'): + with self.subTest(entry=entry): + self.requests.clear() + if entry == 'power': + self.press() + elif entry == 'idle': + self.ask('idle') + else: + self.ask('lock') # what closing the Smart Cover feeds + self.wait(lambda: self.daemon.machine.state == DARK and self.daemon.machine.no_suspend) + self.assertEqual(self.ask('status'), 'DARK') + self.assertEqual(self.brightness(), '0') + self.assertIsNone(self.daemon.settle_at) + self.assertNotIn(('systemctl', 'suspend'), self.requests) + self.assertFalse(any('CanSuspend' in call for call in self.requests)) + if entry == 'power': + self.press() + self.wait(lambda: self.brightness() == '1563') + else: + self.ask('wake') # touch/cover wake path + self.assertEqual(self.brightness(), '1563') + + def test_automatic_brightness_obeys_power_state_and_manual_changes(self): + self.assertEqual(self.ask('brightness panel 1800 1563'), 'ok') + self.assertEqual(self.brightness(), '1800') + self.assertEqual(self.ask('brightness panel 1900 1563'), 'held') + self.assertEqual(self.brightness(), '1800') + for request in ('brightness panel 0 1800', 'brightness panel 3001 1800', + 'brightness other 1900 1800', 'brightness panel ² 1800'): + self.assertEqual(self.ask(request), 'held') + self.assertEqual(self.brightness(), '1800') + self.press(); self.wait(lambda: self.brightness() == '0') + self.assertEqual(self.ask('brightness panel 1800 0'), 'held') + self.assertEqual(self.brightness(), '0') + def test_press_sleep_wake(self): machine = self.daemon.machine self.assertEqual(self.ask('input'), 'lit') diff --git a/tests/test_suspend_ready.py b/tests/test_suspend_ready.py new file mode 100644 index 0000000..345121e --- /dev/null +++ b/tests/test_suspend_ready.py @@ -0,0 +1,116 @@ +"""Readiness is a read-only policy check, never a speculative PM transition.""" +import contextlib +import importlib.machinery +import importlib.util +import io +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'system/overlay/usr/local/bin/ipadlinux-suspend-ready' + + +def load(name, path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +ready = load('suspend_ready', HELPER) +powerd = load('suspend_ready_powerd', ROOT / 'system/overlay/usr/libexec/omarchy-powerd') + + +class SuspendReady(unittest.TestCase): + def invoke(self, readiness=None, argv=()): + with tempfile.TemporaryDirectory() as directory: + query = Path(directory) / 'suspend_ready' + if readiness is not None: + query.write_bytes(readiness) + out, err = io.StringIO(), io.StringIO() + with patch.object(ready, 'READINESS', query), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = ready.main(argv) + if readiness is not None: + self.assertEqual(query.read_bytes(), readiness) + return code, out.getvalue(), err.getvalue() + + def test_no_feature_table_or_board_identity(self): + source = HELPER.read_text() + for name in ('device-feature', 'devicetree', 'compatible', 'subprocess'): + self.assertNotIn(name, source) + + def test_systemd_direct_suspend_uses_same_gate(self): + from release.public_root import system_files + dropin = 'etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf' + data, mode = system_files()[dropin] + self.assertIn(b'ExecStartPre=/usr/local/bin/ipadlinux-suspend-ready', data) + self.assertEqual(mode, 0o644) + logind = system_files()['etc/systemd/logind.conf.d/60-omarchy-a-power.conf'][0].decode() + for setting in ('HandlePowerKey', 'HandleSuspendKey', 'HandleHibernateKey', + 'HandleLidSwitch', 'HandleLidSwitchExternalPower', 'HandleLidSwitchDocked', 'IdleAction'): + self.assertIn(setting + '=ignore', logind) + + def test_refuses_without_an_admission_query(self): + code, out, err = self.invoke() + self.assertEqual((code, out), (1, '')) + self.assertIn('no suspend readiness query', err) + + def test_follows_the_kernel_admission_query(self): + self.assertEqual(self.invoke(b'ready\n'), (0, '', '')) + code, out, err = self.invoke(b'not-ready 201000000.gpu -16\n') + self.assertEqual((code, out), (1, '')) + self.assertIn('refused by 201000000.gpu (-16)', err) + code, _, err = self.invoke(b'not-ready apple_h9p_sleep_precheck -16\n') + self.assertEqual(code, 1) + self.assertIn('apple_h9p_sleep_precheck', err) + code, _, err = self.invoke(b'not-ready transition -16\n') + self.assertEqual(code, 1) + self.assertIn('refused by transition', err) + + def test_malformed_kernel_answer_fails_closed(self): + for answer in (b'', b'readyish\n', b'not-ready\n', b'\xff\n'): + code, out, err = self.invoke(answer) + self.assertEqual((code, out), (1, '')) + self.assertIn('Suspend unavailable:', err) + + def test_unreadable_answer_fails_closed(self): + with patch.object(ready.Path, 'read_text', side_effect=PermissionError('denied')), \ + contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(ready.main([]), 1) + self.assertIn('cannot read kernel suspend readiness', err.getvalue()) + + def test_no_force_or_alternate_sysfs_option(self): + self.assertEqual(self.invoke(b'ready\n', ['--force'])[0], 2) + result = subprocess.run([sys.executable, str(HELPER), '--force'], capture_output=True, text=True, timeout=2) + self.assertEqual((result.returncode, result.stdout), (2, '')) + self.assertIn('Usage:', result.stderr) + + def test_refresh_ships_executable_helper(self): + from release.public_root import system_files + self.assertEqual(system_files()['usr/local/bin/ipadlinux-suspend-ready'], (HELPER.read_bytes(), 0o755)) + self.assertEqual(HELPER.stat().st_mode & 0o777, 0o755) + + def test_daemon_uses_exit_status_and_logs_reason(self): + messages = [] + with patch.object(powerd.Path, 'exists', return_value=True), patch.object(powerd, 'log', messages.append), patch.object(powerd, 'command') as command: + command.return_value = subprocess.CompletedProcess([], 1, '', 'Suspend unavailable: reason\n') + self.assertFalse(powerd.platform_suspend_ready()) + self.assertIn('reason', messages[-1]) + command.assert_called_once_with('/usr/local/bin/ipadlinux-suspend-ready') + command.return_value = subprocess.CompletedProcess([], 0, '', '') + self.assertTrue(powerd.platform_suspend_ready()) + + def test_daemon_refuses_missing_helper(self): + with patch.object(powerd.Path, 'exists', return_value=False), patch.object(powerd, 'log'), patch.object(powerd, 'command') as command: + self.assertFalse(powerd.platform_suspend_ready()) + command.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..b2edcc8 --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,38 @@ +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +PROJECT = Path(__file__).resolve().parents[1] + +def load(name, path): + loader=importlib.machinery.SourceFileLoader(name,str(PROJECT/path)) + spec=importlib.util.spec_from_loader(name,loader) + mod=importlib.util.module_from_spec(spec); loader.exec_module(mod); return mod + +p=load('power','system/overlay/usr/libexec/omarchy-powerd') + +class TabletPort(unittest.TestCase): + pass + + + + + + def test_home_power_overlap_uses_upstream_screenshot_buttons(self): + p=load('power', 'system/overlay/usr/libexec/omarchy-powerd') + for edges in ((('power', True), ('home', True), ('power', False), ('home', False)), + (('home', True), ('power', True), ('home', False), ('power', False))): + buttons=p.Buttons() + result=[buttons.edge(name, down, 10+i/10, True) for i,(name,down) in enumerate(edges)] + self.assertEqual([x for x in result if x], ['home', 'screenshot']) + self.assertTrue(buttons.home_in_combo) + + +if __name__=='__main__': unittest.main() diff --git a/tests/test_volume_keys.py b/tests/test_volume_keys.py index dc5b0d5..94a0f1a 100644 --- a/tests/test_volume_keys.py +++ b/tests/test_volume_keys.py @@ -1,4 +1,4 @@ -"""The volume keys control the volume, and a new output starts quiet.""" +"""The volume keys control the volume, and a new output starts at a usable volume.""" from pathlib import Path import os import subprocess @@ -51,11 +51,11 @@ def test_the_volume_keys_are_bound_to_volume(self): block = rc[rc.index(f''):] self.assertIn(f'omarchy-volume {action}', block[:block.index('')]) - def test_a_new_output_starts_at_ten_percent(self): + def test_a_new_output_starts_at_fifty_percent(self): conf = (PROJECT / 'system/overlay/etc/wireplumber/wireplumber.conf.d/60-omarchy-a-volume.conf').read_text() - self.assertIn('device.routes.default-sink-volume = 0.001', conf) # 0.1 on the cubic scale + self.assertIn('device.routes.default-sink-volume = 0.125', conf) # 0.5 on the cubic scale - def test_the_bar_has_the_audio_mixer_and_the_osd_is_on(self): + def test_the_bar_has_volume_and_the_osd_is_on(self): config = (PROJECT / 'desktop/shell/config/omarchy/shell.json').read_text() self.assertIn('"omarchy.audio"', config) self.assertNotIn('"omarchy.osd"', config) # not in disabledPlugins diff --git a/tests/test_warm_restart.py b/tests/test_warm_restart.py new file mode 100644 index 0000000..a11ff31 --- /dev/null +++ b/tests/test_warm_restart.py @@ -0,0 +1,508 @@ +"""Host-only warm restart state transitions. No sysfs/device access.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT=Path(__file__).resolve().parents[1] +LIB=ROOT/'system/overlay/usr/lib/orchard-kexec' +sys.path.insert(0,str(LIB)) +import device +import cli +import diagnostics +import health + +WARM_SUPPORTED = cli.warm_supported # the real probe, before setUp replaces it + +class WarmRestart(unittest.TestCase): + def setUp(self): + # The state-machine fixtures model a system with the warm restart + # mechanisms; the probe is checked separately and defaults to reboot. + gate = patch.object(cli, 'warm_supported', return_value=True) + gate.start() + self.addCleanup(gate.stop) + + def test_warm_support_comes_from_kexec_and_the_reset_guard(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + def path(name): return root / str(name).lstrip('/') + loaded = path('/sys/kernel/kexec_loaded') + loaded.parent.mkdir(parents=True); loaded.write_text('0\n') + mode = path('/sys/kernel/reboot/mode'); mode.parent.mkdir(parents=True); mode.write_text('cold\n') + node = path('/proc/device-tree/soc/watchdog@123'); node.mkdir(parents=True) + (node/'compatible').write_bytes(b'apple,s8000-wdt\0apple,wdt\0') + (node/'apple,tethered-boot').touch() + with patch.object(cli, 'Path', side_effect=path), patch.object(cli.reset, 'Path', side_effect=path), \ + patch.object(cli.shutil, 'which', return_value='/usr/bin/kexec') as which: + self.assertTrue(self.real_warm_supported()) + which.assert_called_with('kexec') + which.return_value = None + self.assertFalse(self.real_warm_supported()) + which.return_value = '/usr/bin/kexec' + (node/'apple,tethered-boot').unlink() # no guard: J71b and every other board today + self.assertFalse(self.real_warm_supported()) + (node/'apple,tethered-boot').touch(); loaded.unlink() # no kexec in the kernel + self.assertFalse(self.real_warm_supported()) + loaded.write_text('0\n') + self.assertTrue(self.real_warm_supported()) + source = (LIB/'cli.py').read_text() + (ROOT/'system/overlay/usr/bin/orchard-restart').read_text() + self.assertNotIn('device-feature', source) + + def real_warm_supported(self): + return WARM_SUPPORTED() + + def test_supported_action_reports_the_probe_as_exit_status(self): + for answer, code in ((True, 0), (False, 1)): + with patch.object(cli, 'warm_supported', return_value=answer), \ + patch.object(sys, 'argv', ['tool', 'supported']), patch.object(device, 'run') as command: + with self.assertRaises(SystemExit) as exit: + cli.main() + self.assertEqual(exit.exception.code, code) + command.assert_not_called() + script = (ROOT/'system/overlay/usr/bin/orchard-restart').read_text() + self.assertIn('/usr/bin/orchard-kexec supported || exec systemctl reboot', script) + + def test_unqualified_restart_uses_normal_reboot(self): + with patch.object(cli, 'warm_supported', return_value=False), \ + patch.object(sys, 'argv', ['tool', 'reboot']), patch.object(device, 'run') as command, \ + patch.object(cli, 'prepare_installed') as prepare: + cli.main() + command.assert_called_once_with('systemctl', 'reboot') + prepare.assert_not_called() + + def test_reset_guard_new_old_and_missing(self): + import reset + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + def path(name): return root / str(name).lstrip('/') + with patch.object(reset, 'Path', side_effect=path): + mode = path('/sys/kernel/reboot/mode') + mode.parent.mkdir(parents=True); mode.write_text('cold\n') + with self.assertRaises(ValueError): reset.backend() + node = path('/proc/device-tree/soc/watchdog@123') + node.mkdir(parents=True) + (node/'compatible').write_bytes(b'apple,s8000-wdt\0apple,wdt\0') + (node/'apple,tethered-boot').touch() + self.assertEqual(reset.select(), 'reboot-mode=warm') + self.assertEqual(reset.select(False), 'reboot-mode=cold') + (node/'apple,tethered-boot').unlink() + old = path('/sys/module/apple_wdt/parameters/cold_restart') + old.parent.mkdir(parents=True); old.write_text('Y\n') + self.assertEqual(reset.select(), 'cold_restart=N') + self.assertEqual(reset.select(False), 'cold_restart=Y') + + def test_default_bls_changes_after_update(self): + with tempfile.TemporaryDirectory() as tmp,patch.object(device,'BOOT',Path(tmp)): + p=Path(tmp);(p/'loader').mkdir();(p/'loader/loader.conf').write_text('default orchard-*\n') + es=[(p/'orchard-1.conf',{'sort-key':['orchard'],'version':['1']}), + (p/'orchard-2.conf',{'sort-key':['orchard'],'version':['2']})] + self.assertEqual(device.select_entry(es)[0].stem,'orchard-2') + self.assertEqual(device.select_entry(es,'orchard-1')[0].stem,'orchard-1') + with self.assertRaises(ValueError):device.select_entry(es,'../bad') + + def test_updated_bls_assets_load_with_immutable_running_pristine(self): + import gzip + import importlib.machinery + import shutil + from types import SimpleNamespace + fdt=importlib.machinery.SourceFileLoader('warm_fixture_fdt',str(LIB/'fdt-merge')).load_module() + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);boot=root/'boot';running=root/'run';stage=root/'stage' + for p in (boot/'loader/entries',running,stage):p.mkdir(parents=True) + tree=fdt.Node('');tree.props['compatible']=b'apple,j71t\0' + dt=fdt.serialize(tree,[],0) + (running/'pristine.dtb').write_bytes(dt);(running/'boot-id').write_text('boot-id') + (boot/'loader/entries/default.conf').write_text('linux /linux\ninitrd /initrd\ndevicetree /board.dtb\noptions root=PARTUUID=abcd rw\n') + (boot/'board.dtb').write_bytes(dt);(boot/'initrd').write_bytes(b'new-matching-initrd') + shutil.copyfile(LIB/'fdt-merge',stage/'fdt-merge') + pseudo={} + for name,value in [('/sys/devices/system/cpu/possible','0'),('/proc/cmdline','root=PARTUUID=abcd rw'), + ('/proc/sys/kernel/random/boot_id','boot-id'),('/sys/kernel/kexec_loaded','1')]: + path=root/('pseudo'+str(len(pseudo)));path.write_text(value);pseudo[name]=path + def paths(name): + if str(name).startswith(('/proc/','/sys/')): + self.assertIn(str(name),pseudo);return pseudo[str(name)] + return Path(name) + captured=[] + expected_loader=['file'] + def run(*args,**kwargs): + if args[0]=='uname':return SimpleNamespace(stdout='7.3-old\n') + if args[0]=='python3': + self.assertEqual(args[args.index('--old')+1],str(running/'pristine.dtb')) + (stage/'merged.dtb').write_bytes(dt) + elif args[0]=='kexec': + self.assertEqual(args[1],'-s' if expected_loader[0]=='file' else '-c') + self.assertEqual(any(a.startswith('--dtb=') for a in args),expected_loader[0]=='legacy') + captured.append((stage/'Image').read_bytes()) + return SimpleNamespace(stdout='') + with patch.object(device,'BOOT',boot),patch.object(device,'RUN',running),\ + patch.object(device,'Path',side_effect=paths),patch.object(cli,'Path',side_effect=paths),\ + patch.object(device,'run',side_effect=run),patch.object(device.shutil,'which',return_value='/tool'),\ + patch.object(device,'guard_outgoing',return_value='guarded'): + for release in ('7.3-update1','7.4-update2'): + image=bytearray(64);image[56:60]=b'ARM\x64';image+=b'Linux version '+release.encode()+b' (fixture)\0' + (boot/'linux').write_bytes(gzip.compress(image)) + plan=device.prepare(stage,False) + self.assertEqual(plan['release'],release) + self.assertEqual(plan['loader'],'file') + cli.load(stage) + self.assertEqual(captured[-1],image) + self.assertEqual((running/'pristine.dtb').read_bytes(),dt) + self.assertFalse((stage/'Image').exists()) + # Changed pristine DT must merge runtime fixups and use legacy. + tree.props['model']=b'updated board model\0' + (boot/'board.dtb').write_bytes(fdt.serialize(tree,[],0)) + expected_loader[0]='legacy' + plan=device.prepare(stage,False) + self.assertEqual(plan['loader'],'legacy') + cli.load(stage) + + def test_explicit_loader_and_failure_policy(self): + for loader in ('file', 'legacy', 'invalid'): + with self.subTest(loader=loader), tempfile.TemporaryDirectory() as tmp: + stage=Path(tmp); pseudo=stage/'pseudo';pseudo.write_text('boot-id') + (stage/'plan.json').write_text(json.dumps(dict(loader=loader,boot_id='boot-id',options='panic=0',initrd=False))) + def paths(name): + return pseudo if str(name).startswith('/proc/') else Path(name) + with patch.object(cli,'Path',side_effect=paths), patch.object(device,'run',side_effect=RuntimeError('load failed')) as run: + if loader == 'invalid': + with self.assertRaises(ValueError): cli.load(stage) + run.assert_not_called() + else: + with self.assertRaises(RuntimeError): cli.load(stage) + self.assertEqual(run.call_count,1) # No silent legacy retry. + args=run.call_args.args + self.assertEqual(args[1], '-s' if loader=='file' else '-c') + self.assertEqual(any(a.startswith('--dtb=') for a in args),loader=='legacy') + + def test_host_stage_selects_explicit_installed_entry(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(device,'RUN',Path(tmp)), \ + patch.object(cli.os,'geteuid',return_value=0), patch.object(Path,'resolve',lambda p:p), \ + patch.object(device,'prepare',return_value={}) as prepare, \ + patch.object(sys,'argv',['tool','stage','/var/tmp/orchard-warm.abc','prepare','--entry','orchard-test','--profile','full']): + cli.main() + self.assertEqual(prepare.call_args.kwargs,{'entry_name':'orchard-test'}) + self.assertEqual(prepare.call_args.args[-1],'full') + + def test_refresh_failure_does_not_request_a_reset(self): + with tempfile.TemporaryDirectory() as tmp,patch.object(device,'RUN',Path(tmp)),\ + patch.object(cli.os,'geteuid',return_value=0),patch.object(cli,'prepare_installed',side_effect=ValueError('bad DT')),\ + patch.object(device,'run') as run,patch.object(sys,'argv',['tool','refresh']): + with self.assertRaises(ValueError):cli.main() + self.assertEqual(run.call_args_list, [unittest.mock.call('kexec', '-u')]*2) + self.assertFalse((Path(tmp)/'restart-ready').exists()) + + def test_dispatch_gpu_failure_uses_ios_escape_without_kexec_or_halt(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root/'gpu-quiesce-failed').write_text('failed') + (root/'kmsg').touch() + def path(name): + return root/'kmsg' if str(name) == '/dev/kmsg' else Path(name) + events = [] + with patch.object(device, 'RUN', root), patch.object(cli, 'Path', side_effect=path), \ + patch.object(cli.os, 'geteuid', return_value=0), \ + patch.object(cli.reset, 'select', side_effect=lambda **kw: events.append(('select', kw))), \ + patch.object(device, 'run', side_effect=lambda *args: events.append(args)), \ + patch.object(device, 'guard_outgoing') as guard, \ + patch.object(sys, 'argv', ['tool', 'dispatch']): + cli.main() + self.assertEqual(events, [('select', {'warm': False}), + ('kexec', '-u'), ('systemctl', '--force', 'reboot')]) + guard.assert_not_called() + self.assertIn('<0>Warm restart aborted', (root/'kmsg').read_text()) + self.assertIn('GPU td-kexec', (root/'kmsg').read_text()) + + def test_ios_uses_the_same_cold_escape(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(device, 'RUN', Path(tmp)), \ + patch.object(cli.os, 'geteuid', return_value=0), patch.object(cli, 'cold_reset') as cold, \ + patch.object(sys, 'argv', ['tool', 'ios']): + cli.main() + cold.assert_called_once_with() + + def test_quiesce_receipt_retained_on_sysfs_failure_cleared_on_success(self): + script = (LIB/'quiesce-gpu').read_text() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + command = root/'gpu/command'; command.parent.mkdir() + command.mkdir() # Opening a directory for output fails on every host. + self.assertNotIn('device-feature', script) + rewritten = script.replace('/run/orchard-kexec', str(root/'run')) \ + .replace('/sys/bus/platform/drivers/apple-g5p-drm/*/command', str(command)) \ + .replace('/dev/kmsg', str(root/'kmsg')) + result = subprocess.run(['sh', '-c', rewritten], capture_output=True, timeout=5) + self.assertNotEqual(result.returncode, 0) + self.assertTrue((root/'run/gpu-quiesce-failed').exists()) + command.rmdir(); command.touch() + result = subprocess.run(['sh', '-c', rewritten], capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((root/'run/gpu-quiesce-failed').exists()) + self.assertEqual(command.read_text(), 'td-kexec\n') + + def test_quiesce_without_a_gpu_handover_command_arms_nothing(self): + script = (LIB/'quiesce-gpu').read_text() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rewritten = script.replace('/run/orchard-kexec', str(root/'run')) \ + .replace('/sys/bus/platform/drivers/apple-g5p-drm', str(root/'no-g5p')) \ + .replace('/dev/kmsg', str(root/'kmsg')) + result = subprocess.run(['sh', '-c', rewritten], capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((root/'run').exists()) + + def test_dispatch_without_ready_image_uses_cold_escape(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);run=root/'run';run.mkdir() + (root/'loaded').write_text('0');(root/'boot').write_text('test-boot');(root/'kmsg').touch() + def path(name): + return {'/sys/kernel/kexec_loaded':root/'loaded','/proc/sys/kernel/random/boot_id':root/'boot', + '/dev/kmsg':root/'kmsg'}.get(str(name),Path(name)) + with patch.object(device,'RUN',run),patch.object(cli,'Path',side_effect=path),\ + patch.object(cli.os,'geteuid',return_value=0),patch.object(device,'run') as cmd,\ + patch.object(sys,'argv',['tool','dispatch']),\ + patch.object(cli,'cold_reset',return_value=True) as cold: + cli.main() + cmd.assert_not_called() + self.assertIn('warm dispatch refused',cold.call_args.args[0]) + + def test_load_arms_dispatch_without_a_refresh_unit(self): + # Device regression 2026-11-08: load wrote only developer-loaded, refresh no longer runs + # in the kexec transaction, so dispatch refused and halted every warm restart. + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);run=root/'run';stage=root/'stage' + for p in (run,stage):p.mkdir() + (root/'loaded').write_text('1');(root/'boot').write_text('boot-a');(root/'kmsg').touch() + (stage/'plan.json').write_text(json.dumps({'boot_id':'boot-a','loader':'file','options':'root=PARTUUID=x','initrd':False})) + def path(name): + return {'/sys/kernel/kexec_loaded':root/'loaded','/proc/sys/kernel/random/boot_id':root/'boot', + '/dev/kmsg':root/'kmsg'}.get(str(name),Path(name)) + with patch.object(device,'RUN',run),patch.object(cli,'Path',side_effect=path),\ + patch.object(cli.os,'geteuid',return_value=0),patch.object(device,'run') as cmd: + cli.load(stage) + self.assertEqual((run/'restart-ready').read_text().strip(),'boot-a') + cmd.reset_mock() + with patch.object(sys,'argv',['tool','dispatch']), patch.object(device,'guard_outgoing'):cli.main() + cmd.assert_called_once_with('systemctl','--force','kexec') + + def test_refresh_replaces_developer_target_with_current_default(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);(root/'developer-loaded').write_text('boot');(root/'boot').write_text('boot') + (root/'loaded').write_text('1') + def path(name): + return {'/sys/kernel/kexec_loaded':root/'loaded','/proc/sys/kernel/random/boot_id':root/'boot'}.get(str(name),Path(name)) + with patch.object(device,'RUN',root),patch.object(cli,'Path',side_effect=path),\ + patch.object(cli.os,'geteuid',return_value=0),patch.object(cli,'prepare_installed') as prepare, patch.object(device, 'run') as run,\ + patch.object(sys,'argv',['tool','refresh']): + cli.main() + run.assert_called_once_with('kexec', '-u') + prepare.assert_called_once_with('default') + self.assertFalse((root/'developer-loaded').exists()) + self.assertFalse((root/'restart-ready').exists()) + + def test_select_unloads_before_changing_default_and_reload(self): + for fail in (False, True): + with self.subTest(fail=fail), tempfile.TemporaryDirectory() as tmp: + root=Path(tmp); boot=root/'boot'; run=root/'run'; run.mkdir() + entries=boot/'loader/entries'; entries.mkdir(parents=True) + (entries/'new.conf').write_text('linux /new\n') + loader=boot/'loader/loader.conf'; loader.write_text('timeout 5\ndefault old.conf\n') + for name in ('restart-ready', 'developer-loaded', 'loaded.json'): + (run/name).write_text('stale') + def unload(*args): + self.assertEqual(args, ('kexec', '-u')) + self.assertFalse((run/'restart-ready').exists()) + def prepare(entry): + self.assertEqual(entry, 'default') + self.assertEqual(loader.read_text(), 'timeout 5\ndefault new.conf\n') + self.assertFalse((run/'loaded.json').exists()) + if fail: raise ValueError('bad image') + return {} + with patch.object(device, 'BOOT', boot), patch.object(device, 'RUN', run), \ + patch.object(device, 'run', side_effect=unload) as command, \ + patch.object(cli, 'prepare_installed', side_effect=prepare): + if fail: + with self.assertRaises(ValueError): cli.select_default('new') + else: + cli.select_default('new') + self.assertEqual(command.call_count, 2 if fail else 1) + + def test_plain_reboot_preparation_never_dispatches_or_halts(self): + for loaded in ('0', '1'): + with self.subTest(loaded=loaded), tempfile.TemporaryDirectory() as tmp: + node=Path(tmp)/'loaded'; node.write_text(loaded) + with patch.object(cli, 'Path', return_value=node), \ + patch.object(cli.reset, 'backend'), patch.object(cli.reset, 'select') as select, \ + patch.object(device, 'run') as run, \ + patch.object(sys, 'argv', ['tool', 'prepare-reboot']): + cli.main() + select.assert_called_once_with(warm=False) + run.assert_not_called() + + def test_plain_reboot_retains_native_exec_and_bounded_best_effort_handover(self): + conf=(ROOT/'system/overlay/usr/lib/systemd/system/systemd-reboot.service.d/50-orchard-cold.conf').read_text() + self.assertNotIn('ExecStart=', conf) + self.assertNotIn('dispatch', '\n'.join(l for l in conf.splitlines() if not l.startswith('#'))) + self.assertIn('ExecStartPre=-/usr/bin/timeout --kill-after=1 10 ', conf) + self.assertIn('quiesce-gpu', conf) + self.assertIn('prepare-reboot', conf) + watch=(ROOT/'system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.path').read_text() + self.assertIn('PathChanged=/boot/loader/loader.conf', watch) + self.assertIn('PathChanged=/boot/loader/entries', watch) + self.assertIn('WantedBy=multi-user.target', watch) + + def test_netconsole_records_both_macs_and_uses_configfs(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp) + paths={} + for name,data in [('/sys/kernel/config/netconsole',''),('/proc/sys/kernel/printk','4 4 1 7'), + ('/sys/module/kernel/parameters/initcall_debug','N'), + ('/sys/module/printk/parameters/ignore_loglevel','N'), + ('/proc/sys/kernel/random/boot_id','boot-id'), + ('/sys/class/net/usb0/address','02:00:00:00:00:01'),('/dev/kmsg','')]: + p=root/name.lstrip('/');p.parent.mkdir(parents=True,exist_ok=True) + if name.endswith('/netconsole'): + p.mkdir(); t=p/'orchard-warm'; t.mkdir(); (t/'enabled').write_text('0\n') # new targets start disabled + else:p.write_text(data) + paths[name]=p + def path(name): return paths.get(name,root/name.lstrip('/')) + with patch.object(diagnostics,'Path',side_effect=path),patch.object(diagnostics,'run') as run: + run.side_effect=lambda *a: json.dumps([{'dev':'usb0','prefsrc':'172.16.42.1'}]) if a[0]=='ip' else '' + result=diagnostics.netconsole('02:00:00:00:00:02',6777) + target=root/'sys/kernel/config/netconsole/orchard-warm' + self.assertEqual((target/'enabled').read_text(),'1\n') + self.assertEqual((target/'remote_mac').read_text(),'02:00:00:00:00:02\n') + self.assertEqual((target/'local_ip').read_text(),'172.16.42.1\n') + self.assertEqual(result['usb_mac'],'02:00:00:00:00:01') + self.assertEqual(paths['/proc/sys/kernel/printk'].read_text(),'8 4 1 7\n') + + def test_netconsole_bad_route_refuses_before_modprobe(self): + with patch.object(diagnostics,'run',return_value='[{"dev":"wlan0","prefsrc":"192.168.1.2"}]') as run: + with self.assertRaises(ValueError):diagnostics.netconsole('02:00:00:00:00:02') + self.assertEqual(run.call_count,1) + + def test_health_rejects_polled_completion_and_late_timeout(self): + ap=type('Args',(),dict(sudo_max=5,read_max=3))() + p=dict(done='1',boot_id='boot',dmesg_rc='0',dmesg=[],sudo_rc='0',sudo_ms='1',read_rc='0',read_ms='10', + irq_before='1: 3 4 nvme0q0',irq_after='1: 4 4 nvme0q0') + self.assertIsNone(health.judge(p,ap,'boot')) + p['irq_after']=p['irq_before'];self.assertIn('lost MSI',health.judge(p,ap,'boot')) + p['irq_after']='1: 4 4 nvme0q0';p['dmesg']=['nvme I/O tag 5 timeout'] + self.assertIn('timeout/recovery',health.judge(p,ap,'boot')) + + def test_systemd_restart_requires_preload_poweroff_is_unchanged(self): + unit=(ROOT/'system/overlay/usr/lib/systemd/system/orchard-kexec-refresh.service').read_text() + # Never pulled into the shutdown transaction: needing /boot there conflicts with + # umount.target and systemd refuses the kexec (device-found 2026-11-07). + self.assertNotIn('[Install]',unit); self.assertNotIn('RequiredBy=',unit) + self.assertIn('orchard-kexec-refresh.path',(ROOT/'release/public_root.py').read_text()) + self.assertNotIn('orchard-kexec-refresh',(ROOT/'system/assemble-rootfs').read_text()) + self.assertIn('orchard-kexec-load.service',(ROOT/'system/overlay/usr/bin/orchard-restart').read_text()) + self.assertFalse((ROOT/'system/overlay/usr/lib/systemd/system/systemd-poweroff.service.d').exists()) + for name in ('systemd-kexec.service',): + conf=(ROOT/'system/overlay/usr/lib/systemd/system'/(name+'.d')/'50-orchard-warm.conf').read_text() + self.assertIn('orchard-kexec dispatch',conf) + # GPU handover before the jump, on the menu Restart (reboot) path too (device 2026-11-08). + self.assertIn('ExecStartPre=-/usr/bin/timeout --kill-after=1 10 /usr/lib/orchard-kexec/quiesce-gpu',conf) + quiesce=ROOT/'system/overlay/usr/lib/orchard-kexec/quiesce-gpu' + self.assertTrue(os.access(quiesce,os.X_OK)); self.assertIn('td-kexec',quiesce.read_text()) + + def test_cold_escape_continues_after_select_or_unload_failure(self): + for failure in ('select', 'unload'): + with self.subTest(failure=failure), patch.object(cli.reset, 'select', + side_effect=ValueError('bad guard') if failure=='select' else None), \ + patch.object(cli, 'unload', side_effect=OSError('busy') if failure=='unload' else None), \ + patch.object(device, 'run') as run, patch.object(cli, 'Path') as paths: + paths.return_value.read_text.return_value='cold\n' + self.assertTrue(cli.cold_reset('test')) + run.assert_called_once_with('systemctl', '--force', 'reboot') + + def test_cold_escape_timeout_tries_sysrq_and_reports_failure_if_it_returns(self): + with patch.object(cli.reset, 'select', side_effect=OSError('missing')), \ + patch.object(cli, 'unload', side_effect=subprocess.TimeoutExpired('kexec',60)), \ + patch.object(device, 'run', side_effect=subprocess.TimeoutExpired('systemctl',60)), \ + patch.object(cli, 'Path') as paths: + paths.return_value.read_text.return_value='cold\n' + self.assertFalse(cli.cold_reset()) + self.assertIn(unittest.mock.call('/proc/sysrq-trigger'), paths.call_args_list) + self.assertIn(unittest.mock.call('/sys/kernel/reboot/mode'), paths.call_args_list) + paths.return_value.write_text.assert_any_call('b') + + def test_cold_escape_all_io_failures_return_without_exception(self): + with patch.object(cli.reset, 'select', side_effect=ValueError('missing')), \ + patch.object(cli, 'unload', side_effect=OSError('unload')), \ + patch.object(device, 'run', side_effect=OSError('reboot')), \ + patch.object(cli, 'Path') as paths: + paths.return_value.write_text.side_effect=OSError('I/O') + self.assertFalse(cli.cold_reset('test')) + + def test_failed_receipt_precedes_dispatch_lock(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(device,'RUN',Path(tmp)), \ + patch.object(cli.os,'geteuid',return_value=0), \ + patch.object(cli.fcntl,'flock',side_effect=BlockingIOError) as flock, \ + patch.object(cli,'cold_reset',return_value=True) as cold, \ + patch.object(sys,'argv',['tool','dispatch']): + (Path(tmp)/'gpu-quiesce-failed').touch() + cli.main() + flock.assert_not_called() + cold.assert_called_once() + + def test_dispatch_lock_failure_escapes_and_failed_escape_fails_unit(self): + for result in (True,False): + with self.subTest(result=result), tempfile.TemporaryDirectory() as tmp, \ + patch.object(device,'RUN',Path(tmp)),patch.object(cli.os,'geteuid',return_value=0), \ + patch.object(cli.fcntl,'flock',side_effect=BlockingIOError), \ + patch.object(cli,'cold_reset',return_value=result) as cold, \ + patch.object(sys,'argv',['tool','dispatch']): + if result: + cli.main() + else: + with self.assertRaises(SystemExit) as status:cli.main() + self.assertEqual(status.exception.code,1) + cold.assert_called_once() + conf=(ROOT/'system/overlay/usr/lib/systemd/system/systemd-kexec.service.d/50-orchard-warm.conf').read_text() + self.assertIn('[Unit]\n',conf) + self.assertIn('FailureAction=none',conf.split('[Service]')[0]) + self.assertIn('SuccessAction=none',conf.split('[Service]')[0]) + + + def test_failed_guard_selects_cold_before_requesting_reboot(self): + events=[] + with tempfile.TemporaryDirectory() as tmp: + mode=Path(tmp)/'mode';mode.write_text('warm\n') + def path(name): + return mode if str(name)=='/sys/kernel/reboot/mode' else Path(tmp)/'kmsg' + def reboot(*args): + self.assertEqual(mode.read_text(),'cold\n');events.append(args) + with patch.object(cli.reset,'select',side_effect=ValueError('guard')), \ + patch.object(cli,'Path',side_effect=path),patch.object(cli,'unload'), \ + patch.object(device,'run',side_effect=reboot): + self.assertTrue(cli.cold_reset()) + self.assertEqual(events,[('systemctl','--force','reboot')]) + + def test_unselectable_cold_arms_native_watchdog_without_warm_reboot(self): + def path(name): + obj=unittest.mock.Mock() + if str(name).endswith('/identity'):obj.read_text.return_value='Apple SoC Watchdog' + elif str(name).endswith('/mode'):obj.write_text.side_effect=OSError('mode unavailable') + return obj + with patch.object(cli.reset,'select',side_effect=OSError('guard')), \ + patch.object(cli,'Path',side_effect=path),patch.object(cli.os,'open',return_value=9), \ + patch.object(cli.os,'write') as write,patch.object(cli.os,'close'), \ + patch.object(cli.fcntl,'ioctl') as ioctl,patch.object(device,'run') as reboot: + self.assertTrue(cli.cold_reset()) + reboot.assert_not_called();write.assert_called_once_with(9,b'\0') + self.assertEqual(ioctl.call_args.args[:2],(9,0xc0045706)) + + + def test_unqualified_dispatch_escapes_without_enqueuing_reboot(self): + with patch.object(cli,'warm_supported',return_value=False), \ + patch.object(sys,'argv',['tool','dispatch']), \ + patch.object(cli,'cold_reset',return_value=True) as cold, \ + patch.object(device,'run') as command: + cli.main() + cold.assert_called_once();command.assert_not_called()