From b86f4cd437edb47d468c444f8b240c850f0caed6 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 19 Mar 2025 03:51:31 +0200 Subject: [PATCH] allow short edids, import full edid on with kvmd-edidconf --- kvmd/apps/edidconf/__init__.py | 21 ++++++++------- kvmd/apps/kvmd/switch/types.py | 34 +++++++++++++++---------- kvmd/edid.py | 23 +++++++++-------- kvmd/validators/switch.py | 2 +- testenv/tests/validators/test_switch.py | 3 +++ 5 files changed, 47 insertions(+), 36 deletions(-) diff --git a/kvmd/apps/edidconf/__init__.py b/kvmd/apps/edidconf/__init__.py index 717f9e32..f7ea93f4 100644 --- a/kvmd/apps/edidconf/__init__.py +++ b/kvmd/apps/edidconf/__init__.py @@ -61,23 +61,17 @@ def _print_edid(edid: Edid) -> None: pass -def _read_out2_edid() -> (Edid | None): +def _find_out2_edid_path() -> str: card = os.path.basename(os.readlink("/dev/dri/by-path/platform-gpu-card")) path = f"/sys/devices/platform/gpu/drm/{card}/{card}-HDMI-A-2" with open(os.path.join(path, "status")) as file: if file.read().startswith("d"): - return None - with open(os.path.join(path, "edid"), "rb") as file: - data = file.read() - if len(data) == 0: - return None - return Edid.from_file(os.path.join(path, "edid"), allow_short=True) + raise SystemExit("No display found") + return os.path.join(path, "edid") def _adopt_out2_ids(dest: Edid) -> None: - src = _read_out2_edid() - if src is None: - raise SystemExit("No display found") + src = Edid.from_file(_find_out2_edid_path()) dest.set_monitor_name(src.get_monitor_name()) try: dest.get_monitor_serial() @@ -123,7 +117,9 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra parser.add_argument("--import-preset", choices=presets, help="Restore default EDID or choose the preset", metavar=f"{{ {' | '.join(presets)} }}",) parser.add_argument("--import-display-ids", action="store_true", - help="On PiKVM V4, import and adopt IDs from physical display connected to OUT2") + help="On PiKVM V4, import and adopt IDs from a physical display connected to the OUT2 port") + parser.add_argument("--import-display", action="store_true", + help="On PiKVM V4, import full EDID from a physical display connected to the OUT2 port") parser.add_argument("--set-audio", type=valid_bool, help="Enable or disable audio", metavar="") parser.add_argument("--set-mfc-id", @@ -155,6 +151,9 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra imp = f"_{imp}" options.imp = os.path.join(options.presets_path, f"{imp}.hex") + if options.import_display: + options.imp = _find_out2_edid_path() + orig_edid_path = options.edid_path if options.imp: options.export_hex = options.edid_path diff --git a/kvmd/apps/kvmd/switch/types.py b/kvmd/apps/kvmd/switch/types.py index 32225f06..33a7f3ad 100644 --- a/kvmd/apps/kvmd/switch/types.py +++ b/kvmd/apps/kvmd/switch/types.py @@ -59,31 +59,37 @@ class EdidInfo: except ParsedEdidNoBlockError: pass + audio: bool = False + try: + audio = parsed.get_audio() + except ParsedEdidNoBlockError: + pass + return EdidInfo( mfc_id=parsed.get_mfc_id(), product_id=parsed.get_product_id(), serial=parsed.get_serial(), monitor_name=monitor_name, monitor_serial=monitor_serial, - audio=parsed.get_audio(), + audio=audio, ) @dataclasses.dataclass(frozen=True) class Edid: - name: str - data: bytes - crc: int = dataclasses.field(default=0) - valid: bool = dataclasses.field(default=False) - info: (EdidInfo | None) = dataclasses.field(default=None) - - __HEADER = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00" + name: str + data: bytes + crc: int = dataclasses.field(default=0) + valid: bool = dataclasses.field(default=False) + info: (EdidInfo | None) = dataclasses.field(default=None) + _packed: bytes = dataclasses.field(default=b"") def __post_init__(self) -> None: assert len(self.name) > 0 - assert len(self.data) == 256 - object.__setattr__(self, "crc", bitbang.make_crc16(self.data)) - object.__setattr__(self, "valid", self.data.startswith(self.__HEADER)) + assert len(self.data) in [128, 256] + object.__setattr__(self, "_packed", (self.data + (b"\x00" * 128))[:256]) + object.__setattr__(self, "crc", bitbang.make_crc16(self._packed)) # Calculate CRC for filled data + object.__setattr__(self, "valid", ParsedEdid.is_header_valid(self.data)) try: object.__setattr__(self, "info", EdidInfo.from_data(self.data)) except Exception: @@ -93,7 +99,7 @@ class Edid: return "".join(f"{item:0{2}X}" for item in self.data) def pack(self) -> bytes: - return self.data + return self._packed @classmethod def from_data(cls, name: str, data: (str | bytes | None)) -> "Edid": @@ -101,14 +107,14 @@ class Edid: return Edid(name, b"\x00" * 256) if isinstance(data, bytes): - if data.startswith(cls.__HEADER): + if ParsedEdid.is_header_valid(cls.data): return Edid(name, data) # Бинарный едид data_hex = data.decode() # Текстовый едид, прочитанный как бинарный из файла else: # isinstance(data, str) data_hex = str(data) # Текстовый едид data_hex = re.sub(r"\s", "", data_hex) - assert len(data_hex) == 512 + assert len(data_hex) in [256, 512] data = bytes([ int(data_hex[index:index + 2], 16) for index in range(0, len(data_hex), 2) diff --git a/kvmd/edid.py b/kvmd/edid.py index 8532a06b..8e8701a7 100644 --- a/kvmd/edid.py +++ b/kvmd/edid.py @@ -80,27 +80,29 @@ _CEA_SPEAKERS = 4 class Edid: # https://en.wikipedia.org/wiki/Extended_Display_Identification_Data - def __init__(self, data: bytes, allow_short: bool=False) -> None: - if allow_short: - assert len(data) in [_SHORT, _LONG], f"Invalid EDID length: {len(data)}, should be {_SHORT} or {_LONG} bytes" - else: - assert len(data) == _LONG, f"Invalid EDID length: {len(data)}, should be {_LONG} bytes" + def __init__(self, data: bytes) -> None: + assert len(data) in [_SHORT, _LONG], f"Invalid EDID length: {len(data)}, should be {_SHORT} or {_LONG} bytes" + self.__long = (len(data) == _LONG) + if self.__long: assert data[126] == 1, "Zero extensions number" assert (data[_CEA + 0], data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension" self.__data = list(data) - self.__long = (len(data) == _LONG) @classmethod - def from_file(cls, path: str, allow_short: bool=False) -> "Edid": + def is_header_valid(cls, data: bytes) -> bool: + return data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00") + + @classmethod + def from_file(cls, path: str) -> "Edid": with _smart_open(path, "rb") as file: data = file.read() - if not data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"): + if not cls.is_header_valid(data): text = re.sub(r"\s", "", data.decode()) data = bytes([ int(text[index:index + 2], 16) for index in range(0, len(text), 2) ]) - return Edid(data, allow_short) + return Edid(data) def write_hex(self, path: str) -> None: self.__update_checksums() @@ -236,7 +238,8 @@ class Edid: self.__data[_CEA + 3] &= (0xFF - 0b01000000) # ~X def __parse_cea(self) -> tuple[list[_CeaBlock], bytes]: - assert self.__long, "This EDID does not contain any CEA blocks" + if not self.__long: + raise EdidNoBlockError("This EDID does not contain any CEA blocks") cea = self.__data[_CEA:] dtd_begin = cea[2] diff --git a/kvmd/validators/switch.py b/kvmd/validators/switch.py index d4f3ab2f..7b4d13d7 100644 --- a/kvmd/validators/switch.py +++ b/kvmd/validators/switch.py @@ -50,7 +50,7 @@ def valid_switch_edid_data(arg: Any) -> str: name = "switch EDID data" arg = valid_stripped_string(arg, name=name) arg = re.sub(r"\s", "", arg) - return check_re_match(arg, name, "(?i)^[0-9a-f]{512}$").upper() + return check_re_match(arg, name, "(?i)^([0-9a-f]{256}|[0-9a-f]{512})$").upper() def valid_switch_color(arg: Any, allow_default: bool) -> str: diff --git a/testenv/tests/validators/test_switch.py b/testenv/tests/validators/test_switch.py index 6f41c6cf..01dd2062 100644 --- a/testenv/tests/validators/test_switch.py +++ b/testenv/tests/validators/test_switch.py @@ -94,6 +94,9 @@ def test_fail__valid_switch_edid_id__allowed_default(arg: Any) -> None: # ===== @pytest.mark.parametrize("arg", [ + "f" * 256, + "0" * 256, + "1a" * 128, "f" * 512, "0" * 512, "1a" * 256,