diff --git a/jc/parsers/pyedid/LICENSE b/jc/parsers/pyedid/LICENSE new file mode 100644 index 00000000..f6616159 --- /dev/null +++ b/jc/parsers/pyedid/LICENSE @@ -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. diff --git a/jc/parsers/pyedid/__init__.py b/jc/parsers/pyedid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jc/parsers/pyedid/edid.py b/jc/parsers/pyedid/edid.py new file mode 100755 index 00000000..fafe9c2c --- /dev/null +++ b/jc/parsers/pyedid/edid.py @@ -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)) diff --git a/jc/parsers/pyedid/helpers/__init__.py b/jc/parsers/pyedid/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jc/parsers/pyedid/helpers/edid_helper.py b/jc/parsers/pyedid/helpers/edid_helper.py new file mode 100644 index 00000000..b4165ca9 --- /dev/null +++ b/jc/parsers/pyedid/helpers/edid_helper.py @@ -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 diff --git a/jc/parsers/pyedid/helpers/registry.py b/jc/parsers/pyedid/helpers/registry.py new file mode 100644 index 00000000..56ba05eb --- /dev/null +++ b/jc/parsers/pyedid/helpers/registry.py @@ -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) diff --git a/jc/parsers/pyedid/main.py b/jc/parsers/pyedid/main.py new file mode 100644 index 00000000..4ad431f3 --- /dev/null +++ b/jc/parsers/pyedid/main.py @@ -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() diff --git a/jc/parsers/xrandr.py b/jc/parsers/xrandr.py index 283cf19b..9ac4c44e 100644 --- a/jc/parsers/xrandr.py +++ b/jc/parsers/xrandr.py @@ -3,6 +3,7 @@ Usage (cli): $ xrandr | jc --xrandr + $ xrandr --properties | jc --xrandr or @@ -44,6 +45,9 @@ Schema: "is_connected": boolean, "is_primary": boolean, "device_name": string, + "model_name": string, + "product_id" string, + "serial_number": string, "resolution_width": integer, "resolution_height": integer, "offset_width": integer, @@ -135,10 +139,75 @@ Examples: ], "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 from typing import Dict, List, Optional, Union import jc.utils +from jc.parsers.pyedid.edid import Edid +from jc.parsers.pyedid.helpers.edid_helper import EdidHelper class info: @@ -147,6 +216,7 @@ class info: description = "`xrandr` command parser" author = "Kevin Lyter" 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"] magic_commands = ["xrandr"] tags = ['command'] @@ -174,10 +244,21 @@ try: "frequencies": List[Frequency], }, ) + Model = TypedDict( + "Model", + { + "name": str, + "product_id": str, + "serial_number": str, + }, + ) Device = TypedDict( "Device", { "device_name": str, + "model_name": str, + "product_id": str, + "serial_number": str, "is_connected": bool, "is_primary": bool, "resolution_width": int, @@ -216,6 +297,7 @@ except ImportError: Device = Dict[str, Union[str, int, bool]] Frequency = Dict[str, Union[float, bool]] Mode = Dict[str, Union[int, bool, List[Frequency]]] + Model = Dict[str, str] Response = Dict[str, Union[Device, Mode, Screen]] @@ -294,15 +376,67 @@ def _parse_device(next_lines: List[str], quiet: bool = False) -> Optional[Device [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: next_line = next_lines.pop() next_mode: Optional[Mode] = _parse_mode(next_line) if next_mode: device["associated_modes"].append(next_mode) else: + if re.match(_device_pattern, next_line): + next_lines.append(next_line) + break + return device + + +# EDID: +# 00ffffffffffff004ca3523100000000 +# 0014010380221378eac8959e57549226 +# 0f505400000001010101010101010101 +# 010101010101381d56d4500016303020 +# 250058c2100000190000000f00000000 +# 000000000025d9066a00000000fe0053 +# 414d53554e470a204ca34154000000fe +# 004c544e313536415432343430310018 +_edid_head_pattern = r"\s*EDID:\s*" +_edid_line_pattern = r"\s*(?P[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 - return device + + 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 diff --git a/tests/fixtures/generic/xrandr_properties.out b/tests/fixtures/generic/xrandr_properties.out new file mode 100644 index 00000000..e42d9e86 --- /dev/null +++ b/tests/fixtures/generic/xrandr_properties.out @@ -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) diff --git a/tests/test_xrandr.py b/tests/test_xrandr.py index c4ff17f7..2866dc32 100644 --- a/tests/test_xrandr.py +++ b/tests/test_xrandr.py @@ -7,12 +7,16 @@ from jc.parsers.xrandr import ( _parse_screen, _parse_device, _parse_mode, + _parse_model, _device_pattern, _screen_pattern, _mode_pattern, _frequencies_pattern, + _edid_head_pattern, + _edid_line_pattern, parse, Mode, + Model, Device, Screen, ) @@ -59,6 +63,27 @@ class XrandrTests(unittest.TestCase): if match: rest = match.groupdict()["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): 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"]) ) + 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): with open("tests/fixtures/generic/xrandr_fix_spaces.out", "r") as f: txt = f.read() @@ -222,5 +257,62 @@ class XrandrTests(unittest.TestCase): 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__': unittest.main()