# ========================================================================== # # # # KVMD - The main PiKVM daemon. # # # # Copyright (C) 2018-2022 Maxim Devaev # # # # This program is free software: you can redistribute it and/or modify # # it under the terms of the GNU General Public License as published by # # the Free Software Foundation, either version 3 of the License, or # # (at your option) any later version. # # # # This program is distributed in the hope that it will be useful, # # but WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # # GNU General Public License for more details. # # # # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # # # # ========================================================================== # import sys import os import re import subprocess import contextlib import argparse import time from typing import List from typing import IO from typing import Generator from typing import Callable from typing import Optional from ...validators.basic import valid_bool from ...validators.basic import valid_int_f0 # from .. import init # ===== @contextlib.contextmanager def _smart_open(path: str, mode: str) -> Generator[IO, None, None]: fd = (0 if "r" in mode else 1) with (os.fdopen(fd, mode, closefd=False) if path == "-" else open(path, mode)) as file: yield file if "w" in mode: file.flush() class _Edid: # https://en.wikipedia.org/wiki/Extended_Display_Identification_Data def __init__(self, path: str) -> None: with _smart_open(path, "rb") as file: data = file.read() if data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"): self.__data = list(data) else: text = re.sub(r"\s", "", data.decode()) self.__data = [ int(text[index:index + 2], 16) for index in range(0, len(text), 2) ] assert len(self.__data) == 256, f"Invalid EDID length: {len(self.__data)}, should be 256 bytes" assert self.__data[126] == 1, "Zero extensions number" assert (self.__data[128], self.__data[129]) == (0x02, 0x03), "Can't find CEA-861" # ===== def write_hex(self, path: str) -> None: self.__update_checksums() text = "\n".join( "".join( f"{item:0{2}X}" for item in self.__data[index:index + 16] ) for index in range(0, len(self.__data), 16) ) + "\n" with _smart_open(path, "w") as file: file.write(text) def write_bin(self, path: str) -> None: with _smart_open(path, "wb") as file: file.write(bytes(self.__data)) def __update_checksums(self) -> None: self.__data[127] = 256 - (sum(self.__data[:127]) % 256) self.__data[255] = 256 - (sum(self.__data[128:255]) % 256) # ===== def get_mfc_id(self) -> str: raw = self.__data[8] << 8 | self.__data[9] return bytes([ ((raw >> 10) & 0b11111) + 0x40, ((raw >> 5) & 0b11111) + 0x40, (raw & 0b11111) + 0x40, ]).decode("ascii") def set_mfc_id(self, mfc_id: str) -> None: assert len(mfc_id) == 3, "Mfc ID must be 3 characters long" data = mfc_id.upper().encode("ascii") for byte in data: assert 0x41 <= byte <= 0x5A, "Mfc ID must contain only A-Z characters" raw = ( (data[2] - 0x40) | ((data[1] - 0x40) << 5) | ((data[0] - 0x40) << 10) ) self.__data[8] = (raw >> 8) & 0xFF self.__data[9] = raw & 0xFF # ===== def get_product_id(self) -> int: return (self.__data[10] | self.__data[11] << 8) def set_product_id(self, product_id: int) -> None: assert 0 <= product_id <= 0xFFFF, f"Product ID should be from 0 to {0xFFFF}" self.__data[10] = product_id & 0xFF self.__data[11] = (product_id >> 8) & 0xFF # ===== def get_serial(self) -> int: return ( self.__data[12] | self.__data[13] << 8 | self.__data[14] << 16 | self.__data[15] << 24 ) def set_serial(self, serial: int) -> None: assert 0 <= serial <= 0xFFFFFFFF, f"Serial should be from 0 to {0xFFFFFFFF}" self.__data[12] = serial & 0xFF self.__data[13] = (serial >> 8) & 0xFF self.__data[14] = (serial >> 16) & 0xFF self.__data[15] = (serial >> 24) & 0xFF # ===== def get_monitor_name(self) -> str: index = self.__find_mnd_text() return bytes(self.__data[index:index + 13]).decode("cp437").strip() def set_monitor_name(self, name: str) -> None: index = self.__find_mnd_text() encoded = (name[:13] + "\n" + " " * 12)[:13].encode("cp437") for (offset, byte) in enumerate(encoded): self.__data[index + offset] = byte def __find_mnd_text(self) -> int: for index in [54, 72, 90, 108]: if self.__data[index + 3] == 0xFC: return index + 5 raise AssertionError("Can't find DTD Monitor name") # ===== def get_audio(self) -> bool: return bool(self.__data[131] & 0b01000000) def set_audio(self, enabled: bool) -> None: if enabled: self.__data[131] |= 0b01000000 else: self.__data[131] &= (0xFF - 0b01000000) # ~X def _format_bool(value: bool) -> str: return ("yes" if value else "no") def _make_format_hex(size: int) -> Callable[[int], str]: return (lambda value: ("0x{:0%dX} ({})" % (size * 2)).format(value, value)) # ===== def main(argv: Optional[List[str]]=None) -> None: # pylint: disable=too-many-branches # (parent_parser, argv, _) = init( # add_help=False, # argv=argv, # ) if argv is None: argv = sys.argv parser = argparse.ArgumentParser( prog="kvmd-edidconf", description="A simple and primitive KVMD EDID editor", # parents=[parent_parser], ) parser.add_argument("-f", "--edid-file", dest="edid_path", default="/etc/kvmd/tc358743-edid.hex", help="The hex/bin EDID file path", metavar="") parser.add_argument("--export-hex", help="Export [--edid-file] to the new file as a hex text", metavar="") parser.add_argument("--export-bin", help="Export [--edid-file] to the new file as a bin data", metavar="") parser.add_argument("--import", dest="imp", help="Import the specified bin/hex EDID to the [--edid-file] as a hex text", metavar="") parser.add_argument("--set-audio", type=valid_bool, help="Enable or disable basic audio", metavar="") parser.add_argument("--set-mfc-id", help="Set manufacturer ID (https://uefi.org/pnp_id_list)", metavar="") parser.add_argument("--set-product-id", type=valid_int_f0, help="Set product ID (decimal)", metavar="") parser.add_argument("--set-serial", type=valid_int_f0, help="Set serial number (decimal)", metavar="") parser.add_argument("--set-monitor-name", help="Set monitor name in DTD/MND (ASCII, max 13 characters)", metavar="") parser.add_argument("--clear", action="store_true", help="Clear the EDID in the [--device]") parser.add_argument("--apply", action="store_true", help="Apply [--edid-file] on the [--device]") parser.add_argument("--device", dest="device_path", default="/dev/kvmd-video", help="The video device", metavar="") options = parser.parse_args(argv[1:]) if options.imp: options.export_hex = options.edid_path options.edid_path = options.imp edid = _Edid(options.edid_path) changed = False for cmd in dir(_Edid): if cmd.startswith("set_"): value = getattr(options, cmd) if value is not None: getattr(edid, cmd)(value) changed = True if options.export_hex is not None: edid.write_hex(options.export_hex) elif options.export_bin is not None: edid.write_bin(options.export_bin) elif changed: edid.write_hex(options.edid_path) for (key, get, fmt) in [ ("Manufacturer ID:", edid.get_mfc_id, str), ("Product ID: ", edid.get_product_id, _make_format_hex(2)), ("Serial number: ", edid.get_serial, _make_format_hex(4)), ("Monitor name: ", edid.get_monitor_name, str), ("Basic audio: ", edid.get_audio, _format_bool), ]: print(key, fmt(get()), file=sys.stderr) # type: ignore try: if options.clear: subprocess.run([ "/usr/bin/v4l2-ctl", f"--device={options.device_path}", "--clear-edid", ], stdout=sys.stderr, check=True) if options.apply: time.sleep(1) if options.apply: subprocess.run([ "/usr/bin/v4l2-ctl", f"--device={options.device_path}", f"--set-edid=file={options.edid_path}", "--fix-edid-checksums", "--info-edid", ], stdout=sys.stderr, check=True) except subprocess.CalledProcessError as err: raise SystemExit(str(err))