Repair night light on multi screens setup (GNOME / Wayland / Intel)

Continuing the discussion from Night Light not working after upgrade to Fedora 42 (GNOME 48.4, Wayland, Intel GPU):

I’ve found a fix with the help of Claude Opus 4.7:

Script fix-nightlight-edid.sh

#!/usr/bin/env bash
#
# fix-nightlight-edid.sh
# -----------------------
# Repare Night Light (GNOME/Wayland) sur un setup multi-ecrans ou
# DEUX (ou plus) ecrans externes IDENTIQUES ne recoivent pas le filtre.
#
# Cause : des ecrans identiques exposent un EDID binairement identique.
# colord genere alors le meme "device id" pour les deux, le second
# enregistrement echoue ("device id already exists"), et Night Light
# n'a plus de cible gamma sur l'ecran en collision.
#
# Solution : surcharger l'EDID de chaque sortie DisplayPort avec un
# numero de serie unique (+ checksum recalcule), pour TOUS les
# connecteurs susceptibles d'etre utilises par le dock. Ainsi, quel
# que soit le connecteur sur lequel un ecran atterrit au boot, son
# EDID reste unique et colord enregistre chaque ecran separement.
#
# Cible : Fedora / GNOME / Wayland / GPU Intel (i915) ou tout DRM/KMS.
# Necessite : root (sudo), python3, grubby.
#
# Usage :
#   chmod +x fix-nightlight-edid.sh
#   sudo ./fix-nightlight-edid.sh           # applique
#   sudo ./fix-nightlight-edid.sh --status  # diagnostic seul, sans modif
#   sudo ./fix-nightlight-edid.sh --revert  # annule le parametre kernel
#
# Idempotent : relancer le script ne casse rien, il regenere proprement.

set -euo pipefail

# --- Configuration ----------------------------------------------------------

# Connecteurs DP a couvrir. Ajuste si ton dock en utilise d'autres.
# (DP-5..DP-8 couvre la majorite des docks USB-C/Thunderbolt.)
CONNECTORS=(DP-5 DP-6 DP-7 DP-8)

# Repertoire ou seront stockes les EDID patches.
FW_DIR="/lib/firmware/edid"

# Carte DRM (card1 sur la plupart des portables Intel recents ;
# le script la detecte automatiquement, ceci n'est qu'un repli).
CARD_FALLBACK="card1"

# --- Helpers ----------------------------------------------------------------

red()  { printf '\033[31m%s\033[0m\n' "$*"; }
grn()  { printf '\033[32m%s\033[0m\n' "$*"; }
ylw()  { printf '\033[33m%s\033[0m\n' "$*"; }
info() { printf '  %s\n' "$*"; }

need_root() {
  if [[ $EUID -ne 0 ]]; then
    red "Ce script doit etre lance avec sudo."
    exit 1
  fi
}

detect_card() {
  # Trouve la carte qui possede des connecteurs DP connectes.
  for c in /sys/class/drm/card*-DP-*/status; do
    if [[ "$(cat "$c" 2>/dev/null)" == "connected" ]]; then
      basename "$(dirname "$c")" | sed 's/-DP-.*//'
      return 0
    fi
  done
  echo "$CARD_FALLBACK"
}

# --- Sous-commandes ---------------------------------------------------------

cmd_status() {
  local card; card="$(detect_card)"
  ylw "== Diagnostic Night Light / EDID =="
  info "Carte DRM detectee : $card"
  echo

  ylw "Sorties DisplayPort :"
  for f in /sys/class/drm/${card}-DP-*/status; do
    [[ -e "$f" ]] || continue
    local name; name="$(basename "$(dirname "$f")")"
    info "$name : $(cat "$f")"
  done
  echo

  ylw "Empreintes EDID des sorties connectees (collision si identiques) :"
  for f in /sys/class/drm/${card}-DP-*/edid; do
    [[ -s "$f" ]] || continue
    info "$(md5sum "$f")"
  done
  echo

  ylw "Devices couleur connus de colord :"
  if command -v colormgr >/dev/null; then
    colormgr get-devices 2>/dev/null | grep -i "device id" | sed 's/^/  /' || info "(aucun)"
  else
    info "colormgr absent."
  fi
  echo

  ylw "Parametre kernel actif :"
  grep -o "drm.edid_firmware=[^ ]*" /proc/cmdline | sed 's/^/  /' || info "(aucun)"
}

cmd_apply() {
  need_root
  local card; card="$(detect_card)"
  ylw "Carte DRM : $card"

  # 1. Trouver une sortie connectee pour servir de modele EDID.
  local src=""
  for f in /sys/class/drm/${card}-DP-*/edid; do
    if [[ -s "$f" ]]; then src="$f"; break; fi
  done
  if [[ -z "$src" ]]; then
    red "Aucune sortie DP connectee avec EDID. Branche les ecrans externes d'abord."
    exit 1
  fi
  grn "EDID modele : $src"

  # 2. Generer un EDID patche par connecteur, chacun avec un serial unique.
  mkdir -p "$FW_DIR"
  python3 - "$src" "$FW_DIR" "${CONNECTORS[@]}" <<'PY'
import sys
src, fw_dir, *connectors = sys.argv[1:]
with open(src, "rb") as f:
    base = bytearray(f.read())
# Un serial unique par connecteur (octet 0x0C du bloc de base).
for i, name in enumerate(connectors):
    edid = bytearray(base)
    edid[12] = 0x21 + i                      # serial unique, evite 0x00
    somme = sum(edid[0:127]) & 0xFF
    edid[127] = (256 - somme) & 0xFF          # checksum bloc de base
    assert sum(edid[0:128]) & 0xFF == 0, f"checksum invalide {name}"
    out = f"{fw_dir}/edid-{name}.bin"
    with open(out, "wb") as g:
        g.write(edid)
    print(f"  genere {out} (serial 0x{edid[12]:02x})")
PY

  # 3. Construire la valeur du parametre kernel : un fichier par connecteur.
  local arg="drm.edid_firmware="
  local parts=()
  for n in "${CONNECTORS[@]}"; do
    parts+=("${n}:edid/edid-${n}.bin")
  done
  arg+="$(IFS=,; echo "${parts[*]}")"

  # 4. Nettoyer toute ancienne valeur edid_firmware puis appliquer la nouvelle.
  local old
  old="$(grep -o 'drm.edid_firmware=[^ ]*' /proc/cmdline || true)"
  if [[ -n "$old" ]]; then
    info "Suppression ancien parametre : $old"
    grubby --update-kernel=ALL --remove-args="$old" >/dev/null || true
  fi
  grubby --update-kernel=ALL --args="$arg" >/dev/null
  grn "Parametre applique :"
  info "$arg"
  echo
  ylw "Reboote pour activer, puis verifie avec :"
  info "colormgr get-devices | grep -i 'device id'   # doit lister chaque ecran"
}

cmd_revert() {
  need_root
  local old
  old="$(grep -o 'drm.edid_firmware=[^ ]*' /proc/cmdline || true)"
  if [[ -z "$old" ]]; then
    # Repli : reconstruire la valeur theorique pour la retirer.
    local parts=()
    for n in "${CONNECTORS[@]}"; do parts+=("${n}:edid/edid-${n}.bin"); done
    old="drm.edid_firmware=$(IFS=,; echo "${parts[*]}")"
  fi
  grubby --update-kernel=ALL --remove-args="$old" >/dev/null || true
  grn "Parametre kernel retire. Reboote pour revenir a l'etat d'origine."
  info "(Les fichiers $FW_DIR/edid-*.bin restent ; supprime-les a la main si besoin.)"
}

# --- Dispatch ---------------------------------------------------------------

case "${1:-apply}" in
  --status|status) cmd_status ;;
  --revert|revert) cmd_revert ;;
  --apply|apply|"") cmd_apply ;;
  *) red "Argument inconnu : $1"; echo "Usage : $0 [--apply|--status|--revert]"; exit 1 ;;
esac

Readme:

# Réparer Night Light sur un setup multi-écrans (GNOME / Wayland / Intel)

Correctif pour le cas où **Night Light (éclairage nocturne) ne s'applique pas
sur des écrans externes identiques** branchés via un dock, sous GNOME Wayland.

Testé sur **Fedora 42, GNOME/Mutter 48, GPU Intel Iris Xe (i915), Wayland**,
avec deux écrans externes identiques + l'écran d'un portable.

---

## Le symptôme

- Night Light est activé (`enabled = true`), la température est réglée, mais
  l'écran ne se réchauffe pas.
- Le démon `gsd-color` calcule correctement (`night light mode on, using
  temperature of 1700K`) **sans erreur** dans les logs.
- `colormgr get-devices` ne liste **pas** tous les écrans : il en manque un.
- Typiquement, sur deux écrans externes *identiques*, un seul reçoit le filtre
  (ou aucun), tandis que l'écran du portable fonctionne.

## La cause racine

Sous Wayland, GNOME applique Night Light via une **rampe gamma par écran**,
pilotée par `colord`. Or `colord` identifie chaque écran par un *device id*
dérivé de son **EDID** (la carte d'identité que le moniteur transmet).

Deux écrans **strictement identiques** transmettent un EDID **binairement
identique** — y compris le numéro de série, que les moniteurs bon marché ne
rendent pas unique. `colord` génère alors le **même device id** pour les deux.
Le second enregistrement échoue silencieusement :

```
Failed to create colord device for '...': device id 'xrandr-DP-8' already exists
```

Résultat : l'écran en collision n'a **aucun device colord**, donc Night Light
n'a aucune cible où appliquer la rampe gamma. Il reste froid.

## La solution

Surcharger l'EDID de chaque sortie DisplayPort avec un **numéro de série
unique** (octet `0x0C` du bloc de base), en recalculant le **checksum** du bloc
(octet `0x7F`, somme des 128 octets ≡ 0 mod 256). Les deux écrans présentent
alors des EDID différents → `colord` les enregistre séparément → Night Light
s'applique sur chacun.

Le correctif est passé au **kernel** via `drm.edid_firmware`, qui charge un
fichier EDID de remplacement par connecteur dès le démarrage.

### Pourquoi patcher TOUS les connecteurs DP

Les docks USB-C/Thunderbolt **ne donnent pas un nom de connecteur stable** : le
même écran physique peut apparaître comme `DP-5` à un boot, `DP-7` à un autre,
selon l'ordre d'énumération. Si on ne patche qu'un seul connecteur, le correctif
saute dès que le nom change.

La parade : patcher `DP-5` à `DP-8` d'un coup, **chacun avec un fichier au
serial différent**. Quel que soit le connecteur où un écran atterrit, son EDID
reste unique, et deux écrans ne peuvent jamais entrer en collision.

> ⚠️ Ne jamais donner le *même* fichier patché à deux connecteurs : on
> recréerait la collision (même serial). Un fichier = un serial = un connecteur.

---

## Utilisation

```bash
chmod +x fix-nightlight-edid.sh

# 1. Diagnostic (lecture seule, aucune modification)
sudo ./fix-nightlight-edid.sh --status

# 2. Appliquer le correctif
sudo ./fix-nightlight-edid.sh

# 3. Redémarrer (le paramètre kernel n'est lu qu'au boot)
sudo reboot

# 4. Vérifier après reboot : chaque écran doit avoir son device id
colormgr get-devices | grep -i "device id"
```

Le script est **idempotent** : le relancer nettoie l'ancien paramètre et
régénère proprement. Pour annuler complètement :

```bash
sudo ./fix-nightlight-edid.sh --revert
sudo reboot
```

### Adapter à un autre dock

Si ton dock utilise d'autres connecteurs que `DP-5`–`DP-8`, repère-les avec
`--status` (cherche les lignes `connected`) et modifie la liste `CONNECTORS`
en tête du script.

---

## Vérifications utiles (manuelles)

```bash
# Quels écrans sont connectés et sur quels connecteurs
for f in /sys/class/drm/card*-DP-*/status; do echo "$f: $(cat $f)"; done

# Collision EDID ? (md5 identiques = collision)
md5sum /sys/class/drm/card*-DP-*/edid

# Le paramètre kernel est-il actif ?
cat /proc/cmdline | grep -o "drm.edid_firmware=[^ ]*"

# Le kernel a-t-il chargé les EDID de remplacement ?
sudo dmesg | grep -i edid
```

---

## Ce que ce correctif ne règle PAS

Ce script traite **uniquement** la collision EDID (écrans identiques non
distingués par colord).

Sous **GNOME 48 / Fedora 42–43**, il existe par ailleurs un bug distinct où
Night Light peut être **instable au démarrage** (s'applique ou non selon le
boot), indépendamment du nombre d'écrans. Il est suivi côté distribution
(Bugzilla Red Hat #2400989) et côté GNOME (gitlab gnome-shell). Contournements
connus le temps qu'un correctif arrive :

- Rebooter jusqu'à ce que l'application gamma reparte.
- Passer par l'écran de connexion GDM (éviter l'autologin) — aide chez certains.
- Extension GNOME *Bedtime Mode* (option « Amber ») : pose un calque ambré
  logiciel indépendant du gamma matériel. S'applique partout mais peut
  provoquer un léger scintillement selon les configs.
- Filtre « Low Blue Light » intégré à l'OSD du moniteur : 100 % matériel,
  insensible à tous ces bugs.

---

## Détails techniques de l'EDID (pour comprendre le patch)

| Offset (bloc de base) | Contenu |
|---|---|
| `0x08`–`0x0B` | ID fabricant + produit |
| `0x0C`–`0x0F` | **Numéro de série** (4 octets) — patché ici (octet `0x0C`) |
| `0x7F` (127) | **Checksum** : la somme des 128 octets du bloc doit valoir 0 (mod 256) |

Un EDID peut faire 128 octets (1 bloc) ou 256 (bloc de base + extension
CTA-861). On ne modifie que le bloc de base, donc seul le checksum à `0x7F` est
recalculé ; l'éventuelle extension reste intacte.