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/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 0f036f0f9b1..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; 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,69 +56,34 @@ 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" - swaymsg "output * power off" - 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() { - ${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() { @@ -423,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 1a0a0e47e90..b7256f1d12d 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,10 @@ # 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, 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 # processed the new output. Subscribing to sway output events is @@ -16,27 +20,66 @@ # 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. + external-display list } _get_all_internals() { - wlr-randr 2>/dev/null | awk '/^(DSI|eDP|LVDS)-/ { print $1 }' + external-display internals +} + +_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 } }' } @@ -45,6 +88,10 @@ _switch_audio() { 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 @@ -56,43 +103,216 @@ _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" ] && 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 + # 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 & } _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 + # 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 - swaymsg "[app_id=\"emulationstation\"] move output ${ext_out}" 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 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)