Skip to content

Instantly share code, notes, and snippets.

@7tg
Created July 31, 2026 08:27
Show Gist options
  • Select an option

  • Save 7tg/bd5ad6d409dd0f020c67bd0b6750a573 to your computer and use it in GitHub Desktop.

Select an option

Save 7tg/bd5ad6d409dd0f020c67bd0b6750a573 to your computer and use it in GitHub Desktop.
Per-client native resolutions (+HDR) for headless Sunshine game streaming via custom EDID modes (Linux/Wayland/amdgpu)

Per-client native resolutions (+ HDR) for headless Sunshine streaming, via custom EDID modes

Stream from a Linux Sunshine host to devices with non-standard native panels — a 19.5:9 phone (2416×1080@120), a notched MacBook Air 15" M2 (2880×1864@60), a 16:10 120 Hz tablet (2560×1600@120) — at each device's exact resolution and refresh, so nothing is letterboxed or downscaled.

The trick: add custom modes to the EDID of the headless HDMI dongle the host renders to, then point per-client Sunshine apps at those modes.


The problem

Sunshine on Linux captures a real display. For a headless streaming box you plug a cheap HDMI EDID emulator dongle into the GPU so it has a display to drive. But those dongles only advertise standard 16:9 modes (4K60, 1440p, 1080p…). Client devices have odd native geometries — 19.5:9 phones, notched laptops (full panel incl. the menu-bar strip), 16:10 120 Hz tablets — so the stream is either pillar/letter-boxed or scaled off the native grid.

On Wayland this is worse than X11: there is no xrandr --newmode. kscreen-doctor (KDE) can only select modes that already exist — you cannot inject a live modeline. The single lever is the EDID the kernel reads for that connector.

So: craft an EDID that advertises the exact modes you want, apply it to the connector (runtime for testing, kernel cmdline for permanent), and Sunshine can now solo the dongle at a client-matched mode.


Environment

  • Linux, KMS amdgpu (Intel/Nvidia-KMS work too; boot-integration details differ), Wayland + KDE Plasma (kscreen-doctor).
  • A passive HDMI EDID dongle on a connector, e.g. HDMI-A-2 (find yours: ls /sys/class/drm/card*-*).
  • Tools: cvt (libxcvt), edid-decode, Python 3, Sunshine. Optional: rsvg-convert for cover art.
  • For the runtime method, the kernel must not be in lockdown: cat /sys/kernel/security/lockdown[none].

Step 0 — inspect the dongle

# connector name + card number
ls /sys/class/drm/card*-HDMI-A-2
# dump its current EDID and read it
cp /sys/class/drm/card1-HDMI-A-2/edid dongle-orig.bin
edid-decode dongle-orig.bin | less

Two things to note in edid-decode:

  • Max TMDS (HDMI-Forum VSDB, e.g. "Maximum TMDS Character Rate: 600 MHz") — your new mode's pixel clock must stay under this. A 4K60 dongle usually declares 600 MHz, plenty for these modes.
  • Whether HDR is advertised (Colorimetry BT.2020 + "HDR Static Metadata … SMPTE ST2084") — if so, you can stream HDR with no EDID change.

Step 1 — build a custom EDID

Compute a reduced-blanking timing and encode it as a Detailed Timing Descriptor, then add it to the dongle's EDID without losing its existing modes. edid_add_mode.py (in this gist) does all of it:

# reuses a cosmetic descriptor slot (serial/name) if free, else appends a CTA block
./edid_add_mode.py dongle-orig.bin dongle-custom.bin 2416x1080@120   # phone
./edid_add_mode.py dongle-custom.bin dongle-custom.bin 2880x1864@60  # macbook (notch-incl.)
./edid_add_mode.py dongle-custom.bin dongle-custom.bin 2560x1600@120 # tablet

edid-decode dongle-custom.bin   # ALWAYS validate — must show 0 warnings/errors

Under the hood it runs cvt -r W H R, packs the 16-byte-clock / active / blanking / sync fields into an 18-byte DTD, drops it into a free base-block descriptor slot (the cosmetic Serial/Name descriptors are fair game), and recomputes the checksum. A 128-byte base block holds 4 descriptors; once they're full it appends a DTD-only CTA-861 extension block (EDID grows 128 bytes). Multi-block EDIDs are completely standard.

MacBook notch note: the 15" M2 panel is 2880×1864 (the below-notch 16:10 area is 2880×1800). Use 2880×1864 for edge-to-edge including the notch, then on the Mac uncheck Get Info → “Scale to fit below built-in camera” on the Moonlight app and set a matching custom resolution.


Step 2 — Method A: apply at runtime (test, no reboot)

DRM debugfs lets you inject an EDID and re-probe the connector live. Great for testing before you commit.

CARD=1   # the N in /sys/kernel/debug/dri/N that has your connector
sudo sh -c "cat dongle-custom.bin > /sys/kernel/debug/dri/$CARD/HDMI-A-2/edid_override
            echo 1 > /sys/kernel/debug/dri/$CARD/HDMI-A-2/trigger_hotplug"

# the new modes now appear (this is RAM-only; a reboot reverts it):
kscreen-doctor -o | grep -A2 HDMI-A-2

Blocked only if the kernel is in lockdown (Secure Boot). To revert without rebooting, re-inject the original dongle-orig.bin, or just reboot.


Step 3 — Method B: make it permanent (kernel cmdline + initramfs)

Install the EDID as firmware and tell the kernel to use it for that connector. Because the GPU driver is loaded early from the initramfs (KMS/plymouth), the EDID must be embedded in the initramfs too — this is the #1 thing people miss.

sudo install -Dm644 dongle-custom.bin /usr/lib/firmware/edid/dongle-custom.bin

mkinitcpio — add the file to FILES in /etc/mkinitcpio.conf:

FILES=(/usr/lib/firmware/edid/dongle-custom.bin)

Kernel cmdline — add (adjust connector name):

drm.edid_firmware=HDMI-A-2:edid/dongle-custom.bin

Do not add a video= parameter — you want the mode available, not the dongle force-enabled at boot.

Where the cmdline lives depends on your bootloader:

  • GRUB: GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub, then grub-mkconfig -o /boot/grub/grub.cfg.
  • systemd-boot: the entry in /boot/loader/entries/*.conf (or /etc/kernel/cmdline for UKIs).
  • Limine (CachyOS): KERNEL_CMDLINE in /etc/default/limine; regenerate with sudo limine-update.

Then rebuild the initramfs (sudo mkinitcpio -P, or on Limine sudo limine-update does both). Verify before rebooting:

# cmdline made it into the bootloader config
grep edid_firmware /boot/limine.conf   # or grub.cfg / loader entry
# and the EDID is actually inside the initramfs
lsinitcpio /boot/.../initramfs | grep firmware/edid

If runtime (Method A) worked but boot didn't, it's almost always the initramfs/cmdline step above.


Step 4 — Sunshine per-client apps

Sunshine doesn't auto-switch the host resolution on Linux, so each app's prep-cmd solos the dongle at the right mode and restores your desktop on exit. Two helper scripts (in this gist):

  • sunshine-display-state.py save|restore <profile> — snapshots/restores the full KDE display layout.
  • sunshine-display-solo.py <output> <mode> <enable|disable> — enables one output at <mode> and disables the rest; the 3rd arg toggles HDR. Both handle the amdgpu "one head-change per atomic commit" quirk (single-head enables flakily fail the modeset test; retry + shed extra heads first).

Example app (Sunshine apps.json) — a phone at its native mode:

{
  "name": "Phone",
  "prep-cmd": [
    { "do": "sunshine-display-state.py save Phone",
      "undo": "sunshine-display-state.py restore Phone" },
    { "do": "sunshine-display-solo.py HDMI-A-2 2416x1080@120 disable", "undo": "" }
  ],
  "wait-all": true
}

HDR

If the dongle EDID advertises HDR (check Step 0), just flip the solo command's last arg to enable:

sunshine-display-solo.py HDMI-A-2 2560x1600@120 enable

and make sure Sunshine advertises 10-bit codecs (sunshine.conf: hevc_mode = 3, av1_mode = 3). Enable HDR in the Moonlight client too. HDR looks best on actual HDR game content — streaming the SDR desktop through the HDR pipe tends to look washed out.


Step 5 — cover art (optional)

gen_covers.py renders clean 600×800 box-art tiles (SVG → PNG via rsvg-convert) — one per device with a vector glyph, the spec line, and an HDR badge on HDR variants — and you set each as the app's image-path.

python3 gen_covers.py && \
for f in covers_svg/*.svg; do rsvg-convert -w600 -h800 "$f" -o "covers/$(basename "$f" .svg).png"; done

Gotchas

  • Wayland/KDE has no live modeline. EDID injection is the only path; kscreen-doctor only selects existing modes.
  • Initramfs is mandatory for the boot method when KMS loads early — embed the EDID via FILES.
  • Pixel-clock ceiling = the dongle's declared max TMDS (HDMI-Forum VSDB). Passive dongles don't decode the signal, so you're bound by that number, not the dongle's "4K60" label.
  • Don't force-enable (video=…e) unless you want a permanent phantom display.
  • CVT rounds horizontal active up to a multiple of 8 (e.g. 2412 → 2416) — a few px, harmless; the client scales.
  • Always edid-decode the result. Zero warnings/errors before you trust it.

Files in this gist: edid_add_mode.py (build/patch EDIDs), sunshine-display-solo.py + sunshine-display-state.py (Sunshine prep-cmd helpers), gen_covers.py (cover art).

#!/usr/bin/env python3
"""
Add a custom video mode (DTD) to an existing EDID binary.
Reads a base EDID .bin (e.g. dumped from your dongle), computes a CVT
reduced-blanking timing for the requested WxH@R with `cvt -r`, encodes it as an
18-byte Detailed Timing Descriptor, and inserts it:
1. into a free/cosmetic base-block descriptor slot (Serial 0xFF, Name 0xFC, or
Dummy 0x10) if one exists -- keeping the EDID at 128/256 bytes; else
2. appended as a DTD-only CTA-861 extension block (grows the EDID by 128 bytes).
Existing modes are preserved. Checksums are recomputed. Validate the result with
`edid-decode out.bin` before using it.
Usage:
./edid_add_mode.py in.bin out.bin 2560x1600@120
./edid_add_mode.py in.bin out.bin 2880x1864@60 --keep-name
Notes:
* The connector's max TMDS (HDMI-Forum VSDB in the source EDID) must cover the
new pixel clock. Most 4K60 dongles declare 600 MHz -- check `edid-decode`.
* This does NOT force-enable the output; it only makes the mode selectable.
"""
import re
import subprocess
import sys
def cvt_modeline(w, h, r):
out = subprocess.run(["cvt", "-r", str(w), str(h), str(r)],
capture_output=True, text=True, check=True).stdout
m = re.search(r'Modeline\s+"[^"]+"\s+([\d.]+)\s+' + r'(\d+)\s+' * 7 + r'(\d+)\s+([+-]hsync)\s+([+-]vsync)', out)
if not m:
sys.exit(f"could not parse cvt output:\n{out}")
g = m.groups()
return {
"pclk": float(g[0]),
"hact": int(g[1]), "hss": int(g[2]), "hse": int(g[3]), "htot": int(g[4]),
"vact": int(g[5]), "vss": int(g[6]), "vse": int(g[7]), "vtot": int(g[8]),
"hpos": g[9] == "+hsync", "vpos": g[10] == "+vsync",
}
def build_dtd(t):
pclk = round(t["pclk"] * 100) # 10 kHz units
if pclk > 0xFFFF:
sys.exit("pixel clock exceeds the 655.35 MHz DTD field")
hbl, vbl = t["htot"] - t["hact"], t["vtot"] - t["vact"]
hso, hsw = t["hss"] - t["hact"], t["hse"] - t["hss"]
vso, vsw = t["vss"] - t["vact"], t["vse"] - t["vss"]
b = bytearray(18)
b[0], b[1] = pclk & 0xFF, (pclk >> 8) & 0xFF
b[2], b[3] = t["hact"] & 0xFF, hbl & 0xFF
b[4] = ((t["hact"] >> 8) << 4) | ((hbl >> 8) & 0xF)
b[5], b[6] = t["vact"] & 0xFF, vbl & 0xFF
b[7] = ((t["vact"] >> 8) << 4) | ((vbl >> 8) & 0xF)
b[8], b[9] = hso & 0xFF, hsw & 0xFF
b[10] = ((vso & 0xF) << 4) | (vsw & 0xF)
b[11] = (((hso >> 8) & 3) << 6) | (((hsw >> 8) & 3) << 4) | \
(((vso >> 4) & 3) << 2) | ((vsw >> 4) & 3)
flags = 0x18 # digital separate sync
if t["vpos"]: flags |= 0x04
if t["hpos"]: flags |= 0x02
b[17] = flags
return bytes(b)
def cksum(block): # returns the byte that zeroes the sum
return (256 - sum(block[:127]) % 256) % 256
REPLACEABLE = {0xFF, 0xFC, 0x10} # serial, name, dummy
def add_mode(edid, dtd, keep_name):
edid = bytearray(edid)
# try to reuse a cosmetic base-block descriptor slot
for off in (54, 72, 90, 108):
d = edid[off:off + 18]
is_display_desc = d[0] == 0 and d[1] == 0
tag = d[3]
if is_display_desc and tag in REPLACEABLE and not (keep_name and tag == 0xFC):
edid[off:off + 18] = dtd
edid[127] = cksum(edid[:128])
return bytes(edid), f"reused base descriptor @{off} (tag 0x{tag:02x})"
# else append a DTD-only CTA-861 extension block
ext = bytearray(128)
ext[0], ext[1], ext[2], ext[3] = 0x02, 0x03, 0x04, 0x00 # CTA rev3, DTDs at off 4
ext[4:22] = dtd
ext[127] = cksum(ext)
edid[126] += 1 # extension count
edid[127] = cksum(edid[:128])
return bytes(edid) + bytes(ext), f"appended CTA block (EDID now {len(edid) + 128} bytes)"
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
keep_name = "--keep-name" in sys.argv
if len(args) != 3:
sys.exit(__doc__)
inp, outp, mode = args
m = re.fullmatch(r"(\d+)x(\d+)@(\d+)", mode)
if not m:
sys.exit("mode must look like 2560x1600@120")
w, h, r = map(int, m.groups())
dtd = build_dtd(cvt_modeline(w, h, r))
result, how = add_mode(open(inp, "rb").read(), dtd, keep_name)
open(outp, "wb").write(result)
print(f"added {mode}: {how}\nwrote {outp} -- validate with: edid-decode {outp}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Generate Sunshine app cover art (600x800 SVG) for all device/command apps."""
import os
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "covers_svg")
os.makedirs(OUT, exist_ok=True)
# ---------- device glyphs (drawn ~centered around y=300) ----------
def phone_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<rect x="228" y="150" width="144" height="330" rx="30" fill="#161618" stroke="{stroke}" stroke-width="3"/>
<rect x="239" y="163" width="122" height="304" rx="20" fill="{screen}"/>
<circle cx="300" cy="179" r="4" fill="#3a3a3e"/>
<circle cx="300" cy="454" r="9" fill="none" stroke="#d71921" stroke-width="3"/>
</g>'''
def mac_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<rect x="176" y="170" width="248" height="168" rx="11" fill="#0f1114" stroke="{stroke}" stroke-width="3"/>
<rect x="187" y="181" width="226" height="146" rx="5" fill="{screen}"/>
<rect x="286" y="170" width="28" height="8" rx="3" fill="{stroke}"/>
<path d="M150,340 H450 L474,372 Q477,379 469,379 H131 Q123,379 126,372 Z"
fill="#cbced4" stroke="#9aa0a8" stroke-width="1.5"/>
<path d="M278,340 h44 l-5,7 h-34 z" fill="#adb2b9"/>
</g>'''
def tablet_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<rect x="146" y="206" width="308" height="192" rx="20" fill="#181c22" stroke="{stroke}" stroke-width="3"/>
<rect x="159" y="219" width="282" height="166" rx="9" fill="{screen}"/>
<circle cx="300" cy="212" r="3" fill="#3a3a3e"/>
</g>'''
def desktop_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<rect x="168" y="176" width="264" height="168" rx="12" fill="#0f1114" stroke="{stroke}" stroke-width="3"/>
<rect x="179" y="187" width="242" height="146" rx="5" fill="{screen}"/>
<rect x="280" y="344" width="40" height="30" fill="#c5c9d0"/>
<rect x="243" y="372" width="114" height="15" rx="7" fill="#c5c9d0"/>
</g>'''
def tv_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<rect x="138" y="186" width="324" height="190" rx="10" fill="#0e1013" stroke="{stroke}" stroke-width="3"/>
<rect x="149" y="197" width="302" height="168" rx="4" fill="{screen}"/>
<rect x="192" y="376" width="16" height="20" rx="3" fill="#b9bec6"/>
<rect x="392" y="376" width="16" height="20" rx="3" fill="#b9bec6"/>
</g>'''
def nswitch_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<path d="M232,190 h-26 a40,40 0 0 0 -40,40 v140 a40,40 0 0 0 40,40 h26 z" fill="#00a7e0"/>
<path d="M368,190 h26 a40,40 0 0 1 40,40 v140 a40,40 0 0 1 -40,40 h-26 z" fill="#e60012"/>
<rect x="228" y="190" width="144" height="220" rx="8" fill="#0c0e12" stroke="#26262a" stroke-width="2"/>
<rect x="238" y="200" width="124" height="200" rx="4" fill="{screen}"/>
<circle cx="196" cy="236" r="9" fill="#08080a" opacity="0.45"/>
<circle cx="404" cy="234" r="8" fill="#08080a" opacity="0.45"/>
<circle cx="404" cy="262" r="8" fill="#08080a" opacity="0.45"/>
<rect x="190" y="316" width="12" height="36" rx="4" fill="#08080a" opacity="0.4"/>
</g>'''
def power_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<path d="M327.4,224.8 A80,80 0 1 1 272.6,224.8" fill="none"
stroke="{stroke}" stroke-width="18" stroke-linecap="round"/>
<path d="M300,196 V288" fill="none" stroke="{stroke}" stroke-width="18" stroke-linecap="round"/>
</g>'''
def restart_glyph(stroke, screen):
return f'''
<g filter="url(#drop)">
<path d="M300,212 A80,80 0 1 1 236,244" fill="none"
stroke="{stroke}" stroke-width="18" stroke-linecap="round"/>
<polygon points="284,194 284,234 316,214" fill="{stroke}"/>
</g>'''
GLYPH = {"phone": phone_glyph, "mac": mac_glyph, "tablet": tablet_glyph,
"desktop": desktop_glyph, "tv": tv_glyph, "nswitch": nswitch_glyph,
"power": power_glyph, "restart": restart_glyph}
def build(name, spec, device, accent, bg1, bg2, hdr):
screen_fill = "url(#hdrscreen)" if hdr else "url(#sdrscreen)"
hdr_badge = hdr_glow = ""
if hdr:
hdr_glow = '<circle cx="300" cy="312" r="238" fill="url(#hdrglow)" opacity="0.55"/>'
hdr_badge = '''
<g filter="url(#drop)">
<rect x="392" y="40" width="168" height="60" rx="30" fill="url(#hdrgrad)"/>
<text x="476" y="82" font-family="DejaVu Sans, sans-serif" font-size="34"
font-weight="800" fill="#ffffff" text-anchor="middle" letter-spacing="3">HDR</text>
</g>'''
glyph = GLYPH[device](accent, screen_fill)
fs = 64 if len(name) <= 8 else 54
return f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="{bg1}"/><stop offset="1" stop-color="{bg2}"/>
</linearGradient>
<linearGradient id="sdrscreen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0b0e13"/>
<stop offset="0.5" stop-color="{accent}" stop-opacity="0.12"/>
<stop offset="1" stop-color="#10151d"/>
</linearGradient>
<linearGradient id="hdrscreen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff2d55"/><stop offset="0.28" stop-color="#ff9500"/>
<stop offset="0.5" stop-color="#ffe11a"/><stop offset="0.72" stop-color="#28c76f"/>
<stop offset="1" stop-color="#4b7bff"/>
</linearGradient>
<linearGradient id="hdrgrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ff3b6b"/><stop offset="0.35" stop-color="#ff9f1c"/>
<stop offset="0.65" stop-color="#2ec26a"/><stop offset="1" stop-color="#5b8dff"/>
</linearGradient>
<radialGradient id="hdrglow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ff4d6d" stop-opacity="0.9"/>
<stop offset="0.45" stop-color="#8a5bff" stop-opacity="0.5"/>
<stop offset="1" stop-color="#4b7bff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vign" cx="0.5" cy="0.42" r="0.75">
<stop offset="0" stop-color="{accent}" stop-opacity="0.16"/>
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
<filter id="drop" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#000000" flood-opacity="0.55"/>
</filter>
</defs>
<rect x="0" y="0" width="600" height="800" rx="40" fill="url(#bg)"/>
<rect x="0" y="0" width="600" height="800" rx="40" fill="url(#vign)"/>
{hdr_glow}
{glyph}
{hdr_badge}
<rect x="240" y="588" width="120" height="4" rx="2" fill="{accent}"/>
<text x="300" y="660" font-family="DejaVu Sans, sans-serif" font-size="{fs}"
font-weight="800" fill="#ffffff" text-anchor="middle">{name}</text>
<text x="300" y="708" font-family="DejaVu Sans, sans-serif" font-size="26"
fill="#9aa2ad" text-anchor="middle" letter-spacing="1">{spec}</text>
<rect x="1.5" y="1.5" width="597" height="797" rx="39" fill="none"
stroke="#ffffff" stroke-opacity="0.06" stroke-width="3"/>
</svg>'''
APPS = [
# id, name, spec, device, accent, bg1, bg2, hdr
("phone", "Phone", "2416 × 1080 · 120 Hz", "phone", "#e6e6ea", "#0e0e10", "#1a1a1d", False),
("phone-hdr", "Phone HDR", "2416 × 1080 · 120 Hz", "phone", "#e6e6ea", "#140b12", "#241019", True),
("mac", "Mac", "2880 × 1864 · 60 Hz", "mac", "#d3d7dd", "#232830", "#0e1116", False),
("tablet", "Tablet", "2560 × 1600 · 120 Hz", "tablet", "#5b8dff", "#111726", "#0a0d15", False),
("tablet-hdr", "Tablet HDR", "2560 × 1600 · 120 Hz", "tablet", "#5b8dff", "#140b18", "#0c0a1a", True),
("desktop", "Desktop", "3840 × 2160 · 60 Hz", "desktop", "#3daee9", "#1a2028", "#0c1015", False),
("tv", "TV", "3840 × 2160 · 60 Hz", "tv", "#b06cf0", "#1c1526", "#0d0a14", False),
("switch", "Switch", "1920 × 1080 · 60 Hz", "nswitch", "#e60012", "#1a1416", "#0d0a0b", False),
("reboot", "Reboot", "Restart host", "restart", "#22c55e", "#122016", "#0a120c", False),
("shutdown", "Shutdown", "Power off host", "power", "#ef4444", "#1f1214", "#120a0b", False),
]
for fid, name, spec, device, accent, bg1, bg2, hdr in APPS:
with open(os.path.join(OUT, f"{fid}.svg"), "w") as f:
f.write(build(name, spec, device, accent, bg1, bg2, hdr))
print("wrote", fid + ".svg")
#!/usr/bin/env python3
"""Enable the target output at the given mode and HDR setting; disable all other outputs."""
import json, subprocess, sys, time
USAGE = "usage: sunshine-display-solo.py <target> <mode> <enable|disable>"
def wait_for_enabled(name: str, timeout: float = 5.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
out = subprocess.run(
["kscreen-doctor", "--json"], check=True, capture_output=True, text=True
)
for o in json.loads(out.stdout)["outputs"]:
if o["name"] == name and o.get("enabled"):
return True
time.sleep(0.5)
return False
def apply_with_retry(target: str, target_args: list, attempts: int = 3) -> bool:
# the amdgpu atomic test flakily rejects even single-head enables;
# kwin recovers on retry, so retry until the output actually comes up
for _ in range(attempts):
subprocess.run(["kscreen-doctor", *target_args], check=True)
if wait_for_enabled(target):
return True
return False
def main() -> int:
if len(sys.argv) != 4 or sys.argv[3] not in ("enable", "disable"):
print(USAGE, file=sys.stderr)
return 1
target, mode, hdr = sys.argv[1], sys.argv[2], sys.argv[3]
out = subprocess.run(
["kscreen-doctor", "--json"], check=True, capture_output=True, text=True
)
state = json.loads(out.stdout)
outputs = state["outputs"]
target_out = next((o for o in outputs if o["name"] == target), None)
if target_out is None:
print(f"target {target} not present in kscreen output", file=sys.stderr)
return 1
target_args = [
f"output.{target}.enable",
f"output.{target}.mode.{mode}",
f"output.{target}.rotation.none",
f"output.{target}.hdr.{hdr}",
]
if "vrrPolicy" in target_out:
target_args.append(f"output.{target}.vrrpolicy.Never")
# One head change per commit: adding an output in a commit that also
# touches other heads fails amdgpu's modeset test (EINVAL) and kwin then
# disables every output. Shed extra heads first, keep one alive until the
# target is up (disabling the last output is refused), then drop the rest.
others_on = [
o["name"] for o in outputs if o["name"] != target and o.get("enabled")
]
if not target_out.get("enabled") and len(others_on) > 1:
subprocess.run(
["kscreen-doctor", *[f"output.{n}.disable" for n in others_on[:-1]]],
check=True,
)
time.sleep(1)
if not apply_with_retry(target, target_args):
print(f"failed to enable {target}", file=sys.stderr)
return 1
disable_rest = [
f"output.{o['name']}.disable" for o in outputs if o["name"] != target
]
if disable_rest:
subprocess.run(["kscreen-doctor", *disable_rest], check=True)
want_hdr = hdr == "enable"
if want_hdr and target_out.get("hdr") is not True:
subprocess.run(["kscreen-doctor", f"output.{target}.disable"], check=True)
time.sleep(1)
apply_with_retry(target, target_args)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Snapshot / restore KDE display state for Sunshine prep-cmd."""
import json, os, subprocess, sys, time
USAGE = "usage: sunshine-display-state.py save|restore <profile>"
STATE_DIR = os.path.expanduser("~/.config/sunshine-display")
ROTATION = {1: "none", 2: "left", 4: "inverted", 8: "right"}
VRR_POLICY = {0: "Never", 1: "Always", 2: "Automatic"}
def state_path(profile: str) -> str:
return os.path.join(STATE_DIR, f"sunshine-displays-{profile}.json")
def save(profile: str) -> int:
out = subprocess.run(
["kscreen-doctor", "--json"], check=True, capture_output=True, text=True
)
os.makedirs(STATE_DIR, exist_ok=True)
with open(state_path(profile), "w") as f:
f.write(out.stdout)
return 0
def output_args(o: dict) -> list:
name = o["name"]
if not o.get("enabled"):
return [f"output.{name}.disable"]
args = [f"output.{name}.enable"]
cm_id = o.get("currentModeId")
cm = next((m for m in o.get("modes", []) if m.get("id") == cm_id), None)
if cm and cm.get("name"):
args.append(f"output.{name}.mode.{cm['name']}")
if "hdr" in o:
args.append(f"output.{name}.hdr.{'enable' if o['hdr'] else 'disable'}")
if "wcg" in o:
args.append(f"output.{name}.wcg.{'enable' if o['wcg'] else 'disable'}")
if "brightness" in o:
pct = max(0, min(100, int(round(o["brightness"] * 100))))
args.append(f"output.{name}.brightness.{pct}")
if o.get("rotation") in ROTATION:
args.append(f"output.{name}.rotation.{ROTATION[o['rotation']]}")
if o.get("vrrPolicy") in VRR_POLICY:
args.append(f"output.{name}.vrrpolicy.{VRR_POLICY[o['vrrPolicy']]}")
if isinstance(o.get("priority"), int):
args.append(f"output.{name}.priority.{o['priority']}")
pos = o.get("pos")
if isinstance(pos, dict) and "x" in pos and "y" in pos:
args.append(f"output.{name}.position.{pos['x']},{pos['y']}")
return args
def wait_for_enabled(name: str, timeout: float = 15.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
cur = subprocess.run(
["kscreen-doctor", "--json"], check=True, capture_output=True, text=True
)
for o in json.loads(cur.stdout)["outputs"]:
if o["name"] == name and o.get("connected") and o.get("enabled"):
return True
time.sleep(0.5)
return False
def apply_output(o: dict, attempts: int = 3) -> bool:
# the amdgpu atomic test flakily rejects even single-head enables;
# kwin recovers on retry, so retry until the output actually comes up
for _ in range(attempts):
subprocess.run(["kscreen-doctor", *output_args(o)], check=True)
if wait_for_enabled(o["name"], 5.0):
return True
return False
def restore(profile: str) -> int:
path = state_path(profile)
if not os.path.exists(path):
return 0
with open(path) as f:
state = json.load(f)
out = subprocess.run(
["kscreen-doctor", "--json"], check=True, capture_output=True, text=True
)
cur = {o["name"]: o for o in json.loads(out.stdout)["outputs"]}
disable_args = []
enabled = []
for o in state["outputs"]:
if o.get("enabled"):
enabled.append(o)
else:
disable_args.extend(output_args(o))
# One output per commit: enabling both 4K@160 heads in a single atomic
# commit fails amdgpu's modeset test (EINVAL) and kwin then disables
# every output. Reconfigure already-enabled outputs first so a solo
# monitor drops HDR before the second high-bandwidth stream is added.
# Disables run after the first enable: turning off the last enabled
# output (e.g. the stream target) is refused by kscreen-doctor.
enabled.sort(key=lambda o: not cur.get(o["name"], {}).get("enabled"))
for i, o in enumerate(enabled):
apply_output(o)
if i == 0 and disable_args:
subprocess.run(["kscreen-doctor", *disable_args], check=True)
for o in enabled:
name = o["name"]
if "hdr" not in o:
continue
if bool(cur.get(name, {}).get("hdr")) == bool(o["hdr"]):
continue
subprocess.run(["kscreen-doctor", f"output.{name}.disable"], check=True)
time.sleep(1)
apply_output(o)
primary = next((o for o in enabled if o.get("priority") == 1), None)
if primary:
wait_for_enabled(primary["name"])
# kwin auto-rearranges while outputs come up one at a time; fix layout
# in one pass (position/priority changes need no modeset).
fixup = []
for o in enabled:
pos = o.get("pos")
if isinstance(pos, dict) and "x" in pos and "y" in pos:
fixup.append(f"output.{o['name']}.position.{pos['x']},{pos['y']}")
if isinstance(o.get("priority"), int):
fixup.append(f"output.{o['name']}.priority.{o['priority']}")
if fixup:
subprocess.run(["kscreen-doctor", *fixup], check=True)
time.sleep(1)
out = subprocess.run(
["kscreen-doctor", "--json"], check=True, capture_output=True, text=True
)
after = {o["name"]: o.get("enabled") for o in json.loads(out.stdout)["outputs"]}
missing = [o for o in enabled if not after.get(o["name"])]
still_off = [o["name"] for o in missing if not apply_output(o)]
if missing and fixup:
subprocess.run(["kscreen-doctor", *fixup], check=True)
if still_off:
print(
f"restore incomplete, still off: {', '.join(still_off)}",
file=sys.stderr,
)
return 1
return 0
def main() -> int:
if len(sys.argv) != 3:
print(USAGE, file=sys.stderr)
return 1
cmd, profile = sys.argv[1], sys.argv[2]
if cmd == "save":
return save(profile)
if cmd == "restore":
return restore(profile)
print(USAGE, file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment