Fixing a black laptop screen after undocking in Omarchy


A local Hyprland fix for reliable switching between a laptop panel and docked monitors. Tested on Omarchy 4.0.6 on November 10, 2026.


The problem

My laptop worked with two external monitors through a Dell WD19TB/WD19TBS Thunderbolt dock. The built-in panel was intentionally disabled while docked. After unplugging the dock, however, the laptop screen stayed black—even after waiting. Reconnecting the dock restored access to the desktop.

The final fix was verified with a physical unplug and reconnect: the laptop panel activated after a few seconds, and reconnecting the dock enabled the external monitors and disabled the laptop panel again.


What blocked recovery

Hyprland can create a virtual output named FALLBACK when no usable physical output remains. Logs from the failed unplug showed this fallback output. The recovery code classified every non-internal output as external, so FALLBACK counted as an active external monitor. Recovery returned early instead of restoring the laptop panel.

The first attempted fix checked physical connector status but still accepted outputs with no matching DRM connector. That allowed FALLBACK through and failed a real unplug test. The working fix requires a connected DRM connector and explicitly rejects FALLBACK and HEADLESS outputs. This is the diagnosed behavior on this installation; it is not a claim that every Omarchy installation has the same issue.


How the installed solution works

A user-owned Python script reconciles display state. If either known dock monitor is physically connected and enabled, it calls Omarchy’s existing internal-monitor helper to disable the laptop panel. If another real external screen is active, it leaves the configuration alone. If no real external screen is active, it removes the persistent internal-disable and clamshell flags, reloads Hyprland when necessary, and explicitly restores a panel that remains disabled.

The script uses hyprctl monitors all -j so that disabled internal panels are still visible. It checks /sys/class/drm/card*-<connector>/status for physical connection status. The direct restore uses the panel’s reported scale, preferred mode, and automatic position. DPMS wake is sent only when recovery changes something, preserving normal idle and lock-screen blanking on an already-enabled panel.

Hyprland events invoke the script at startup, monitor addition/removal, and configuration reload. A systemd user timer runs it again roughly every two to three seconds as a safety net. A runtime file lock serializes timer and event invocations. No files under /usr/share/omarchy are modified.


Compatibility and prerequisites

This example targets the Lua-based Hyprland configuration installed on this Omarchy 4.0.6 machine. It uses hl.monitor, hl.on, hyprctl eval, and hl.dsp.dpms. Check your installed configuration and helpers before copying it onto a different Omarchy or Hyprland version. It requires Python 3, systemd user services, and the installed omarchy-hyprland-monitor-internal helper; that helper also uses jq.

The laptop in this setup is an HP Spectre with a 3840×2160 eDP-1 panel at scale 3. The dock drives two AOC CQ32G4 monitors at 2560×1440, 60 Hz, scale 1. These values are examples, not requirements for the recovery logic. This article covers the tested open-lid dock workflow; lid-close and suspend policies require separate validation.


1. Identify your outputs and back up your settings

hyprctl monitors all -j
mkdir -p ~/.config/hypr ~/.config/systemd/user
cp -a ~/.config/hypr/monitors.lua ~/.config/hypr/monitors.lua.before-dock-fix


Record the serial field for each dock monitor and its full description. Match monitors by description and serial rather than DP connector numbers, which can change across reconnections. Back up existing docked-panel scripts or service files too if those names already exist.


2. Install the recovery script


Save the following as ~/.config/hypr/docked-panel.py. Replace REPLACE_WITH_LEFT_SERIAL and REPLACE_WITH_RIGHT_SERIAL with the serial values from your own monitors. The rest is the installed, working script.

#!/usr/bin/env python3
"""Disable the panel on the known dock; recover it after physical disconnect."""
import fcntl
import json
import os
import re
import subprocess
from pathlib import Path

DOCK_SERIALS = {"REPLACE_WITH_LEFT_SERIAL", "REPLACE_WITH_RIGHT_SERIAL"}
DRM_ROOT = Path("/sys/class/drm")
TOGGLES = Path.home() / ".local/state/omarchy/toggles/hypr"


def connected(monitor):
    """Reject stale compositor outputs once DRM reports them disconnected."""
    if monitor["name"] == "FALLBACK" or monitor["name"].startswith("HEADLESS-"):
        return False
    statuses = list(DRM_ROOT.glob(f"card*-{monitor['name']}/status"))
    return any(p.read_text().strip() == "connected" for p in statuses)


def docked(monitors):
    return any(m.get("serial") in DOCK_SERIALS
               and not m.get("disabled", False) and connected(m) for m in monitors)


def run(*args):
    result = subprocess.run(args, capture_output=True, text=True, check=True, timeout=5)
    if args[:2] == ("hyprctl", "eval") and result.stdout.strip() != "ok":
        raise RuntimeError(f"Hyprland rejected panel recovery: {result.stdout}")
    return result


def read_monitors():
    return json.loads(run("hyprctl", "monitors", "all", "-j").stdout)


def reconcile(monitors):
    if docked(monitors):
        run("omarchy-hyprland-monitor-internal", "off")
        return
    external = any(not re.match(r"^(eDP|LVDS|DSI)-", m["name"])
                   and not m.get("disabled", False) and connected(m) for m in monitors)
    if external:
        return
    # 'recover' trusts the compositor's external list, which can lag DRM.
    # Clear the persistent disable even if a disconnected dock is still listed.
    flags = [TOGGLES / f"internal-monitor-{name}.lua" for name in ("disable", "clamshell")]
    changed = False
    for flag in flags:
        if flag.exists():
            flag.unlink()
            changed = True
    if changed:
        print("Undocked: cleared laptop disable flags", flush=True)
        run("hyprctl", "reload")
        monitors = read_monitors()
    for panel in monitors:
        if not re.fullmatch(r"(?:eDP|LVDS|DSI)-[A-Za-z0-9._-]+", panel["name"]):
            continue
        if panel.get("disabled", False):
            # Preserve the panel's configured scale while restoring its output.
            scale = float(panel.get("scale", 3))
            run("hyprctl", "eval", 'hl.monitor({ output = %s, mode = "preferred", position = "auto", scale = %s })'
                % (json.dumps(panel["name"]), scale))
            print(f"Restored {panel['name']} at scale {scale}", flush=True)
            changed = True
        if changed:
            run("hyprctl", "dispatch", 'hl.dsp.dpms({ action = "enable", monitor = %s })'
                % json.dumps(panel["name"]))


def main():
    lock_path = Path(os.environ["XDG_RUNTIME_DIR"]) / "docked-panel.lock"
    with lock_path.open("w") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        reconcile(read_monitors())


if __name__ == "__main__":
    main()


An output without a matching connected DRM connector is deliberately excluded. This keeps virtual outputs from blocking recovery. For remote or virtual-display workflows, adapt that policy deliberately. The fallback scale of 3 matches this laptop; change it if your machine needs another value and Hyprland does not report a scale.


3. Configure monitor layout and event hooks


Edit ~/.config/hypr/monitors.lua, preserving your existing environment and layout preferences. This is the relevant pattern from the tested configuration. Substitute your monitor descriptions, modes, positions, and scales. The laptop default scale of 3 and external layout are specific to this setup.

hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 3 })
hl.monitor({ output = "desc:AOC CQ32G4 REPLACE_WITH_LEFT_SERIAL", mode = "2560x1440@60", position = "0x0", scale = 1 })
hl.monitor({ output = "desc:AOC CQ32G4 REPLACE_WITH_RIGHT_SERIAL", mode = "2560x1440@60", position = "2560x0", scale = 1 })

-- Replace YOUR_USERNAME with your actual login name.
local function sync_docked_panel()
  hl.exec_cmd("python3 /home/YOUR_USERNAME/.config/hypr/docked-panel.py")
end
hl.on("hyprland.start", sync_docked_panel)
hl.on("monitor.added", sync_docked_panel)
hl.on("monitor.removed", sync_docked_panel)
hl.on("config.reloaded", sync_docked_panel)


Use the actual absolute home path if yours is outside /home. In the installed setup this path is /home/john/.config/hypr/docked-panel.py. The main ~/.config/hypr/hyprland.lua already loads hypr.monitors and then Omarchy’s dynamic toggle configuration. That toggle loading matters: removing a disable flag and reloading must remove its monitor rule.


4. Add the systemd safety timer


Save this as ~/.config/systemd/user/docked-panel.service. The installed service uses an absolute /home/john path; %h below makes the same service reusable for other users.

[Unit]
Description=Reconcile laptop display after dock changes
PartOf=graphical-session.target
After=graphical-session.target

[Service]
Type=oneshot
ExecStart=/usr/bin/python3 %h/.config/hypr/docked-panel.py
TimeoutStartSec=15


Save this as ~/.config/systemd/user/docked-panel.timer:

[Unit]
Description=Check laptop display recovery every two seconds
PartOf=graphical-session.target

[Timer]
OnActiveSec=2
OnUnitInactiveSec=2
AccuracySec=500ms

[Install]
WantedBy=graphical-session.target


Enable the timer from a terminal inside the running graphical session:

systemctl --user daemon-reload
systemctl --user enable --now docked-panel.timer
hyprctl reload
hyprctl configerrors
systemctl --user status docked-panel.timer --no-pager


Resolve any reported configuration errors before testing. The user service needs the graphical session environment, including XDG_RUNTIME_DIR and HYPRLAND_INSTANCE_SIGNATURE. If manual execution works but the timer fails to connect to Hyprland, inspect systemctl –user show-environment. In an affected session, systemctl –user import-environment HYPRLAND_INSTANCE_SIGNATURE WAYLAND_DISPLAY XDG_RUNTIME_DIR can import the current values. Avoid hardcoding an instance signature, which changes with the compositor session.


5. Verify both transitions

With the dock connected and its screens active, confirm that the laptop panel is disabled. Unplug the dock while the laptop lid is open: the panel should activate after a few seconds. Reconnect the dock: the external monitors should return to their configured layout and the laptop panel should disable again. Also check that ordinary idle blanking still works.

These are the two physical transitions confirmed by the user after installing the final fix. A live compositor test also successfully restored the panel at 3840×2160, scale 3. Simulated tests covered FALLBACK, stale disconnected dock entries, a missing disable flag, and avoiding an unnecessary wake of an already-enabled panel. Simulated checks alone were insufficient: the earlier version passed them but failed the physical unplug test.


Troubleshooting

hyprctl monitors all -j
hyprctl configerrors
systemctl --user status docked-panel.timer --no-pager
journalctl --user -u docked-panel.service -n 80 --no-pager
python3 ~/.config/hypr/docked-panel.py
for connector in /sys/class/drm/card*-*/status; do
  printf "%s: " "$connector"
  cat "$connector"
done

Look for FALLBACK in the compositor output, verify that real connectors change to disconnected after unplugging, and check the service journal for failures or recovery messages. A oneshot service normally becomes inactive after a successful run; the timer should remain active. A recovery message indicates work was attempted, not proof that the physical panel lit up.

The policy intentionally clears internal-monitor-disable.lua and internal-monitor-clamshell.lua when there is no active physical external output. That means a manual laptop-disable setting will not persist in that situation. It does not implement mirroring recovery, arbitrary GPU hotplug handling, or a replacement for the system’s lid and suspend policies.


Rollback


Disable the timer, remove the event-hook block added to monitors.lua, and restore your backed-up monitor configuration if desired. Remove only the script and units introduced by this procedure. Then reload the user manager and Hyprland.

systemctl --user disable --now docked-panel.timer
# Remove the added event hooks before deleting the script.
# Restore your monitor backup if appropriate.
rm ~/.config/systemd/user/docked-panel.service
rm ~/.config/systemd/user/docked-panel.timer
rm ~/.config/hypr/docked-panel.py
systemctl --user daemon-reload
omarchy-hyprland-monitor-internal on
hyprctl reload
hyprctl configerrors


Why this fix matters

A virtual fallback output is useful to the compositor, but it is not a physical screen someone can use after undocking. Recovery must distinguish the two. Requiring a connected DRM output before treating an external display as usable lets the laptop panel recover while preserving the intended docked layout.

This is a local workaround for the tested installation, not an upstream Omarchy release or an officially supported patch. Keep a backup and recheck it after upgrades that change monitor helpers, toggle paths, or Hyprland’s Lua API.

Leave a Reply

Your email address will not be published. Required fields are marked *