@@ -210,62 +210,151 @@ final class DDCService: @unchecked Sendable {
210210
211211 // MARK: - Apple Silicon: IOAVService
212212
213- /// Finds the IOAVService for a given display.
214- /// Strategy: enumerate DCPAVServiceProxy services, skip those with Location=Embedded
215- /// (built-in display), and return external services. For multi-monitor setups,
216- /// caches a mapping of display ID to service index.
217- private func avService( for displayID: CGDirectDisplayID ) -> AnyObject ? {
218- guard let createFn = avCreateFn else { return nil }
213+ /// One external DCPAVServiceProxy (a physical HDMI / Thunderbolt display port) wrapped
214+ /// in an IOAVService, plus what the registry says about the display behind it.
215+ private struct AVServiceCandidate {
216+ let registryID : UInt64
217+ let service : AnyObject
218+ let index : Int
219+ }
220+
221+ /// Registry entry ID of the DCPAVServiceProxy that last answered DDC for each display.
222+ /// Machines such as the Mac mini publish one proxy per physical port even when the
223+ /// port is empty, so the display→port mapping has to be discovered, not assumed by index.
224+ private var resolvedProxyIDs : [ CGDirectDisplayID : UInt64 ] = [ : ]
225+
226+ /// Forgets which port each display answered on. Call when displays are (re)connected —
227+ /// a display ID can come back on a different physical port.
228+ func invalidateServiceCache( ) {
229+ ddcQueue. sync { resolvedProxyIDs. removeAll ( ) }
230+ }
231+
232+ /// Enumerates the external DCPAVServiceProxy services, ordered by how likely each is to
233+ /// be the port `displayID` is attached to:
234+ /// 1. the proxy that already answered DDC for this display (cached),
235+ /// 2. proxies whose dcp subtree publishes this display's EDID UUID,
236+ /// 3. the proxy at the display's position in CoreGraphics' external-display order
237+ /// (the historical guess),
238+ /// 4. proxies no other connected display would claim by that positional rule.
239+ /// Ports another display would claim positionally are left out unless the EDID says
240+ /// otherwise, so a display without DDC support can't fall through to its neighbour.
241+ private func avServiceCandidates( for displayID: CGDirectDisplayID ) -> [ AVServiceCandidate ] {
242+ guard let createFn = avCreateFn else { return [ ] }
219243
220244 // Built-in displays don't support DDC
221245 if CGDisplayIsBuiltin ( displayID) != 0 {
222246 log. log ( " DDC: Skipping built-in display \( displayID) " )
223- return nil
247+ return [ ]
224248 }
225249
226250 var iter : io_iterator_t = 0
227- guard let matching = IOServiceMatching ( " DCPAVServiceProxy " ) else { return nil }
228- guard IOServiceGetMatchingServices ( kIOMainPortDefault, matching, & iter) == KERN_SUCCESS else {
229- return nil
251+ guard let matching = IOServiceMatching ( " DCPAVServiceProxy " ) ,
252+ IOServiceGetMatchingServices ( kIOMainPortDefault, matching, & iter) == KERN_SUCCESS else {
253+ return [ ]
230254 }
231255 defer { IOObjectRelease ( iter) }
232256
233- // Collect all external (non-Embedded) services
234- var externalServices : [ io_service_t ] = [ ]
257+ // Collect all external (non-Embedded) proxies with their registry IDs and nearby EDID UUIDs.
258+ var externals : [ ( service : io_service_t , registryID : UInt64 , edidUUIDs : Set < String > ) ] = [ ]
235259 var service = IOIteratorNext ( iter)
236260 while service != 0 {
237261 let location = registryString ( for: " Location " , in: service)
238- let isEmbedded = location? . lowercased ( ) == " embedded "
239-
240- if !isEmbedded {
241- externalServices. append ( service)
242- } else {
262+ if location? . lowercased ( ) == " embedded " {
243263 IOObjectRelease ( service)
264+ } else {
265+ var registryID : UInt64 = 0
266+ _ = IORegistryEntryGetRegistryEntryID ( service, & registryID)
267+ externals. append ( ( service, registryID, edidUUIDs ( near: service) ) )
244268 }
245269 service = IOIteratorNext ( iter)
246270 }
271+ defer { for external in externals { IOObjectRelease ( external. service) } }
247272
248- // If no external services found, return nil
249- guard !externalServices. isEmpty else {
273+ guard !externals. isEmpty else {
250274 log. log ( " DDC: No external DCPAVServiceProxy services found " )
251- return nil
275+ return [ ]
252276 }
253277
254- // For single external display, just use it
255- // For multiple externals, try to match by probing DDC — each display
256- // reports its own EDID vendor/model via VCP, so we pick the first that works
257- // (multi-monitor matching by index: external display order matches CGDisplay order)
258278 let externalDisplayIDs = Self . externalDisplayIDs ( )
259- let targetIndex = externalDisplayIDs. firstIndex ( of: displayID) ?? 0
260- let serviceIndex = min ( targetIndex, externalServices. count - 1 )
279+ let guessIndex = min ( externalDisplayIDs. firstIndex ( of: displayID) ?? 0 , externals. count - 1 )
280+ // Indices the other connected displays would pick by the same positional rule.
281+ let claimedByOthers = Set ( ( 0 ..< min ( externalDisplayIDs. count, externals. count) ) . filter { $0 != guessIndex } )
282+ let targetUUID = Self . edidUUID ( for: displayID)
283+
284+ var order : [ Int ] = [ ]
285+ func append( _ index: Int ) {
286+ if !order. contains ( index) { order. append ( index) }
287+ }
288+ if let cached = resolvedProxyIDs [ displayID] ,
289+ let cachedIndex = externals. firstIndex ( where: { $0. registryID == cached } ) {
290+ append ( cachedIndex)
291+ }
292+ if let uuid = targetUUID {
293+ for (index, external) in externals. enumerated ( ) where external. edidUUIDs. contains ( uuid) {
294+ append ( index)
295+ }
296+ }
297+ append ( guessIndex)
298+ for index in externals. indices where !claimedByOthers. contains ( index) {
299+ append ( index)
300+ }
261301
262- let chosen = externalServices [ serviceIndex]
263- let avService = createFn ( kCFAllocatorDefault, chosen) ? . takeRetainedValue ( )
302+ log. log ( " DDC: display= \( displayID) uuid= \( targetUUID ?? " n/a " ) externalProxies= \( externals. count) externalDisplays= \( externalDisplayIDs. count) candidateOrder= \( order) proxyEDIDs= \( externals. map { Array ( $0. edidUUIDs) . sorted ( ) } ) " )
264303
265- log. log ( " DDC: display= \( displayID) serviceIndex= \( serviceIndex) / \( externalServices. count) avService= \( avService != nil ? " found " : " nil " ) " )
304+ return order. compactMap { index -> AVServiceCandidate ? in
305+ let external = externals [ index]
306+ guard let avService = createFn ( kCFAllocatorDefault, external. service) ? . takeRetainedValue ( ) else {
307+ log. log ( " DDC: IOAVServiceCreateWithService failed for proxy # \( index) " )
308+ return nil
309+ }
310+ return AVServiceCandidate ( registryID: external. registryID, service: avService, index: index)
311+ }
312+ }
266313
267- for s in externalServices { IOObjectRelease ( s) }
268- return avService
314+ /// EDID UUIDs published in the registry subtree `proxy` belongs to (walking up to three
315+ /// ancestors). Each physical display port lives in its own dcp subtree, so a shared
316+ /// ancestor identifies the port; once an ancestor's subtree holds more than one proxy the
317+ /// walk has left the port and stops.
318+ private func edidUUIDs( near proxy: io_service_t ) -> Set < String > {
319+ var current : io_registry_entry_t = proxy
320+ IOObjectRetain ( current)
321+ defer { IOObjectRelease ( current) }
322+
323+ for _ in 0 ..< 3 {
324+ var parent : io_registry_entry_t = 0
325+ guard IORegistryEntryGetParentEntry ( current, kIOServicePlane, & parent) == KERN_SUCCESS,
326+ parent != 0 else { break }
327+ IOObjectRelease ( current)
328+ current = parent
329+
330+ var iter : io_iterator_t = 0
331+ guard IORegistryEntryCreateIterator ( current, kIOServicePlane, IOOptionBits ( kIORegistryIterateRecursively) , & iter) == KERN_SUCCESS else { break }
332+ var proxyCount = 0
333+ var found = Set < String > ( )
334+ var child = IOIteratorNext ( iter)
335+ while child != 0 {
336+ if IOObjectConformsTo ( child, " DCPAVServiceProxy " ) != 0 {
337+ proxyCount += 1
338+ } else if let uuid = registryString ( for: " EDID UUID " , in: child) {
339+ found. insert ( uuid. uppercased ( ) )
340+ }
341+ IOObjectRelease ( child)
342+ child = IOIteratorNext ( iter)
343+ }
344+ IOObjectRelease ( iter)
345+
346+ if proxyCount > 1 { break } // ancestor spans several ports — no longer port-specific
347+ if !found. isEmpty { return found }
348+ }
349+ return [ ]
350+ }
351+
352+ /// CoreGraphics' UUID for a display. It is derived from the EDID, so it matches the
353+ /// "EDID UUID" the DCP driver publishes in the IORegistry.
354+ private static func edidUUID( for displayID: CGDirectDisplayID ) -> String ? {
355+ guard let uuid = CGDisplayCreateUUIDFromDisplayID ( displayID) ? . takeRetainedValue ( ) ,
356+ let string = CFUUIDCreateString ( kCFAllocatorDefault, uuid) else { return nil }
357+ return ( string as String ) . uppercased ( )
269358 }
270359
271360 /// Returns ordered list of external display IDs (non-built-in).
@@ -278,10 +367,48 @@ final class DDCService: @unchecked Sendable {
278367 . filter { CGDisplayIsBuiltin ( $0) == 0 }
279368 }
280369
370+ private func remember( _ candidate: AVServiceCandidate , for displayID: CGDirectDisplayID ) {
371+ guard resolvedProxyIDs [ displayID] != candidate. registryID else { return }
372+ resolvedProxyIDs [ displayID] = candidate. registryID
373+ log. log ( " DDC: display= \( displayID) answers on DCPAVServiceProxy # \( candidate. index) (registryID=0x \( String ( candidate. registryID, radix: 16 ) ) ) " )
374+ }
375+
376+ /// Picks the port a write should go to. Prefers the port that already answered a read for
377+ /// this display; otherwise probes the candidates with a read (the VCP being written, then
378+ /// brightness) so the write can't land on an empty or neighbouring port. Falls back to the
379+ /// first candidate when nothing answers (write-only monitors).
380+ private func resolveWriteTarget(
381+ from candidates: [ AVServiceCandidate ] ,
382+ for displayID: CGDirectDisplayID ,
383+ command: UInt8
384+ ) -> AVServiceCandidate ? {
385+ guard let first = candidates. first else { return nil }
386+ if let cached = resolvedProxyIDs [ displayID] ,
387+ let candidate = candidates. first ( where: { $0. registryID == cached } ) {
388+ return candidate
389+ }
390+ if candidates. count == 1 { return first }
391+
392+ var probes = [ command]
393+ if command != VCPCode . brightness. rawValue { probes. append ( VCPCode . brightness. rawValue) }
394+ for vcp in probes {
395+ for candidate in candidates {
396+ if avServiceRead ( command: vcp, on: candidate. service) != nil {
397+ remember ( candidate, for: displayID)
398+ usleep ( busCooldownMicros)
399+ return candidate
400+ }
401+ }
402+ }
403+ log. log ( " DDC: no port answered a probe read for display \( displayID) — writing to candidate # \( first. index) " )
404+ return first
405+ }
406+
281407 private func avServiceWrite( command: UInt8 , value: UInt16 , displayID: CGDirectDisplayID ) -> Bool {
282- guard let writeFn = avWriteI2CFn,
283- let service = avService ( for: displayID) else {
284- print ( " [Glint] DDC: No IOAVService found for display \( displayID) " )
408+ guard let writeFn = avWriteI2CFn else { return false }
409+ let candidates = avServiceCandidates ( for: displayID)
410+ guard let target = resolveWriteTarget ( from: candidates, for: displayID, command: command) else {
411+ log. log ( " DDC: No IOAVService found for display \( displayID) " )
285412 return false
286413 }
287414
@@ -300,23 +427,38 @@ final class DDCService: @unchecked Sendable {
300427 data. append ( checksum)
301428
302429 let result = data. withUnsafeMutableBufferPointer { buffer -> IOReturn in
303- writeFn ( service, 0x37 , 0x51 , buffer. baseAddress!, UInt32 ( buffer. count) )
430+ writeFn ( target . service, 0x37 , 0x51 , buffer. baseAddress!, UInt32 ( buffer. count) )
304431 }
305432
306433 if result == KERN_SUCCESS {
307434 usleep ( 50_000 )
308435 return true
309436 }
310- print ( " [Glint] DDC write failed: \( result) " )
437+ log . log ( " DDC write failed on proxy # \( target . index ) : \( result) " )
311438 return false
312439 }
313440
441+ /// Reads a VCP from whichever external port answers for `displayID`, trying the
442+ /// candidates in likelihood order and remembering the one that replied.
314443 private func avServiceRead( command: UInt8 , displayID: CGDirectDisplayID ) -> DDCReadResult ? {
315- guard let writeFn = avWriteI2CFn , let readFn = avReadI2CFn ,
316- let service = avService ( for : displayID ) else {
317- print ( " [Glint] DDC: No IOAVService found for display \( displayID) " )
444+ let candidates = avServiceCandidates ( for : displayID )
445+ guard !candidates . isEmpty else {
446+ log . log ( " DDC: No IOAVService found for display \( displayID) " )
318447 return nil
319448 }
449+ for candidate in candidates {
450+ if let result = avServiceRead ( command: command, on: candidate. service) {
451+ remember ( candidate, for: displayID)
452+ return result
453+ }
454+ }
455+ return nil
456+ }
457+
458+ /// One DDC GET VCP round-trip on a specific IOAVService. Returns nil when the port has no
459+ /// display, the display doesn't answer, or the reply doesn't echo the requested VCP.
460+ private func avServiceRead( command: UInt8 , on service: AnyObject ) -> DDCReadResult ? {
461+ guard let writeFn = avWriteI2CFn, let readFn = avReadI2CFn else { return nil }
320462
321463 // Step 1: Send GET VCP Feature request
322464 var sendData : [ UInt8 ] = [
@@ -333,7 +475,7 @@ final class DDCService: @unchecked Sendable {
333475 }
334476
335477 guard writeResult == KERN_SUCCESS else {
336- print ( " [Glint] DDC read (write phase) failed: \( writeResult) " )
478+ log . log ( " DDC read (write phase) failed: \( writeResult) " )
337479 return nil
338480 }
339481
@@ -347,7 +489,7 @@ final class DDCService: @unchecked Sendable {
347489 }
348490
349491 guard readResult == KERN_SUCCESS else {
350- print ( " [Glint] DDC read (read phase) failed: \( readResult) " )
492+ log . log ( " DDC read (read phase) failed: \( readResult) " )
351493 return nil
352494 }
353495
@@ -357,7 +499,7 @@ final class DDCService: @unchecked Sendable {
357499 guard let replyStart = replyData. firstIndex ( of: 0x02 ) ,
358500 replyStart + 8 <= replyData. count,
359501 replyData [ replyStart + 2 ] == command else {
360- print ( " [Glint] DDC read: invalid reply for VCP 0x\( String ( command, radix: 16 ) ) " )
502+ log . log ( " DDC read: invalid reply for VCP 0x \( String ( command, radix: 16 ) ) : \( replyData . map { String ( $0 , radix : 16 ) } ) " )
361503 return nil
362504 }
363505
0 commit comments