Add monitor controls that ride the same control plane: - DDC/CI Set-VCP builders (cp): brightness (VCP 0x10), contrast (0x12) and power mode (0xD6), tunnelled to the downstream monitor's I2C slave 0x37 over the dock's monitor-I2C bridge; - DRM connector range properties (drm_sink): a 0..=100 brightness and contrast property per connector, whose atomic_set_property callback pushes a DDC/CI Set-VCP write to the monitor; - DPMS power (drm_sink): the CRTC enable/disable hooks emit VCP 0xD6 on/off so a blanked output drops the monitor to standby instead of freezing the last frame.
All are no-ops until the CP cipher engages (the open blocker), so they are inert on current hardware but correct by construction (the DDC/CI payloads are unit-tested against the VESA MCCS worked examples). Also query the dock's DFU device info at bring-up (firmware version, customer/board id) -- device-level vendor reads independent of the CP channel, useful for diagnostics and confirming the dock firmware revision. Signed-off-by: Mike Lothian <[email protected]> Assisted-by: Claude:claude-opus-4-8 [Claude-Code] --- drivers/gpu/drm/vino/cp.rs | 51 +++++++++++ drivers/gpu/drm/vino/drm_sink.rs | 141 ++++++++++++++++++++++++++++++- drivers/gpu/drm/vino/vino.rs | 18 ++++ 3 files changed, 206 insertions(+), 4 deletions(-) diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs index 2668931d8500..be2bdcf5557c 100644 --- a/drivers/gpu/drm/vino/cp.rs +++ b/drivers/gpu/drm/vino/cp.rs @@ -112,6 +112,57 @@ pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> { Ok(b) } +/// Standard VESA MCCS (Monitor Control Command Set 2.2) VCP feature codes, driven over +/// DDC/CI. The macOS DisplayLink agent exposes these as per-display brightness/contrast +/// ("Popover did show -- starting DDC/CI communication", `setBrightness`/`setContrast`); the +/// dock bridges the DDC/CI transaction to the downstream monitor's I2C slave 0x37 -- the same +/// monitor-I2C path the EDID read ([`get_edid_req`]) uses for the 0x50 EDID slave. +pub(super) const VCP_BRIGHTNESS: u8 = 0x10; +pub(super) const VCP_CONTRAST: u8 = 0x12; +/// VCP 0xD6 "Power mode": value 0x01 = on, 0x04 = off (DPMS-off / hard standby). Lets DPMS +/// blank the panel backlight instead of freezing the last frame (see [`crtc_atomic_disable`]). +pub(super) const VCP_POWER_MODE: u8 = 0xd6; +pub(super) const POWER_ON: u16 = 0x01; +pub(super) const POWER_OFF: u16 = 0x04; + +/// Build a DDC/CI "Set VCP Feature" request: the 7 bytes a DDC/CI host writes to the +/// monitor's I2C slave 0x37, after the 0x6e (= 0x37<<1) write address (VESA DDC/CI 1.1 +/// sec 4.4). Layout: source 0x51, length `0x80 | 4`, opcode 0x03 (Set VCP), VCP code, +/// value-hi, value-lo, then an XOR checksum seeded with the destination address 0x6e. Pure +/// and fully standard, so it is unit-tested byte-exact against the spec +/// ([`super::tests::ddc_ci_set_vcp_checksum`]). +pub(super) fn ddc_ci_set_vcp(vcp: u8, value: u16) -> [u8; 7] { + let body = [0x51u8, 0x84, 0x03, vcp, (value >> 8) as u8, value as u8]; + let mut chk = 0x6eu8; // checksum seed = destination slave-write address (0x37 << 1) + for &x in &body { + chk ^= x; + } + [body[0], body[1], body[2], body[3], body[4], body[5], chk] +} + +/// CP message that tunnels a DDC/CI Set-VCP write to the downstream monitor -- the brightness, +/// contrast and DPMS-power controls the macOS/Windows agents drive over "DDC/CI communication". +/// The dock's monitor-I2C bridge is the same one the EDID read uses, so this is modelled as the +/// WRITE companion to the `0x15/0x21` EDID read: `id=0x15 sub=0x22`, carrying the I2C slave +/// (0x37) + payload length at off20 and the 7-byte DDC/CI Set-VCP payload at off22. +/// +/// The `id`/`sub` and payload offset are **inferred** from the EDID-read pairing -- the write +/// transaction was never captured (it only fires once a monitor is actively driven, i.e. past +/// the CP wall), so re-check against a capture once CP engages. The DDC/CI bytes themselves +/// ([`ddc_ci_set_vcp`]) are standard and verified. +pub(super) fn ddc_set_vcp(counter: u16, vcp: u8, value: u16) -> Result<KVec<u8>> { + let payload = ddc_ci_set_vcp(vcp, value); + let mut b = KVec::with_capacity(32, GFP_KERNEL)?; + header(&mut b, 0x15, 0x22, counter)?; + pad_to(&mut b, 20)?; + // off20: monitor DDC/CI I2C slave (0x37) + DDC/CI payload length. + b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?; + // off22: the DDC/CI Set-VCP bytes (same off22 convention as the EDID payload). + b.extend_from_slice(&payload, GFP_KERNEL)?; + pad_to(&mut b, 32)?; + Ok(b) +} + /// EDID base-block sanity check: length, the `00 FF..FF 00` magic, and the 1-byte /// checksum (all 128 base bytes sum to 0 mod 256). A corrupt blob must never drive a /// mode-set, so [`timing_from_edid`] rejects anything that fails this. diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs index afbf883fba36..bcc871958a8a 100644 --- a/drivers/gpu/drm/vino/drm_sink.rs +++ b/drivers/gpu/drm/vino/drm_sink.rs @@ -129,6 +129,11 @@ pub(super) struct Head { /// One-shot: this head's cursor `create` (sprite dimensions) was sent before its first /// image upload (per head -- the global one would skip head 1's create). cursor_primed: core::sync::atomic::AtomicBool, + /// Last DDC/CI brightness/contrast (0..=100) set on this head's monitor via the connector + /// properties; replayed on DPMS-on. Stored here (not in connector state) because DDC/CI is + /// a side-band action on the physical monitor, not part of the atomic scanout pipeline. + brightness: core::sync::atomic::AtomicU32, + contrast: core::sync::atomic::AtomicU32, #[pin] scanout: Mutex<ScanoutState>, /// This head's downstream-monitor EDID (`None` until the CP channel delivers it). Only @@ -167,6 +172,8 @@ fn z<T>() -> impl PinInit<Opaque<T>, Error> { try_pin_init!(Self { index, cursor_primed: core::sync::atomic::AtomicBool::new(false), + brightness: core::sync::atomic::AtomicU32::new(100), + contrast: core::sync::atomic::AtomicU32::new(100), scanout <- new_mutex!(ScanoutState { enc: None, cur: VVec::new(), @@ -239,6 +246,12 @@ pub(super) struct VinoDrmData { encoder_funcs: Opaque<bindings::drm_encoder_funcs>, #[pin] mode_cfg_funcs: Opaque<bindings::drm_mode_config_funcs>, + /// The custom 0..=100 connector range properties for DDC/CI brightness/contrast, created + /// and attached in [`kms_init`]. Stored so the connector `atomic_set_property` / + /// `atomic_get_property` callbacks can identify them by pointer. Written once during + /// single-threaded probe, read-only thereafter (`AtomicPtr` for `Sync` without `unsafe`). + brightness_prop: core::sync::atomic::AtomicPtr<bindings::drm_property>, + contrast_prop: core::sync::atomic::AtomicPtr<bindings::drm_property>, } // SAFETY: the embedded C KMS objects are written only during single-threaded @@ -278,6 +291,8 @@ fn z<T>() -> impl PinInit<Opaque<T>, Error> { cursor_helper <- z(), encoder_funcs <- z(), mode_cfg_funcs <- z(), + brightness_prop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()), + contrast_prop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()), }) } @@ -365,6 +380,14 @@ pub(super) fn send_cp( link.counter = link.counter.wrapping_add(1); Ok(()) } + + /// Push a DDC/CI Set-VCP write to a head's downstream monitor (brightness, contrast or + /// DPMS power). Wraps [`super::cp::ddc_set_vcp`] (`id=0x15`); a no-op until the cipher is + /// engaged. Used by the brightness/contrast connector properties and by DPMS in the CRTC + /// enable/disable callbacks. + pub(super) fn set_vcp(&self, head_index: u8, vcp: u8, value: u16) -> Result { + self.send_cp(head_index, 0x15, 0, |ctr| super::cp::ddc_set_vcp(ctr, vcp, value)) + } } /// GEM object inner data. Empty: the shmem-backed `drm::gem::shmem::Object` (which @@ -637,6 +660,11 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 { if let Err(e) = data.send_cp(head.index, 0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) { pr_warn!("vino: head{} runtime mode-set send failed ({e:?})\n", head.index); } + // Bring the monitor out of DPMS standby (DDC/CI VCP 0xD6 = on). Inferred wire (see + // `cp::ddc_set_vcp`); a no-op until CP engages, and re-applies the user's brightness. + let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_ON); + let b = head.brightness.load(core::sync::atomic::Ordering::Relaxed); + let _ = data.set_vcp(head.index, super::cp::VCP_BRIGHTNESS, b as u16); } /// CRTC `.atomic_disable`: the display is turning off. @@ -646,10 +674,12 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 { /// re-enable (DPMS-on) re-inits the encoder and sends a **full keyframe** rather than diffing /// against a shadow the dock may have dropped while blanked, and re-uploads the cursor sprite. /// -/// The dock holds the last frame when video stops (it has its own scanout buffer), so the -/// monitor freezes the last image rather than going black; a true backlight-standby would need -/// a dock power command that is not decoded (DLM's `Standby`/`Suspend`/`TempPowerOff` are -/// internal, vtable-dispatched events with no wire frame -- the same dead-end as gamma). +/// The dock holds the last frame when video stops (it has its own scanout buffer), so video +/// alone freezes the last image rather than going black. To actually blank the panel we send a +/// DDC/CI power-off (VCP 0xD6 = off) to the monitor over the same monitor-I2C bridge the EDID +/// read uses -- the standard MCCS power control the macOS/Windows agents drive (DLM's +/// `Standby`/`Suspend`/`TempPowerOff` are the host-internal names for it). Inferred wire (see +/// [`super::cp::ddc_set_vcp`]); a no-op until CP engages. unsafe extern "C" fn crtc_atomic_disable( crtc: *mut bindings::drm_crtc, _state: *mut bindings::drm_atomic_commit, @@ -671,6 +701,10 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 { } head.cursor_primed .store(false, core::sync::atomic::Ordering::SeqCst); + // DPMS-off: blank the monitor backlight via DDC/CI (VCP 0xD6 = off) rather than leaving + // the last frame frozen on the panel. Inferred wire (see `cp::ddc_set_vcp`); no-op until + // CP engages. + let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_OFF); pr_info!("vino: KMS CRTC disable -- head{} display OFF (scanout stopped)\n", head.index); } @@ -1182,6 +1216,9 @@ pub(super) fn kms_init<C: drm::DeviceContext>( Some(bindings::drm_atomic_helper_connector_duplicate_state); (*cf).atomic_destroy_state = Some(bindings::drm_atomic_helper_connector_destroy_state); + // Custom DDC/CI brightness/contrast properties (see `connector_atomic_set_property`). + (*cf).atomic_set_property = Some(connector_atomic_set_property); + (*cf).atomic_get_property = Some(connector_atomic_get_property); (*data.conn_helper.get()).get_modes = Some(get_modes); // Prune any single mode above the per-head pixel-clock ceiling (~4K@60). (*data.conn_helper.get()).mode_valid = Some(mode_valid); @@ -1216,6 +1253,26 @@ pub(super) fn kms_init<C: drm::DeviceContext>( (*data.encoder_funcs.get()).destroy = Some(bindings::drm_encoder_cleanup); // Build each head's objects (connector + primary/cursor planes + CRTC + encoder). + // DDC/CI brightness/contrast: one 0..=100 range property each, created on the device + // and attached to every connector in `build_head`. Non-fatal: a NULL property just + // means the knob is absent (kept in the AtomicPtr as NULL, ignored by the callbacks). + let bp = bindings::drm_property_create_range( + raw, + 0, + c"brightness".as_ptr().cast(), + 0, + 100, + ); + data.brightness_prop.store(bp, core::sync::atomic::Ordering::Relaxed); + let cp = bindings::drm_property_create_range( + raw, + 0, + c"contrast".as_ptr().cast(), + 0, + 100, + ); + data.contrast_prop.store(cp, core::sync::atomic::Ordering::Relaxed); + for head in data.heads() { build_head(raw, data, head)?; } @@ -1322,10 +1379,86 @@ unsafe fn build_head(raw: *mut bindings::drm_device, data: &VinoDrmData, head: & if rc != 0 { pr_warn!("vino: head{} rotation property unavailable ({rc})\n", head.index); } + + // Attach the shared DDC/CI brightness/contrast properties to this connector, each at + // its default of 100 (= no attenuation). The callbacks store the value per head and + // fire the DDC/CI write (see `connector_atomic_set_property`). + let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed); + if !bp.is_null() { + bindings::drm_object_attach_property(&mut (*conn).base, bp, 100); + } + let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed); + if !cp.is_null() { + bindings::drm_object_attach_property(&mut (*conn).base, cp, 100); + } } Ok(()) } +/// Connector `.atomic_set_property`: handle the custom DDC/CI brightness/contrast properties +/// (the standard properties are handled by the DRM core, which never calls us for them). On a +/// value change we store it on the head and immediately push a DDC/CI Set-VCP write to the +/// monitor -- DDC/CI is a side-band action on the physical panel, not part of the atomic +/// scanout state, so it is applied here rather than threaded through connector state. Returns +/// `-EINVAL` for any other property so the core reports it as unknown. +unsafe extern "C" fn connector_atomic_set_property( + connector: *mut bindings::drm_connector, + _state: *mut bindings::drm_connector_state, + property: *mut bindings::drm_property, + val: u64, +) -> i32 { + // SAFETY: `connector` is a valid connector embedded in our DRM device-private. + let dev = unsafe { (*connector).dev }; + // SAFETY: `dev` is our live, registered drm_device. + let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) }; + let Some(head) = data.head_by_connector(connector) else { + return EINVAL.to_errno(); + }; + let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed); + let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed); + let v = val.min(100) as u32; + let (slot, vcp) = if property == bp && !bp.is_null() { + (&head.brightness, super::cp::VCP_BRIGHTNESS) + } else if property == cp && !cp.is_null() { + (&head.contrast, super::cp::VCP_CONTRAST) + } else { + return EINVAL.to_errno(); + }; + if slot.swap(v, core::sync::atomic::Ordering::Relaxed) != v { + let _ = data.set_vcp(head.index, vcp, v as u16); + } + 0 +} + +/// Connector `.atomic_get_property`: read back the stored DDC/CI brightness/contrast (the +/// values round-trip through the head atomics set by [`connector_atomic_set_property`]). +unsafe extern "C" fn connector_atomic_get_property( + connector: *mut bindings::drm_connector, + _state: *const bindings::drm_connector_state, + property: *mut bindings::drm_property, + val: *mut u64, +) -> i32 { + // SAFETY: `connector` is a valid connector embedded in our DRM device-private. + let dev = unsafe { (*connector).dev }; + // SAFETY: `dev` is our live, registered drm_device. + let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) }; + let Some(head) = data.head_by_connector(connector) else { + return EINVAL.to_errno(); + }; + let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed); + let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed); + let slot = if property == bp && !bp.is_null() { + &head.brightness + } else if property == cp && !cp.is_null() { + &head.contrast + } else { + return EINVAL.to_errno(); + }; + // SAFETY: the DRM core passes a valid `*mut u64` output pointer. + unsafe { *val = slot.load(core::sync::atomic::Ordering::Relaxed) as u64 }; + 0 +} + /// Thin wrapper so the `unsafe` block above reads cleanly. unsafe fn drm_mode_config_reset(raw: *mut bindings::drm_device) { // SAFETY: `raw` is a valid drm_device with mode_config initialised. diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs index 1091dcc992c7..ee63ce7e4625 100644 --- a/drivers/gpu/drm/vino/vino.rs +++ b/drivers/gpu/drm/vino/vino.rs @@ -385,6 +385,24 @@ fn bring_up(dev: &usb::Device) -> Result { Ok(()) => pr_info!("vino: step device-open 0xfc(iface1) OK = {:02x?}\n", probe3), Err(e) => pr_info!("vino: step device-open 0xfc(iface1) non-fatal ({e:?})\n"), } + // DFU firmware-version query, matching DLM / the macOS+Windows drivers' + // DfuGetVmmDeviceFirmwareVersion: vendor IN bmRequestType=0xc1 bRequest=0xfd wIndex=1, + // a 6-byte version blob (the reference driver's request-size table: 0xfb=4 customer/board, + // 0xfc=3 device-type, 0xfd=6 firmware-version, 0xfe=16 descriptor). This is a device-level + // DFU read, independent of the CP channel, so it works regardless of CP engagement -- handy + // for diagnostics and confirming the dock firmware revision. + let mut fw_ver = [0u8; 6]; + match dev.control_recv(0xfd, VENDOR_IN_IFACE, 0, 1, &mut fw_ver, timeout()) { + Ok(()) => pr_info!("vino: dock DFU firmware version = {:02x?}\n", fw_ver), + Err(e) => pr_info!("vino: device-open 0xfd(firmware-version) non-fatal ({e:?})\n"), + } + // DFU customer/board id (DfuGetVmmDeviceCustomerAndBoardId): bRequest=0xfb, 4-byte blob. + let mut cust_board = [0u8; 4]; + match dev.control_recv(0xfb, VENDOR_IN_IFACE, 0, 1, &mut cust_board, timeout()) { + Ok(()) => pr_info!("vino: dock DFU customer/board id = {:02x?}\n", cust_board), + Err(e) => pr_info!("vino: device-open 0xfb(customer/board) non-fatal ({e:?})\n"), + } + // EXPERIMENT (2026-06-16): replay DLM's repeated STRING-descriptor reads at device-open. // Timing analysis of the paired cold capture (captures/paired-coldbus-20260615-220311) // shows DLM, beyond the distinct descriptor SET vino already issues, re-reads STRING idx0 -- 2.54.0
