Files
PowerToys/doc/specs/power-display-adaptive-brightness.md
Rishabh Jain ccb647b2f0 Spec: Power Display ambient-light (ALS) adaptive brightness for external monitors (#49199)
## Summary

Design spec for **ambient-light (ALS) adaptive brightness for external
monitors** in Power Display, as discussed and agreed in #49038.

The feature continuously maps the device''s ambient light sensor reading
(the same ALS **Light Switch** already reads) to each external
monitor''s brightness over **DDC/CI** (the path **Power Display**
already uses) — i.e. auto-brightness for external monitors, the
continuous/sensor-driven counterpart to the schedule-based #47480.

This PR adds only the spec doc
(`doc/specs/power-display-adaptive-brightness.md`), per the repo''s
spec-first process (`doc/specs/readme.md`). No code.

## What the spec covers

- **Core model** — a single per-monitor formula: `target =
clamp(curve(lux) + offset, min, max)`.
- **Per-monitor calibration curve** — the mechanism that keeps
differently-behaving panels visually matched across the whole ambient
range.
- **Per-monitor offset** — live, phone-style personalization; manual/CLI
nudges become an offset (default) with an optional `pause` behaviour.
- **Live slider UX** — when adaptive is on, the slider reflects the
computed value and dragging it sets the offset.
- **Sensor trust & lifecycle** — lid-closed / clamshell detection
(`GUID_LIDSWITCH_STATE_CHANGE` + `QueryDisplayConfig`), hold-last-good
instead of dimming to black, and honest fallbacks.
- Smoothing/hysteresis/rate-limiting for flicker- and wear-safe DDC/CI
writes, settings persistence, telemetry, a11y/localization, risks, and
phased delivery.

## Discussion / sign-off

Behaviour was reviewed with @moooyo on #49038 (configurability,
CLI/manual +/- while adaptive, slider behaviour) — agreed to proceed.

Closes nothing yet; tracks #49038. Related: #47480, #42566, #35564.

---------

Co-authored-by: Rishabh Jain <14334305+MrRishabhJain@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 09:21:44 +00:00

13 KiB
Raw Blame History

Power Display Ambient-Light (ALS) Adaptive Brightness for External Monitors

Tracking issue: microsoft/PowerToys#49038 Behaviour discussion: #49038 (with @moooyo) Related: #47480 (schedule-based auto-brightness), #42566 (Light Switch ALS → theme), #35564 (manual multi-monitor brightness), #1052 (Quick Display adjustment umbrella, completed)

1. Overview

Windows can auto-adjust the built-in laptop panel from the device's ambient light sensor (ALS), but external monitors have no such capability — they almost never carry an ALS, and Windows will not pipe the laptop's ALS reading to an external display. Today's PowerToys covers the two halves separately:

  • Light Switch already reads the device ALS (Windows.Devices.Sensors.LightSensorIlluminanceInLux) to drive theme.
  • Power Display already writes external-monitor brightness over DDC/CI (SetMonitorBrightness, VCP 0x10).

This feature wires them together: a per-monitor Adaptive (ambient light) mode in Power Display that continuously maps the device's ambient lux to each external monitor's brightness. It is the continuous, sensor-driven counterpart to the time/schedule-based request tracked in #47480 — and it is what most users actually mean by "auto-brightness".

2. Goals / Non-goals

Goals

  • Per-external-monitor opt-in adaptive brightness driven by the device ALS.
  • A per-monitor calibration curve (lux → %), so differently-behaving panels can be matched.
  • A per-monitor offset for manual/CLI nudges that rides on top of the curve (iOS-style personalization).
  • Correct, safe behaviour when the sensor can't be trusted (e.g. laptop lid closed).
  • Smooth, flicker-free, DDC/CI-friendly updates (smoothing + hysteresis + rate limiting).
  • Coexist cleanly with manual brightness changes and the existing schedule/profile features.

Non-goals

  • Driving the built-in laptop panel (Windows already does this).
  • Bundling external/USB ambient sensors (possible future extension).
  • Content-adaptive / gamma-overlay dimming (a different, software-only approach).
  • Colour-temperature / Night-Light behaviour.

3. Scenarios

  1. A laptop is docked to one or two external monitors. As the room darkens through the evening, the externals dim automatically to track the room — today only the built-in panel does this.
  2. A light-sensitive user wants externals to follow ambient light continuously, without manual sliders or rigid schedules.
  3. Two different external panels are calibrated so they look equally bright across the whole ambient range.
  4. Clamshell (lid closed): the sensor is blocked, so brightness holds steady instead of dimming to black.
  5. A non-DDC/CI monitor: adaptive is greyed out for that monitor with a clear "not supported" reason.

4. Fit with existing modules

[ Device ALS ]
 LightSensor.ReadingChanged (lux)        <-- already consumed by Light Switch (#42566)
        │
        ▼
[ Adaptive Brightness engine ]  (new glue, lives in Power Display)
   smoothing (EMA) → hysteresis/deadband → curve(lux) + offset (per monitor) → rate limit
        │
        ▼
[ Power Display DDC/CI writer ]          <-- already exists (SetMonitorBrightness, VCP 0x10)
   per external monitor

No new module. The ALS reader can be refactored into a small shared helper so both Light Switch and Power Display consume one sensor source (avoids two sensor subscriptions). On #47480 a maintainer noted Light Switch can already link to a Power Display profile on a scheduler; this design adds an ambient-light trigger alongside the existing time/sun trigger and addresses the "apply a Power Display profile without forcing a theme change" limitation raised there.

5. Core model — adaptive curve + per-monitor offset

The whole feature reduces to a single formula, applied independently per external monitor:

target%(lux) = clamp( curve(lux) + offset, minPercent, maxPercent )

5.1 The loop

Continuously: read device ALS (lux) → smooth → map through the monitor's curve → add the monitor's offset → clamp → write via DDC/CI. One shared sensor subscription drives an independent engine per external monitor.

5.2 Per-monitor calibration curve — the multi-monitor sync mechanism

Each monitor has its own editable lux → % curve. This is what keeps multiple monitors looking equally bright: panels differ in nits and have non-linear backlight response, so the same ambient lux should map to a different percentage on different panels. A per-monitor curve corrects this across the entire ambient range — something a single scalar value cannot do (a uniform shift would keep them matched at one light level but drift apart at others).

5.3 Per-monitor offset — live personalization

A signed per-monitor offset% added on top of the curve. Manual/CLI nudges adjust it, so the system learns the user's preference (iOS-style) instead of overriding them. The curve does calibration; the offset does live, personal fine-tuning (and is the quick way to nudge one screen to match the others without editing its curve).

5.4 Manual / CLI interaction while adaptive is ON

When the user changes brightness (slider or CLI increase/decrease) with adaptive on, per-monitor manualBehavior:

  • offset (default): the delta becomes a persistent offset; adaptation keeps tracking ambient, just shifted. A CLI "brightness +10" makes the monitor 10% brighter than the curve would pick, and tracking continues.
  • pause: adaptation suspends for that monitor (hard manual override) until the user re-enables it.

6. UX / Settings design

Per-monitor (WinUI3 SettingsCard/SettingsExpander):

  • Brightness source: Manual (default) | Adaptive (ambient light). Adaptive is disabled with a reason string when (a) no ALS is present, or (b) the monitor has no DDC/CI brightness control.
  • Default (zero-config): on/off + a single Responsiveness slider (Calm ↔ Snappy). A sensible default curve ships out of the box so most users touch nothing else.
  • Advanced (collapsible): editable calibration curve (anchor points, e.g. 0 lx→20%, 50→40%, 300→70%, 1000→100% with a "Reset"), min/max clamps, and the manualBehavior choice.
  • Live slider: when adaptive is on, the brightness slider becomes live — it reflects the current computed target and moves on its own as ambient changes, with an "Auto" badge and a readout (Ambient 240 lx → 65%). Dragging it applies the offset (§5.3); it does not silently disable adaptive.

Global: a module-level toggle and an enable/disable hotkey (consistent with other Power Display shortcuts, e.g. #48784).

7. Sensor trust & lifecycle

The ALS reading is used only when it can be trusted.

7.1 ALS / DDC-CI availability

LightSensor.GetDefault() null → adaptive unavailable everywhere (e.g. a desktop). Per monitor, if DDC/CI brightness is unsupported → adaptive is disabled for that monitor with a reason.

7.2 Lid closed / clamshell (sensor blocked)

When the lid is closed the ALS faces the keyboard deck and reads ~darkness, so we must not drive brightness from it.

  • Detect: lid state via RegisterPowerSettingNotification(GUID_LIDSWITCH_STATE_CHANGE) (0 = closed, 1 = open), corroborated by QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS) for an active internal panel (DISPLAYCONFIG_OUTPUT_TECHNOLOGY_INTERNAL). Lid position is the true determinant — a user may have the lid open but the internal display disabled, and there the sensor still sees the room.
  • Behaviour: on lid-close → pause adaptation and hold the last known-good brightness (never dim-to-black), surface a status ("Adaptive paused — lid closed / ambient sensor blocked"), and resume live on reopen.

7.3 Honest boundary + fallbacks

Clamshell-docked is a primary scenario for this feature, yet it's exactly where the laptop ALS can't see the room. Therefore:

  • v1: full ALS adaptive when the lid is open; hold/pause when closed.
  • Optional fallback: when the ALS is untrusted, fall back to time/schedule-based brightness (the #47480 mechanism) so clamshell users still get some automation.
  • Future: an external ambient sensor (monitor-integrated or USB) for true clamshell tracking (deferred).

8. Technical design

8.1 ALS acquisition

  • LightSensor.GetDefault(); subscribe to ReadingChanged; set ReportInterval = max(minimumReportInterval, ~500ms) to limit churn/battery. Value: LightSensorReading.IlluminanceInLux (double).

8.2 Smoothing + hysteresis (anti-flicker, anti-wear)

  • EMA on lux: s_t = α·lux + (1α)·s_{t1} (α from the Responsiveness slider).
  • Deadband: ignore changes whose mapped target moves < ~23% from the last applied value.
  • Min update interval per monitor (~12 s) and optional ramping (step toward target).
  • Rationale: DDC/CI writes are slow (tenshundreds of ms) and excessive writes can flicker or wear some panels.

8.3 Mapping

  • Piecewise-linear interpolation over the anchor points; add offset; clamp to per-monitor min/max.
  • Curve + offset stored per monitor (keyed by stable monitor id, as Power Display already does for profiles).

8.4 Brightness write

  • Reuse Power Display's DDC/CI path (GetPhysicalMonitorsFromHMONITORSetMonitorBrightness, VCP 0x10).
  • Capability probe per monitor (GetMonitorBrightness / VCP support); if unsupported, disable adaptive for it.

8.5 Concurrency

  • One engine per external monitor, one shared ALS subscription. Serialize DDC/CI writes per monitor on a worker; never block the sensor callback. Handle monitor hotplug / dock connect-disconnect (Power Display already tracks add/remove).

9. Settings persistence

Extend Power Display's settings.json per-monitor entry, e.g.:

{
  "monitors": [{
    "id": "<stable-monitor-id>",
    "brightnessSource": "adaptive",          // "manual" | "adaptive"
    "adaptive": {
      "curve": [[0,20],[50,40],[300,70],[1000,100]],  // [lux, percent] calibration, per monitor
      "offsetPercent": 0,                    // signed live nudge added on top of the curve
      "minPercent": 15,
      "maxPercent": 100,
      "responsiveness": 0.4,                 // 0..1 → EMA α / update cadence
      "manualBehavior": "offset",            // "offset" (default) | "pause"
      "deadbandPercent": 3,
      "minUpdateMs": 1500
    }
  }]
}

target = clamp( interpolate(curve, lux) + offsetPercent, minPercent, maxPercent ).

10. Telemetry

Per doc/devdocs/development/logging.md, emit (privacy-respecting, counts only):

  • Adaptive mode enabled/disabled per monitor; ALS-present; DDC/CI-supported; manualBehavior chosen.
  • Apply-rate (writes/min), deadband/clamp suppression counts, and lid-close pause events (to validate anti-wear tuning).
  • No raw lux series or PII.

11. Performance & reliability

  • Sensor at ~0.52 s cadence; writes throttled per §8.2 → negligible CPU and few DDC/CI writes.
  • Worker isolates slow DDC/CI calls from the UI/sensor thread.
  • Graceful degradation: no ALS → mode hidden/disabled; no DDC/CI → mode disabled per monitor with reason; lid closed → hold last-good.

12. Accessibility / Localization

  • All new strings via .resw + x:Uid (and discoverable by the settings search index, settings-search.md).
  • Curve editor keyboard-navigable; the live slider value, "Auto" badge, and ambient readout exposed to narrator.

13. Risks & open questions

  • Manual-vs-adaptive arbitration — resolved: default offset (iOS-style), with pause opt-in (direction agreed in #49038).
  • ALS placement — chassis lux ≠ panel-facing lux; mitigated by the per-monitor curve; documented as a limitation.
  • Clamshell — laptop ALS is blind when the lid is closed; handled via hold/pause + optional time fallback; external sensor is future work.
  • DDC/CI variance across docks/USB-C/adapters (cf. #47577) — robust capability probing + clear unsupported messaging.
  • Shared ALS source — refactor Light Switch's reader into a shared helper (preferred) vs. two subscriptions.
  • Open (tune during implementation): exact default curve values and the Responsiveness → (α, min-update-interval) mapping.

14. Phased delivery

  1. Engine + per-monitor adaptive (curve + offset) + default curve + DDC/CI write reuse + sensor-trust gating (JSON-config; minimal UI).
  2. Settings UX: source dropdown, live slider + "Auto" badge/readout, curve editor, responsiveness, clamps, manualBehavior.
  3. Shared ALS helper refactor (Light Switch + Power Display); precedence with schedule/profile; optional time-based fallback.
  4. Telemetry + docs (doc/devdocs/modules/powerdisplay/…) + tests.