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