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/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/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/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/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_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()