From 50076bf0a123a67d8cd384d85ee67fbdbd00f697 Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Thu, 30 Jul 2026 09:52:43 +0800 Subject: [PATCH 1/3] odin2: external display support, dock detection and audio fixes --- ...-typec-mux-dont-swallow-EPROBE_DEFER.patch | 93 +++++++ .../AYN Odin 2 Mini/011-external-display | 37 +++ .../devices/AYN Odin 2/011-external-display | 37 +++ .../hardware/quirks/profile.d/999-export | 2 + .../rocknix/config/system/configs/system.cfg | 1 + .../sources/scripts/rocknix-fake-suspend | 19 +- .../sources/scripts/output_monitor | 243 ++++++++++++++++-- .../compositor/sway/autostart/111-sway-init | 8 +- 8 files changed, 409 insertions(+), 31 deletions(-) create mode 100644 projects/ROCKNIX/devices/SM8550/patches/linux/0517-usb-typec-mux-dont-swallow-EPROBE_DEFER.patch create mode 100644 projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2 Mini/011-external-display create mode 100644 projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2/011-external-display diff --git a/projects/ROCKNIX/devices/SM8550/patches/linux/0517-usb-typec-mux-dont-swallow-EPROBE_DEFER.patch b/projects/ROCKNIX/devices/SM8550/patches/linux/0517-usb-typec-mux-dont-swallow-EPROBE_DEFER.patch new file mode 100644 index 00000000000..d5ad9cc89c0 --- /dev/null +++ b/projects/ROCKNIX/devices/SM8550/patches/linux/0517-usb-typec-mux-dont-swallow-EPROBE_DEFER.patch @@ -0,0 +1,93 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:03 2001 +From: Anze +Date: Mon, 27 Jul 2026 18:00:00 +0200 +Subject: [PATCH] usb: typec: mux: don't swallow EPROBE_DEFER in the dedup check + +The 7.1 merge window added a "skip duplicates" filter to typec_mux_match() +and typec_switch_match(), and started passing the output array in as the +match data so it can be compared against. Both halves are broken for a +connector whose mux is not registered yet. + +struct device is the FIRST member of struct typec_mux_dev and struct +typec_switch_dev, so container_of() on a NULL device yields NULL. When +class_find_device() finds nothing - the normal case while the mux driver +has not probed yet, e.g. an i2c retimer such as wcd939x-usbss - the dedup +loop compares that NULL against an array slot that is also NULL, matches, +and returns NULL. The caller reads that as "no such connection" instead +of the -EPROBE_DEFER it used to get, so the connector is created WITHOUT +its mux, permanently: the DP lanes and the AUX/SBU pair are never routed. + +The arrays are also uninitialised stack (`struct typec_mux_dev +*mux_devs[TYPEC_MUX_MAX_DEVS];`), yet the dedup loop reads every slot +before fwnode_connection_find_matches() has filled them, so a valid mux +can be dropped by a chance comparison against stack garbage. + +Bail out with -EPROBE_DEFER before the dedup loop when no device was +found, and zero-initialise both arrays. + +Device-observed on an AYN Odin 3 (SM8750): dock DisplayPort output died +with the 7.0.11 -> 7.1.3 bump. The firmware enters DP alt mode correctly +(mux_ctrl=3, pin_assignment=4), raises HPD, and msm_dp powers up its +clocks and PHY - then the very first DPCD read times out +(drm_dp_dpcd_probe ... AUX -> (ret=-110)), link_ready stays false and the +connector reports disconnected, because nothing ever routed the lanes. + +Signed-off-by: Anze +--- +--- a/drivers/usb/typec/mux.c 2026-07-27 17:07:50.988901877 +0200 ++++ b/drivers/usb/typec/mux.c 2026-07-27 17:07:51.010296597 +0200 +@@ -57,6 +57,8 @@ + */ + dev = class_find_device(&typec_mux_class, NULL, fwnode, + switch_fwnode_match); ++ if (!dev) ++ return ERR_PTR(-EPROBE_DEFER); + + /* Skip duplicates */ + for (i = 0; i < TYPEC_MUX_MAX_DEVS; i++) +@@ -65,7 +67,7 @@ + return NULL; + } + +- return dev ? to_typec_switch_dev(dev) : ERR_PTR(-EPROBE_DEFER); ++ return to_typec_switch_dev(dev); + } + + /** +@@ -79,7 +81,7 @@ + */ + struct typec_switch *fwnode_typec_switch_get(struct fwnode_handle *fwnode) + { +- struct typec_switch_dev *sw_devs[TYPEC_MUX_MAX_DEVS]; ++ struct typec_switch_dev *sw_devs[TYPEC_MUX_MAX_DEVS] = {}; + struct typec_switch *sw; + int count; + int err; +@@ -292,6 +294,8 @@ + + dev = class_find_device(&typec_mux_class, NULL, fwnode, + mux_fwnode_match); ++ if (!dev) ++ return ERR_PTR(-EPROBE_DEFER); + + /* Skip duplicates */ + for (i = 0; i < TYPEC_MUX_MAX_DEVS; i++) +@@ -300,8 +304,7 @@ + return NULL; + } + +- +- return dev ? to_typec_mux_dev(dev) : ERR_PTR(-EPROBE_DEFER); ++ return to_typec_mux_dev(dev); + } + + /** +@@ -315,7 +318,7 @@ + */ + struct typec_mux *fwnode_typec_mux_get(struct fwnode_handle *fwnode) + { +- struct typec_mux_dev *mux_devs[TYPEC_MUX_MAX_DEVS]; ++ struct typec_mux_dev *mux_devs[TYPEC_MUX_MAX_DEVS] = {}; + struct typec_mux *mux; + int count; + int err; diff --git a/projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2 Mini/011-external-display b/projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2 Mini/011-external-display new file mode 100644 index 00000000000..7a8e24e9974 --- /dev/null +++ b/projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2 Mini/011-external-display @@ -0,0 +1,37 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 ROCKNIX (https://github.com/ROCKNIX) + +# External display support for the micro HDMI port. +# +# The micro HDMI port sits behind a Lontium LT8912B DSI-to-HDMI bridge +# with no DDC support at all (per Lontium's product brief), so the +# display's EDID can never be read and without help everything falls +# back to 1024x768. Arm a synthetic EDID on the connector with the two +# modes the bridge handles well: +# +# 1920x1080@60 (preferred) +# 1280x720@60 +# +# The bridge caps at 1920x1080 / 150 MHz. 30 Hz modes were tested and +# come out broken, so they are not offered. +# +# Pick the mode with: set_setting external_display.mode auto| +# +# No audio on this port: the lt8912b driver has no audio support, so +# sound stays on the speakers. The USB-C DisplayPort path is unrelated +# and already handles EDID and audio natively. + +# Mode list for a future settings menu, and the audio capability flag. +cat </storage/.config/profile.d/011-external_display +DEVICE_EXTERNAL_DISPLAY_MODES="1280x720@60 1920x1080@60" +DEVICE_EXTERNAL_DISPLAY_AUDIO="false" +EOF + +# Arm the EDID before sway starts; the kernel picks it up on the next +# connector probe (i.e. when a display is plugged in). +EDID_B64="AP///////wAx2AAAAAAAAAUkAQOAEAl4AgAAAAAAAAAAAAAAAAABAQEBAQEBAQEBAQEBAQEBAjqAGHE4LUBYLEUAAAAAAAAeAR0AclHQHiBuKFUAAAAAAAAeAAAAEAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAMQ=" +for OVERRIDE in /sys/kernel/debug/dri/*/HDMI-A-1/edid_override; do + [ -e "${OVERRIDE}" ] || continue + echo "${EDID_B64}" | base64 -d >"${OVERRIDE}" +done diff --git a/projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2/011-external-display b/projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2/011-external-display new file mode 100644 index 00000000000..7a8e24e9974 --- /dev/null +++ b/projects/ROCKNIX/packages/hardware/quirks/devices/AYN Odin 2/011-external-display @@ -0,0 +1,37 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026 ROCKNIX (https://github.com/ROCKNIX) + +# External display support for the micro HDMI port. +# +# The micro HDMI port sits behind a Lontium LT8912B DSI-to-HDMI bridge +# with no DDC support at all (per Lontium's product brief), so the +# display's EDID can never be read and without help everything falls +# back to 1024x768. Arm a synthetic EDID on the connector with the two +# modes the bridge handles well: +# +# 1920x1080@60 (preferred) +# 1280x720@60 +# +# The bridge caps at 1920x1080 / 150 MHz. 30 Hz modes were tested and +# come out broken, so they are not offered. +# +# Pick the mode with: set_setting external_display.mode auto| +# +# No audio on this port: the lt8912b driver has no audio support, so +# sound stays on the speakers. The USB-C DisplayPort path is unrelated +# and already handles EDID and audio natively. + +# Mode list for a future settings menu, and the audio capability flag. +cat </storage/.config/profile.d/011-external_display +DEVICE_EXTERNAL_DISPLAY_MODES="1280x720@60 1920x1080@60" +DEVICE_EXTERNAL_DISPLAY_AUDIO="false" +EOF + +# Arm the EDID before sway starts; the kernel picks it up on the next +# connector probe (i.e. when a display is plugged in). +EDID_B64="AP///////wAx2AAAAAAAAAUkAQOAEAl4AgAAAAAAAAAAAAAAAAABAQEBAQEBAQEBAQEBAQEBAjqAGHE4LUBYLEUAAAAAAAAeAR0AclHQHiBuKFUAAAAAAAAeAAAAEAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAMQ=" +for OVERRIDE in /sys/kernel/debug/dri/*/HDMI-A-1/edid_override; do + [ -e "${OVERRIDE}" ] || continue + echo "${EDID_B64}" | base64 -d >"${OVERRIDE}" +done diff --git a/projects/ROCKNIX/packages/hardware/quirks/profile.d/999-export b/projects/ROCKNIX/packages/hardware/quirks/profile.d/999-export index 9e5db1b2316..d83040578e4 100755 --- a/projects/ROCKNIX/packages/hardware/quirks/profile.d/999-export +++ b/projects/ROCKNIX/packages/hardware/quirks/profile.d/999-export @@ -17,6 +17,8 @@ export OS_VERSION \ DEVICE_AUDIO_MIXER \ DEVICE_BATTERY_LED_STATUS \ DEVICE_DTB_SWITCH \ + DEVICE_EXTERNAL_DISPLAY_AUDIO \ + DEVICE_EXTERNAL_DISPLAY_MODES \ DEVICE_FAKE_JACKSENSE \ DEVICE_FUNC_KEYA_MODIFIER \ DEVICE_FUNC_KEYB_MODIFIER \ diff --git a/projects/ROCKNIX/packages/rocknix/config/system/configs/system.cfg b/projects/ROCKNIX/packages/rocknix/config/system/configs/system.cfg index eb9d866b726..51b8f75fe1f 100644 --- a/projects/ROCKNIX/packages/rocknix/config/system/configs/system.cfg +++ b/projects/ROCKNIX/packages/rocknix/config/system/configs/system.cfg @@ -47,6 +47,7 @@ display.saturation=50 dreamcast.integerscale=0 dreamcast.ratio=4/3 easyrpg.integerscale=0 +external_display.mode=auto famicom.integerscale=0 famicom.ratio=4/3 fbn.integerscale=0 diff --git a/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend b/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend index 0f036f0f9b1..da2f36524ca 100755 --- a/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend +++ b/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend @@ -25,7 +25,7 @@ check_hardware_suspend_enabled() { } check_hdmi_connected() { - for status_file in /sys/class/drm/card*-HDMI-A-[0-9]/status; do + for status_file in /sys/class/drm/card*-HDMI-A-[0-9]/status /sys/class/drm/card*-DP-[0-9]/status; do if [[ -f "$status_file" ]]; then local HDMI_STATUS=$(cat "$status_file") if [[ "${HDMI_STATUS}" = "connected" ]]; then @@ -88,7 +88,12 @@ display_off() { if [[ -n $(echo "${UI_SERVICE}" | grep "sway") && -n $(pgrep "sway") ]]; then ${DEBUG} && log $0 "Display power off" - swaymsg "output * power off" + # Only power off internal panels: power cycling an external + # output kills the DP alt mode audio jack until a physical + # replug (verified on AYN Odin 2 with the official dock). + for out in $(wlr-randr 2>/dev/null | awk '/^(DSI|eDP|LVDS)-/ { print $1 }'); do + swaymsg "output ${out} power off" + done elif [[ -n $(echo "${UI_SERVICE}" | grep "weston") && -n $(pgrep "weston") ]]; then weston-dpms -m off else @@ -121,13 +126,15 @@ display_on() { } mute_audio() { - ${DEBUG} && log $0 "Mute audio" - pactl set-sink-mute @DEFAULT_SINK@ true + # Deliberately no sink muting: wireplumber persists sink mute per + # route, and a sink that disappears while muted (external display + # audio during a sleep cycle) stays muted on disk forever. The + # frozen processes stop producing audio anyway. + ${DEBUG} && log $0 "Mute audio: skipped (freeze silences the apps)" } unmute_audio() { - ${DEBUG} && log $0 "Unmute audio" - pactl set-sink-mute @DEFAULT_SINK@ false + ${DEBUG} && log $0 "Unmute audio: nothing to undo" } disable_leds() { diff --git a/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor b/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor index 1a0a0e47e90..fd6599209a6 100644 --- a/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor +++ b/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor @@ -5,6 +5,9 @@ # Monitors external display outputs (DP, HDMI) via sway IPC and powers # internal panels (DSI, eDP) off/on accordingly. # +# The external_display.mode setting picks the mode for the external +# output: "auto" (1080p60, the default) or a fixed WIDTHxHEIGHT@RATE. +# # The sway IPC subscription is used rather than udev because the actions # (wlr-randr, swaymsg) require sway to be running and to have already # processed the new output. Subscribing to sway output events is @@ -16,32 +19,79 @@ # Exit if this device has no external display connectors at all ls /sys/class/drm/card*-DP-* /sys/class/drm/card*-HDMI-A-* 2>/dev/null | grep -q . || exit 0 -_LAST_STATE="init" +# Last applied "state|mode". Including the mode means a settings +# change gets applied on the next wakeup (~10s), no replug needed. +_LAST_SIG="init" + +_get_external_settings() { + EXT_MODE=$(get_setting external_display.mode) + [ -n "${EXT_MODE}" ] || EXT_MODE="auto" +} -_get_active_externals() { - wlr-randr 2>/dev/null | awk ' - /^(DP|HDMI|DisplayPort)-/ { name=$1; in_ext=1; next } - /^[A-Za-z]/ { in_ext=0 } - in_ext && /Enabled: yes/ { print name } - ' +_get_present_externals() { + # Go by DRM connector status, not by what sway has enabled: the + # kernel can know a display is connected while sway missed the + # hotplug event (seen with the DP alt mode dock on the AYN Odin 2, + # which otherwise stays undetected forever), and sway can also + # leave a connected output disabled after an EDID reprobe. In both + # cases the explicit enable below brings the display up. + local c + for c in /sys/class/drm/card*-DP-* /sys/class/drm/card*-HDMI-A-*; do + [ -e "${c}/status" ] || continue + [ "$(cat "${c}/status")" = "connected" ] && basename "${c}" | sed 's/^card[0-9]*-//' + done } _get_all_internals() { wlr-randr 2>/dev/null | awk '/^(DSI|eDP|LVDS)-/ { print $1 }' } +_panel_orientation_transform() { + # Rotated panels report a "panel orientation" DRM property, but + # sway only picks it up for the boot display. On a device that + # booted with an external display connected, the internal panel + # would come back unrotated, so re-apply the transform here. + local m v + for m in "" "msm" "rockchip" "amdgpu" "i915"; do + v=$(modetest ${m:+-M "$m"} -c 2>/dev/null | awk ' + /panel orientation/ { f = 1 } + f && /value:/ { print $2; exit }') + [ -n "$v" ] && break + done + # Same mapping as the kernel's fbcon rotation. + case "$v" in + 1) echo 180 ;; + 2) echo 90 ;; + 3) echo 270 ;; + esac +} + +_enable_internal() { + local t + t=$(_panel_orientation_transform) + if [ -n "$t" ]; then + wlr-randr --output "$1" --preferred --on --transform "$t" 2>/dev/null + else + wlr-randr --output "$1" --preferred --on 2>/dev/null + fi +} + _wp_audio_card() { - wpctl status 2>/dev/null | awk -F'[.[:space:]]+' ' + timeout 3 wpctl status 2>/dev/null | awk -F'[.[:space:]]+' ' /\[alsa\]/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }' } _wp_profile_index() { - pw-cli enum-params "$1" EnumProfile 2>/dev/null | awk -v want="$2" ' + timeout 3 pw-cli enum-params "$1" EnumProfile 2>/dev/null | awk -v want="$2" ' /Param:Profile:index/ { getline; idx = $2 } /Param:Profile:name/ { getline; gsub(/"/, "", $2); if ($2 == want) { print idx; exit } }' } _switch_audio() { + # Skip on devices whose external display cannot carry audio (e.g. + # AYN Odin 2). Sound stays on the speakers, and the profile churn + # that can reset volumes to 100% never happens. + [ "${DEVICE_EXTERNAL_DISPLAY_AUDIO}" = "false" ] && return 0 local card target i card=$(_wp_audio_card); [ -n "$card" ] || return 0 if [ "$1" = "external" ]; then @@ -58,41 +108,188 @@ _switch_audio() { else target=$(_wp_profile_index "$card" "HiFi") fi - [ -n "$card" ] && [ -n "$target" ] && wpctl set-profile "$card" "$target" 2>/dev/null || true + [ -n "$card" ] && [ -n "$target" ] && timeout 3 wpctl set-profile "$card" "$target" 2>/dev/null || true +} + +_all_sink_ids() { + timeout 3 wpctl status 2>/dev/null | awk ' + /Sinks:/ { f = 1; next } + /Sources:|Filters:|Streams:/ { f = 0 } + f && match($0, /[0-9]+\./) { print substr($0, RSTART, RLENGTH - 1) }' +} + +_quiet_sinks() { + # Run synchronously the moment a connect/disconnect is seen: drop + # every sink to zero so the profile flip and amp handoff happen in + # silence. _restore_volume then fades the sound back in. + local s + for s in $(_all_sink_ids); do + timeout 2 wpctl set-volume "$s" 0 2>/dev/null + done +} + +_set_amp_volume() { + # Speaker amp hardware volume: 0 is full output, 1023 is silent + # (attenuation register). Parking the amps while an external + # display is active makes the sink-migration burst at unplug + # inaudible: sink volumes race the stream hand-off, this register + # does not. Devices without these controls are unaffected. + local ctl + for ctl in "SPK_L PCM Playback Volume" "SPK_R PCM Playback Volume"; do + timeout 2 amixer -c 0 cset name="${ctl}" "$1" >/dev/null 2>&1 + done +} + +_default_sink_id() { + timeout 3 wpctl status 2>/dev/null | awk ' + /Sinks:/ { f = 1; next } + /Sources:|Filters:|Streams:/ { f = 0 } + f && /\*/ && match($0, /[0-9]+\./) { print substr($0, RSTART, RLENGTH - 1); exit }' +} + +_pick_sink() { + # First sink id whose listing line matches the regex in $1 + timeout 3 wpctl status 2>/dev/null | awk -v re="$1" ' + /Sinks:/ { f = 1; next } + /Sources:|Filters:|Streams:/ { f = 0 } + f && $0 ~ re && match($0, /[0-9]+\./) { print substr($0, RSTART, RLENGTH - 1); exit }' +} + +_ensure_default_sink() { + # wireplumber loses its default sink on some profile flips (seen + # repeatedly on the AYN Odin 2: no sink marked default, volume + # keys dead, silence). Pick the right one and set it explicitly. + local def + def=$(_default_sink_id) + if [ -z "${def}" ]; then + if [ "$1" = "external" ]; then + def=$(_pick_sink "DisplayPort|HDMI") + else + def=$(_pick_sink "Speaker") + fi + [ -n "${def}" ] && timeout 2 wpctl set-default "${def}" 2>/dev/null + sleep 0.5 + def=$(_default_sink_id) + [ -n "${def}" ] || def=$(_pick_sink ".") + fi + echo "${def}" +} + +_restore_volume() { + # Connect/disconnect flips the audio profile and recreates the + # sinks; wireplumber restores whatever the new sink last had + # stored (100% blasts observed), and streams can migrate before + # the stored volume applies. Going external, fade the new sink in + # from the _quiet_sinks silence. Going internal, do it without any + # sink writes (each one draws the volume OSD): the speaker sink + # keeps the user volume at all times, the migration burst lands on + # the parked amps, and the audible fade is the amp attenuation + # ramping back to full. + ( + local a f s v def cur target + target=$(get_setting audio.volume) + [ -n "${target}" ] || target=50 + if [ "$1" = "external" ]; then + sleep 1 + else + sleep 0.5 + fi + if [ "$1" = "external" ]; then + def=$(_ensure_default_sink external) + # Video-only external output (micro HDMI on the AYN Odin 2): + # audio never left the speakers, so unpark their amps. + if [ -n "${def}" ] && [ "${def}" != "$(_pick_sink "DisplayPort|HDMI")" ]; then + _set_amp_volume 0 + fi + for f in 0.15 0.3 0.5 0.7 0.85 1.0; do + v=$(awk "BEGIN { printf \"%.2f\", ${target} / 100 * ${f} }") + [ -n "${def}" ] || def=$(_ensure_default_sink external) + [ -n "${def}" ] || continue + timeout 2 wpctl set-mute "${def}" 0 2>/dev/null + timeout 2 wpctl set-volume "${def}" "${v}" 2>/dev/null + sleep 0.15 + done + # Pre-store the speaker volume while it is not the default: + # no OSD is drawn, and the sink born on the next unplug + # starts at the user volume instead of a stale store value. + s=$(_pick_sink "Speaker") + [ -n "${s}" ] && [ "${s}" != "${def}" ] && \ + timeout 2 wpctl set-volume "${s}" \ + "$(awk "BEGIN { printf \"%.2f\", ${target} / 100 }")" 2>/dev/null + else + def=$(_ensure_default_sink internal) + # Repair the speaker sink only if it drifted; a matching + # value means zero writes and no OSD. + if [ -n "${def}" ]; then + cur=$(timeout 2 wpctl get-volume "${def}" 2>/dev/null) + case "${cur}" in *MUTED*) timeout 2 wpctl set-mute "${def}" 0 2>/dev/null ;; esac + cur=$(echo "${cur}" | awk '{ printf "%d", $2 * 100 }') + [ "${cur}" = "${target}" ] || \ + timeout 2 wpctl set-volume "${def}" \ + "$(awk "BEGIN { printf \"%.2f\", ${target} / 100 }")" 2>/dev/null + fi + # Single write: the amp applies its own internal fade + # (Fade Step register); stepping it from the shell instead + # produced audible sound/stop/sound gaps. + _set_amp_volume 0 + fi + ) /dev/null 2>&1 & } _apply() { local state="internal" - [ -n "$(_get_active_externals)" ] && state="external" - [ "$_LAST_STATE" = "$state" ] && return - local prev="$_LAST_STATE" - _LAST_STATE="$state" + local ext_out + ext_out=$(_get_present_externals | head -1) + [ -n "$ext_out" ] && state="external" + + _get_external_settings + local sig="${state}|${EXT_MODE}" + [ "$_LAST_SIG" = "$sig" ] && return + local prev="${_LAST_SIG%%|*}" + _LAST_SIG="$sig" + + # Going external: silence the sinks so the flip is quiet and the + # TV fades in from zero. Going internal the sinks stay untouched + # (writes would draw the volume OSD); the parked amps keep the + # speakers silent instead. + [ "$prev" != "init" ] && [ "$prev" != "$state" ] \ + && [ "$state" = "external" ] && _quiet_sinks if [ "$state" = "external" ]; then - local ext_out - ext_out=$(_get_active_externals | head -1) + # sway may have left the output disabled; turn it on first. + wlr-randr --output "$ext_out" --on 2>/dev/null while IFS= read -r out; do wlr-randr --output "$out" --off 2>/dev/null done < <(_get_all_internals) - if [ -n "$ext_out" ]; then + local mode="${EXT_MODE}" + if [ "$mode" != "auto" ]; then + wlr-randr --output "$ext_out" --mode "$mode" 2>/dev/null || mode="auto" + fi + if [ "$mode" = "auto" ]; then wlr-randr --output "$ext_out" --mode 1920x1080@60 2>/dev/null \ || wlr-randr --output "$ext_out" --mode 1920x1080 2>/dev/null || true - swaymsg "[app_id=\"emulationstation\"] move output ${ext_out}" 2>/dev/null || true fi + swaymsg "[app_id=\"emulationstation\"] move output ${ext_out}" 2>/dev/null || true # Stop USB gadget: its data lanes conflict with DP Alt Mode on RK3576 systemctl is-active --quiet usbgadget && systemctl stop usbgadget - _switch_audio external + if [ "$prev" != "external" ]; then + # Park the speaker amps right away: they are not in use + # while docked, and the migration burst of the next unplug + # must land on silent hardware. + _set_amp_volume 1023 + _switch_audio external + _restore_volume external + fi else # Only re-enable internal panels on actual disconnect, not first-boot - if [ "$prev" != "init" ]; then + if [ "$prev" = "external" ]; then while IFS= read -r out; do - wlr-randr --output "$out" --preferred --on 2>/dev/null + _enable_internal "$out" done < <(_get_all_internals) _switch_audio internal + _restore_volume internal fi fi - - } # Allow sway to finish initialising outputs before first check diff --git a/projects/ROCKNIX/packages/wayland/compositor/sway/autostart/111-sway-init b/projects/ROCKNIX/packages/wayland/compositor/sway/autostart/111-sway-init index 5e0dbe2acb4..4884cde93bc 100755 --- a/projects/ROCKNIX/packages/wayland/compositor/sway/autostart/111-sway-init +++ b/projects/ROCKNIX/packages/wayland/compositor/sway/autostart/111-sway-init @@ -80,9 +80,13 @@ fi output="output ${con}" echo "${output} transform ${angle}" >> $SWAY_HOME/config echo "WLR_CON=${con}" >> ${env_file} -# Force HDMI to 1080p60hz +# Force HDMI to the configured external display mode (default 1080p60) if [[ $con == HDMI* ]]; then - echo "${output} mode 1920x1080@60Hz" >> "$SWAY_HOME/config" + EXT_MODE=$(get_setting external_display.mode) + case "${EXT_MODE}" in + ""|auto) EXT_MODE="1920x1080@60";; + esac + echo "${output} mode ${EXT_MODE}Hz" >> "$SWAY_HOME/config" fi # check refresh rate DISPLAY_MODE=$(get_setting system.display_mode) From 129530e51a915941042d2cbf7eaf8ed64d9cb83c Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Fri, 14 Aug 2026 16:22:09 +0800 Subject: [PATCH 2/3] odin2: share the external display checks in one script check_hdmi_connected in rocknix-fake-suspend, _get_present_externals and _get_all_internals in output_monitor, and the internal panel blanking all answered the same two questions from their own copies: is an external display attached, and which outputs are internal panels. Three copies of each invites them to drift apart, and drifting changes behaviour silently. Move both into /usr/bin/external-display and have the callers ask: external-display connected|list|internals|blank [backlight]|unblank fake-suspend loses its connector glob, its compositor ladder and its backlight helpers, 66 lines net. output_monitor loses two functions. Two behaviour changes fall out. blank falls back to the backlight when the DPMS path powered nothing off, where before an internal panel missing from wlr-randr left the screen on. And the connector match now accepts a two digit index, so HDMI-A-10 counts. blank touches internal panels only while unblank restores every output. That asymmetry is deliberate: powering off an external output drops the DP audio jack, while powering one on is free and recovers an output that something else left dark. Co-Authored-By: Claude Opus 5 --- .../rocknix/sources/scripts/external-display | 139 ++++++++++++++++++ .../sources/scripts/rocknix-fake-suspend | 75 ++-------- .../sources/scripts/output_monitor | 8 +- 3 files changed, 150 insertions(+), 72 deletions(-) create mode 100755 projects/ROCKNIX/packages/rocknix/sources/scripts/external-display diff --git a/projects/ROCKNIX/packages/rocknix/sources/scripts/external-display b/projects/ROCKNIX/packages/rocknix/sources/scripts/external-display new file mode 100755 index 00000000000..40756e791c6 --- /dev/null +++ b/projects/ROCKNIX/packages/rocknix/sources/scripts/external-display @@ -0,0 +1,139 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-2.0 +# Copyright (C) 2026-present ROCKNIX (https://github.com/ROCKNIX) + +### Summary +# External display state, and blanking that is safe around one. +# +# Three callers need the same two answers and kept their own copies of +# both: rocknix-fake-suspend, output_monitor, and the platform power +# handlers for devices that have real suspend. Drifting apart on either +# one is a bug in itself, so they all come here now. +### + +### Usage +# external-display connected exit 0 if an external display is attached +# external-display list connected external connectors, one per line +# external-display internals internal panel outputs, as the compositor names them +# external-display blank [backlight] +# external-display unblank +### + +. /etc/profile + +case $(get_setting system.loglevel) in +verbose) DEBUG=true ;; +*) DEBUG=false ;; +esac + +### Connector status is the kernel's, so it is right even when the +### compositor missed the hotplug event. DP covers the USB-C alt mode docks +### and any adapter behind them; the leading dash is what keeps eDP panels +### from matching as external. +_external_status_files() { + local STATUS + for STATUS in /sys/class/drm/card*-HDMI-A-[0-9]*/status \ + /sys/class/drm/card*-DP-[0-9]*/status; do + [ -f "${STATUS}" ] && echo "${STATUS}" + done +} + +_connector_name() { + local NAME="${1%/status}" + NAME="${NAME##*/}" + echo "${NAME#card*-}" +} + +### Which compositor is both configured and running. +_compositor_is() { + case "${UI_SERVICE}" in + *"${1}"*) ;; + *) return 1 ;; + esac + pgrep "${1}" >/dev/null 2>&1 +} + +_backlight() { + local BL + for BL in /sys/class/backlight/*/bl_power; do + [ -w "${BL}" ] && echo "${1}" >"${BL}" + done +} + +list() { + local STATUS + for STATUS in $(_external_status_files); do + [ "$(cat "${STATUS}")" = "connected" ] && _connector_name "${STATUS}" + done + return 0 +} + +connected() { + local STATUS + for STATUS in $(_external_status_files); do + [ "$(cat "${STATUS}")" = "connected" ] && return 0 + done + return 1 +} + +internals() { + wlr-randr 2>/dev/null | awk '/^(DSI|eDP|LVDS)-/ { print $1 }' +} + +### Internal panels only. A DPMS off on an active DP output makes +### msm_dp_display_disable() report the audio jack as unplugged, and the HPD +### plug events that would undo it are discarded while the output is off, so +### dock audio stays dead until a physical replug. +blank() { + local OUT COUNT=0 + + if [ "${1}" = "backlight" ]; then + ${DEBUG} && log $0 "Blank: backlight" + _backlight 4 + return 0 + fi + + if _compositor_is sway; then + for OUT in $(internals); do + swaymsg "output ${OUT} power off" >/dev/null 2>&1 && COUNT=$((COUNT + 1)) + done + elif _compositor_is weston; then + weston-dpms -m off >/dev/null 2>&1 && COUNT=1 + fi + + ### No compositor, or it knew of no internal panel to blank. Without this + ### the screen would simply stay on. + if [ "${COUNT}" -eq 0 ]; then + ${DEBUG} && log $0 "Blank: no panel powered off via DPMS, using backlight" + _backlight 4 + else + ${DEBUG} && log $0 "Blank: ${COUNT} panel(s) powered off" + fi +} + +### Both layers, unconditionally: whichever one blanked the panel, the other +### must not be left holding it dark. Every output rather than the internals +### alone, so an external that something else powered down comes back as +### well; powering on a display that is already on does nothing. +unblank() { + ${DEBUG} && log $0 "Unblank" + _backlight 0 + + if _compositor_is sway; then + swaymsg "output * power on" >/dev/null 2>&1 + elif _compositor_is weston; then + weston-dpms -m on >/dev/null 2>&1 + fi +} + +case "${1}" in + connected) connected ;; + list) list ;; + internals) internals ;; + blank) blank "${2}" ;; + unblank) unblank ;; + *) + echo "usage: ${0##*/} connected|list|internals|blank [backlight]|unblank" >&2 + exit 2 + ;; +esac diff --git a/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend b/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend index da2f36524ca..f38d96ee0ea 100755 --- a/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend +++ b/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-fake-suspend @@ -25,16 +25,7 @@ check_hardware_suspend_enabled() { } check_hdmi_connected() { - for status_file in /sys/class/drm/card*-HDMI-A-[0-9]/status /sys/class/drm/card*-DP-[0-9]/status; do - if [[ -f "$status_file" ]]; then - local HDMI_STATUS=$(cat "$status_file") - if [[ "${HDMI_STATUS}" = "connected" ]]; then - return 0 - fi - fi - done - - return 1 + /usr/bin/external-display connected } check_charging() { @@ -65,64 +56,22 @@ check_es_running_game() { fi } -backlight_off() { - ${DEBUG} && log $0 "Backlight off" - - for BL_POWER_DEVICE in "${BL_POWER_DEVICES[@]}"; do - echo 4 > "${BL_POWER_DEVICE}" - done -} - -backlight_on() { - ${DEBUG} && log $0 "Backlight on" - - for BL_POWER_DEVICE in "${BL_POWER_DEVICES[@]}"; do - echo 0 > "${BL_POWER_DEVICE}" - done -} - display_off() { + # external-display blanks internal panels only. A DPMS off on an + # active DP output kills the alt mode audio jack until a physical + # replug (verified on AYN Odin 2 with the official dock). if [[ "${ENABLE_DPMS}" == "1" ]]; then - # DPMS enabled, use it to turn off the display ${DEBUG} && log $0 "DPMS - display off" - - if [[ -n $(echo "${UI_SERVICE}" | grep "sway") && -n $(pgrep "sway") ]]; then - ${DEBUG} && log $0 "Display power off" - # Only power off internal panels: power cycling an external - # output kills the DP alt mode audio jack until a physical - # replug (verified on AYN Odin 2 with the official dock). - for out in $(wlr-randr 2>/dev/null | awk '/^(DSI|eDP|LVDS)-/ { print $1 }'); do - swaymsg "output ${out} power off" - done - elif [[ -n $(echo "${UI_SERVICE}" | grep "weston") && -n $(pgrep "weston") ]]; then - weston-dpms -m off - else - # Fallback - just turn off the backlight - backlight_off - fi + /usr/bin/external-display blank else - # DPMS disabled, just turn off the backlight - backlight_off + ${DEBUG} && log $0 "Backlight off" + /usr/bin/external-display blank backlight fi } display_on() { - if [[ "${ENABLE_DPMS}" == "1" ]]; then - # DPMS enabled, use it to turn on the display - ${DEBUG} && log $0 "DPMS - display on" - - if [[ -n $(echo "${UI_SERVICE}" | grep "sway") && -n $(pgrep "sway") ]]; then - ${DEBUG} && log $0 "Display power on" - swaymsg "output * power on" - elif [[ -n $(echo "${UI_SERVICE}" | grep "weston") && -n $(pgrep "weston") ]]; then - weston-dpms -m on - else - backlight_on - fi - else - # Fallback - just turn on the backlight - backlight_on - fi + ${DEBUG} && log $0 "Display on" + /usr/bin/external-display unblank } mute_audio() { @@ -430,12 +379,6 @@ INPUT_WHITELIST=( # Ayn Thor gpio-keys contains lid events [[ "${QUIRK_DEVICE}" == "AYN Thor" ]] && INPUT_WHITELIST+=("gpio-keys") -# Backlight power devices -declare -a BL_POWER_DEVICES=() -while IFS= read -r line; do - BL_POWER_DEVICES+=("$line") -done < <(find /sys/class/backlight/*/ -name bl_power 2>/dev/null) - # Source = power / lid SOURCE=$1 diff --git a/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor b/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor index fd6599209a6..ab5c5c399ba 100644 --- a/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor +++ b/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor @@ -35,15 +35,11 @@ _get_present_externals() { # which otherwise stays undetected forever), and sway can also # leave a connected output disabled after an EDID reprobe. In both # cases the explicit enable below brings the display up. - local c - for c in /sys/class/drm/card*-DP-* /sys/class/drm/card*-HDMI-A-*; do - [ -e "${c}/status" ] || continue - [ "$(cat "${c}/status")" = "connected" ] && basename "${c}" | sed 's/^card[0-9]*-//' - done + external-display list } _get_all_internals() { - wlr-randr 2>/dev/null | awk '/^(DSI|eDP|LVDS)-/ { print $1 }' + external-display internals } _panel_orientation_transform() { From d8f0ef3cbe56d3eaaf702e266a989a21792e33af Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Fri, 14 Aug 2026 16:22:09 +0800 Subject: [PATCH 3/3] odin2: restore the speaker route when an external display goes away Found on an AYN Odin 2 on kernel 7.1.2. Unplug an external display and audio is gone until a reboot. Every userspace indicator looks healthy while it happens, which is what made it hard to see: the Speaker sink is marked default, both sinks sit at the user volume, and the amp registers read 0. The DSP is where it goes wrong. DP0 Jack stays on after the cable is pulled, so the route stays on DISPLAY_PORT_RX_0 with RX_CODEC_DMA_RX_0 and PRIMARY_MI2S_RX switched off, and playback lands on a backend with nothing on the end of it. Userspace does not notice because nothing in userspace is wrong. _switch_audio was gated off entirely here because the micro HDMI port carries no audio. That gate also suppressed the switch back, which is the only thing that rebuilds the route. It was invisible until now because on 7.0 the jack reported the unplug and wireplumber moved audio back by itself. Gate the outbound direction only: docking still needs no help, coming back does. Re-selecting the profile re-initialises the codec and resets SPK_L and SPK_R PCM Playback Volume to their silent 1023 default, and that can land after the single write that unparks them. Assert the unpark until it sticks, otherwise the speakers stay mute with everything else looking fine. Pin auto to 60 Hz while here. Falling back to 1920x1080 at any refresh let a dock's EDID put the output at 100 or 144 Hz. auto now tries 1080p60, then 720p60, then leaves the sink on its preferred mode. Tested on an AYN Odin 2, dock and micro HDMI, connect and disconnect: audio returns to the speakers every time with no reboot. Co-Authored-By: Claude Opus 5 --- .../sources/scripts/output_monitor | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor b/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor index ab5c5c399ba..b7256f1d12d 100644 --- a/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor +++ b/projects/ROCKNIX/packages/sysutils/system-utils/sources/scripts/output_monitor @@ -6,7 +6,8 @@ # internal panels (DSI, eDP) off/on accordingly. # # The external_display.mode setting picks the mode for the external -# output: "auto" (1080p60, the default) or a fixed WIDTHxHEIGHT@RATE. +# output: "auto" (1080p60, else 720p60, else the sink's preferred mode) or +# a fixed WIDTHxHEIGHT@RATE. auto never picks a refresh other than 60. # # The sway IPC subscription is used rather than udev because the actions # (wlr-randr, swaymsg) require sway to be running and to have already @@ -84,13 +85,13 @@ _wp_profile_index() { } _switch_audio() { - # Skip on devices whose external display cannot carry audio (e.g. - # AYN Odin 2). Sound stays on the speakers, and the profile churn - # that can reset volumes to 100% never happens. - [ "${DEVICE_EXTERNAL_DISPLAY_AUDIO}" = "false" ] && return 0 local card target i card=$(_wp_audio_card); [ -n "$card" ] || return 0 if [ "$1" = "external" ]; then + # Gated only in this direction: where the external output carries no + # audio (micro HDMI on the AYN Odin 2) there is nothing to switch to, + # and the profile probing below can reset sink volumes to 100%. + [ "${DEVICE_EXTERNAL_DISPLAY_AUDIO}" = "false" ] && return 0 target=$(_wp_profile_index "$card" "HDMI") if [ -z "$target" ]; then systemctl restart wireplumber 2>/dev/null || true @@ -102,6 +103,16 @@ _switch_audio() { done fi else + # Never gated. Restoring the internal route is right whatever the + # external output was, and it is the only thing that recovers a stuck + # DP jack: on 7.1.2 "DP0 Jack" can stay on after unplug, leaving the + # DSP routed to DISPLAY_PORT_RX_0 with the speaker path disconnected, + # so playback goes to a dead backend until something re-selects the + # profile. Verified on an AYN Odin 2: silent until a reboot without it. + # + # The card exposes two profiles both named "HiFi"; the first is the one + # without the DisplayPort sink, which is why it has to be picked by + # index rather than trusted by name. target=$(_wp_profile_index "$card" "HiFi") fi [ -n "$card" ] && [ -n "$target" ] && timeout 3 wpctl set-profile "$card" "$target" 2>/dev/null || true @@ -224,10 +235,22 @@ _restore_volume() { timeout 2 wpctl set-volume "${def}" \ "$(awk "BEGIN { printf \"%.2f\", ${target} / 100 }")" 2>/dev/null fi - # Single write: the amp applies its own internal fade - # (Fade Step register); stepping it from the shell instead - # produced audible sound/stop/sound gaps. - _set_amp_volume 0 + # The amp applies its own internal fade (Fade Step register), so + # this is one write and not a shell-side ramp, which produced + # audible sound/stop/sound gaps. + # + # Asserted until it sticks: _switch_audio above re-selects the card + # profile, which re-initialises the codec and puts these registers + # back to their silent 1023 default. That can land after this point, + # and a single write then loses the race and leaves the speakers + # mute with everything else looking healthy. + for a in 1 2 3 4 5; do + _set_amp_volume 0 + sleep 0.4 + cur=$(timeout 2 amixer -c 0 cget name="SPK_L PCM Playback Volume" 2>/dev/null \ + | awk -F= '/: values/ { print $2 }') + [ "${cur}" = "0" ] && break + done fi ) /dev/null 2>&1 & } @@ -262,8 +285,12 @@ _apply() { wlr-randr --output "$ext_out" --mode "$mode" 2>/dev/null || mode="auto" fi if [ "$mode" = "auto" ]; then + # 60 Hz only. Falling back to 1920x1080 at any refresh let a dock's + # own EDID put the output at 100 or 144 Hz, which is not what auto + # should mean here. If the sink offers no 60 Hz mode at all, leave it + # on its preferred one rather than guessing. wlr-randr --output "$ext_out" --mode 1920x1080@60 2>/dev/null \ - || wlr-randr --output "$ext_out" --mode 1920x1080 2>/dev/null || true + || wlr-randr --output "$ext_out" --mode 1280x720@60 2>/dev/null || true fi swaymsg "[app_id=\"emulationstation\"] move output ${ext_out}" 2>/dev/null || true # Stop USB gadget: its data lanes conflict with DP Alt Mode on RK3576