From ef86da3a39bb31d4ee8e369ff474e9e9864ec3ca Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 01/20] Keep rotation layers mapped and touch on the panel Assisted-by: LLM --- desktop/configuration/rotate-output | 14 ++++-- desktop/shell/bin/omarchy-rotate | 24 ++++++++++ desktop/shell/bin/omarchy-rotation-lock | 12 +++++ .../shell/default/omarchy/omarchy-menu.jsonc | 3 ++ desktop/shell/labwc-rc.xml | 1 + desktop/shell/shell/Ui/ScreenMoveRemap.qml | 44 +++---------------- release/public_root.py | 7 +++ tests/test_rotate_output.py | 18 ++++++++ tests/test_tablet_port.py | 8 ++++ 9 files changed, 90 insertions(+), 41 deletions(-) create mode 100755 desktop/shell/bin/omarchy-rotate create mode 100755 desktop/shell/bin/omarchy-rotation-lock diff --git a/desktop/configuration/rotate-output b/desktop/configuration/rotate-output index 82083d2..39661ac 100644 --- a/desktop/configuration/rotate-output +++ b/desktop/configuration/rotate-output @@ -23,7 +23,7 @@ transform() { apply() { local t output - [[ -e $lock ]] && return + [[ -e $lock ]] && return 1 t=$(transform "$1") || return output=$(wlr-randr | awk '/^[^[:space:]]/ {print $1; exit}') [[ -n $output ]] && wlr-randr --output "$output" --transform "$t" @@ -36,7 +36,7 @@ orientation() { esac } -exec {sensor}< <(exec monitor-sensor --accel) +exec {sensor}< <(exec stdbuf -oL monitor-sensor --accel) sensor_pid=$! watcher='' if [[ -n ${LABWC_PID:-} ]]; then @@ -55,8 +55,14 @@ while :; do continue fi ((status > 128)) || break # end of input, not a timeout - [[ $pending != "$applied" ]] && apply "$pending" && applied=$pending - pending='' + [[ -e $lock ]] && applied='' + if [[ $pending != "$applied" ]]; then + # Keep the latest settled pose while locked. Retrying the read timeout + # reapplies it after unlock even if the sensor sends no fresh event. + apply "$pending" || continue + applied=$pending + fi + # Keep the last pose to restore auto orientation after a manual lock. else IFS= read -r -u "$sensor" line || break next=$(orientation "$line") && [[ -n $next ]] && pending=$next diff --git a/desktop/shell/bin/omarchy-rotate b/desktop/shell/bin/omarchy-rotate new file mode 100755 index 0000000..512d377 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotate @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Manual rotation uses the same output protocol as auto-rotation; holds auto until unlock.""" +import json, os, subprocess, sys +from pathlib import Path +mode = sys.argv[1] if len(sys.argv)>1 else 'status' +outputs = json.loads(subprocess.check_output(['wlr-randr', '--json'], text=True)) +panel = next((o for o in outputs if o['name'] == 'eDP-1'), None) +if panel is None: + panel = next(o for o in outputs if o.get('enabled')) +current = panel['transform'] +turns = ['normal', '90', '180', '270'] +if mode == 'status': + print(current); raise SystemExit(0) +if mode == 'next': mode = turns[(turns.index(current)+1)%4] +mode = {'portrait':'normal','landscape':'90','portrait-flipped':'180','landscape-flipped':'270'}.get(mode,mode) +if mode not in turns: raise SystemExit('Usage: omarchy-rotate status|next|normal|90|180|270') +lock = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'omarchy/rotation-lock' +was_locked = lock.exists() +lock.parent.mkdir(parents=True,exist_ok=True); lock.touch() +try: + subprocess.run(['wlr-randr','--output',panel['name'],'--transform',mode],check=True,timeout=5) +except (OSError, subprocess.SubprocessError): + if not was_locked: lock.unlink(missing_ok=True) + raise diff --git a/desktop/shell/bin/omarchy-rotation-lock b/desktop/shell/bin/omarchy-rotation-lock new file mode 100755 index 0000000..744dc37 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotation-lock @@ -0,0 +1,12 @@ +#!/bin/bash +# Share the orientation lock with arch-ipad-rotate-output. +set -euo pipefail +lock=${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/rotation-lock +case ${1:-toggle} in + enabled) [[ -e $lock ]]; exit ;; + status) if [[ -e $lock ]]; then echo locked; else echo automatic; fi ;; + on) mkdir -p "$(dirname "$lock")"; touch "$lock" ;; + off) rm -f -- "$lock" ;; + toggle) if [[ -e $lock ]]; then rm -f -- "$lock"; else mkdir -p "$(dirname "$lock")"; touch "$lock"; fi ;; + *) echo 'Usage: omarchy-rotation-lock [on|off|toggle|enabled|status]' >&2; exit 2 ;; +esac diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index b337560..8cb5c0d 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -181,6 +181,8 @@ "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.rotation-lock": {"icon":"󰿆","label":"Rotation Lock","checked":"omarchy-rotation-lock enabled","action":"omarchy-rotation-lock 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\""}, @@ -450,4 +452,5 @@ "update.hardware.trackpad": {"icon":"󰟸","label":"Trackpad","action":"omarchy-launch-floating-terminal-with-presentation omarchy-restart-trackpad"}, "update.password.drive": {"icon":"","label":"Drive Encryption","action":"omarchy-launch-floating-terminal-with-presentation omarchy-drive-password"}, "update.password.user": {"icon":"","label":"User","action":"omarchy-launch-floating-terminal-with-presentation passwd"}, + "setup.tablet.rotate": {"icon": "󰑢", "label": "Rotate Once and Lock", "action": "omarchy-rotate next"}, } diff --git a/desktop/shell/labwc-rc.xml b/desktop/shell/labwc-rc.xml index 6fea059..7ec8313 100644 --- a/desktop/shell/labwc-rc.xml +++ b/desktop/shell/labwc-rc.xml @@ -117,6 +117,7 @@ + no diff --git a/desktop/shell/shell/Ui/ScreenMoveRemap.qml b/desktop/shell/shell/Ui/ScreenMoveRemap.qml index 44d2308..7c9d24d 100644 --- a/desktop/shell/shell/Ui/ScreenMoveRemap.qml +++ b/desktop/shell/shell/Ui/ScreenMoveRemap.qml @@ -1,43 +1,13 @@ import QtQuick -// Hyprland leaves an already-mapped layer surface at its old global position -// when its monitor moves within the layout: undocking disables the internal -// panel, the external monitor shifts to x=0, and long-lived surfaces such as -// the bar and background keep rendering at the old offset — or entirely -// off-screen — until they are unmapped and remapped. Watch the screen's -// origin and pulse `remapping` when it moves; the owning window folds that -// into its `visible` binding so the compositor re-places the surface at the -// monitor's new origin. +// Labwc repositions mapped layer-shell surfaces itself when outputs move or +// resize. Qt emits x/y notifications for a size-only change too. The old +// Hyprland workaround hid every panel and wallpaper 200ms later, destroying +// their layer surfaces and buffers for at least another 50ms. On a software +// renderer recreation/decoding extends that gap. Keep the public component +// interface, but never unmap a labwc layer just because its output changed. Item { - id: root - required property var window - readonly property var screen: window ? window.screen : null - - // Fold into the window's binding: visible: && !guard.remapping - property bool remapping: false - + readonly property bool remapping: false visible: false - - // A layout reshuffle can move the monitor more than once before it lands. - // Let the positions settle before the single remap pulse. - Timer { - id: settleTimer - interval: 200 - onTriggered: root.remapping = true - } - - // Hold the surface unmapped for a beat so the compositor processes the - // unmap before the remap instead of coalescing them into a no-op. - Timer { - interval: 50 - running: root.remapping - onTriggered: root.remapping = false - } - - Connections { - target: root.screen - function onXChanged() { settleTimer.restart() } - function onYChanged() { settleTimer.restart() } - } } diff --git a/release/public_root.py b/release/public_root.py index d4626fe..d21cc85 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -39,10 +39,12 @@ def desktop_replacements(): 'bin/omarchy-bluetooth-device')} 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[prefix + 'shell/Ui/ScreenMoveRemap.qml'] = (PROJECT / 'desktop/shell/shell/Ui/ScreenMoveRemap.qml').read_bytes() replacements[prefix + 'shell/plugins/services/idle/Service.qml'] = (PROJECT / 'desktop/shell/shell/plugins/services/idle/Service.qml').read_bytes() menu = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_text() menu = menu.replace('! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled', 'command -v ipadlinux-suspend-ready >/dev/null && ipadlinux-suspend-ready') replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = menu.encode() + replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-output').read_bytes() return replacements @@ -127,6 +129,11 @@ def labwc_files(directory, architecture='aarch64'): def desktop_additions(): prefix = 'home/tablet/.local/share/omarchy-ipad/' files = {prefix + 'shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + files['usr/local/libexec/arch-ipad-touch-output'] = ((PROJECT / 'desktop/configuration/touch-output').read_bytes(), 0o755) + files[prefix + 'bin/omarchy-rotation-lock'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755) + files['usr/local/bin/omarchy-rotation-lock'] = files[prefix + 'bin/omarchy-rotation-lock'] + files[prefix + 'bin/omarchy-rotate'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755) + files['usr/local/bin/omarchy-rotate'] = files[prefix + 'bin/omarchy-rotate'] files[prefix + 'bin/omarchy-system'] = ((PROJECT / 'desktop/shell/bin/omarchy-system').read_bytes(), 0o755) files['usr/local/bin/omarchy-system'] = files[prefix + 'bin/omarchy-system'] return files diff --git a/tests/test_rotate_output.py b/tests/test_rotate_output.py index d97c256..bdd3489 100644 --- a/tests/test_rotate_output.py +++ b/tests/test_rotate_output.py @@ -87,6 +87,24 @@ def test_lock_holds_the_orientation(self): self.assertEqual(self.run_script(events, locked=True), []) + def test_unlock_applies_last_settled_pose_without_another_sensor_event(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); bindir = root/'bin'; bindir.mkdir() + lock = root/'config/omarchy/rotation-lock'; lock.parent.mkdir(parents=True); lock.touch() + log = root/'randr.log' + for name, body in (('wlr-randr', RANDR), ('monitor-sensor', + monitor([(0, '=== Has accelerometer (orientation: left-up)'), (0.8, 'waiting')]))): + f=bindir/name; f.write_text(body); f.chmod(0o755) + env={**os.environ,'PATH':str(bindir)+':'+os.environ['PATH'], + 'XDG_CONFIG_HOME':str(root/'config'),'RANDR_LOG':str(log),'ROTATE_SETTLE':'0.1'} + proc=subprocess.Popen([BASH,str(SCRIPT)],env=env) + try: + threading.Event().wait(0.35); self.assertFalse(log.exists()); lock.unlink() + proc.wait(timeout=3) + self.assertEqual(log.read_text().splitlines(),['--output Unknown-1 --transform 90']) + finally: + if proc.poll() is None: proc.kill(); proc.wait() + def test_ends_with_the_compositor(self): # monitor-sensor never ends by itself; the helper must stop it once # the compositor is gone instead of outliving the session diff --git a/tests/test_tablet_port.py b/tests/test_tablet_port.py index 2c0e5c9..d3caa21 100644 --- a/tests/test_tablet_port.py +++ b/tests/test_tablet_port.py @@ -35,6 +35,14 @@ def test_home_power_overlap_in_either_order_cancels_power(self): self.assertEqual(f.events(event(116,0)),[('power',1.0)]) + def test_no_false_client_remap_and_native_touch(self): + source=(PROJECT/'desktop/shell/shell/Ui/ScreenMoveRemap.qml').read_text() + self.assertIn('readonly property bool remapping: false',source) + self.assertNotIn('Timer {',source) + tree=ET.fromstring((PROJECT/'desktop/shell/labwc-rc.xml').read_text()) + touch=next(t for t in tree.findall('touch') if t.get('deviceName')=='iPad6,11 Touchscreen') + self.assertEqual(touch.get('mapToOutput'),'eDP-1') + self.assertEqual(touch.get('mouseEmulation'),'no') From d07e4bfc21cf7486cb7af01bc104717049168073 Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 02/20] Keep rotation layers mapped and touch on the panel Assisted-by: LLM --- desktop/configuration/rotate-output | 14 ++++-- desktop/shell/bin/omarchy-rotate | 24 ++++++++++ desktop/shell/bin/omarchy-rotation-lock | 12 +++++ .../shell/default/omarchy/omarchy-menu.jsonc | 3 ++ desktop/shell/labwc-rc.xml | 1 + desktop/shell/shell/Ui/ScreenMoveRemap.qml | 44 +++---------------- release/public_root.py | 14 +++++- tests/test_rotate_output.py | 18 ++++++++ tests/test_tablet_port.py | 40 +++++++++++++++++ 9 files changed, 127 insertions(+), 43 deletions(-) create mode 100755 desktop/shell/bin/omarchy-rotate create mode 100755 desktop/shell/bin/omarchy-rotation-lock create mode 100644 tests/test_tablet_port.py diff --git a/desktop/configuration/rotate-output b/desktop/configuration/rotate-output index 82083d2..39661ac 100644 --- a/desktop/configuration/rotate-output +++ b/desktop/configuration/rotate-output @@ -23,7 +23,7 @@ transform() { apply() { local t output - [[ -e $lock ]] && return + [[ -e $lock ]] && return 1 t=$(transform "$1") || return output=$(wlr-randr | awk '/^[^[:space:]]/ {print $1; exit}') [[ -n $output ]] && wlr-randr --output "$output" --transform "$t" @@ -36,7 +36,7 @@ orientation() { esac } -exec {sensor}< <(exec monitor-sensor --accel) +exec {sensor}< <(exec stdbuf -oL monitor-sensor --accel) sensor_pid=$! watcher='' if [[ -n ${LABWC_PID:-} ]]; then @@ -55,8 +55,14 @@ while :; do continue fi ((status > 128)) || break # end of input, not a timeout - [[ $pending != "$applied" ]] && apply "$pending" && applied=$pending - pending='' + [[ -e $lock ]] && applied='' + if [[ $pending != "$applied" ]]; then + # Keep the latest settled pose while locked. Retrying the read timeout + # reapplies it after unlock even if the sensor sends no fresh event. + apply "$pending" || continue + applied=$pending + fi + # Keep the last pose to restore auto orientation after a manual lock. else IFS= read -r -u "$sensor" line || break next=$(orientation "$line") && [[ -n $next ]] && pending=$next diff --git a/desktop/shell/bin/omarchy-rotate b/desktop/shell/bin/omarchy-rotate new file mode 100755 index 0000000..512d377 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotate @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Manual rotation uses the same output protocol as auto-rotation; holds auto until unlock.""" +import json, os, subprocess, sys +from pathlib import Path +mode = sys.argv[1] if len(sys.argv)>1 else 'status' +outputs = json.loads(subprocess.check_output(['wlr-randr', '--json'], text=True)) +panel = next((o for o in outputs if o['name'] == 'eDP-1'), None) +if panel is None: + panel = next(o for o in outputs if o.get('enabled')) +current = panel['transform'] +turns = ['normal', '90', '180', '270'] +if mode == 'status': + print(current); raise SystemExit(0) +if mode == 'next': mode = turns[(turns.index(current)+1)%4] +mode = {'portrait':'normal','landscape':'90','portrait-flipped':'180','landscape-flipped':'270'}.get(mode,mode) +if mode not in turns: raise SystemExit('Usage: omarchy-rotate status|next|normal|90|180|270') +lock = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'omarchy/rotation-lock' +was_locked = lock.exists() +lock.parent.mkdir(parents=True,exist_ok=True); lock.touch() +try: + subprocess.run(['wlr-randr','--output',panel['name'],'--transform',mode],check=True,timeout=5) +except (OSError, subprocess.SubprocessError): + if not was_locked: lock.unlink(missing_ok=True) + raise diff --git a/desktop/shell/bin/omarchy-rotation-lock b/desktop/shell/bin/omarchy-rotation-lock new file mode 100755 index 0000000..744dc37 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotation-lock @@ -0,0 +1,12 @@ +#!/bin/bash +# Share the orientation lock with arch-ipad-rotate-output. +set -euo pipefail +lock=${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/rotation-lock +case ${1:-toggle} in + enabled) [[ -e $lock ]]; exit ;; + status) if [[ -e $lock ]]; then echo locked; else echo automatic; fi ;; + on) mkdir -p "$(dirname "$lock")"; touch "$lock" ;; + off) rm -f -- "$lock" ;; + toggle) if [[ -e $lock ]]; then rm -f -- "$lock"; else mkdir -p "$(dirname "$lock")"; touch "$lock"; fi ;; + *) echo 'Usage: omarchy-rotation-lock [on|off|toggle|enabled|status]' >&2; exit 2 ;; +esac diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index b337560..8cb5c0d 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -181,6 +181,8 @@ "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.rotation-lock": {"icon":"󰿆","label":"Rotation Lock","checked":"omarchy-rotation-lock enabled","action":"omarchy-rotation-lock 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\""}, @@ -450,4 +452,5 @@ "update.hardware.trackpad": {"icon":"󰟸","label":"Trackpad","action":"omarchy-launch-floating-terminal-with-presentation omarchy-restart-trackpad"}, "update.password.drive": {"icon":"","label":"Drive Encryption","action":"omarchy-launch-floating-terminal-with-presentation omarchy-drive-password"}, "update.password.user": {"icon":"","label":"User","action":"omarchy-launch-floating-terminal-with-presentation passwd"}, + "setup.tablet.rotate": {"icon": "󰑢", "label": "Rotate Once and Lock", "action": "omarchy-rotate next"}, } diff --git a/desktop/shell/labwc-rc.xml b/desktop/shell/labwc-rc.xml index 6fea059..7ec8313 100644 --- a/desktop/shell/labwc-rc.xml +++ b/desktop/shell/labwc-rc.xml @@ -117,6 +117,7 @@ + no diff --git a/desktop/shell/shell/Ui/ScreenMoveRemap.qml b/desktop/shell/shell/Ui/ScreenMoveRemap.qml index 44d2308..7c9d24d 100644 --- a/desktop/shell/shell/Ui/ScreenMoveRemap.qml +++ b/desktop/shell/shell/Ui/ScreenMoveRemap.qml @@ -1,43 +1,13 @@ import QtQuick -// Hyprland leaves an already-mapped layer surface at its old global position -// when its monitor moves within the layout: undocking disables the internal -// panel, the external monitor shifts to x=0, and long-lived surfaces such as -// the bar and background keep rendering at the old offset — or entirely -// off-screen — until they are unmapped and remapped. Watch the screen's -// origin and pulse `remapping` when it moves; the owning window folds that -// into its `visible` binding so the compositor re-places the surface at the -// monitor's new origin. +// Labwc repositions mapped layer-shell surfaces itself when outputs move or +// resize. Qt emits x/y notifications for a size-only change too. The old +// Hyprland workaround hid every panel and wallpaper 200ms later, destroying +// their layer surfaces and buffers for at least another 50ms. On a software +// renderer recreation/decoding extends that gap. Keep the public component +// interface, but never unmap a labwc layer just because its output changed. Item { - id: root - required property var window - readonly property var screen: window ? window.screen : null - - // Fold into the window's binding: visible: && !guard.remapping - property bool remapping: false - + readonly property bool remapping: false visible: false - - // A layout reshuffle can move the monitor more than once before it lands. - // Let the positions settle before the single remap pulse. - Timer { - id: settleTimer - interval: 200 - onTriggered: root.remapping = true - } - - // Hold the surface unmapped for a beat so the compositor processes the - // unmap before the remap instead of coalescing them into a no-op. - Timer { - interval: 50 - running: root.remapping - onTriggered: root.remapping = false - } - - Connections { - target: root.screen - function onXChanged() { settleTimer.restart() } - function onYChanged() { settleTimer.restart() } - } } diff --git a/release/public_root.py b/release/public_root.py index 2df5ef0..76e3f6c 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -39,6 +39,10 @@ def desktop_replacements(): 'bin/omarchy-bluetooth-device')} 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[prefix + 'shell/Ui/ScreenMoveRemap.qml'] = (PROJECT / 'desktop/shell/shell/Ui/ScreenMoveRemap.qml').read_bytes() + menu = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_text() + replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = menu.encode() + replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-output').read_bytes() return replacements @@ -121,8 +125,14 @@ 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)} + prefix = 'home/tablet/.local/share/omarchy-ipad/' + files = {prefix + 'shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + files['usr/local/libexec/arch-ipad-touch-output'] = ((PROJECT / 'desktop/configuration/touch-output').read_bytes(), 0o755) + files[prefix + 'bin/omarchy-rotation-lock'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755) + files['usr/local/bin/omarchy-rotation-lock'] = files[prefix + 'bin/omarchy-rotation-lock'] + files[prefix + 'bin/omarchy-rotate'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755) + files['usr/local/bin/omarchy-rotate'] = files[prefix + 'bin/omarchy-rotate'] + return files def system_files(): diff --git a/tests/test_rotate_output.py b/tests/test_rotate_output.py index d97c256..bdd3489 100644 --- a/tests/test_rotate_output.py +++ b/tests/test_rotate_output.py @@ -87,6 +87,24 @@ def test_lock_holds_the_orientation(self): self.assertEqual(self.run_script(events, locked=True), []) + def test_unlock_applies_last_settled_pose_without_another_sensor_event(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); bindir = root/'bin'; bindir.mkdir() + lock = root/'config/omarchy/rotation-lock'; lock.parent.mkdir(parents=True); lock.touch() + log = root/'randr.log' + for name, body in (('wlr-randr', RANDR), ('monitor-sensor', + monitor([(0, '=== Has accelerometer (orientation: left-up)'), (0.8, 'waiting')]))): + f=bindir/name; f.write_text(body); f.chmod(0o755) + env={**os.environ,'PATH':str(bindir)+':'+os.environ['PATH'], + 'XDG_CONFIG_HOME':str(root/'config'),'RANDR_LOG':str(log),'ROTATE_SETTLE':'0.1'} + proc=subprocess.Popen([BASH,str(SCRIPT)],env=env) + try: + threading.Event().wait(0.35); self.assertFalse(log.exists()); lock.unlink() + proc.wait(timeout=3) + self.assertEqual(log.read_text().splitlines(),['--output Unknown-1 --transform 90']) + finally: + if proc.poll() is None: proc.kill(); proc.wait() + def test_ends_with_the_compositor(self): # monitor-sensor never ends by itself; the helper must stop it once # the compositor is gone instead of outliving the session diff --git a/tests/test_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..269e16c --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,40 @@ +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): + + + + + + + + def test_no_false_client_remap_and_native_touch(self): + source=(PROJECT/'desktop/shell/shell/Ui/ScreenMoveRemap.qml').read_text() + self.assertIn('readonly property bool remapping: false',source) + self.assertNotIn('Timer {',source) + tree=ET.fromstring((PROJECT/'desktop/shell/labwc-rc.xml').read_text()) + touch=next(t for t in tree.findall('touch') if t.get('deviceName')=='iPad6,11 Touchscreen') + self.assertEqual(touch.get('mapToOutput'),'eDP-1') + self.assertEqual(touch.get('mouseEmulation'),'no') + + + +if __name__=='__main__': unittest.main() From e83a6287f4f4a73d8bf32aafadaefb2d0ae13da9 Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 03/20] Power, idle dim and native logout Assisted-by: LLM --- desktop/shell/bin/omarchy-system | 10 ++ .../shell/plugins/services/idle/Service.qml | 13 +++ release/public_root.py | 11 ++- .../logind.conf.d/60-omarchy-a-power.conf | 4 + .../90-omarchy-a-floor.conf | 2 + .../usr/libexec/omarchy-backlight-floor | 8 ++ system/overlay/usr/libexec/omarchy-powerd | 92 ++++++++++++++++++- tests/test_menu_shutdown.py | 2 +- tests/test_powerd.py | 36 +++++++- tests/test_tablet_port.py | 41 +++++++++ 10 files changed, 212 insertions(+), 7 deletions(-) create mode 100755 desktop/shell/bin/omarchy-system create mode 100644 system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf create mode 100755 system/overlay/usr/libexec/omarchy-backlight-floor create mode 100644 tests/test_tablet_port.py diff --git a/desktop/shell/bin/omarchy-system b/desktop/shell/bin/omarchy-system new file mode 100755 index 0000000..51f5b0f --- /dev/null +++ b/desktop/shell/bin/omarchy-system @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +case ${1:-} in + logout) exec labwc --exit ;; + lock) exec omarchy-shell lock lock ;; + suspend) ipadlinux-suspend-ready && exec systemctl suspend ;; + reboot) exec systemctl reboot ;; + shutdown|poweroff) exec systemctl poweroff ;; + *) echo 'Usage: omarchy-system logout|lock|suspend|reboot|shutdown' >&2; exit 2 ;; +esac diff --git a/desktop/shell/shell/plugins/services/idle/Service.qml b/desktop/shell/shell/plugins/services/idle/Service.qml index 9922e2b..f16e4f2 100644 --- a/desktop/shell/shell/plugins/services/idle/Service.qml +++ b/desktop/shell/shell/plugins/services/idle/Service.qml @@ -281,6 +281,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/release/public_root.py b/release/public_root.py index 2df5ef0..d4626fe 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -39,6 +39,10 @@ def desktop_replacements(): 'bin/omarchy-bluetooth-device')} 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[prefix + 'shell/plugins/services/idle/Service.qml'] = (PROJECT / 'desktop/shell/shell/plugins/services/idle/Service.qml').read_bytes() + menu = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_text() + menu = menu.replace('! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled', 'command -v ipadlinux-suspend-ready >/dev/null && ipadlinux-suspend-ready') + replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = menu.encode() return replacements @@ -121,8 +125,11 @@ 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)} + prefix = 'home/tablet/.local/share/omarchy-ipad/' + files = {prefix + 'shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + files[prefix + 'bin/omarchy-system'] = ((PROJECT / 'desktop/shell/bin/omarchy-system').read_bytes(), 0o755) + files['usr/local/bin/omarchy-system'] = files[prefix + 'bin/omarchy-system'] + return files def system_files(): 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/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf b/system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf new file mode 100644 index 0000000..33045b3 --- /dev/null +++ b/system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf @@ -0,0 +1,2 @@ +[Service] +ExecStartPost=/usr/libexec/omarchy-backlight-floor diff --git a/system/overlay/usr/libexec/omarchy-backlight-floor b/system/overlay/usr/libexec/omarchy-backlight-floor new file mode 100755 index 0000000..bf96173 --- /dev/null +++ b/system/overlay/usr/libexec/omarchy-backlight-floor @@ -0,0 +1,8 @@ +#!/usr/bin/python3 +"""Recover a dim/off saved backlight on boot, after systemd restores it.""" +from pathlib import Path +for panel in Path('/sys/class/backlight').glob('*'): + maximum = int((panel/'max_brightness').read_text()) + current = int((panel/'brightness').read_text()) + if current * 100 < maximum * 15: + (panel/'brightness').write_text(str(max(1,maximum*40//100))+'\n') diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index a6373f3..da6f2f0 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -250,6 +250,34 @@ def key_presses(data): return presses +class KeyFilter: + def __init__(self): + self.power = self.home = self.cancelled = False + + def events(self, data): + out = [] + for off in range(0, len(data)-INPUT_EVENT.size+1, INPUT_EVENT.size): + sec,usec,kind,code,value = INPUT_EVENT.unpack_from(data, off) + if kind != EV_KEY or value not in (0,1): continue + when = sec + usec/1000000 + if code == KEY_HOMEPAGE: + self.home = bool(value) + if value and self.power: self.cancelled = True + elif value: out.append(('home',when)) + elif code == KEY_POWER: + if value: + self.power, self.cancelled = True, self.home + elif self.power: + self.power = False + if not self.cancelled and not self.home: out.append(('power',when)) + return out + + +def platform_suspend_ready(): + helper = '/usr/local/bin/ipadlinux-suspend-ready' + return Path(helper).exists() and command(helper).returncode == 0 + + def command(*argv, timeout=5, **options): return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, **options) @@ -287,8 +315,11 @@ class Daemon: self.saved = None self.counters = None self.sleep_started = None + self.key_filter = KeyFilter() 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 @@ -405,12 +436,21 @@ 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: - self.saved = current + self.saved = self.dim_saved or current + self.dim_saved = self.dim_level = None (panel / 'brightness').write_text('0\n') def do_light(self, origin): @@ -481,6 +521,10 @@ class Daemon: def request_suspend(self): try: + 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"': @@ -503,6 +547,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') @@ -552,6 +598,48 @@ class Daemon: connection.sendall(b'lit\n' if self.machine.state == ON else b'dark\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.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.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 == 'cover-suspend': + if self.machine.state == DARK: + self.machine.no_suspend = False + self.feed('not-playing') + connection.sendall(b'ok\n') + elif request == 'cover-close': + self.feed('idle') + # Lock, darken and stay dark without automatic suspend. + # Cover polarity is opt-in; sleep remains separately qualified. + if self.machine.state == SETTLING: + self.settle_at = None + self.machine.state = DARK + self.machine.no_suspend = True + connection.sendall(b'ok\n') elif request in ('idle', 'wake'): self.feed(request) connection.sendall(b'ok\n') @@ -641,7 +729,7 @@ class Daemon: # Keys first: a press that woke the tablet decides before the sleep's end is seen. for fd in [fd for fd in readable if fd in self.keys]: try: - for name, when in key_presses(os.read(fd, INPUT_EVENT.size * 64)): + for name, when in self.key_filter.events(os.read(fd, INPUT_EVENT.size * 64)): # Pressed before this wake's panel lit: that wake took care of it. if self.machine.inflight and self.lit_at is not None and when <= self.lit_at: log(f't={when:.6f} {name}: consumed by the wake') diff --git a/tests/test_menu_shutdown.py b/tests/test_menu_shutdown.py index 329ede7..568be7d 100644 --- a/tests/test_menu_shutdown.py +++ b/tests/test_menu_shutdown.py @@ -36,7 +36,7 @@ def test_shutdown_requests_normal_system_poweroff(self): self.check_system_action('shutdown', 'poweroff') def test_reboot_requests_normal_system_reboot(self): - self.check_system_action('reboot', 'reboot') + self.check_system_action("reboot", "reboot") if __name__ == '__main__': diff --git a/tests/test_powerd.py b/tests/test_powerd.py index 30a4ff9..a4b100f 100644 --- a/tests/test_powerd.py +++ b/tests/test_powerd.py @@ -364,7 +364,7 @@ def setUp(self): self.unit_state = 'activating' for target, value in (('SETTLE_SECONDS', 0.1), ('STATS', self.stats), ('BACKLIGHTS', self.work / 'backlight'), ('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) @@ -410,7 +410,7 @@ def set_stats(self, success, fail): def press(self, code=116, when=None): when = time.monotonic() if when is None else when - os.write(self.key_write, powerd.INPUT_EVENT.pack(int(when), int(when % 1 * 1e6), 1, code, 1)) + os.write(self.key_write, b''.join(powerd.INPUT_EVENT.pack(int(when), int(when % 1 * 1e6), 1, code, v) for v in (1,0))) def wait(self, condition, seconds=3): deadline = time.monotonic() + seconds @@ -430,6 +430,38 @@ def ask(self, request): connection.sendall(request.encode() + b'\n') return connection.recv(64).decode().strip() + 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_cover_close_locks_without_automatic_suspend_and_open_wakes(self): + self.ask('cover-close') + self.wait(lambda: self.daemon.machine.lock_ok) + self.assertEqual(self.ask('status'), 'DARK') + self.assertEqual(self.brightness(), '0') + time.sleep(0.2) + self.assertNotIn(('systemctl', 'suspend'), self.requests) + self.ask('wake'); 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_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..2c0e5c9 --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,41 @@ +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): + + + + + + def test_home_power_overlap_in_either_order_cancels_power(self): + event=lambda code,v: p.INPUT_EVENT.pack(1,0,1,code,v) + for codes in ([(116,1),(172,1),(172,0),(116,0)],[(172,1),(116,1),(172,0),(116,0)]): + f=p.KeyFilter(); result=[] + for code,v in codes: result+=f.events(event(code,v)) + self.assertNotIn('power',[name for name,when in result]) + f=p.KeyFilter() + self.assertEqual(f.events(event(116,1)),[]) + self.assertEqual(f.events(event(116,0)),[('power',1.0)]) + + + + + +if __name__=='__main__': unittest.main() From c9beeb9fa28e61fb9ceb6b212e373525f83bbfb9 Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 04/20] J71t Mac current profile opt-in Assisted-by: LLM --- docs/power-and-lock.md | 30 ++++++++++++++++++++ system/overlay/etc/orchard/mac-usb-current | 1 + tests/test_mac_usb_current.py | 18 ++++++++++++ tests/test_tablet_port.py | 33 ++++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 system/overlay/etc/orchard/mac-usb-current create mode 100644 tests/test_mac_usb_current.py create mode 100644 tests/test_tablet_port.py diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 3da346d..3514450 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -190,3 +190,33 @@ last frame; the lock screen's own update follows 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). + +## J71t Mac USB charging candidate + +`system/overlay/etc/orchard/mac-usb-current` opts J71t into the matching +Orchard root initrd's Mac-current compatibility profile. It selects +05ac:1014 with ACM+NCM and 500 mA descriptor power. Only a real host vendor +grant allows the kernel charger to ramp higher, within board/pack limits. +This requires the 2026-11-08 hardware changes and a freshly matched initrd; +the file alone has no effect with older hardware bundles. No systemd gadget +service rewrites the initrd's gadget or serial during switch-root. + +This candidate has host tests but no device charging result. Unlock the Mac, +approve the new accessory and, if needed, reconnect it. The BOOT serial is +retained for Orchard discovery and can require fresh authorization each boot. +To disable the profile, remove `/etc/orchard/mac-usb-current` and reboot. +No PMIC current limit or host authorization policy is forced by this file. + +The USB recovery service recognizes the bound NCM or ECM function. In addition +to its carrier-loss handler, it probes 172.16.42.2 through usb0 while carrier +and UDC configuration remain up. After a peer has replied once, 15 seconds +of failed replies trigger an `ip link` down/up cycle, without rebinding UDC +or disturbing ACM/charging. It logs `usb0-stall-bounce` to journal and kmsg, +including packet/drop counters. There is a 60-second cooldown and at most +three attempts without a new successful reply. Suspended, unconfigured, +unbound, admin-down and VBUS-absent links do not trigger this fallback; +unknown charging current_max does not prevent network recovery. Hosts that +never answer ICMP do not arm it. All recovery remains disabled on NBD roots. +The runner's transient oi-usb0-watchdog should not run alongside this service +for an acceptance test; count all bounce markers and require zero for a clean +NCM sustained-transfer result. diff --git a/system/overlay/etc/orchard/mac-usb-current b/system/overlay/etc/orchard/mac-usb-current new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/system/overlay/etc/orchard/mac-usb-current @@ -0,0 +1 @@ +1 diff --git a/tests/test_mac_usb_current.py b/tests/test_mac_usb_current.py new file mode 100644 index 0000000..c6f718e --- /dev/null +++ b/tests/test_mac_usb_current.py @@ -0,0 +1,18 @@ +"""The system opts in through data, leaving USB ownership to the initrd.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +class MacUsbCurrent(unittest.TestCase): + def test_profile_is_exact_opt_in_for_matching_hardware(self): + self.assertEqual((ROOT/'system/overlay/etc/orchard/mac-usb-current').read_bytes(), b'1\n') + self.assertIn('freshly matched initrd', (ROOT/'docs/power-and-lock.md').read_text()) + + def test_system_root_includes_the_opt_in(self): + from release.public_root import system_files + data, mode = system_files()['etc/orchard/mac-usb-current'] + self.assertEqual((data, mode), (b'1\n', 0o644)) + +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..9e73609 --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,33 @@ +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 + + + + + + + + + + +if __name__=='__main__': unittest.main() From ac329853ccfda4c80ef28cdbf46a9f17b81b21fa Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 05/20] RAM-sized zram and bounded journal Assisted-by: LLM --- desktop/configuration/login-session | 1 + .../etc/sysctl.d/20-omarchy-a-console.conf | 2 ++ .../etc/sysctl.d/90-omarchy-a-memory.conf | 5 +++ .../journald.conf.d/90-omarchy-a-memory.conf | 4 +++ .../system.conf.d/90-omarchy-a-quiet.conf | 2 ++ .../zram-generator.conf.d/99-omarchy-a.conf | 3 ++ .../usr/lib/systemd/zram-generator.conf | 5 ++- .../usr/lib/tmpfiles.d/omarchy-a-memory.conf | 3 ++ tests/test_tablet_port.py | 33 +++++++++++++++++++ 9 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 system/overlay/etc/sysctl.d/20-omarchy-a-console.conf create mode 100644 system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf create mode 100644 system/overlay/etc/systemd/journald.conf.d/90-omarchy-a-memory.conf create mode 100644 system/overlay/etc/systemd/system.conf.d/90-omarchy-a-quiet.conf create mode 100644 system/overlay/etc/systemd/zram-generator.conf.d/99-omarchy-a.conf create mode 100644 system/overlay/usr/lib/tmpfiles.d/omarchy-a-memory.conf create mode 100644 tests/test_tablet_port.py diff --git a/desktop/configuration/login-session b/desktop/configuration/login-session index f8fe96e..4f26750 100644 --- a/desktop/configuration/login-session +++ b/desktop/configuration/login-session @@ -19,6 +19,7 @@ export WLR_RENDERER=pixman QT_QUICK_BACKEND=software QT_QPA_PLATFORM=wayland # Match pinned upstream Omarchy's GTK-backed Qt platform theme. export QT_QPA_PLATFORMTHEME=gtk3 export OMARCHY_TOUCH_MODE=1 +export LP_NUM_THREADS=${LP_NUM_THREADS:-2} export LIBGL_ALWAYS_SOFTWARE=1 LIBSEAT_BACKEND=seatd MOZ_ENABLE_WAYLAND=1 export PATH=/usr/local/bin:/usr/bin # Services such as PipeWire and WirePlumber use the same user manager/bus as 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..1897028 --- /dev/null +++ b/system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf @@ -0,0 +1,5 @@ +# Owner-qualified 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/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/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/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/tests/test_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..9e73609 --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,33 @@ +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 + + + + + + + + + + +if __name__=='__main__': unittest.main() From 1bdc70d47c2483e9fa56ef05d1edfb5a76e17179 Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:42:23 +0100 Subject: [PATCH 06/20] Optional smooth automatic brightness Assisted-by: LLM --- desktop/configuration/auto-brightness | 862 ++++++++++++++++++ desktop/configuration/labwc-autostart | 4 + desktop/shell/bin/omarchy-auto-brightness | 2 + desktop/shell/default/i18n/en/shell.json | 2 + .../shell/default/omarchy/omarchy-menu.jsonc | 1 + .../shell/plugins/panels/monitor/Panel.qml | 38 + desktop/stage.py | 2 + docs/tablet-sensors.md | 25 + release/public_root.py | 5 + tests/test_auto_brightness.py | 111 +++ 10 files changed, 1052 insertions(+) create mode 100755 desktop/configuration/auto-brightness create mode 100755 desktop/shell/bin/omarchy-auto-brightness create mode 100644 docs/tablet-sensors.md create mode 100644 tests/test_auto_brightness.py diff --git a/desktop/configuration/auto-brightness b/desktop/configuration/auto-brightness new file mode 100755 index 0000000..65cbb32 --- /dev/null +++ b/desktop/configuration/auto-brightness @@ -0,0 +1,862 @@ +#!/usr/bin/env python3 +"""Ambient-light auto-brightness for omarchy-a. + +Ported from linux-a9's tested ALS curve, smoothing, hysteresis, ramp and manual +pause policy. 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 +# Owner tuning 2026-11-08: the linux-a9 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 index b6bd407..71f21d0 100644 --- a/desktop/configuration/labwc-autostart +++ b/desktop/configuration/labwc-autostart @@ -9,8 +9,12 @@ /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/tablet/.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/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/default/i18n/en/shell.json b/desktop/shell/default/i18n/en/shell.json index 1dc5da4..32ffbc5 100644 --- a/desktop/shell/default/i18n/en/shell.json +++ b/desktop/shell/default/i18n/en/shell.json @@ -324,6 +324,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 8cb5c0d..144d46e 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -182,6 +182,7 @@ // Setup "setup.tablet": {"icon":"󰦧","label":"Tablet"}, + "setup.tablet.auto-brightness": {"icon":"󰃠","label":"Auto Brightness","when":"omarchy-auto-brightness available","checked":"omarchy-auto-brightness enabled","action":"omarchy-auto-brightness toggle"}, "setup.tablet.rotation-lock": {"icon":"󰿆","label":"Rotation Lock","checked":"omarchy-rotation-lock enabled","action":"omarchy-rotation-lock 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\""}, diff --git a/desktop/shell/shell/plugins/panels/monitor/Panel.qml b/desktop/shell/shell/plugins/panels/monitor/Panel.qml index a8d1889..b9b1289 100644 --- a/desktop/shell/shell/plugins/panels/monitor/Panel.qml +++ b/desktop/shell/shell/plugins/panels/monitor/Panel.qml @@ -15,9 +15,19 @@ 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 function refresh() { if (!readProc.running && !writeProc.running) readProc.running = true + if (!autoReadProc.running && !autoWriteProc.running) autoReadProc.running = true + } + 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 setBrightness(value) { if (!brightnessAvailable) return @@ -65,6 +75,24 @@ 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: autoWriteProc + stdout: StdioCollector { waitForEnd: true } + onRunningChanged: if (!running) root.refresh() + } + BarIconButton { id: button anchors.fill: parent @@ -133,6 +161,16 @@ 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) + } } } } diff --git a/desktop/stage.py b/desktop/stage.py index 411db39..2794437 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", @@ -126,6 +127,7 @@ def main(): (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/tablet-sensors.md b/docs/tablet-sensors.md new file mode 100644 index 0000000..34315d2 --- /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 linux-a9 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 d21cc85..38d9906 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -45,6 +45,8 @@ def desktop_replacements(): menu = menu.replace('! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled', 'command -v ipadlinux-suspend-ready >/dev/null && ipadlinux-suspend-ready') replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = menu.encode() replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-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() return replacements @@ -129,7 +131,10 @@ def labwc_files(directory, architecture='aarch64'): def desktop_additions(): prefix = 'home/tablet/.local/share/omarchy-ipad/' files = {prefix + 'shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + files['usr/local/libexec/arch-ipad-auto-brightness'] = ((PROJECT / 'desktop/configuration/auto-brightness').read_bytes(), 0o755) files['usr/local/libexec/arch-ipad-touch-output'] = ((PROJECT / 'desktop/configuration/touch-output').read_bytes(), 0o755) + files[prefix + 'bin/omarchy-auto-brightness'] = ((PROJECT / 'desktop/shell/bin/omarchy-auto-brightness').read_bytes(), 0o755) + files['usr/local/bin/omarchy-auto-brightness'] = files[prefix + 'bin/omarchy-auto-brightness'] files[prefix + 'bin/omarchy-rotation-lock'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755) files['usr/local/bin/omarchy-rotation-lock'] = files[prefix + 'bin/omarchy-rotation-lock'] files[prefix + 'bin/omarchy-rotate'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755) diff --git a/tests/test_auto_brightness.py b/tests/test_auto_brightness.py new file mode 100644 index 0000000..b796a70 --- /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): + # Owner 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() From 84bf7a196783479552137386b83361fa05b03d0b Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 07/20] shell: persist display scale and gather off-screen windows Assisted-by: LLM --- desktop/configuration/scale-output | 9 +++- desktop/shell/bin/omarchy-display-scale | 21 +++++++++ desktop/shell/labwc-rc.xml | 12 +++++ .../shell/plugins/panels/monitor/Panel.qml | 44 +++++++++++++++++++ release/public_root.py | 3 ++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100755 desktop/shell/bin/omarchy-display-scale diff --git a/desktop/configuration/scale-output b/desktop/configuration/scale-output index 587dd1b..91866dc 100644 --- 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-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/labwc-rc.xml b/desktop/shell/labwc-rc.xml index 7ec8313..d7d67df 100644 --- a/desktop/shell/labwc-rc.xml +++ b/desktop/shell/labwc-rc.xml @@ -93,6 +93,18 @@ + + + + + + + + + + + diff --git a/desktop/shell/shell/plugins/panels/monitor/Panel.qml b/desktop/shell/shell/plugins/panels/monitor/Panel.qml index b9b1289..3c0d7f8 100644 --- a/desktop/shell/shell/plugins/panels/monitor/Panel.qml +++ b/desktop/shell/shell/plugins/panels/monitor/Panel.qml @@ -18,10 +18,20 @@ Panel { // 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: "" 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 + } + function setScale(value) { + if (scaleWriteProc.running || value === root.scaleValue) return + root.scaleValue = value + scaleWriteProc.command = ["omarchy-display-scale", value] + scaleWriteProc.running = true } function setAuto(on) { if (!autoAvailable || autoWriteProc.running) return @@ -93,6 +103,23 @@ Panel { 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 @@ -161,6 +188,23 @@ Panel { onMoved: function(v) { root.setBrightness(v) } onReleased: function(v) { root.setBrightness(v) } } + 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) } + } Toggle { width: parent.width visible: root.autoAvailable diff --git a/release/public_root.py b/release/public_root.py index 38d9906..52862df 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -47,6 +47,7 @@ def desktop_replacements(): replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-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['usr/local/libexec/arch-ipad-scale-output'] = (PROJECT / 'desktop/configuration/scale-output').read_bytes() return replacements @@ -141,6 +142,8 @@ def desktop_additions(): files['usr/local/bin/omarchy-rotate'] = files[prefix + 'bin/omarchy-rotate'] files[prefix + 'bin/omarchy-system'] = ((PROJECT / 'desktop/shell/bin/omarchy-system').read_bytes(), 0o755) files['usr/local/bin/omarchy-system'] = files[prefix + 'bin/omarchy-system'] + files[prefix + 'bin/omarchy-display-scale'] = ((PROJECT / 'desktop/shell/bin/omarchy-display-scale').read_bytes(), 0o755) + files['usr/local/bin/omarchy-display-scale'] = files[prefix + 'bin/omarchy-display-scale'] return files From 4724b8157938c1379d081649f1f92040982bf9ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 08/20] Rotation: manual rotation and rotation lock next to automatic rotation Automatic rotation could not be overridden: there was no manual rotation or lock in the menu. Add omarchy-rotate with rotate and lock commands, menu entries for them, and the release root wiring. Assisted-by: LLM --- desktop/shell/bin/omarchy-rotate | 24 +++++++++++++++++++ .../shell/default/omarchy/omarchy-menu.jsonc | 2 ++ release/public_root.py | 13 ++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100755 desktop/shell/bin/omarchy-rotate 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/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index de7b289..28043c5 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -184,6 +184,8 @@ "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.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/release/public_root.py b/release/public_root.py index 63a2d35..45a909c 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -42,6 +42,7 @@ 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[prefix + 'default/omarchy/omarchy-menu.jsonc'] = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_bytes() return replacements @@ -129,10 +130,14 @@ 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), + '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(): From cc58e93e2d444f5671531e04b81385de78f1fcd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 09/20] Power: cover lock, idle dim and suspend readiness The tablet had no lock on cover close, no dim before idle lock, and suspend was offered even when the kernel could not complete it. Lock on cover close, dim before the idle lock, disable hibernation, and gate suspend on ipadlinux-suspend-ready, the kernel's readiness report. Documented in docs/power-and-lock.md. Assisted-by: LLM --- .../shell/default/omarchy/omarchy-menu.jsonc | 2 +- .../shell/plugins/services/idle/Service.qml | 13 ++ desktop/stage.py | 6 - docs/power-and-lock.md | 75 ++++++++- release/public_root.py | 2 +- .../logind.conf.d/60-omarchy-a-power.conf | 4 + .../60-omarchy-a-no-hibernate.conf | 4 +- .../60-omarchy-a-qualification.conf | 4 + system/overlay/usr/libexec/omarchy-powerd | 80 +++++++++- .../usr/local/bin/ipadlinux-suspend-ready | 79 ++++++++++ tests/test_greeter_staging.py | 4 + tests/test_menu_shutdown.py | 11 +- tests/test_powerd.py | 68 +++++++- tests/test_suspend_ready.py | 148 ++++++++++++++++++ tests/test_tablet_port.py | 38 +++++ 15 files changed, 518 insertions(+), 20 deletions(-) create mode 100644 system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf create mode 100755 system/overlay/usr/local/bin/ipadlinux-suspend-ready create mode 100644 tests/test_suspend_ready.py create mode 100644 tests/test_tablet_port.py diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index 28043c5..cf0eacc 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. 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..b0c1ac0 100644 --- a/desktop/stage.py +++ b/desktop/stage.py @@ -123,12 +123,6 @@ 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) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 44c0cee..a07981c 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -1,11 +1,11 @@ # Power, lock and sleep: the state model -`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one owner +`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one maintainer 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 public device qualification, then kernel admission; +this model decides when. Unqualified boards retain dim, blank and wake. ## Requirements @@ -29,7 +29,7 @@ orchard `docs/bring-up/suspend.md`); this model decides when. 9. Being plugged in lights the screen, as on any tablet, asleep or awake. Being unplugged wakes the kernel (orchard) but stays dark and sleeps again. -## One owner +## One maintainer A system service, because it serves the greeter and the desktop alike, and because system services thaw with the kernel while `user.slice` stays frozen a @@ -217,3 +217,70 @@ 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). + +## J71t Mac USB current profile + +`/etc/orchard/mac-usb-current` opts into Orchard's J71t Mac-current profile. +Merge and install the matching Orchard #108 hardware bundle first. Gadget +identity, host-grant handling and board/pack limits remain Orchard's policy; +this flag does not override the charger limits or host authorization. + +Pair with Orchard's stable USB serial path (#110). No root service should +rewrite the initrd's identity during switch-root. Cold and warm charging, +stable serial and USB traffic require exact-revision device checks. The +previous warm 500 mA observation remains unresolved; the flag alone does not +prove a higher current grant. Remove the flag and reboot to disable the profile. + +Use a freshly matched initrd from the integrated Orchard kernel bundle so its +stable USB identity and current-grant handling travel with the kernel modules. + +## Suspend qualification and readiness + +The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both +fresh roots and public-root refreshes. It first requires +`/usr/lib/orchard/device-feature suspend working`: Orchard's platform layer ships +that helper and `device-features.json`, matched against live DT compatibles. +Missing, unknown, untested, broken or ambiguous device data refuses sleep before +any kernel admission check. J71t currently declares suspend `untested`. +After qualification, it reads DeviceTree identity and checks admission below; +other qualified boards keep their existing logind/kernel path. 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. + +On J71t it reads the kernel's admission query, `/sys/power/suspend_ready` +(Orchard's suspend topic): `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: an older kernel cannot prove +admission. 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 `cover-suspend`); a refusal leaves the +screen dark until touch/Home/Power wake, without retrying sleep. Cover-close +itself only locks/blanks. 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 qualification override is installed, so a board +whose registry changes to `working` can sleep after kernel admission succeeds. + +Device verification on a rebuilt image: + +```sh +timeout -k 5 5 /usr/lib/orchard/device-feature suspend working +timeout -k 5 10 /usr/local/bin/ipadlinux-suspend-ready +timeout -k 5 10 systemctl suspend +``` + +On the current unqualified J71t all three must refuse; the journal must name +qualification rather than a kernel suspend attempt, and suspend success +counters must not advance. Check that the final session menu omits Suspend. +Use `timeout -k 5 5 omarchy-powerctl idle`, cover-close/cover-suspend, and a +short Power press separately; each must blank without sleep and touch/Power +must wake. Dim/undim must still restore the previous brightness. Repeat after +reboot with the installed platform data; do not edit qualification on hardware +just to exercise sleep. These are pending device checks, not host-test results. diff --git a/release/public_root.py b/release/public_root.py index 45a909c..5780274 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -147,7 +147,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/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..e0f8135 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 +# qualified at runtime by systemd-suspend.service, using platform device data. [Sleep] AllowHibernation=no AllowHybridSleep=no 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..876e8ca --- /dev/null +++ b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf @@ -0,0 +1,4 @@ +# Enforce the same qualification/admission policy 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/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index 6ebf69d..a45d119 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -341,6 +341,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) @@ -373,7 +384,6 @@ def as_user(user, *argv, timeout=5): class Daemon: def __init__(self): self.machine = Machine() - self.buttons = Buttons() self.events = queue.Queue() self.wake_read, self.wake_write = os.pipe() os.set_blocking(self.wake_read, False) @@ -385,8 +395,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 @@ -503,13 +516,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): @@ -646,6 +668,12 @@ class Daemon: def request_suspend(self): try: + # Shared with the final menu and systemd's direct suspend guard: + # public qualification first, then non-reserving kernel admission. + 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"': @@ -668,6 +696,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') @@ -727,6 +757,48 @@ 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 == 'cover-suspend': + if self.machine.state == DARK: + self.machine.no_suspend = False + self.feed('not-playing') + connection.sendall(b'ok\n') + elif request == 'cover-close': + self.feed('lock') + # Lock, darken and stay dark without automatic suspend. + # Cover polarity is opt-in; sleep remains separately qualified. + if self.machine.state == SETTLING: + self.settle_at = None + self.machine.state = DARK + self.machine.no_suspend = True + connection.sendall(b'ok\n') elif request in ('idle', 'wake', 'lock'): self.feed(request) connection.sendall(b'ok\n') @@ -803,7 +875,7 @@ class Daemon: self.restore_brightness() panel = self.backlight() if panel and int((panel / 'brightness').read_text()) == 0: - # State ON after a restart: never dark without an owner. + # State ON after a restart: never dark without an maintainer. self.key_time = time.monotonic() self.do_light(ON) server = self.open_socket() 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..2127c8f --- /dev/null +++ b/system/overlay/usr/local/bin/ipadlinux-suspend-ready @@ -0,0 +1,79 @@ +#!/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. Public device data must first +qualify suspend as working. Missing data/helper and all other states refuse. +On J71t admission comes from the kernel's own query, +/sys/power/suspend_ready ("ready", or +"not-ready "); PM callback presence, a loaded module and +power/state's "freeze" are not proof of admission. +""" +from pathlib import Path +import sys +import subprocess + +COMPATIBLE = Path('/sys/firmware/devicetree/base/compatible') +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 ('J71t kernel has no suspend readiness query (/sys/power/suspend_ready); ' + 'GPU/VXD/SEP 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 refusal(compatible): + if not compatible or not compatible.endswith(b'\0'): + return 'cannot identify the board from a valid DeviceTree compatible list' + if b'apple,j71t' in compatible.split(b'\0'): + # The kernel runs every admission provider's check and every early + # precheck without reserving; a later suspend still checks again. + return admission() + # Other qualified boards keep logind + the kernel's existing suspend path. + return None + + +def qualification(): + """Public platform data, never inferred from kernel admission.""" + try: + result = subprocess.run(['/usr/lib/orchard/device-feature', 'suspend', 'working'], + capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError) as error: + return f'cannot verify suspend qualification: {error}' + if result.returncode: + return 'public device registry does not qualify suspend as working' + return None + + +def main(argv=None): + if argv is None: + argv = sys.argv[1:] + if argv: + print('Usage: ipadlinux-suspend-ready', file=sys.stderr) + return 2 + try: + reason = qualification() + if reason is None: + reason = refusal(COMPATIBLE.read_bytes()) + except OSError as error: + reason = f'cannot read board identity: {error}' + 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_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..65245ab 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()) @@ -176,7 +185,7 @@ def test_the_shell_answers_both_calls(self): class PowerOwnerTests(unittest.TestCase): def test_lock_and_wake_only_feed_omarchy_powerd(self): - # docs/power-and-lock.md: one owner of the lock, the screen and sleep. + # docs/power-and-lock.md: one maintainer of the lock, the screen and sleep. for name, verb in (('omarchy-system-lock', 'lock'), ('omarchy-system-wake', 'wake')): lines = (BIN / name).read_text().splitlines() code = [line for line in lines if line and not line.startswith('#')] diff --git a/tests/test_powerd.py b/tests/test_powerd.py index 8b4edae..8770501 100644 --- a/tests/test_powerd.py +++ b/tests/test_powerd.py @@ -556,7 +556,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) @@ -625,6 +625,72 @@ 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_cover_close_locks_without_automatic_suspend_and_open_wakes(self): + self.ask('cover-close') + self.wait(lambda: self.daemon.machine.lock_ok) + self.assertEqual(self.ask('status'), 'DARK') + self.assertEqual(self.brightness(), '0') + time.sleep(0.2) + self.assertNotIn(('systemctl', 'suspend'), self.requests) + self.ask('wake'); self.assertEqual(self.brightness(), '1563') + + def test_unqualified_power_idle_and_cover_suspend_stay_dark_and_can_wake(self): + with patch.object(powerd, 'platform_suspend_ready', return_value=False): + for entry in ('power', 'idle', 'cover-suspend'): + with self.subTest(entry=entry): + self.requests.clear() + if entry == 'power': + self.press() + elif entry == 'idle': + self.ask('idle') + else: + self.ask('cover-close') + self.ask('cover-suspend') + 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..d6cbbdb --- /dev/null +++ b/tests/test_suspend_ready.py @@ -0,0 +1,148 @@ +"""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, identity, argv=(), readiness=None): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'compatible' + query = Path(directory) / 'suspend_ready' + if identity is not None: + path.write_bytes(identity) + if readiness is not None: + query.write_bytes(readiness) + out, err = io.StringIO(), io.StringIO() + with patch.object(ready, 'qualification', return_value=None), patch.object(ready, 'COMPATIBLE', path), patch.object(ready, 'READINESS', query), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = ready.main(argv) + if identity is not None: + self.assertEqual(path.read_bytes(), identity) + if readiness is not None: + self.assertEqual(query.read_bytes(), readiness) + return code, out.getvalue(), err.getvalue() + + def test_qualification_precedes_identity_and_kernel_admission(self): + with patch.object(ready, 'qualification', return_value='not qualified'), \ + patch.object(ready, 'refusal') as admission, \ + patch.object(ready.Path, 'read_bytes') as identity, \ + contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(ready.main([]), 1) + self.assertIn('not qualified', err.getvalue()) + identity.assert_not_called() + admission.assert_not_called() + + def test_qualification_uses_public_helper_and_fails_closed(self): + with patch.object(ready.subprocess, 'run') as run: + for status in (0, 1, 2): + run.return_value = subprocess.CompletedProcess([], status) + self.assertEqual(ready.qualification() is None, status == 0) + run.assert_called_with(['/usr/lib/orchard/device-feature', 'suspend', 'working'], + capture_output=True, text=True, timeout=5) + for error in (FileNotFoundError(), PermissionError(), subprocess.TimeoutExpired('helper', 5)): + run.side_effect = error + self.assertIn('cannot verify', ready.qualification()) + + 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_j71t_refuses_without_an_admission_query(self): + for identity in (b'apple,j71t\0apple,s8003\0', b'apple,j71t\0apple,s8000\0'): + code, out, err = self.invoke(identity) + self.assertEqual((code, out), (1, '')) + self.assertIn('no suspend readiness query', err) + self.assertIn('GPU/VXD/SEP', err) + + def test_j71t_follows_the_kernel_admission_query(self): + identity = b'apple,j71t\0apple,s8003\0' + self.assertEqual(self.invoke(identity, readiness=b'ready\n'), (0, '', '')) + code, out, err = self.invoke(identity, readiness=b'not-ready 201000000.gpu -16\n') + self.assertEqual((code, out), (1, '')) + self.assertIn('refused by 201000000.gpu (-16)', err) + code, _, err = self.invoke(identity, readiness=b'not-ready apple_h9p_sleep_precheck -16\n') + self.assertEqual(code, 1) + self.assertIn('apple_h9p_sleep_precheck', err) + + def test_j71t_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(b'apple,j71t\0', readiness=answer) + self.assertEqual((code, out), (1, '')) + self.assertIn('Suspend unavailable:', err) + + def test_other_boards_ignore_the_kernel_query(self): + self.assertEqual(self.invoke(b'apple,j71b\0', readiness=b'not-ready x -16\n'), (0, '', '')) + + def test_other_boards_keep_logind_and_kernel_path(self): + for board in (b'apple,j71b', b'apple,j72b', b'apple,j71s', b'raspberrypi,4-model-b'): + self.assertEqual(self.invoke(board + b'\0apple,arm-platform\0'), (0, '', '')) + + def test_compatible_is_an_exact_nul_delimited_match(self): + self.assertEqual(self.invoke(b'apple,j71t-extra\0'), (0, '', '')) + self.assertEqual(self.invoke(b'apple,s8003\0apple,j71t\0')[0], 1) + + def test_missing_or_malformed_identity_fails_closed(self): + for identity in (None, b'', b'apple,j71t'): + code, out, err = self.invoke(identity) + self.assertEqual((code, out), (1, '')) + self.assertIn('Suspend unavailable:', err) + + def test_no_force_or_alternate_sysfs_option(self): + self.assertEqual(self.invoke(b'apple,j71t\0', ['--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() From 674577a4ba24600b8b268410aede690d634de1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 10/20] Brightness: optional smooth automatic brightness The panel kept a fixed brightness; there was no automatic brightness from the ambient-light sensor. Add an optional, off-by-default automatic brightness daemon (generic IIO lux or iio-sensor-proxy, no board calibration), a toggle in the Display panel and its CLI. Assisted-by: LLM --- desktop/configuration/auto-brightness | 862 ++++++++++++++++++ desktop/configuration/labwc-autostart | 4 + desktop/shell/bin/omarchy-auto-brightness | 2 + desktop/shell/default/i18n/en/shell.json | 2 + .../shell/default/omarchy/omarchy-menu.jsonc | 1 + .../shell/plugins/panels/monitor/Panel.qml | 86 ++ desktop/stage.py | 2 + docs/tablet-sensors.md | 25 + release/public_root.py | 4 + tests/test_auto_brightness.py | 111 +++ 10 files changed, 1099 insertions(+) create mode 100755 desktop/configuration/auto-brightness mode change 100644 => 100755 desktop/configuration/labwc-autostart create mode 100755 desktop/shell/bin/omarchy-auto-brightness create mode 100644 docs/tablet-sensors.md create mode 100644 tests/test_auto_brightness.py 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/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/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 cf0eacc..6e7d121 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -186,6 +186,7 @@ // 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/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/stage.py b/desktop/stage.py index b0c1ac0..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", @@ -127,6 +128,7 @@ def main(): (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/tablet-sensors.md b/docs/tablet-sensors.md new file mode 100644 index 0000000..4d9b845 --- /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 maintainer 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 5780274..5cb3cc6 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -42,6 +42,8 @@ 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[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 @@ -135,6 +137,8 @@ def desktop_additions(): '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), '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), } diff --git a/tests/test_auto_brightness.py b/tests/test_auto_brightness.py new file mode 100644 index 0000000..aca256a --- /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): + # maintainer 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() From 5fbc38a97e00338cd50ffb0d640c60d8c6514d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 11/20] J71t: Mac current profile opt-in Orchard's guarded Mac current profile needs a device-side opt-in. Opt J71t into the Mac current profile through /etc/orchard/mac-usb-current, with a test. Assisted-by: LLM --- system/overlay/etc/orchard/mac-usb-current | 1 + tests/test_mac_usb_current.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 system/overlay/etc/orchard/mac-usb-current create mode 100644 tests/test_mac_usb_current.py diff --git a/system/overlay/etc/orchard/mac-usb-current b/system/overlay/etc/orchard/mac-usb-current new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/system/overlay/etc/orchard/mac-usb-current @@ -0,0 +1 @@ +1 diff --git a/tests/test_mac_usb_current.py b/tests/test_mac_usb_current.py new file mode 100644 index 0000000..c6f718e --- /dev/null +++ b/tests/test_mac_usb_current.py @@ -0,0 +1,18 @@ +"""The system opts in through data, leaving USB ownership to the initrd.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +class MacUsbCurrent(unittest.TestCase): + def test_profile_is_exact_opt_in_for_matching_hardware(self): + self.assertEqual((ROOT/'system/overlay/etc/orchard/mac-usb-current').read_bytes(), b'1\n') + self.assertIn('freshly matched initrd', (ROOT/'docs/power-and-lock.md').read_text()) + + def test_system_root_includes_the_opt_in(self): + from release.public_root import system_files + data, mode = system_files()['etc/orchard/mac-usb-current'] + self.assertEqual((data, mode), (b'1\n', 0o644)) + +if __name__ == '__main__': + unittest.main() From 0be01260c0ddcb3e5693f189c483523670967b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 12/20] Shell: persist display scale and gather off-screen windows A chosen display scale was lost at the next login, and after a scale or rotation change windows could end up off screen. Add omarchy-display-scale and a Display panel picker, restore the saved scale at login, and gather windows on screen with Super+Shift+G. Assisted-by: LLM --- desktop/configuration/scale-output | 9 ++++++++- desktop/shell/bin/omarchy-display-scale | 21 +++++++++++++++++++++ desktop/shell/labwc-rc.xml | 12 ++++++++++++ release/public_root.py | 2 ++ 4 files changed, 43 insertions(+), 1 deletion(-) mode change 100644 => 100755 desktop/configuration/scale-output create mode 100755 desktop/shell/bin/omarchy-display-scale 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-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/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/release/public_root.py b/release/public_root.py index 5cb3cc6..2871d22 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -42,6 +42,7 @@ 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() @@ -139,6 +140,7 @@ def desktop_additions(): '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), } From 8d88876d8a98f91e061047374a73885eb0695c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 13/20] Memory: RAM-sized zram and a bounded journal On a 1.5 GB device the default zram size and an unbounded journal waste memory. Size zram to RAM, favour compressed swap (swappiness, page cluster, watermarks), bound the runtime and system journal, and quiet the console. Assisted-by: LLM --- system/overlay/etc/sysctl.d/20-omarchy-a-console.conf | 2 ++ system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf | 5 +++++ .../etc/systemd/journald.conf.d/90-omarchy-a-memory.conf | 4 ++++ .../etc/systemd/system.conf.d/90-omarchy-a-quiet.conf | 2 ++ .../etc/systemd/zram-generator.conf.d/99-omarchy-a.conf | 3 +++ system/overlay/usr/lib/systemd/zram-generator.conf | 5 ++--- system/overlay/usr/lib/tmpfiles.d/omarchy-a-memory.conf | 3 +++ 7 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 system/overlay/etc/sysctl.d/20-omarchy-a-console.conf create mode 100644 system/overlay/etc/sysctl.d/90-omarchy-a-memory.conf create mode 100644 system/overlay/etc/systemd/journald.conf.d/90-omarchy-a-memory.conf create mode 100644 system/overlay/etc/systemd/system.conf.d/90-omarchy-a-quiet.conf create mode 100644 system/overlay/etc/systemd/zram-generator.conf.d/99-omarchy-a.conf create mode 100644 system/overlay/usr/lib/tmpfiles.d/omarchy-a-memory.conf 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/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/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/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 From 78ec07087bfc6f53d43cf0b9bf900ced8ceb447d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 14/20] Warm restart: report a GPU quiesce failure to the caller A warm restart went on even when the GPU could not be quiesced. Add the quiesce-gpu helper, which reports a failure to the restart caller. Assisted-by: LLM --- .../overlay/usr/lib/orchard-kexec/quiesce-gpu | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100755 system/overlay/usr/lib/orchard-kexec/quiesce-gpu 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..e88173b --- /dev/null +++ b/system/overlay/usr/lib/orchard-kexec/quiesce-gpu @@ -0,0 +1,26 @@ +#!/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; harmless when there is no G5P. +[ -x /usr/lib/orchard/device-feature ] && /usr/lib/orchard/device-feature warm_restart working || 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" From f689e0991aae2e9f0d8b1a3a838994d71d226996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 04:49:43 +0100 Subject: [PATCH 15/20] Power: keep upstream's wording for powerd's role An earlier wording pass on this branch turned "owner" into "maintainer" in lines that come from main: powerd is the one owner of the screen, the lock request and sleep, and the shutdown menu test quotes that. Restore main's wording in the state model doc, the powerd comment and the test. Assisted-by: LLM --- docs/power-and-lock.md | 4 ++-- system/overlay/usr/libexec/omarchy-powerd | 2 +- tests/test_menu_shutdown.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index d0451a7..4cf7ccb 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -1,6 +1,6 @@ # Power, lock and sleep: the state model -`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one maintainer +`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one owner 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 @@ -29,7 +29,7 @@ this model decides when. Unqualified boards retain dim, blank and wake. 9. Being plugged in lights the screen, as on any tablet, asleep or awake. Being unplugged wakes the kernel (orchard) but stays dark and sleeps again. -## One maintainer +## One owner A system service, because it serves the greeter and the desktop alike, and because system services thaw with the kernel while `user.slice` stays frozen a diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index f7a1aa3..52da960 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -915,7 +915,7 @@ class Daemon: self.restore_brightness() panel = self.backlight() if panel and int((panel / 'brightness').read_text()) == 0: - # State ON after a restart: never dark without an maintainer. + # State ON after a restart: never dark without an owner. self.key_time = time.monotonic() self.do_light(ON) server = self.open_socket() diff --git a/tests/test_menu_shutdown.py b/tests/test_menu_shutdown.py index 65245ab..3088831 100644 --- a/tests/test_menu_shutdown.py +++ b/tests/test_menu_shutdown.py @@ -185,7 +185,7 @@ def test_the_shell_answers_both_calls(self): class PowerOwnerTests(unittest.TestCase): def test_lock_and_wake_only_feed_omarchy_powerd(self): - # docs/power-and-lock.md: one maintainer of the lock, the screen and sleep. + # docs/power-and-lock.md: one owner of the lock, the screen and sleep. for name, verb in (('omarchy-system-lock', 'lock'), ('omarchy-system-wake', 'wake')): lines = (BIN / name).read_text().splitlines() code = [line for line in lines if line and not line.startswith('#')] From 3bf8d884184b0a77e47bfc7cf92cf5353a50d80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 05:13:19 +0100 Subject: [PATCH 16/20] Brightness: neutral wording in a test note and the sensor doc The sensor test's note on a hands-on check named no person but read as a maintainer's report; call it a hands-on test. The tablet sensor notes call the power daemon the sole owner of screen-off and sleep, as the power state model does. Assisted-by: LLM --- docs/tablet-sensors.md | 2 +- tests/test_auto_brightness.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tablet-sensors.md b/docs/tablet-sensors.md index 4d9b845..cdd13fe 100644 --- a/docs/tablet-sensors.md +++ b/docs/tablet-sensors.md @@ -18,7 +18,7 @@ User preferences live in `~/.config/omarchy/als-brightness.conf` and the switch 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 maintainer of screen-off and sleep. +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. diff --git a/tests/test_auto_brightness.py b/tests/test_auto_brightness.py index aca256a..a27f61f 100644 --- a/tests/test_auto_brightness.py +++ b/tests/test_auto_brightness.py @@ -40,7 +40,7 @@ def test_curve_and_median_ignore_one_spike(self): self.assertAlmostEqual(smooth.lux,20) def test_brightening_is_fast_and_darkening_slow(self): - # maintainer 2026-11-08: a flashlight took seconds to register, then moved in steps. + # 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) From d5bb8b8d38e636db7749d731e86335578d7177b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 11:08:36 +0100 Subject: [PATCH 17/20] Power: drop the J71t Mac USB current profile notes Orchard main now gives every installed system the Mac current identity (0x1014) on its own, and nothing reads /etc/orchard/mac-usb-current any more, so the notes on opting in with that flag describe a switch that no longer exists. Assisted-by: LLM --- docs/power-and-lock.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 4cf7ccb..64bc856 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -223,22 +223,6 @@ when `user.slice` thaws. fixed window after resume), `omarchy-screen-toggle` and `omarchy-greeter-wake` (a second and third writer of the backlight). -## J71t Mac USB current profile - -`/etc/orchard/mac-usb-current` opts into Orchard's J71t Mac-current profile. -Merge and install the matching Orchard #108 hardware bundle first. Gadget -identity, host-grant handling and board/pack limits remain Orchard's policy; -this flag does not override the charger limits or host authorization. - -Pair with Orchard's stable USB serial path (#110). No root service should -rewrite the initrd's identity during switch-root. Cold and warm charging, -stable serial and USB traffic require exact-revision device checks. The -previous warm 500 mA observation remains unresolved; the flag alone does not -prove a higher current grant. Remove the flag and reboot to disable the profile. - -Use a freshly matched initrd from the integrated Orchard kernel bundle so its -stable USB identity and current-grant handling travel with the kernel modules. - ## Suspend qualification and readiness The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both From 1412b2e981df68bc59fa00f957a01aa56b7ea7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 11:08:36 +0100 Subject: [PATCH 18/20] Power: take suspend readiness from the kernel alone ipadlinux-suspend-ready first asked Orchard's device-feature helper whether the board's feature table marked suspend as working. Orchard does not ship that table: feature states describe a board, they do not switch behaviour. Without the helper every sleep request was refused. Decide from the kernel's own admission query on every board instead. /sys/power/suspend_ready answers "ready" or names the first driver or precheck that would refuse; a kernel without the query cannot show that a suspend would be admitted, so sleep stays refused there, as do an unreadable or malformed answer. The helper no longer reads the board identity. Powerd, the session menu and systemd's direct suspend guard keep calling the same helper. Assisted-by: LLM --- docs/power-and-lock.md | 61 ++++++-------- .../60-omarchy-a-no-hibernate.conf | 2 +- .../60-omarchy-a-qualification.conf | 2 +- system/overlay/usr/libexec/omarchy-powerd | 2 +- .../usr/local/bin/ipadlinux-suspend-ready | 46 ++--------- tests/test_suspend_ready.py | 82 ++++++------------- 6 files changed, 63 insertions(+), 132 deletions(-) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 64bc856..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 decided by public device qualification, then kernel admission; -this model decides when. Unqualified boards retain dim, blank and wake. +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 @@ -223,28 +223,22 @@ when `user.slice` thaws. fixed window after resume), `omarchy-screen-toggle` and `omarchy-greeter-wake` (a second and third writer of the backlight). -## Suspend qualification and readiness +## Suspend readiness The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both -fresh roots and public-root refreshes. It first requires -`/usr/lib/orchard/device-feature suspend working`: Orchard's platform layer ships -that helper and `device-features.json`, matched against live DT compatibles. -Missing, unknown, untested, broken or ambiguous device data refuses sleep before -any kernel admission check. J71t currently declares suspend `untested`. -After qualification, it reads DeviceTree identity and checks admission below; -other qualified boards keep their existing logind/kernel path. 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. - -On J71t it reads the kernel's admission query, `/sys/power/suspend_ready` -(Orchard's suspend topic): `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: an older kernel cannot prove -admission. The answer is advisory; the real suspend reserves and checks again. +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 @@ -254,22 +248,21 @@ 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 qualification override is installed, so a board -whose registry changes to `working` can sleep after kernel admission succeeds. +suspend mask or board-specific override is installed: the kernel's admission is +the only gate. Device verification on a rebuilt image: ```sh -timeout -k 5 5 /usr/lib/orchard/device-feature suspend working +cat /sys/power/suspend_ready timeout -k 5 10 /usr/local/bin/ipadlinux-suspend-ready -timeout -k 5 10 systemctl suspend ``` -On the current unqualified J71t all three must refuse; the journal must name -qualification rather than a kernel suspend attempt, and suspend success -counters must not advance. Check that the final session menu omits Suspend. -Use `timeout -k 5 5 omarchy-powerctl idle`, closing the Smart Cover, and a -short Power press separately; each must blank without sleep, and touch, Power -or opening the cover must wake. Dim/undim must still restore the previous brightness. Repeat after -reboot with the installed platform data; do not edit qualification on hardware -just to exercise sleep. These are pending device checks, not host-test results. +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/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 e0f8135..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. Suspend is -# qualified at runtime by systemd-suspend.service, using platform device data. +# 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/systemd-suspend.service.d/60-omarchy-a-qualification.conf b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf index 876e8ca..4c9384e 100644 --- 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 @@ -1,4 +1,4 @@ -# Enforce the same qualification/admission policy for direct systemctl/logind +# 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/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index 52da960..30219dc 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -723,7 +723,7 @@ class Daemon: def request_suspend(self): try: # Shared with the final menu and systemd's direct suspend guard: - # public qualification first, then non-reserving kernel admission. + # the kernel's non-reserving admission query. if not platform_suspend_ready(): log('no suspend: platform readiness contract refused') self.post('suspend-refused') diff --git a/system/overlay/usr/local/bin/ipadlinux-suspend-ready b/system/overlay/usr/local/bin/ipadlinux-suspend-ready index 2127c8f..7e3e2fd 100755 --- a/system/overlay/usr/local/bin/ipadlinux-suspend-ready +++ b/system/overlay/usr/local/bin/ipadlinux-suspend-ready @@ -2,18 +2,16 @@ """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. Public device data must first -qualify suspend as working. Missing data/helper and all other states refuse. -On J71t admission comes from the kernel's own query, -/sys/power/suspend_ready ("ready", or -"not-ready "); PM callback presence, a loaded module and +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 -import subprocess -COMPATIBLE = Path('/sys/firmware/devicetree/base/compatible') READINESS = Path('/sys/power/suspend_ready') @@ -22,8 +20,8 @@ def admission(): try: report = READINESS.read_text(encoding='ascii').strip() except FileNotFoundError: - return ('J71t kernel has no suspend readiness query (/sys/power/suspend_ready); ' - 'GPU/VXD/SEP admission and platform prechecks cannot be verified') + 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': @@ -34,41 +32,13 @@ def admission(): return f'malformed kernel suspend readiness: {report!r}' -def refusal(compatible): - if not compatible or not compatible.endswith(b'\0'): - return 'cannot identify the board from a valid DeviceTree compatible list' - if b'apple,j71t' in compatible.split(b'\0'): - # The kernel runs every admission provider's check and every early - # precheck without reserving; a later suspend still checks again. - return admission() - # Other qualified boards keep logind + the kernel's existing suspend path. - return None - - -def qualification(): - """Public platform data, never inferred from kernel admission.""" - try: - result = subprocess.run(['/usr/lib/orchard/device-feature', 'suspend', 'working'], - capture_output=True, text=True, timeout=5) - except (OSError, subprocess.SubprocessError) as error: - return f'cannot verify suspend qualification: {error}' - if result.returncode: - return 'public device registry does not qualify suspend as working' - return None - - def main(argv=None): if argv is None: argv = sys.argv[1:] if argv: print('Usage: ipadlinux-suspend-ready', file=sys.stderr) return 2 - try: - reason = qualification() - if reason is None: - reason = refusal(COMPATIBLE.read_bytes()) - except OSError as error: - reason = f'cannot read board identity: {error}' + reason = admission() if reason: print(f'Suspend unavailable: {reason}', file=sys.stderr) return 1 diff --git a/tests/test_suspend_ready.py b/tests/test_suspend_ready.py index d6cbbdb..345121e 100644 --- a/tests/test_suspend_ready.py +++ b/tests/test_suspend_ready.py @@ -27,44 +27,23 @@ def load(name, path): class SuspendReady(unittest.TestCase): - def invoke(self, identity, argv=(), readiness=None): + def invoke(self, readiness=None, argv=()): with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / 'compatible' query = Path(directory) / 'suspend_ready' - if identity is not None: - path.write_bytes(identity) if readiness is not None: query.write_bytes(readiness) out, err = io.StringIO(), io.StringIO() - with patch.object(ready, 'qualification', return_value=None), patch.object(ready, 'COMPATIBLE', path), patch.object(ready, 'READINESS', query), \ + with patch.object(ready, 'READINESS', query), \ contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): code = ready.main(argv) - if identity is not None: - self.assertEqual(path.read_bytes(), identity) if readiness is not None: self.assertEqual(query.read_bytes(), readiness) return code, out.getvalue(), err.getvalue() - def test_qualification_precedes_identity_and_kernel_admission(self): - with patch.object(ready, 'qualification', return_value='not qualified'), \ - patch.object(ready, 'refusal') as admission, \ - patch.object(ready.Path, 'read_bytes') as identity, \ - contextlib.redirect_stderr(io.StringIO()) as err: - self.assertEqual(ready.main([]), 1) - self.assertIn('not qualified', err.getvalue()) - identity.assert_not_called() - admission.assert_not_called() - - def test_qualification_uses_public_helper_and_fails_closed(self): - with patch.object(ready.subprocess, 'run') as run: - for status in (0, 1, 2): - run.return_value = subprocess.CompletedProcess([], status) - self.assertEqual(ready.qualification() is None, status == 0) - run.assert_called_with(['/usr/lib/orchard/device-feature', 'suspend', 'working'], - capture_output=True, text=True, timeout=5) - for error in (FileNotFoundError(), PermissionError(), subprocess.TimeoutExpired('helper', 5)): - run.side_effect = error - self.assertIn('cannot verify', ready.qualification()) + 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 @@ -77,48 +56,37 @@ def test_systemd_direct_suspend_uses_same_gate(self): 'HandleLidSwitch', 'HandleLidSwitchExternalPower', 'HandleLidSwitchDocked', 'IdleAction'): self.assertIn(setting + '=ignore', logind) - def test_j71t_refuses_without_an_admission_query(self): - for identity in (b'apple,j71t\0apple,s8003\0', b'apple,j71t\0apple,s8000\0'): - code, out, err = self.invoke(identity) - self.assertEqual((code, out), (1, '')) - self.assertIn('no suspend readiness query', err) - self.assertIn('GPU/VXD/SEP', err) + 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_j71t_follows_the_kernel_admission_query(self): - identity = b'apple,j71t\0apple,s8003\0' - self.assertEqual(self.invoke(identity, readiness=b'ready\n'), (0, '', '')) - code, out, err = self.invoke(identity, readiness=b'not-ready 201000000.gpu -16\n') + 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(identity, readiness=b'not-ready apple_h9p_sleep_precheck -16\n') + 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_j71t_malformed_kernel_answer_fails_closed(self): + 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(b'apple,j71t\0', readiness=answer) + code, out, err = self.invoke(answer) self.assertEqual((code, out), (1, '')) self.assertIn('Suspend unavailable:', err) - def test_other_boards_ignore_the_kernel_query(self): - self.assertEqual(self.invoke(b'apple,j71b\0', readiness=b'not-ready x -16\n'), (0, '', '')) - - def test_other_boards_keep_logind_and_kernel_path(self): - for board in (b'apple,j71b', b'apple,j72b', b'apple,j71s', b'raspberrypi,4-model-b'): - self.assertEqual(self.invoke(board + b'\0apple,arm-platform\0'), (0, '', '')) - - def test_compatible_is_an_exact_nul_delimited_match(self): - self.assertEqual(self.invoke(b'apple,j71t-extra\0'), (0, '', '')) - self.assertEqual(self.invoke(b'apple,s8003\0apple,j71t\0')[0], 1) - - def test_missing_or_malformed_identity_fails_closed(self): - for identity in (None, b'', b'apple,j71t'): - code, out, err = self.invoke(identity) - 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'apple,j71t\0', ['--force'])[0], 2) + 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) From 72c95c7775be2a8d12e78f08a24a7af92bde9c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 11:08:59 +0100 Subject: [PATCH 19/20] Drop the J71t Mac current opt-in flag This branch was stacked on the Mac current opt-in (#39), which was closed: Orchard main now uses the Mac current identity on every installed system, and nothing reads /etc/orchard/mac-usb-current. Remove the flag file and its test, so this branch and the ones after it do not carry that change. Assisted-by: LLM --- system/overlay/etc/orchard/mac-usb-current | 1 - tests/test_mac_usb_current.py | 18 ------------------ 2 files changed, 19 deletions(-) delete mode 100644 system/overlay/etc/orchard/mac-usb-current delete mode 100644 tests/test_mac_usb_current.py diff --git a/system/overlay/etc/orchard/mac-usb-current b/system/overlay/etc/orchard/mac-usb-current deleted file mode 100644 index d00491f..0000000 --- a/system/overlay/etc/orchard/mac-usb-current +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/tests/test_mac_usb_current.py b/tests/test_mac_usb_current.py deleted file mode 100644 index c6f718e..0000000 --- a/tests/test_mac_usb_current.py +++ /dev/null @@ -1,18 +0,0 @@ -"""The system opts in through data, leaving USB ownership to the initrd.""" -from pathlib import Path -import unittest - -ROOT = Path(__file__).resolve().parents[1] - -class MacUsbCurrent(unittest.TestCase): - def test_profile_is_exact_opt_in_for_matching_hardware(self): - self.assertEqual((ROOT/'system/overlay/etc/orchard/mac-usb-current').read_bytes(), b'1\n') - self.assertIn('freshly matched initrd', (ROOT/'docs/power-and-lock.md').read_text()) - - def test_system_root_includes_the_opt_in(self): - from release.public_root import system_files - data, mode = system_files()['etc/orchard/mac-usb-current'] - self.assertEqual((data, mode), (b'1\n', 0o644)) - -if __name__ == '__main__': - unittest.main() From e49b01e7f171996822ba90d0d718171fbcdc823c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 11:09:10 +0100 Subject: [PATCH 20/20] Warm restart: quiesce the GPU wherever its handover command exists quiesce-gpu ran only when Orchard's device-feature helper said the board's warm_restart feature was working. Orchard does not ship that helper, so the GPU was never quiesced. Decide from the mechanism instead: when the G5P driver exposes its handover command, arm the failure receipt and send td-kexec as before; without it there is nothing to quiesce and the helper exits without arming anything. Assisted-by: LLM --- system/overlay/usr/lib/orchard-kexec/quiesce-gpu | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/system/overlay/usr/lib/orchard-kexec/quiesce-gpu b/system/overlay/usr/lib/orchard-kexec/quiesce-gpu index e88173b..3049001 100755 --- a/system/overlay/usr/lib/orchard-kexec/quiesce-gpu +++ b/system/overlay/usr/lib/orchard-kexec/quiesce-gpu @@ -4,8 +4,10 @@ # 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; harmless when there is no G5P. -[ -x /usr/lib/orchard/device-feature ] && /usr/lib/orchard/device-feature warm_restart working || exit 0 +# 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