Skip to content

Commit d916963

Browse files
authored
feat: Add identifying information about monitors under Linux (#477)
* Whitespace and README typo cleanups * Make XIDs and other primitive types comparable Otherwise, you need things like `this_visual_id.value == that_visual_id.value`, and get unexpected negatives for just `this_visual_id == that_visual_id`. This can be a potential source of bugs. * Add InternAtom support * Add EDID parser This will be needed to get the monitor's human-friendly name under Linux: XRandR will give us the EDID block, but we need to parse it. * Add details about monitors on Linux See BoboTiG/python-mss PR #469 and issue #153. There are no plans to add similar code to the legacy Xlib backend. * Implement suggested fixes from review Uses the := walrus operator to reduce redundant dict lookups. Also (this wasn't in the review), refactors an "in" test from a version I wrote when I needed a more complicated test, to one that's easier to read. * Add CHANGELOG and CHANGES entries * Set monitor["is_primary"] = False if applicable In the previous code, "is_primary" would only be set on the primary monitor (where it's True). This change sets it on all monitors, to parallel the Windows behavior. This code won't set it at all if the primary monitor cannot be determined (XRandR 1.2), although it might set all of them to False if XRandR tells us explicitly that no monitor is primary. (MSSBase.primary_monitor, in the pending PR #469, will use the first monitor in that event.) * Type fix
1 parent a0fb1d2 commit d916963

12 files changed

Lines changed: 1001 additions & 114 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ See Git commit messages for full history.
77
- Add `primary_monitor` property to MSS base class for easy access to the primary monitor (#153)
88
- Windows: add primary monitor detection using `GetMonitorInfoW` API (#153)
99
- Windows: add monitor device name and unique device interface name using `EnumDisplayDevicesW` API (#153)
10+
- Linux: add primary monitor detection, monitor device name, unique device interface name, and output name using XRandR (#153)
1011
- Windows: switch from `GetDIBits` to more memory efficient `CreateDIBSection` for `MSS.grab` implementation (#449)
1112
- Windows: fix gdi32.GetDIBits() failed after a couple of minutes of recording (#268)
1213
- Linux: check the server for Xrandr support version (#417)

CHANGES.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@
1717
- Added `EnumDisplayDevicesW` to `CFUNCTIONS` for querying device details.
1818
- Modified `_monitors_impl()` callback to extract primary monitor flag, device names, and device interface name (unique_id) using Win32 APIs; `unique_id` uses `EDD_GET_DEVICE_INTERFACE_NAME` when available.
1919

20+
### linux/base.py
21+
- Reworked `_monitors_impl()` to prefer XRandR 1.5+ `GetMonitors` when available, falling back to enumerating active CRTCs.
22+
- Added monitor identification fields from RandR + EDID where available: `is_primary`, `output`, `name`, and `unique_id`.
23+
- Added EDID lookup via RandR `EDID`/`EdidData` output property and parsing via `mss.tools.parse_edid()`.
24+
25+
### linux/xcb.py
26+
- Added `intern_atom()` helper with per-connection caching and support for predefined atoms.
27+
- Added `XCB_NONE` constant (`Atom(0)`).
28+
- Added additional XRandR request wrappers used for monitor identification (`GetMonitors`, `GetOutputInfo`, `GetOutputPrimary`, `GetOutputProperty`).
29+
30+
### linux/xcbhelpers.py
31+
- Added `InternAtomReply` structure and typed binding for `xcb_intern_atom`.
32+
- Added `__eq__()`/`__hash__()` to `XID` for value-based comparisons.
33+
34+
### xcbproto/gen_xcb_to_py.py
35+
- Extended the generator to include additional XRandR requests used by the XCB backends (`GetOutputInfo`, `GetOutputPrimary`, `GetOutputProperty`, `GetMonitors`).
36+
- Updated typedef generation to emit value-based `__eq__()`/`__hash__()` implementations.
37+
- Refactored code generation helpers and formatting (use `textwrap.indent`/`dedent`).
38+
39+
### tools.py
40+
- Added `parse_edid()` helper for extracting identifying fields (legacy model id, serial number, manufacture/model year, and display name) from EDID blocks.
41+
42+
### linux/xshmgetimage.py
43+
- Fixed XID type handling for `drawable`/`visual` (avoid mixing raw `.value` with typed IDs).
44+
2045
## 10.1.1 (2025-xx-xx)
2146

2247
### linux/__init__.py

src/mss/linux/base.py

Lines changed: 197 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from __future__ import annotations
22

33
from typing import TYPE_CHECKING, Any
4+
from urllib.parse import urlencode
45

56
from mss.base import MSSBase
67
from mss.exception import ScreenShotError
8+
from mss.tools import parse_edid
79

810
from . import xcb
911
from .xcb import LIB
1012

1113
if TYPE_CHECKING:
14+
from ctypes import Array
15+
1216
from mss.models import Monitor
1317
from mss.screenshot import ScreenShot
1418

@@ -66,7 +70,7 @@ def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912
6670
# we'll have to ask the server for its depth and visual.
6771
assert self.root == self.drawable # noqa: S101
6872
self.drawable_depth = self.pref_screen.root_depth
69-
self.drawable_visual_id = self.pref_screen.root_visual.value
73+
self.drawable_visual_id = self.pref_screen.root_visual
7074
# Server image byte order
7175
if xcb_setup.image_byte_order != xcb.ImageOrder.LSBFirst:
7276
msg = "Only X11 servers using LSB-First images are supported."
@@ -103,7 +107,7 @@ def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912
103107
msg = "Internal error: drawable's depth not found in screen's supported depths"
104108
raise ScreenShotError(msg)
105109
for visual_info in xcb.depth_visuals(xcb_depth):
106-
if visual_info.visual_id.value == self.drawable_visual_id:
110+
if visual_info.visual_id == self.drawable_visual_id:
107111
break
108112
else:
109113
msg = "Internal error: drawable's visual not found in screen's supported visuals"
@@ -140,8 +144,37 @@ def _monitors_impl(self) -> None:
140144
msg = "Cannot identify monitors while the connection is closed"
141145
raise ScreenShotError(msg)
142146

143-
# The first entry is the whole X11 screen that the root is on. That's the one that covers all the
144-
# monitors.
147+
self._append_root_monitor()
148+
149+
randr_version = self._randr_get_version()
150+
if randr_version is None or randr_version < (1, 2):
151+
return
152+
153+
# XRandR terminology (very abridged, but enough for this code):
154+
# - X screen / framebuffer: the overall drawable area for this root.
155+
# - CRTC: a display controller that scans out a rectangular region of the X screen. A CRTC with zero
156+
# outputs is inactive. A CRTC may drive multiple outputs in clone/mirroring mode.
157+
# - Output: a physical connector (e.g. "HDMI-1", "DP-1"). The RandR "connection" state (connected vs
158+
# disconnected) is separate from whether the output is currently driven by a CRTC.
159+
# - Monitor (RandR 1.5+): a logical rectangle presented to clients. Monitors may be client-defined (useful
160+
# for tiled displays) and are the closest match to what MSS wants.
161+
#
162+
# This implementation prefers RandR 1.5+ Monitors when available; otherwise it falls back to enumerating
163+
# active CRTCs.
164+
165+
primary_output = self._randr_get_primary_output(randr_version)
166+
edid_atom = self._randr_get_edid_atom()
167+
168+
if randr_version >= (1, 5):
169+
self._monitors_from_randr_monitors(primary_output, edid_atom)
170+
else:
171+
self._monitors_from_randr_crtcs(randr_version, primary_output, edid_atom)
172+
173+
def _append_root_monitor(self) -> None:
174+
if self.conn is None:
175+
msg = "Cannot identify monitors while the connection is closed"
176+
raise ScreenShotError(msg)
177+
145178
root_geom = xcb.get_geometry(self.conn, self.root)
146179
self._monitors.append(
147180
{
@@ -152,47 +185,181 @@ def _monitors_impl(self) -> None:
152185
}
153186
)
154187

155-
# After that, we have one for each monitor on that X11 screen. For decades, that's been handled by
156-
# Xrandr. We don't presently try to work with Xinerama. So, we're going to check the different outputs,
157-
# according to Xrandr. If that fails, we'll just leave the one root covering everything.
188+
def _randr_get_version(self) -> tuple[int, int] | None:
189+
if self.conn is None:
190+
msg = "Cannot identify monitors while the connection is closed"
191+
raise ScreenShotError(msg)
158192

159-
# Make sure we have the Xrandr extension we need. This will query the cache that we started populating in
160-
# __init__.
161193
randr_ext_data = xcb.get_extension_data(self.conn, LIB.randr_id)
162194
if not randr_ext_data.present:
163-
return
195+
return None
164196

165-
# We ask the server to give us anything up to the version we support (i.e., what we expect the reply
166-
# structs to look like). If the server only supports 1.2, then that's what it'll give us, and we're ok
167-
# with that, but we also use a faster path if the server implements at least 1.3.
168197
randr_version_data = xcb.randr_query_version(self.conn, xcb.RANDR_MAJOR_VERSION, xcb.RANDR_MINOR_VERSION)
169-
randr_version = (randr_version_data.major_version, randr_version_data.minor_version)
170-
if randr_version < (1, 2):
171-
return
198+
return (randr_version_data.major_version, randr_version_data.minor_version)
199+
200+
def _randr_get_primary_output(self, randr_version: tuple[int, int], /) -> xcb.RandrOutput | None:
201+
if self.conn is None:
202+
msg = "Cannot identify monitors while the connection is closed"
203+
raise ScreenShotError(msg)
204+
205+
if randr_version >= (1, 3):
206+
primary_output_data = xcb.randr_get_output_primary(self.conn, self.drawable)
207+
return primary_output_data.output
208+
# Python None means that there was no way to identify a primary output. This is distinct from XCB_NONE (that
209+
# is, xcb.RandROutput(0)), which means that there is not a primary monitor.
210+
return None
211+
212+
def _randr_get_edid_atom(self) -> xcb.Atom | None:
213+
if self.conn is None:
214+
msg = "Cannot identify monitors while the connection is closed"
215+
raise ScreenShotError(msg)
216+
217+
edid_atom = xcb.intern_atom(self.conn, "EDID", only_if_exists=True)
218+
if edid_atom is not None:
219+
return edid_atom
220+
221+
# Formerly, "EDID" was known as "EdidData". I don't know when it changed.
222+
return xcb.intern_atom(self.conn, "EdidData", only_if_exists=True)
223+
224+
def _randr_output_ids(
225+
self,
226+
output: xcb.RandrOutput,
227+
timestamp: xcb.Timestamp,
228+
edid_atom: xcb.Atom | None,
229+
/,
230+
) -> dict[str, Any]:
231+
if self.conn is None:
232+
msg = "Cannot identify monitors while the connection is closed"
233+
raise ScreenShotError(msg)
234+
235+
output_info = xcb.randr_get_output_info(self.conn, output, timestamp)
236+
if output_info.status != 0:
237+
msg = "Display configuration changed while detecting monitors."
238+
raise ScreenShotError(msg)
239+
240+
rv: dict[str, Any] = {}
241+
242+
output_name_arr = xcb.randr_get_output_info_name(output_info)
243+
rv["output"] = bytes(output_name_arr).decode("utf_8", errors="replace")
244+
245+
if edid_atom is not None:
246+
edid_prop = xcb.randr_get_output_property(
247+
self.conn, # connection
248+
output, # output
249+
edid_atom, # property
250+
xcb.XCB_NONE, # property type: Any
251+
0, # long-offset: 0
252+
1024, # long-length: in 4-byte units; 4k is plenty for an EDID
253+
0, # delete: false
254+
0, # pending: false
255+
)
256+
if edid_prop.type_.value != 0:
257+
edid_block = bytes(xcb.randr_get_output_property_data(edid_prop))
258+
edid_data = parse_edid(edid_block)
259+
if (display_name := edid_data.get("display_name")) is not None:
260+
rv["name"] = display_name
261+
262+
edid_params: dict[str, str] = {}
263+
if (id_legacy := edid_data.get("id_legacy")) is not None:
264+
edid_params["model"] = id_legacy
265+
if (serial_number := edid_data.get("serial_number")) is not None:
266+
edid_params["serial"] = str(serial_number)
267+
if (manufacture_year := edid_data.get("manufacture_year")) is not None:
268+
if (manufacture_week := edid_data.get("manufacture_week")) is not None:
269+
edid_params["mfr_date"] = f"{manufacture_year:04d}W{manufacture_week:02d}"
270+
else:
271+
edid_params["mfr_date"] = f"{manufacture_year:04d}"
272+
if (model_year := edid_data.get("model_year")) is not None:
273+
edid_params["model_year"] = f"{model_year:04d}"
274+
if edid_params:
275+
rv["unique_id"] = urlencode(edid_params)
276+
277+
return rv
278+
279+
@staticmethod
280+
def _choose_randr_output(
281+
outputs: Array[xcb.RandrOutput], primary_output: xcb.RandrOutput | None, /
282+
) -> xcb.RandrOutput:
283+
if len(outputs) == 0:
284+
msg = "No RandR outputs available"
285+
raise ScreenShotError(msg)
286+
if primary_output is None:
287+
# We don't want to use the `in` check if this could be None, according to MyPy.
288+
return outputs[0]
289+
if primary_output in outputs:
290+
return primary_output
291+
return outputs[0]
292+
293+
def _monitors_from_randr_monitors(
294+
self, primary_output: xcb.RandrOutput | None, edid_atom: xcb.Atom | None, /
295+
) -> None:
296+
if self.conn is None:
297+
msg = "Cannot identify monitors while the connection is closed"
298+
raise ScreenShotError(msg)
299+
300+
monitors_reply = xcb.randr_get_monitors(self.conn, self.drawable, 1)
301+
timestamp = monitors_reply.timestamp
302+
for randr_monitor in xcb.randr_get_monitors_monitors(monitors_reply):
303+
monitor = {
304+
"left": randr_monitor.x,
305+
"top": randr_monitor.y,
306+
"width": randr_monitor.width,
307+
"height": randr_monitor.height,
308+
}
309+
# Under XRandR, it's legal for no monitor to be primary. In this case, case MSSBase.primary_monitor will
310+
# return the first monitor. That said, we note in the dict that we explicitly are told by XRandR that
311+
# all of the monitors are not primary. (This is distinct from the XRandR 1.2 path, which doesn't have
312+
# any information about primary monitors.)
313+
monitor["is_primary"] = bool(randr_monitor.primary)
314+
315+
if randr_monitor.nOutput > 0:
316+
outputs = xcb.randr_monitor_info_outputs(randr_monitor)
317+
chosen_output = self._choose_randr_output(outputs, primary_output)
318+
monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom)
319+
320+
self._monitors.append(monitor)
321+
322+
def _monitors_from_randr_crtcs(
323+
self,
324+
randr_version: tuple[int, int],
325+
primary_output: xcb.RandrOutput | None,
326+
edid_atom: xcb.Atom | None,
327+
/,
328+
) -> None:
329+
if self.conn is None:
330+
msg = "Cannot identify monitors while the connection is closed"
331+
raise ScreenShotError(msg)
172332

173333
screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply
174-
# Check to see if we have the xcb_randr_get_screen_resources_current function in libxcb-randr, and that
175-
# the server supports it.
176334
if hasattr(LIB.randr, "xcb_randr_get_screen_resources_current") and randr_version >= (1, 3):
177-
screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable.value)
335+
screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable)
178336
crtcs = xcb.randr_get_screen_resources_current_crtcs(screen_resources)
179337
else:
180-
# Either the client or the server doesn't support the _current form. That's ok; we'll use the old
181-
# function, which forces a new query to the physical monitors.
182338
screen_resources = xcb.randr_get_screen_resources(self.conn, self.drawable)
183339
crtcs = xcb.randr_get_screen_resources_crtcs(screen_resources)
340+
timestamp = screen_resources.config_timestamp
184341

185342
for crtc in crtcs:
186-
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp)
343+
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, timestamp)
187344
if crtc_info.num_outputs == 0:
188345
continue
189-
self._monitors.append(
190-
{"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height}
191-
)
346+
monitor = {
347+
"left": crtc_info.x,
348+
"top": crtc_info.y,
349+
"width": crtc_info.width,
350+
"height": crtc_info.height,
351+
}
352+
353+
outputs = xcb.randr_get_crtc_info_outputs(crtc_info)
354+
chosen_output = self._choose_randr_output(outputs, primary_output)
355+
monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom)
356+
# The concept of primary outputs was added in XRandR 1.3. We distinguish between "all the monitors are
357+
# not primary" (RRGetOutputPrimary returned XCB_NONE, a valid case) and "we have no way to get
358+
# information about the primary monitor": in the latter case, we don't populate "is_primary".
359+
if primary_output is not None:
360+
monitor["is_primary"] = chosen_output == primary_output
192361

193-
# Extra credit would be to enumerate the virtual desktops; see
194-
# https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that
195-
# style is.
362+
self._monitors.append(monitor)
196363

197364
def _cursor_impl_check_xfixes(self) -> bool:
198365
"""Check XFixes availability and version.
@@ -277,11 +444,11 @@ def _grab_impl_xgetimage(self, monitor: Monitor, /) -> ScreenShot:
277444
# Copy this into a new bytearray, so that it will persist after we clear the image structure.
278445
img_data = bytearray(img_data_arr)
279446

280-
if img_reply.depth != self.drawable_depth or img_reply.visual.value != self.drawable_visual_id:
447+
if img_reply.depth != self.drawable_depth or img_reply.visual != self.drawable_visual_id:
281448
# This should never happen; a window can't change its visual.
282449
msg = (
283450
"Server returned an image with a depth or visual different than it initially reported: "
284-
f"expected {self.drawable_depth},{hex(self.drawable_visual_id)}, "
451+
f"expected {self.drawable_depth},{hex(self.drawable_visual_id.value)}, "
285452
f"got {img_reply.depth},{hex(img_reply.visual.value)}"
286453
)
287454
raise ScreenShotError(msg)

0 commit comments

Comments
 (0)