## 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>
13 KiB
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.LightSensor→IlluminanceInLux) to drive theme. - Power Display already writes external-monitor brightness over DDC/CI (
SetMonitorBrightness, VCP0x10).
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
- 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.
- A light-sensitive user wants externals to follow ambient light continuously, without manual sliders or rigid schedules.
- Two different external panels are calibrated so they look equally bright across the whole ambient range.
- Clamshell (lid closed): the sensor is blocked, so brightness holds steady instead of dimming to black.
- 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 themanualBehaviorchoice. - 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 byQueryDisplayConfig(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 toReadingChanged; setReportInterval = 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_{t−1}(α from the Responsiveness slider). - Deadband: ignore changes whose mapped target moves < ~2–3% from the last applied value.
- Min update interval per monitor (~1–2 s) and optional ramping (step toward target).
- Rationale: DDC/CI writes are slow (tens–hundreds 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 (
GetPhysicalMonitorsFromHMONITOR→SetMonitorBrightness, VCP0x10). - 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;
manualBehaviorchosen. - 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.5–2 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), withpauseopt-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
- Engine + per-monitor
adaptive(curve + offset) + default curve + DDC/CI write reuse + sensor-trust gating (JSON-config; minimal UI). - Settings UX: source dropdown, live slider + "Auto" badge/readout, curve editor, responsiveness, clamps,
manualBehavior. - Shared ALS helper refactor (Light Switch + Power Display); precedence with schedule/profile; optional time-based fallback.
- Telemetry + docs (
doc/devdocs/modules/powerdisplay/…) + tests.