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/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/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/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-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-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-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/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..6e7d121 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -35,7 +35,7 @@ // 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. @@ -184,6 +184,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/release/public_root.py b/release/public_root.py index 63a2d35..2871d22 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -42,6 +42,10 @@ 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() return replacements @@ -129,10 +133,17 @@ 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 { + '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(): @@ -142,7 +153,7 @@ def system_files(): 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 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/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/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/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/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/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_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()