[FEAT]: Use stable physical monitor identifiers for the multimonitor case #272

Closed
opened 2026-01-05 14:49:24 +01:00 by adam · 5 comments
Owner

Originally created by @EBNull on GitHub (Dec 20, 2023).

Hi, I noticed issues like #364, #275, and thought I could add some additional context.

I think your solution in #275 will work most of the time (as I think it relies on GDI's virtual screen coordinates), though if you want to uniquely identify physical monitors, I have some pointers for you.

I was working on a map-virtual-to-physical monitor problem in the past and really found only the EDID was persistent and unique enough to identify monitors. I wrote up my journey at https://gist.github.com/EBNull/d65bacceefc58f5f1d728a66039807d2 .

In short, using a combination of GDI, SetupDi, EDD_GET_DEVICE_INTERFACE_NAME , and a little bit of EDID decoding, you can map these around.

This is probably more in-depth than needed (since komorebi is really exclusively in the "virtual screen" layer of abstraction) since you now remember monitors by virtual position, but I thought you might appreciate the additional context.

And thanks for referencing my gist in e04ba0e033 ! It was really neat to stumble across this project, read a few commits and issues, and find my old gist helping someone out.

Originally created by @EBNull on GitHub (Dec 20, 2023). Hi, I noticed issues like #364, #275, and thought I could add some additional context. I think your solution in #275 will work most of the time (as I think it relies on [GDI's virtual screen](https://learn.microsoft.com/en-us/windows/win32/gdi/the-virtual-screen) coordinates), though if you want to uniquely identify physical monitors, I have some pointers for you. I was working on a map-virtual-to-physical monitor problem in the past and really found only the `EDID` was persistent and unique enough to identify monitors. I wrote up my journey at https://gist.github.com/EBNull/d65bacceefc58f5f1d728a66039807d2 . In short, using a combination of `GDI`, `SetupDi`, `EDD_GET_DEVICE_INTERFACE_NAME `, and a little bit of `EDID` decoding, you can map these around. This is probably more in-depth than needed (since `komorebi` is really exclusively in the "virtual screen" layer of abstraction) since you now remember monitors by virtual position, but I thought you might appreciate the additional context. And thanks for referencing my gist in https://github.com/LGUG2Z/komorebi/commit/e04ba0e033c4515095d1290019b21bec7a644204 ! It was really neat to stumble across this project, read a few commits and issues, and find my old gist helping someone out.
adam added the enhancement label 2026-01-05 14:49:24 +01:00
adam closed this issue 2026-01-05 14:49:24 +01:00
Author
Owner

@LGUG2Z commented on GitHub (Dec 23, 2023):

Thanks so much for sharing this, and for the gist about programmatically changing focus!

I have started slowly working on assigning EDIDs to monitors in komorebi if anyone wants to follow along on YouTube: https://www.youtube.com/watch?v=N_IL53wYcXs

@LGUG2Z commented on GitHub (Dec 23, 2023): Thanks so much for sharing this, and for the gist about programmatically changing focus! I have started slowly working on assigning EDIDs to monitors in `komorebi` if anyone wants to follow along on YouTube: https://www.youtube.com/watch?v=N_IL53wYcXs
Author
Owner

@EBNull commented on GitHub (Dec 24, 2023):

Thanks for recording! Watching that reminded me of the similar quagmire I found myself in clicking through those docs - I forgot how bad it was.

I added some small notes to the linked commit above.

General thoughts:

  • I would have tried for a PR, but I don't know Rust :)
  • The "other code" I make reference to in the gist is actually (really bad) golang - I wrote the FFIs, and cut a bunch of corners (zero instead of null, etc) :)
  • I don't recommend trying to parse the device instance string nor doing an enum on those registry keys - the SetupDi family gets you to the right key
@EBNull commented on GitHub (Dec 24, 2023): Thanks for recording! Watching that reminded me of the similar quagmire I found myself in clicking through those docs - I forgot how bad it was. I added some small notes to the linked commit above. General thoughts: - I would have tried for a PR, but I don't know Rust :) - The "other code" I make reference to in the gist is actually (really bad) golang - I wrote the FFIs, and cut a bunch of corners (zero instead of null, etc) :) - I don't recommend trying to parse the device instance string nor doing an enum on those registry keys - the `SetupDi` family gets you to the right key
Author
Owner

@EBNull commented on GitHub (Dec 24, 2023):

I started writing more here, but then I figured, eh, have some code. I assume you share the same username on gitlab, so, see https://gitlab.com/ebnull/hudctl/-/blob/master/monman/collectmoninfo_windows.go and https://gitlab.com/ebnull/hudctl/-/blob/master/monman/monitors_windows.go .

@EBNull commented on GitHub (Dec 24, 2023): I started writing more here, but then I figured, eh, have some code. I assume you share the same username on gitlab, so, see https://gitlab.com/ebnull/hudctl/-/blob/master/monman/collectmoninfo_windows.go and https://gitlab.com/ebnull/hudctl/-/blob/master/monman/monitors_windows.go .
Author
Owner

@EBNull commented on GitHub (Dec 24, 2023):

Additional pointers (in no specific order):

My own code, "augmented" structures and a combination function:

type DisplayMonitorInfoAugmented struct {
	DisplayMonitorInfo gowin32.DisplayMonitorInfo
	MonitorInfoEx      *win32.MONITORINFOEXW
}

type PhysicalMonitorInfoAugmented struct {
	DisplayDevices   []win32.AdapterMonitorDisplayDevices
	PhysicalMonitors *win32.PhysicalMonitorArray
}

type CollectedWindowsAdapterInfo struct {
	DisplayMonitorInfoAugmented
	PhysicalMonitorInfoAugmented
}

func CollectWindowsAdapterAndMonitorInfo() ([]CollectedWindowsAdapterInfo, error) {
	dmmap := map[string]gowin32.DisplayDevice{}
	for _, dd := range gowin32.GetAllDisplayDevices() {

		dmmap[dd.DeviceName] = dd

	}
	var err error
	ret := []CollectedWindowsAdapterInfo{}

	for _, dm := range gowin32.GetAllDisplayMonitors() {
		cmi := CollectedWindowsAdapterInfo{DisplayMonitorInfoAugmented: DisplayMonitorInfoAugmented{DisplayMonitorInfo: dm}}

		cmi.MonitorInfoEx, err = win32.GetMonitorInfo(windows.Handle(dm.Handle))
		if err != nil {
			return nil, err
		}

		cmi.PhysicalMonitors, err = win32.GetPhysicalMonitorsFromHMONITOR(windows.Handle(dm.Handle))
		if err != nil {
			return nil, err
		}
		cmi.DisplayDevices = win32.GetDisplayDevicesFromAdapterDeviceName(cmi.MonitorInfoEx.Device())

		ret = append(ret, cmi)
	}

	return ret, nil
}

Usage of the data to get the EDIDs:

// +build windows

package monman

import (
	"fmt"
	"github.com/davecgh/go-spew/spew"
	"gitlab.com/ebnull/hudctl/monman/win32"
	"strings"
	//"golang.org/x/sys/windows/registry"
)

const (
	DISPLAY_DEVICE_ACTIVE           = 0x00000001
	DISPLAY_DEVICE_PRIMARY_DEVICE   = 0x00000004
	DISPLAY_DEVICE_MIRRORING_DRIVER = 0x00000008
	DISPLAY_DEVICE_VGA_COMPATIBLE   = 0x00000010
	DISPLAY_DEVICE_REMOVABLE        = 0x00000020
	DISPLAY_DEVICE_MODESPRUNED      = 0x08000000
)

func init() {
	MonMan = &WindowsMonitorManager{}
}

type WindowsMonitorManager struct{}

func (wmm *WindowsMonitorManager) GetMonitors() ([]BasicMonitor, error) {
	ret := []BasicMonitor{}

	seen := map[string]struct{}{}

	cmis, err := CollectWindowsAdapterAndMonitorInfo()
	if err != nil {
		return ret, err
	}
	for _, cmi := range cmis {
		for _, ddinfo := range cmi.DisplayDevices {
			if ddinfo.Monitor == nil {
				// Enabled monitor but not configured?
				spew.Dump(ddinfo)
				// TODO: Expose this issue
				continue
			}
			if DISPLAY_DEVICE_ACTIVE&ddinfo.Monitor.StateFlags == 0 {
				continue
			}
			if ddinfo.Monitor.DeviceName == "" {
				continue
			}
			_, ok := seen[ddinfo.Monitor.DeviceName]
			if ok {
				continue
			}
			seen[ddinfo.Monitor.DeviceName] = struct{}{}
			regPath, edidBytes, err := GetMonitorRegPathAndEdid(ddinfo.MonitorDeviceInterfaceName)
			if err != nil {
				return ret, err
			}
			regPath = strings.Replace(regPath, "\\REGISTRY\\MACHINE", "HKEY_LOCAL_MACHINE", 1)
			vd, err := NewVendorDataFromEdid(edidBytes)
			if err != nil {
				return ret, err
			}
			hd := HostData{
				PhysicalPath:           ddinfo.Monitor.DeviceName,
				LogicalPath:            ddinfo.MonitorDeviceInterfaceName,
				LogicalMonitorIdentity: fmt.Sprintf("%s (%s%04X) %s", vd.Name, vd.ManufacturerId, vd.ProductCode, vd.SerialNumber),
			}
			mon := WindowsMonitor{HostData: hd, VendorData: vd, RegPath: regPath, DisplayMonitorInfoAugmented: cmi.DisplayMonitorInfoAugmented, PhysicalMonitors: cmi.PhysicalMonitors, DDInfo: ddinfo}
			ret = append(ret, mon)
		}
	}

	//fmt.Printf("hMonitor %d @ (%d, %d) [%s] - %s\n", i, m.Rectangle.Left, m.Rectangle.Top, mie, pma.PhysicalMonitor)
	//pma.Close()

	return ret, nil
}

type WindowsMonitor struct {
	HostData
	VendorData
	RegPath                     string
	DisplayMonitorInfoAugmented DisplayMonitorInfoAugmented
	PhysicalMonitors            *win32.PhysicalMonitorArray
	DDInfo                      win32.AdapterMonitorDisplayDevices
}

func (wm WindowsMonitor) GetHostData() HostData {
	return wm.HostData
}

func (wm WindowsMonitor) GetVendorData() VendorData {
	return wm.VendorData
}

EDID stuff:

package monman

import (
	"fmt"
	"gitlab.com/ebnull/hudctl/monman/win32"
	//"github.com/davecgh/go-spew/spew"
	//"github.com/winlabs/gowin32"
	//w "github.com/winlabs/gowin32/wrappers"
	"golang.org/x/sys/windows/registry"
	"strings"
)

func GetMonitorRegPathAndEdid(monitorPath string) (string, []byte, error) {
	monitorPath = strings.ToLower(monitorPath)

	//fmt.Printf("Looking for %s\n", spew.Sdump(monitorPath))

	hDevInfo, err := win32.SetupDiGetClassDevsEx(win32.GUID_DEVINTERFACE_MONITOR, "", 0, win32.DIGCF_DEVICEINTERFACE, 0, "")
	if err != nil {
		return "", []byte{}, err
	}
	//fmt.Printf("Got hDevInfo: %#v\n", hDevInfo)

	devpath := ""
	i := 0
	for i = 0; true; i++ {
		difd, err := win32.SetupDiEnumDeviceInterfaces(hDevInfo, 0, win32.GUID_DEVINTERFACE_MONITOR, i)
		if difd == nil && err == nil {
			break
		}
		//fmt.Printf(spew.Sdump(difd))
		if err != nil {
			return "", []byte{}, err
		}

		devpath, err = win32.SetupDiGetDeviceInterfaceDetail(hDevInfo, difd)
		if err != nil {
			return "", []byte{}, err
		}
		if strings.ToLower(devpath) == monitorPath {
			break
		}
	}
	if strings.ToLower(devpath) != monitorPath {
		return "", []byte{}, fmt.Errorf("Could not find %s in SetupApi", monitorPath)
	}

	did, err := win32.SetupDiEnumDeviceInfo(hDevInfo, i)
	if err != nil {
		return "", []byte{}, err
	}
	hKey, err := win32.SetupDiOpenDevRegKey(hDevInfo, did, win32.DICS_FLAG_GLOBAL, 0, win32.DIREG_DEV, win32.KEY_READ)
	if err != nil {
		return "", []byte{}, err
	}
	regkey := registry.Key(hKey)
	edidBytes, _, err := regkey.GetBinaryValue("EDID")
	if err != nil {
		return "", []byte{}, err
	}

	keyName, err := win32.NtQueryKeyName(hKey)
	if err != nil {
		return "", []byte{}, err
	}

	return keyName, edidBytes, err
}

@EBNull commented on GitHub (Dec 24, 2023): Additional pointers (in no specific order): - gowin32 `GetAllDisplayDevices` - https://github.com/winlabs/gowin32/blob/0d265587d3c90d3dca04c4927c845b5334ade86f/sysinfo.go#L70-L92 - gowin32 `GetAllDisplayMonitors` - https://github.com/winlabs/gowin32/blob/0d265587d3c90d3dca04c4927c845b5334ade86f/sysinfo.go#L105C6-L119 My own code, "augmented" structures and a combination function: ```go type DisplayMonitorInfoAugmented struct { DisplayMonitorInfo gowin32.DisplayMonitorInfo MonitorInfoEx *win32.MONITORINFOEXW } type PhysicalMonitorInfoAugmented struct { DisplayDevices []win32.AdapterMonitorDisplayDevices PhysicalMonitors *win32.PhysicalMonitorArray } type CollectedWindowsAdapterInfo struct { DisplayMonitorInfoAugmented PhysicalMonitorInfoAugmented } func CollectWindowsAdapterAndMonitorInfo() ([]CollectedWindowsAdapterInfo, error) { dmmap := map[string]gowin32.DisplayDevice{} for _, dd := range gowin32.GetAllDisplayDevices() { dmmap[dd.DeviceName] = dd } var err error ret := []CollectedWindowsAdapterInfo{} for _, dm := range gowin32.GetAllDisplayMonitors() { cmi := CollectedWindowsAdapterInfo{DisplayMonitorInfoAugmented: DisplayMonitorInfoAugmented{DisplayMonitorInfo: dm}} cmi.MonitorInfoEx, err = win32.GetMonitorInfo(windows.Handle(dm.Handle)) if err != nil { return nil, err } cmi.PhysicalMonitors, err = win32.GetPhysicalMonitorsFromHMONITOR(windows.Handle(dm.Handle)) if err != nil { return nil, err } cmi.DisplayDevices = win32.GetDisplayDevicesFromAdapterDeviceName(cmi.MonitorInfoEx.Device()) ret = append(ret, cmi) } return ret, nil } ``` Usage of the data to get the EDIDs: ```go // +build windows package monman import ( "fmt" "github.com/davecgh/go-spew/spew" "gitlab.com/ebnull/hudctl/monman/win32" "strings" //"golang.org/x/sys/windows/registry" ) const ( DISPLAY_DEVICE_ACTIVE = 0x00000001 DISPLAY_DEVICE_PRIMARY_DEVICE = 0x00000004 DISPLAY_DEVICE_MIRRORING_DRIVER = 0x00000008 DISPLAY_DEVICE_VGA_COMPATIBLE = 0x00000010 DISPLAY_DEVICE_REMOVABLE = 0x00000020 DISPLAY_DEVICE_MODESPRUNED = 0x08000000 ) func init() { MonMan = &WindowsMonitorManager{} } type WindowsMonitorManager struct{} func (wmm *WindowsMonitorManager) GetMonitors() ([]BasicMonitor, error) { ret := []BasicMonitor{} seen := map[string]struct{}{} cmis, err := CollectWindowsAdapterAndMonitorInfo() if err != nil { return ret, err } for _, cmi := range cmis { for _, ddinfo := range cmi.DisplayDevices { if ddinfo.Monitor == nil { // Enabled monitor but not configured? spew.Dump(ddinfo) // TODO: Expose this issue continue } if DISPLAY_DEVICE_ACTIVE&ddinfo.Monitor.StateFlags == 0 { continue } if ddinfo.Monitor.DeviceName == "" { continue } _, ok := seen[ddinfo.Monitor.DeviceName] if ok { continue } seen[ddinfo.Monitor.DeviceName] = struct{}{} regPath, edidBytes, err := GetMonitorRegPathAndEdid(ddinfo.MonitorDeviceInterfaceName) if err != nil { return ret, err } regPath = strings.Replace(regPath, "\\REGISTRY\\MACHINE", "HKEY_LOCAL_MACHINE", 1) vd, err := NewVendorDataFromEdid(edidBytes) if err != nil { return ret, err } hd := HostData{ PhysicalPath: ddinfo.Monitor.DeviceName, LogicalPath: ddinfo.MonitorDeviceInterfaceName, LogicalMonitorIdentity: fmt.Sprintf("%s (%s%04X) %s", vd.Name, vd.ManufacturerId, vd.ProductCode, vd.SerialNumber), } mon := WindowsMonitor{HostData: hd, VendorData: vd, RegPath: regPath, DisplayMonitorInfoAugmented: cmi.DisplayMonitorInfoAugmented, PhysicalMonitors: cmi.PhysicalMonitors, DDInfo: ddinfo} ret = append(ret, mon) } } //fmt.Printf("hMonitor %d @ (%d, %d) [%s] - %s\n", i, m.Rectangle.Left, m.Rectangle.Top, mie, pma.PhysicalMonitor) //pma.Close() return ret, nil } type WindowsMonitor struct { HostData VendorData RegPath string DisplayMonitorInfoAugmented DisplayMonitorInfoAugmented PhysicalMonitors *win32.PhysicalMonitorArray DDInfo win32.AdapterMonitorDisplayDevices } func (wm WindowsMonitor) GetHostData() HostData { return wm.HostData } func (wm WindowsMonitor) GetVendorData() VendorData { return wm.VendorData } ``` EDID stuff: ```go package monman import ( "fmt" "gitlab.com/ebnull/hudctl/monman/win32" //"github.com/davecgh/go-spew/spew" //"github.com/winlabs/gowin32" //w "github.com/winlabs/gowin32/wrappers" "golang.org/x/sys/windows/registry" "strings" ) func GetMonitorRegPathAndEdid(monitorPath string) (string, []byte, error) { monitorPath = strings.ToLower(monitorPath) //fmt.Printf("Looking for %s\n", spew.Sdump(monitorPath)) hDevInfo, err := win32.SetupDiGetClassDevsEx(win32.GUID_DEVINTERFACE_MONITOR, "", 0, win32.DIGCF_DEVICEINTERFACE, 0, "") if err != nil { return "", []byte{}, err } //fmt.Printf("Got hDevInfo: %#v\n", hDevInfo) devpath := "" i := 0 for i = 0; true; i++ { difd, err := win32.SetupDiEnumDeviceInterfaces(hDevInfo, 0, win32.GUID_DEVINTERFACE_MONITOR, i) if difd == nil && err == nil { break } //fmt.Printf(spew.Sdump(difd)) if err != nil { return "", []byte{}, err } devpath, err = win32.SetupDiGetDeviceInterfaceDetail(hDevInfo, difd) if err != nil { return "", []byte{}, err } if strings.ToLower(devpath) == monitorPath { break } } if strings.ToLower(devpath) != monitorPath { return "", []byte{}, fmt.Errorf("Could not find %s in SetupApi", monitorPath) } did, err := win32.SetupDiEnumDeviceInfo(hDevInfo, i) if err != nil { return "", []byte{}, err } hKey, err := win32.SetupDiOpenDevRegKey(hDevInfo, did, win32.DICS_FLAG_GLOBAL, 0, win32.DIREG_DEV, win32.KEY_READ) if err != nil { return "", []byte{}, err } regkey := registry.Key(hKey) edidBytes, _, err := regkey.GetBinaryValue("EDID") if err != nil { return "", []byte{}, err } keyName, err := win32.NtQueryKeyName(hKey) if err != nil { return "", []byte{}, err } return keyName, edidBytes, err } ```
Author
Owner

@gazpachoking commented on GitHub (Feb 8, 2024):

Oh, this seems related to my question here https://github.com/LGUG2Z/komorebi/discussions/657, but I'm not sure if the solution implemented is applicable to my case? Is there a way now I can pin a given monitor workspace to a specific monitor when I change which monitors are plugged in to my laptop?

@gazpachoking commented on GitHub (Feb 8, 2024): Oh, this seems related to my question here https://github.com/LGUG2Z/komorebi/discussions/657, but I'm not sure if the solution implemented is applicable to my case? Is there a way now I can pin a given monitor workspace to a specific monitor when I change which monitors are plugged in to my laptop?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/komorebi#272