mirror of
https://github.com/kellyjonbrazil/jc.git
synced 2025-07-15 01:24:29 +02:00
Merge pull request #365 from tzeikob/xrandr-edid-364
Modify the xrandr parser to extract display model info from EDID #364
This commit is contained in:
18
jc/parsers/pyedid/LICENSE
Normal file
18
jc/parsers/pyedid/LICENSE
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
Copyright 2019-2020 Jonas Lieb, Davydov Denis
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
0
jc/parsers/pyedid/__init__.py
Normal file
0
jc/parsers/pyedid/__init__.py
Normal file
171
jc/parsers/pyedid/edid.py
Executable file
171
jc/parsers/pyedid/edid.py
Executable file
@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Edid module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from collections import namedtuple
|
||||||
|
from typing import ByteString
|
||||||
|
|
||||||
|
__all__ = ["Edid"]
|
||||||
|
|
||||||
|
|
||||||
|
class Edid:
|
||||||
|
"""Edid class
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`ValueError`: if invalid edid data
|
||||||
|
"""
|
||||||
|
|
||||||
|
_STRUCT_FORMAT = (
|
||||||
|
"<" # little-endian
|
||||||
|
"8s" # constant header (8 bytes)
|
||||||
|
"H" # manufacturer id (2 bytes)
|
||||||
|
"H" # product id (2 bytes)
|
||||||
|
"I" # serial number (4 bytes)
|
||||||
|
"B" # manufactoring week (1 byte)
|
||||||
|
"B" # manufactoring year (1 byte)
|
||||||
|
"B" # edid version (1 byte)
|
||||||
|
"B" # edid revision (1 byte)
|
||||||
|
"B" # video input type (1 byte)
|
||||||
|
"B" # horizontal size in cm (1 byte)
|
||||||
|
"B" # vertical size in cm (1 byte)
|
||||||
|
"B" # display gamma (1 byte)
|
||||||
|
"B" # supported features (1 byte)
|
||||||
|
"10s" # color characteristics (10 bytes)
|
||||||
|
"H" # supported timings (2 bytes)
|
||||||
|
"B" # reserved timing (1 byte)
|
||||||
|
"16s" # EDID supported timings (16 bytes)
|
||||||
|
"18s" # detailed timing block 1 (18 bytes)
|
||||||
|
"18s" # detailed timing block 2 (18 bytes)
|
||||||
|
"18s" # detailed timing block 3 (18 bytes)
|
||||||
|
"18s" # detailed timing block 4 (18 bytes)
|
||||||
|
"B" # extension flag (1 byte)
|
||||||
|
"B"
|
||||||
|
) # checksum (1 byte)
|
||||||
|
|
||||||
|
_TIMINGS = {
|
||||||
|
0: (1280, 1024, 75.0),
|
||||||
|
1: (1024, 768, 75.0),
|
||||||
|
2: (1024, 768, 70.0),
|
||||||
|
3: (1024, 768, 60.0),
|
||||||
|
4: (1024, 768, 87.0),
|
||||||
|
5: (832, 624, 75.0),
|
||||||
|
6: (800, 600, 75.0),
|
||||||
|
7: (800, 600, 72.0),
|
||||||
|
8: (800, 600, 60.0),
|
||||||
|
9: (800, 600, 56.0),
|
||||||
|
10: (640, 480, 75.0),
|
||||||
|
11: (640, 480, 72.0),
|
||||||
|
12: (640, 480, 67.0),
|
||||||
|
13: (640, 480, 60.0),
|
||||||
|
14: (720, 400, 88.0),
|
||||||
|
15: (720, 400, 70.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ASPECT_RATIOS = {
|
||||||
|
0b00: (16, 10),
|
||||||
|
0b01: ( 4, 3),
|
||||||
|
0b10: ( 5, 4),
|
||||||
|
0b11: (16, 9),
|
||||||
|
}
|
||||||
|
|
||||||
|
_RawEdid = namedtuple("RawEdid",
|
||||||
|
("header",
|
||||||
|
"manu_id",
|
||||||
|
"prod_id",
|
||||||
|
"serial_no",
|
||||||
|
"manu_week",
|
||||||
|
"manu_year",
|
||||||
|
"edid_version",
|
||||||
|
"edid_revision",
|
||||||
|
"input_type",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"gamma",
|
||||||
|
"features",
|
||||||
|
"color",
|
||||||
|
"timings_supported",
|
||||||
|
"timings_reserved",
|
||||||
|
"timings_edid",
|
||||||
|
"timing_1",
|
||||||
|
"timing_2",
|
||||||
|
"timing_3",
|
||||||
|
"timing_4",
|
||||||
|
"extension",
|
||||||
|
"checksum")
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, edid: ByteString):
|
||||||
|
self._parse_edid(edid)
|
||||||
|
|
||||||
|
def _parse_edid(self, edid: ByteString):
|
||||||
|
"""Convert edid byte string to edid object"""
|
||||||
|
if struct.calcsize(self._STRUCT_FORMAT) != 128:
|
||||||
|
raise ValueError("Wrong edid size.")
|
||||||
|
|
||||||
|
if sum(map(int, edid)) % 256 != 0:
|
||||||
|
raise ValueError("Checksum mismatch.")
|
||||||
|
|
||||||
|
unpacked = struct.unpack(self._STRUCT_FORMAT, edid)
|
||||||
|
raw_edid = self._RawEdid(*unpacked)
|
||||||
|
|
||||||
|
if raw_edid.header != b'\x00\xff\xff\xff\xff\xff\xff\x00':
|
||||||
|
raise ValueError("Invalid header.")
|
||||||
|
|
||||||
|
self.raw = edid
|
||||||
|
self.manufacturer_id = raw_edid.manu_id
|
||||||
|
self.product = raw_edid.prod_id
|
||||||
|
self.year = raw_edid.manu_year + 1990
|
||||||
|
self.edid_version = "{:d}.{:d}".format(raw_edid.edid_version, raw_edid.edid_revision)
|
||||||
|
self.type = "digital" if (raw_edid.input_type & 0xFF) else "analog"
|
||||||
|
self.width = float(raw_edid.width)
|
||||||
|
self.height = float(raw_edid.height)
|
||||||
|
self.gamma = (raw_edid.gamma+100)/100
|
||||||
|
self.dpms_standby = bool(raw_edid.features & 0xFF)
|
||||||
|
self.dpms_suspend = bool(raw_edid.features & 0x7F)
|
||||||
|
self.dpms_activeoff = bool(raw_edid.features & 0x3F)
|
||||||
|
|
||||||
|
self.resolutions = []
|
||||||
|
for i in range(16):
|
||||||
|
bit = raw_edid.timings_supported & (1 << i)
|
||||||
|
if bit:
|
||||||
|
self.resolutions.append(self._TIMINGS[i])
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
bytes_data = raw_edid.timings_edid[2*i:2*i+2]
|
||||||
|
if bytes_data == b'\x01\x01':
|
||||||
|
continue
|
||||||
|
byte1, byte2 = bytes_data
|
||||||
|
x_res = 8*(int(byte1)+31)
|
||||||
|
aspect_ratio = self._ASPECT_RATIOS[(byte2>>6) & 0b11]
|
||||||
|
y_res = int(x_res * aspect_ratio[1]/aspect_ratio[0])
|
||||||
|
rate = (int(byte2) & 0b00111111) + 60.0
|
||||||
|
self.resolutions.append((x_res, y_res, rate))
|
||||||
|
|
||||||
|
self.name = None
|
||||||
|
self.serial = None
|
||||||
|
|
||||||
|
for timing_bytes in (raw_edid.timing_1, raw_edid.timing_2, raw_edid.timing_3, raw_edid.timing_4):
|
||||||
|
# "other" descriptor
|
||||||
|
if timing_bytes[0:2] == b'\x00\x00':
|
||||||
|
timing_type = timing_bytes[3]
|
||||||
|
if timing_type in (0xFF, 0xFE, 0xFC):
|
||||||
|
buffer = timing_bytes[5:]
|
||||||
|
buffer = buffer.partition(b"\x0a")[0]
|
||||||
|
text = buffer.decode("cp437")
|
||||||
|
if timing_type == 0xFF:
|
||||||
|
self.serial = text
|
||||||
|
elif timing_type == 0xFC:
|
||||||
|
self.name = text
|
||||||
|
|
||||||
|
if not self.serial:
|
||||||
|
self.serial = raw_edid.serial_no
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
clsname = self.__class__.__name__
|
||||||
|
attributes = []
|
||||||
|
for name in dir(self):
|
||||||
|
if not name.startswith("_"):
|
||||||
|
value = getattr(self, name)
|
||||||
|
attributes.append("\t{}={}".format(name, value))
|
||||||
|
return "{}(\n{}\n)".format(clsname, ", \n".join(attributes))
|
0
jc/parsers/pyedid/helpers/__init__.py
Normal file
0
jc/parsers/pyedid/helpers/__init__.py
Normal file
61
jc/parsers/pyedid/helpers/edid_helper.py
Normal file
61
jc/parsers/pyedid/helpers/edid_helper.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
EDID helper
|
||||||
|
"""
|
||||||
|
|
||||||
|
from subprocess import CalledProcessError, check_output
|
||||||
|
from typing import ByteString, List
|
||||||
|
|
||||||
|
__all__ = ["EdidHelper"]
|
||||||
|
|
||||||
|
|
||||||
|
class EdidHelper:
|
||||||
|
"""Class for working with EDID data"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hex2bytes(hex_data: str) -> ByteString:
|
||||||
|
"""Convert hex EDID string to bytes
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hex_data (str): hex edid string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ByteString: edid byte string
|
||||||
|
"""
|
||||||
|
# delete edid 1.3 additional block
|
||||||
|
if len(hex_data) > 256:
|
||||||
|
hex_data = hex_data[:256]
|
||||||
|
|
||||||
|
numbers = []
|
||||||
|
for i in range(0, len(hex_data), 2):
|
||||||
|
pair = hex_data[i : i + 2]
|
||||||
|
numbers.append(int(pair, 16))
|
||||||
|
return bytes(numbers)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_edids(cls) -> List[ByteString]:
|
||||||
|
"""Get edids from xrandr
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
`RuntimeError`: if error with retrieving xrandr util data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[ByteString]: list with edids
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
output = check_output(["xrandr", "--verbose"])
|
||||||
|
except (CalledProcessError, FileNotFoundError) as err:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Error retrieving xrandr util data: {}".format(err)
|
||||||
|
) from None
|
||||||
|
|
||||||
|
edids = []
|
||||||
|
lines = output.splitlines()
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
line = line.decode().strip()
|
||||||
|
if line.startswith("EDID:"):
|
||||||
|
selection = lines[i + 1 : i + 9]
|
||||||
|
selection = list(s.decode().strip() for s in selection)
|
||||||
|
selection = "".join(selection)
|
||||||
|
bytes_section = cls.hex2bytes(selection)
|
||||||
|
edids.append(bytes_section)
|
||||||
|
return edids
|
136
jc/parsers/pyedid/helpers/registry.py
Normal file
136
jc/parsers/pyedid/helpers/registry.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Module for working with PNP ID REGISTRY
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import string
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
|
__all__ = ["Registry"]
|
||||||
|
|
||||||
|
|
||||||
|
class WebPnpIdParser(HTMLParser):
|
||||||
|
"""Parser pnp id from https://uefi.org/PNP_ID_List
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
p = WebPnpIdParser()
|
||||||
|
p.feed(html_data)
|
||||||
|
p.result
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._find_table = False
|
||||||
|
self._find_row = False
|
||||||
|
# first -- company name, second -- pnp id, third -- approved date
|
||||||
|
self._last_field = []
|
||||||
|
# key -- pnp id, value -- tuple (company_name, approved_date)
|
||||||
|
self.result = {}
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == "tbody":
|
||||||
|
self._find_table = True
|
||||||
|
elif self._find_table and tag == "tr":
|
||||||
|
self._find_row = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == "tbody":
|
||||||
|
self._find_table = False
|
||||||
|
elif self._find_table and tag == "tr":
|
||||||
|
self._find_row = False
|
||||||
|
# add table row to result
|
||||||
|
self.result[self._last_field[1]] = (
|
||||||
|
self._last_field[0],
|
||||||
|
self._last_field[-1],
|
||||||
|
)
|
||||||
|
self._last_field.clear()
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
# skip processing until table is found
|
||||||
|
if not self._find_table:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._find_row:
|
||||||
|
data = data.strip()
|
||||||
|
if data:
|
||||||
|
self._last_field.append(data)
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
|
||||||
|
class Registry(dict):
|
||||||
|
"""Registry pnp id data dictionary
|
||||||
|
|
||||||
|
key -- pnp_id
|
||||||
|
value -- company name
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_web(cls, filter_by_id: str = None):
|
||||||
|
"""Get registry from https://uefi.org/PNP_ID_List
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_by_id (str), optional: filter registry by id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = "https://uefi.org/PNP_ID_List"
|
||||||
|
if filter_by_id:
|
||||||
|
url += "?search={}".format(filter_by_id)
|
||||||
|
|
||||||
|
with request.urlopen(url) as req:
|
||||||
|
parse = WebPnpIdParser()
|
||||||
|
parse.feed(req.read().decode())
|
||||||
|
|
||||||
|
registry = cls()
|
||||||
|
for key, value in parse.result.items():
|
||||||
|
# skip invalid search value
|
||||||
|
if filter_by_id and key != filter_by_id:
|
||||||
|
continue
|
||||||
|
registry[key] = value[0]
|
||||||
|
return registry
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_csv(cls, csv_path: str, filter_by_id: str = None):
|
||||||
|
"""Get registry by csv local file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
csv_path (str): path to csv file
|
||||||
|
filter_by_id (str), optional: filter registry by id
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
"""
|
||||||
|
registry = cls()
|
||||||
|
with open(csv_path, "r") as file:
|
||||||
|
reader = csv.reader(file)
|
||||||
|
for line in reader:
|
||||||
|
# filter
|
||||||
|
if filter_by_id and filter_by_id != line[0]:
|
||||||
|
continue
|
||||||
|
registry[line[0]] = line[1]
|
||||||
|
return registry
|
||||||
|
|
||||||
|
def to_csv(self, csv_path: str):
|
||||||
|
"""Dump registry to csv file"""
|
||||||
|
with open(csv_path, "w") as csv_file:
|
||||||
|
writer = csv.writer(csv_file)
|
||||||
|
writer.writerows(self.items())
|
||||||
|
return self
|
||||||
|
|
||||||
|
def get_company_from_id(self, pnp_id: str) -> str:
|
||||||
|
"""Convert PNP id to company name"""
|
||||||
|
return self.get(pnp_id, "Unknown")
|
||||||
|
|
||||||
|
def get_company_from_raw(self, raw: int) -> str:
|
||||||
|
"""Convert raw edid value to company name"""
|
||||||
|
tmp = [(raw >> 10) & 31, (raw >> 5) & 31, raw & 31]
|
||||||
|
pnp_id = "".join(string.ascii_uppercase[n - 1] for n in tmp)
|
||||||
|
return self.get_company_from_id(pnp_id)
|
29
jc/parsers/pyedid/main.py
Normal file
29
jc/parsers/pyedid/main.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Entrypoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyedid.edid import Edid
|
||||||
|
from pyedid.helpers.edid_helper import EdidHelper
|
||||||
|
from pyedid.helpers.registry import Registry
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main func"""
|
||||||
|
|
||||||
|
edid_csv_cache = "/tmp/pyedid-database.csv"
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry = Registry.from_csv(edid_csv_cache)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Loading registry from web...")
|
||||||
|
registry = Registry.from_web()
|
||||||
|
print("Done!\n")
|
||||||
|
registry.to_csv(edid_csv_cache)
|
||||||
|
|
||||||
|
for raw in EdidHelper.get_edids():
|
||||||
|
edid = Edid(raw, registry)
|
||||||
|
print(edid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
@ -3,6 +3,7 @@
|
|||||||
Usage (cli):
|
Usage (cli):
|
||||||
|
|
||||||
$ xrandr | jc --xrandr
|
$ xrandr | jc --xrandr
|
||||||
|
$ xrandr --properties | jc --xrandr
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
@ -44,6 +45,9 @@ Schema:
|
|||||||
"is_connected": boolean,
|
"is_connected": boolean,
|
||||||
"is_primary": boolean,
|
"is_primary": boolean,
|
||||||
"device_name": string,
|
"device_name": string,
|
||||||
|
"model_name": string,
|
||||||
|
"product_id" string,
|
||||||
|
"serial_number": string,
|
||||||
"resolution_width": integer,
|
"resolution_width": integer,
|
||||||
"resolution_height": integer,
|
"resolution_height": integer,
|
||||||
"offset_width": integer,
|
"offset_width": integer,
|
||||||
@ -135,10 +139,75 @@ Examples:
|
|||||||
],
|
],
|
||||||
"unassociated_devices": []
|
"unassociated_devices": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ xrandr --properties | jc --xrandr -p
|
||||||
|
{
|
||||||
|
"screens": [
|
||||||
|
{
|
||||||
|
"screen_number": 0,
|
||||||
|
"minimum_width": 8,
|
||||||
|
"minimum_height": 8,
|
||||||
|
"current_width": 1920,
|
||||||
|
"current_height": 1080,
|
||||||
|
"maximum_width": 32767,
|
||||||
|
"maximum_height": 32767,
|
||||||
|
"associated_device": {
|
||||||
|
"associated_modes": [
|
||||||
|
{
|
||||||
|
"resolution_width": 1920,
|
||||||
|
"resolution_height": 1080,
|
||||||
|
"is_high_resolution": false,
|
||||||
|
"frequencies": [
|
||||||
|
{
|
||||||
|
"frequency": 60.03,
|
||||||
|
"is_current": true,
|
||||||
|
"is_preferred": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frequency": 59.93,
|
||||||
|
"is_current": false,
|
||||||
|
"is_preferred": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resolution_width": 1680,
|
||||||
|
"resolution_height": 1050,
|
||||||
|
"is_high_resolution": false,
|
||||||
|
"frequencies": [
|
||||||
|
{
|
||||||
|
"frequency": 59.88,
|
||||||
|
"is_current": false,
|
||||||
|
"is_preferred": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_connected": true,
|
||||||
|
"is_primary": true,
|
||||||
|
"device_name": "eDP1",
|
||||||
|
"model_name": "ASUS VW193S",
|
||||||
|
"product_id": "54297",
|
||||||
|
"serial_number": "78L8021107",
|
||||||
|
"resolution_width": 1920,
|
||||||
|
"resolution_height": 1080,
|
||||||
|
"offset_width": 0,
|
||||||
|
"offset_height": 0,
|
||||||
|
"dimension_width": 310,
|
||||||
|
"dimension_height": 170,
|
||||||
|
"rotation": "normal",
|
||||||
|
"reflection": "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unassociated_devices": []
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
import jc.utils
|
import jc.utils
|
||||||
|
from jc.parsers.pyedid.edid import Edid
|
||||||
|
from jc.parsers.pyedid.helpers.edid_helper import EdidHelper
|
||||||
|
|
||||||
|
|
||||||
class info:
|
class info:
|
||||||
@ -147,6 +216,7 @@ class info:
|
|||||||
description = "`xrandr` command parser"
|
description = "`xrandr` command parser"
|
||||||
author = "Kevin Lyter"
|
author = "Kevin Lyter"
|
||||||
author_email = "lyter_git at sent.com"
|
author_email = "lyter_git at sent.com"
|
||||||
|
details = 'Using parts of the pyedid library at https://github.com/jojonas/pyedid.'
|
||||||
compatible = ["linux", "darwin", "cygwin", "aix", "freebsd"]
|
compatible = ["linux", "darwin", "cygwin", "aix", "freebsd"]
|
||||||
magic_commands = ["xrandr"]
|
magic_commands = ["xrandr"]
|
||||||
tags = ['command']
|
tags = ['command']
|
||||||
@ -174,10 +244,21 @@ try:
|
|||||||
"frequencies": List[Frequency],
|
"frequencies": List[Frequency],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Model = TypedDict(
|
||||||
|
"Model",
|
||||||
|
{
|
||||||
|
"name": str,
|
||||||
|
"product_id": str,
|
||||||
|
"serial_number": str,
|
||||||
|
},
|
||||||
|
)
|
||||||
Device = TypedDict(
|
Device = TypedDict(
|
||||||
"Device",
|
"Device",
|
||||||
{
|
{
|
||||||
"device_name": str,
|
"device_name": str,
|
||||||
|
"model_name": str,
|
||||||
|
"product_id": str,
|
||||||
|
"serial_number": str,
|
||||||
"is_connected": bool,
|
"is_connected": bool,
|
||||||
"is_primary": bool,
|
"is_primary": bool,
|
||||||
"resolution_width": int,
|
"resolution_width": int,
|
||||||
@ -216,6 +297,7 @@ except ImportError:
|
|||||||
Device = Dict[str, Union[str, int, bool]]
|
Device = Dict[str, Union[str, int, bool]]
|
||||||
Frequency = Dict[str, Union[float, bool]]
|
Frequency = Dict[str, Union[float, bool]]
|
||||||
Mode = Dict[str, Union[int, bool, List[Frequency]]]
|
Mode = Dict[str, Union[int, bool, List[Frequency]]]
|
||||||
|
Model = Dict[str, str]
|
||||||
Response = Dict[str, Union[Device, Mode, Screen]]
|
Response = Dict[str, Union[Device, Mode, Screen]]
|
||||||
|
|
||||||
|
|
||||||
@ -294,17 +376,69 @@ def _parse_device(next_lines: List[str], quiet: bool = False) -> Optional[Device
|
|||||||
[f"{next_line} : {k} - {v} is not int-able"]
|
[f"{next_line} : {k} - {v} is not int-able"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
model: Optional[Model] = _parse_model(next_lines, quiet)
|
||||||
|
if model:
|
||||||
|
device["model_name"] = model["name"]
|
||||||
|
device["product_id"] = model["product_id"]
|
||||||
|
device["serial_number"] = model["serial_number"]
|
||||||
|
|
||||||
while next_lines:
|
while next_lines:
|
||||||
next_line = next_lines.pop()
|
next_line = next_lines.pop()
|
||||||
next_mode: Optional[Mode] = _parse_mode(next_line)
|
next_mode: Optional[Mode] = _parse_mode(next_line)
|
||||||
if next_mode:
|
if next_mode:
|
||||||
device["associated_modes"].append(next_mode)
|
device["associated_modes"].append(next_mode)
|
||||||
else:
|
else:
|
||||||
|
if re.match(_device_pattern, next_line):
|
||||||
next_lines.append(next_line)
|
next_lines.append(next_line)
|
||||||
break
|
break
|
||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# EDID:
|
||||||
|
# 00ffffffffffff004ca3523100000000
|
||||||
|
# 0014010380221378eac8959e57549226
|
||||||
|
# 0f505400000001010101010101010101
|
||||||
|
# 010101010101381d56d4500016303020
|
||||||
|
# 250058c2100000190000000f00000000
|
||||||
|
# 000000000025d9066a00000000fe0053
|
||||||
|
# 414d53554e470a204ca34154000000fe
|
||||||
|
# 004c544e313536415432343430310018
|
||||||
|
_edid_head_pattern = r"\s*EDID:\s*"
|
||||||
|
_edid_line_pattern = r"\s*(?P<edid_line>[0-9a-fA-F]{32})\s*"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_model(next_lines: List[str], quiet: bool = False) -> Optional[Model]:
|
||||||
|
if not next_lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_line = next_lines.pop()
|
||||||
|
if not re.match(_edid_head_pattern, next_line):
|
||||||
|
next_lines.append(next_line)
|
||||||
|
return None
|
||||||
|
|
||||||
|
edid_hex_value = ""
|
||||||
|
|
||||||
|
while next_lines:
|
||||||
|
next_line = next_lines.pop()
|
||||||
|
result = re.match(_edid_line_pattern, next_line)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
next_lines.append(next_line)
|
||||||
|
break
|
||||||
|
|
||||||
|
matches = result.groupdict()
|
||||||
|
edid_hex_value += matches["edid_line"]
|
||||||
|
|
||||||
|
edid = Edid(EdidHelper.hex2bytes(edid_hex_value))
|
||||||
|
|
||||||
|
model: Model = {
|
||||||
|
"name": edid.name or "Generic",
|
||||||
|
"product_id": str(edid.product),
|
||||||
|
"serial_number": str(edid.serial),
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
# 1920x1080i 60.03*+ 59.93
|
# 1920x1080i 60.03*+ 59.93
|
||||||
# 1920x1080 60.00 + 50.00 59.94
|
# 1920x1080 60.00 + 50.00 59.94
|
||||||
_mode_pattern = r"\s*(?P<resolution_width>\d+)x(?P<resolution_height>\d+)(?P<is_high_resolution>i)?\s+(?P<rest>.*)"
|
_mode_pattern = r"\s*(?P<resolution_width>\d+)x(?P<resolution_height>\d+)(?P<is_high_resolution>i)?\s+(?P<rest>.*)"
|
||||||
|
110
tests/fixtures/generic/xrandr_properties.out
vendored
Normal file
110
tests/fixtures/generic/xrandr_properties.out
vendored
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
Screen 0: minimum 320 x 200, current 2806 x 900, maximum 8192 x 8192
|
||||||
|
LVDS-1 connected primary 1366x768+0+0 (normal left inverted right x axis y axis) 344mm x 194mm
|
||||||
|
EDID:
|
||||||
|
00ffffffffffff004ca3523100000000
|
||||||
|
0014010380221378eac8959e57549226
|
||||||
|
0f505400000001010101010101010101
|
||||||
|
010101010101381d56d4500016303020
|
||||||
|
250058c2100000190000000f00000000
|
||||||
|
000000000025d9066a00000000fe0053
|
||||||
|
414d53554e470a204ca34154000000fe
|
||||||
|
004c544e313536415432343430310018
|
||||||
|
scaling mode: Full aspect
|
||||||
|
supported: Full, Center, Full aspect
|
||||||
|
link-status: Good
|
||||||
|
supported: Good, Bad
|
||||||
|
CONNECTOR_ID: 61
|
||||||
|
supported: 61
|
||||||
|
non-desktop: 0
|
||||||
|
range: (0, 1)
|
||||||
|
1366x768 60.00*+
|
||||||
|
1280x720 60.00 59.99 59.86 59.74
|
||||||
|
1024x768 60.04 60.00
|
||||||
|
960x720 60.00
|
||||||
|
928x696 60.05
|
||||||
|
896x672 60.01
|
||||||
|
1024x576 59.95 59.96 59.90 59.82
|
||||||
|
960x600 59.93 60.00
|
||||||
|
960x540 59.96 59.99 59.63 59.82
|
||||||
|
800x600 60.00 60.32 56.25
|
||||||
|
840x525 60.01 59.88
|
||||||
|
864x486 59.92 59.57
|
||||||
|
700x525 59.98
|
||||||
|
800x450 59.95 59.82
|
||||||
|
640x512 60.02
|
||||||
|
700x450 59.96 59.88
|
||||||
|
640x480 60.00 59.94
|
||||||
|
720x405 59.51 58.99
|
||||||
|
684x384 59.88 59.85
|
||||||
|
640x400 59.88 59.98
|
||||||
|
640x360 59.86 59.83 59.84 59.32
|
||||||
|
512x384 60.00
|
||||||
|
512x288 60.00 59.92
|
||||||
|
480x270 59.63 59.82
|
||||||
|
400x300 60.32 56.34
|
||||||
|
432x243 59.92 59.57
|
||||||
|
320x240 60.05
|
||||||
|
360x202 59.51 59.13
|
||||||
|
320x180 59.84 59.32
|
||||||
|
VGA-1 connected 1440x900+1366+0 (normal left inverted right x axis y axis) 408mm x 255mm
|
||||||
|
EDID:
|
||||||
|
00ffffffffffff000469d41901010101
|
||||||
|
2011010308291a78ea8585a6574a9c26
|
||||||
|
125054bfef80714f8100810f81408180
|
||||||
|
9500950f01019a29a0d0518422305098
|
||||||
|
360098ff1000001c000000fd00374b1e
|
||||||
|
530f000a202020202020000000fc0041
|
||||||
|
535553205657313933530a20000000ff
|
||||||
|
0037384c383032313130370a20200077
|
||||||
|
link-status: Good
|
||||||
|
supported: Good, Bad
|
||||||
|
CONNECTOR_ID: 64
|
||||||
|
supported: 64
|
||||||
|
non-desktop: 0
|
||||||
|
range: (0, 1)
|
||||||
|
1440x900 59.89*+ 74.98
|
||||||
|
1280x1024 75.02 60.02
|
||||||
|
1280x960 60.00
|
||||||
|
1280x800 74.93 59.81
|
||||||
|
1152x864 75.00
|
||||||
|
1024x768 75.03 70.07 60.00
|
||||||
|
832x624 74.55
|
||||||
|
800x600 72.19 75.00 60.32 56.25
|
||||||
|
640x480 75.00 72.81 66.67 59.94
|
||||||
|
720x400 70.08
|
||||||
|
HDMI-1 disconnected (normal left inverted right x axis y axis)
|
||||||
|
max bpc: 12
|
||||||
|
range: (8, 12)
|
||||||
|
content type: No Data
|
||||||
|
supported: No Data, Graphics, Photo, Cinema, Game
|
||||||
|
Colorspace: Default
|
||||||
|
supported: Default, SMPTE_170M_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, opRGB, BT2020_CYCC, BT2020_RGB, BT2020_YCC, DCI-P3_RGB_D65, DCI-P3_RGB_Theater
|
||||||
|
aspect ratio: Automatic
|
||||||
|
supported: Automatic, 4:3, 16:9
|
||||||
|
Broadcast RGB: Automatic
|
||||||
|
supported: Automatic, Full, Limited 16:235
|
||||||
|
audio: auto
|
||||||
|
supported: force-dvi, off, auto, on
|
||||||
|
link-status: Good
|
||||||
|
supported: Good, Bad
|
||||||
|
CONNECTOR_ID: 68
|
||||||
|
supported: 68
|
||||||
|
non-desktop: 0
|
||||||
|
range: (0, 1)
|
||||||
|
DP-1 disconnected (normal left inverted right x axis y axis)
|
||||||
|
Colorspace: Default
|
||||||
|
supported: Default, RGB_Wide_Gamut_Fixed_Point, RGB_Wide_Gamut_Floating_Point, opRGB, DCI-P3_RGB_D65, BT2020_RGB, BT601_YCC, BT709_YCC, XVYCC_601, XVYCC_709, SYCC_601, opYCC_601, BT2020_CYCC, BT2020_YCC
|
||||||
|
max bpc: 12
|
||||||
|
range: (6, 12)
|
||||||
|
Broadcast RGB: Automatic
|
||||||
|
supported: Automatic, Full, Limited 16:235
|
||||||
|
audio: auto
|
||||||
|
supported: force-dvi, off, auto, on
|
||||||
|
subconnector: Unknown
|
||||||
|
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||||
|
link-status: Good
|
||||||
|
supported: Good, Bad
|
||||||
|
CONNECTOR_ID: 76
|
||||||
|
supported: 76
|
||||||
|
non-desktop: 0
|
||||||
|
range: (0, 1)
|
@ -7,12 +7,16 @@ from jc.parsers.xrandr import (
|
|||||||
_parse_screen,
|
_parse_screen,
|
||||||
_parse_device,
|
_parse_device,
|
||||||
_parse_mode,
|
_parse_mode,
|
||||||
|
_parse_model,
|
||||||
_device_pattern,
|
_device_pattern,
|
||||||
_screen_pattern,
|
_screen_pattern,
|
||||||
_mode_pattern,
|
_mode_pattern,
|
||||||
_frequencies_pattern,
|
_frequencies_pattern,
|
||||||
|
_edid_head_pattern,
|
||||||
|
_edid_line_pattern,
|
||||||
parse,
|
parse,
|
||||||
Mode,
|
Mode,
|
||||||
|
Model,
|
||||||
Device,
|
Device,
|
||||||
Screen,
|
Screen,
|
||||||
)
|
)
|
||||||
@ -60,6 +64,27 @@ class XrandrTests(unittest.TestCase):
|
|||||||
rest = match.groupdict()["rest"]
|
rest = match.groupdict()["rest"]
|
||||||
self.assertIsNotNone(re.match(_frequencies_pattern, rest))
|
self.assertIsNotNone(re.match(_frequencies_pattern, rest))
|
||||||
|
|
||||||
|
edid_lines = [
|
||||||
|
" EDID: ",
|
||||||
|
" 00ffffffffffff000469d41901010101 ",
|
||||||
|
" 2011010308291a78ea8585a6574a9c26 ",
|
||||||
|
" 125054bfef80714f8100810f81408180 ",
|
||||||
|
" 9500950f01019a29a0d0518422305098 ",
|
||||||
|
" 360098ff1000001c000000fd00374b1e ",
|
||||||
|
" 530f000a202020202020000000fc0041 ",
|
||||||
|
" 535553205657313933530a20000000ff ",
|
||||||
|
" 0037384c383032313130370a20200077 "
|
||||||
|
]
|
||||||
|
|
||||||
|
for i in range(len(edid_lines)):
|
||||||
|
line = edid_lines[i]
|
||||||
|
if i == 0:
|
||||||
|
match = re.match(_edid_head_pattern, line)
|
||||||
|
else:
|
||||||
|
match = re.match(_edid_line_pattern, line)
|
||||||
|
|
||||||
|
self.assertIsNotNone(match)
|
||||||
|
|
||||||
def test_screens(self):
|
def test_screens(self):
|
||||||
sample = "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767"
|
sample = "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767"
|
||||||
|
|
||||||
@ -202,6 +227,16 @@ class XrandrTests(unittest.TestCase):
|
|||||||
2, len(actual["screens"][0]["associated_device"]["associated_modes"])
|
2, len(actual["screens"][0]["associated_device"]["associated_modes"])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with open("tests/fixtures/generic/xrandr_properties.out", "r") as f:
|
||||||
|
txt = f.read()
|
||||||
|
actual = parse(txt, quiet=True)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(actual["screens"]))
|
||||||
|
self.assertEqual(3, len(actual["unassociated_devices"]))
|
||||||
|
self.assertEqual(
|
||||||
|
29, len(actual["screens"][0]["associated_device"]["associated_modes"])
|
||||||
|
)
|
||||||
|
|
||||||
def test_infinite_loop_fix(self):
|
def test_infinite_loop_fix(self):
|
||||||
with open("tests/fixtures/generic/xrandr_fix_spaces.out", "r") as f:
|
with open("tests/fixtures/generic/xrandr_fix_spaces.out", "r") as f:
|
||||||
txt = f.read()
|
txt = f.read()
|
||||||
@ -222,5 +257,62 @@ class XrandrTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(actual, json_dict)
|
self.assertEqual(actual, json_dict)
|
||||||
|
|
||||||
|
def test_model(self):
|
||||||
|
asus_edid = [
|
||||||
|
" EDID: ",
|
||||||
|
" 00ffffffffffff000469d41901010101",
|
||||||
|
" 2011010308291a78ea8585a6574a9c26",
|
||||||
|
" 125054bfef80714f8100810f81408180",
|
||||||
|
" 9500950f01019a29a0d0518422305098",
|
||||||
|
" 360098ff1000001c000000fd00374b1e",
|
||||||
|
" 530f000a202020202020000000fc0041",
|
||||||
|
" 535553205657313933530a20000000ff",
|
||||||
|
" 0037384c383032313130370a20200077"
|
||||||
|
]
|
||||||
|
asus_edid.reverse()
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"name": "ASUS VW193S",
|
||||||
|
"product_id": "6612",
|
||||||
|
"serial_number": "78L8021107",
|
||||||
|
}
|
||||||
|
|
||||||
|
actual: Optional[Model] = _parse_model(asus_edid)
|
||||||
|
self.assertIsNotNone(actual)
|
||||||
|
|
||||||
|
if actual:
|
||||||
|
for k, v in expected.items():
|
||||||
|
self.assertEqual(v, actual[k], f"mode regex failed on {k}")
|
||||||
|
|
||||||
|
generic_edid = [
|
||||||
|
" EDID: ",
|
||||||
|
" 00ffffffffffff004ca3523100000000",
|
||||||
|
" 0014010380221378eac8959e57549226",
|
||||||
|
" 0f505400000001010101010101010101",
|
||||||
|
" 010101010101381d56d4500016303020",
|
||||||
|
" 250058c2100000190000000f00000000",
|
||||||
|
" 000000000025d9066a00000000fe0053",
|
||||||
|
" 414d53554e470a204ca34154000000fe",
|
||||||
|
" 004c544e313536415432343430310018"
|
||||||
|
]
|
||||||
|
generic_edid.reverse()
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"name": "Generic",
|
||||||
|
"product_id": "12626",
|
||||||
|
"serial_number": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
actual: Optional[Model] = _parse_model(generic_edid)
|
||||||
|
self.assertIsNotNone(actual)
|
||||||
|
|
||||||
|
if actual:
|
||||||
|
for k, v in expected.items():
|
||||||
|
self.assertEqual(v, actual[k], f"mode regex failed on {k}")
|
||||||
|
|
||||||
|
empty_edid = [""]
|
||||||
|
actual: Optional[Model] = _parse_model(empty_edid)
|
||||||
|
self.assertIsNone(actual)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
Reference in New Issue
Block a user