From 0fd3b21d6d3db7dac6a878101f114c24a615b425 Mon Sep 17 00:00:00 2001 From: imterah Date: Wed, 4 Jun 2025 22:53:38 -0400 Subject: [PATCH] chore: Implement display initialization --- .gitignore | 203 ++++++++++++++++++++++++++++++--- .gitmodules | 3 + README.md | 12 +- evdi | 1 + libunreal/__init__.py | 8 ++ libunreal/edid.py | 8 ++ libunreal/linux/__init__.py | 74 ++++++++++++ libunreal/supported_devices.py | 11 ++ main.py | 159 ++++++++++++++++++++++++++ requirements.txt | 5 + shell.nix | 35 ++++++ unrealxr | 2 + 12 files changed, 501 insertions(+), 20 deletions(-) create mode 100644 .gitmodules create mode 160000 evdi create mode 100644 libunreal/__init__.py create mode 100644 libunreal/edid.py create mode 100644 libunreal/linux/__init__.py create mode 100644 libunreal/supported_devices.py create mode 100755 main.py create mode 100644 requirements.txt create mode 100644 shell.nix create mode 100755 unrealxr diff --git a/.gitignore b/.gitignore index 6fb8789..9557f07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,192 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions *.so -*.dylib -# Test binary, built with `go test -c` -*.test +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST -# Output of the go coverage tool, specifically when used with LiteIDE -*.out +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec -# Dependency directories (remove the comment below to include it) -# vendor/ +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -# Go workspace file -go.work -go.work.sum +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ -# env file +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments .env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc # ---> C # Prerequisites @@ -113,3 +276,5 @@ dkms.conf *.out *.app +# ---> UnrealXR +data diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e62726d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "evdi"] + path = evdi + url = https://github.com/DisplayLink/evdi diff --git a/README.md b/README.md index 5007309..1bd48eb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ -# openglass +# UnrealXR +UnrealXR is a display multiplexer for the original Nreal Air (other devices may be supported). + +## Development + +1. Clone this repository with submodules: `git clone --recurse-submodules https://git.terah.dev/imterah/unrealxr.git` +2. Initialize the development shell: `nix-shell` +3. Create a virtual environment: `python3 -m venv .venv; source .venv/bin/activate` +4. Install Python dependencies: `pip install -r requirements.txt` +4. Build libevdi: `cd evdi/library; make -j$(nproc); cd ../..` +5. Build pyevdi: `cd evdi/pyevdi; make -j$(nproc); make install; cd ../..` diff --git a/evdi b/evdi new file mode 160000 index 0000000..600ed43 --- /dev/null +++ b/evdi @@ -0,0 +1 @@ +Subproject commit 600ed4342a7251d4575ee4810756649f9e77cdf0 diff --git a/libunreal/__init__.py b/libunreal/__init__.py new file mode 100644 index 0000000..d57fd0f --- /dev/null +++ b/libunreal/__init__.py @@ -0,0 +1,8 @@ +from libunreal.supported_devices import * +from libunreal.edid import * +from sys import platform + +if platform == "linux" or platform == "linux2": + from libunreal.linux import * +else: + raise OSError("Unsupported operating system") diff --git a/libunreal/edid.py b/libunreal/edid.py new file mode 100644 index 0000000..b44363e --- /dev/null +++ b/libunreal/edid.py @@ -0,0 +1,8 @@ +import dataclasses + +@dataclasses.dataclass +class EvdiDisplaySpec: + edid: bytes + max_width: int + max_height: int + max_refresh_rate: int diff --git a/libunreal/linux/__init__.py b/libunreal/linux/__init__.py new file mode 100644 index 0000000..a4507f7 --- /dev/null +++ b/libunreal/linux/__init__.py @@ -0,0 +1,74 @@ +import subprocess +import os + +from libunreal.supported_devices import supported_devices +from libunreal.edid import EvdiDisplaySpec +import pyedid + +def fetch_xr_glass_edid(allow_unsupported_devices) -> EvdiDisplaySpec: + # Scan for all VGA devices and their IDs + pci_device_comand = subprocess.run(["lspci"], capture_output=True) + + if pci_device_comand.returncode != 0: + raise OSError("Failed to scan PCI devices") + + pci_devices: list[str] = pci_device_comand.stdout.decode("utf-8").split("\n") + pci_devices = pci_devices[:-1] + + vga_devices: list[str] = [] + + for pci_device in pci_devices: + if "VGA compatible controller:" in pci_device: + vga_devices.append(pci_device[:pci_device.index(" ")]) + + # Attempt to find any XR glasses + for vga_device in vga_devices: + card_devices = list(os.listdir(f"/sys/devices/pci0000:00/0000:{vga_device}/drm/")) + + for card_device in card_devices: + if "card" not in card_device: + continue + + monitors = list(os.listdir(f"/sys/devices/pci0000:00/0000:{vga_device}/drm/{card_device}/")) + + for monitor in monitors: + if card_device not in monitor: + continue + + with open(f"/sys/devices/pci0000:00/0000:{vga_device}/drm/{card_device}/{monitor}/edid", "rb") as edid: + raw_edid_file = edid.read() + + if len(raw_edid_file) == 0: + continue + + edid = pyedid.parse_edid(raw_edid_file) + + for manufacturer, manufacturer_supported_devices in supported_devices.items(): + if edid.manufacturer_pnp_id == manufacturer and (edid.name in manufacturer_supported_devices or allow_unsupported_devices): + max_width = 0 + max_height = 0 + max_refresh = 0 + + for resolution in edid.resolutions: + if resolution[0] > max_width and resolution[1] > max_height: + max_width = resolution[0] + max_height = resolution[1] + + max_refresh = max(max_refresh, int(resolution[2])) + + if max_width == 0 or max_height == 0: + if "max_width" not in manufacturer_supported_devices[edid.name] or "max_height" not in manufacturer_supported_devices[edid.name]: + raise ValueError("Couldn't determine maximum width and height, and the maximum width and height isn't defined in the device quirks section") + + max_width = int(manufacturer_supported_devices[edid.name]["max_width"]) + max_height = int(manufacturer_supported_devices[edid.name]["max_height"]) + + if max_refresh == 0: + if "max_refresh" not in manufacturer_supported_devices[edid.name]: + raise ValueError("Couldn't determine maximum refresh rate, and the maximum refresh rate isn't defined in the device quirks section") + + max_refresh = int(manufacturer_supported_devices[edid.name]["max_refresh"]) + + return EvdiDisplaySpec(raw_edid_file, max_width, max_height, max_refresh) + + raise ValueError("Could not find supported device. Check if the device is plugged in. If it is plugged in and working correctly, check the README or open an issue.") diff --git a/libunreal/supported_devices.py b/libunreal/supported_devices.py new file mode 100644 index 0000000..a0f7ba1 --- /dev/null +++ b/libunreal/supported_devices.py @@ -0,0 +1,11 @@ +# Sourced from "https://uefi.org/uefi-pnp-export" +supported_devices: dict[str, dict[str, dict[str, str | int]]] = { + "MRG": { + # Quirks section + "Air": { + "max_width": 1920, + "max_height": 1080, + "max_refresh": 120 + } + } +} diff --git a/main.py b/main.py new file mode 100755 index 0000000..925317f --- /dev/null +++ b/main.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import atexit +import json +import os + +from platformdirs import user_data_dir, user_config_dir +from loguru import logger +import PyEvdi +import pyedid +import time + +import libunreal +from libunreal.edid import EvdiDisplaySpec + +default_configuration: dict[str, str | int] = { + "display_angle": 45, + "display_count": 3, + "allow_unsupported_devices": False, + "allow_unsupported_vendors": False, + "override_default_edid": False, + "edid_override_path": "/file/here", + "override_width": 0, + "override_height": 0, + "override_refresh_rate": 0, +} + +def initialize_configuration(): + pass + +used_cards: list[int] = [] + +def find_suitable_evdi_card() -> int: + for i in range(20): + if PyEvdi.check_device(i) == PyEvdi.AVAILABLE and i not in used_cards: + used_cards.append(i) + return i + + PyEvdi.add_device() + + for i in range(20): + if PyEvdi.check_device(i) == PyEvdi.AVAILABLE and i not in used_cards: + used_cards.append(i) + return i + + raise ValueError("Failed to allocate available device") + +@logger.catch +def main(): + configuration = {} + + logger.info("Loading configuration") + config_dir = os.environ["UNREALXR_CONFIG_PATH"] if "UNREALXR_CONFIG_PATH" in os.environ else "" + data_dir = os.environ["UNREALXR_DATA_PATH"] if "UNREALXR_DATA_PATH" in os.environ else "" + + # Use OS defaults if we weren't overriden in env + if config_dir == "": + config_dir = user_config_dir("UnrealXR", "Tera") + + if data_dir == "": + data_dir = user_data_dir("UnrealXR", "Tera") + + try: + os.stat(data_dir) + except OSError: + os.makedirs(data_dir) + + # Read config and create it if it doesn't exist + config_path = os.path.join(config_dir, "config.json") + + try: + os.stat(config_path) + + with open(config_path, "r") as config_file: + configuration = json.load(config_file) + except OSError: + try: + os.makedirs(config_dir) + except OSError: + pass + + with open(config_path, "w") as config_file: + json.dump(default_configuration, config_file, indent=4) + + configuration = default_configuration + + # Set unbound values (ie. if user is using an older version) + for key, default_value in default_configuration.items(): + if key not in configuration: + # Add quotes if we're a string + value = default_value + + if isinstance(value, str): + value = f'"{value}"' + + logger.warning(f"Setting unbound key '{key}' with default value {value}. You might want to define this! If not, this warning can be safely ignored.") + configuration[key] = default_value + + # Initialize logging to files + logger.add(os.path.join(data_dir, "unrealxr.log"), format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {message}") + logger.info("Loaded configuration") + + if os.geteuid() != 0: + raise OSError("You are not running as root! Running as root is necessary to talk to the EVDI service") + + # Get the display EDID + logger.info("Attempting to read display EDID file") + edid: EvdiDisplaySpec | None = None + + if configuration["override_default_edid"] or configuration["allow_unsupported_vendors"]: + # We need to parse it to get the maximum width, height, and refresh rate for EVDI's calculations + with open(configuration["edid_override_path"], "rb") as edid_file: + edid_file = edid_file.read() + parsed_edid_file = pyedid.parse_edid(edid_file) + + max_width = int(configuration["override_width"]) + max_height = int(configuration["override_height"]) + max_refresh = int(configuration["override_refresh_rate"]) + + if configuration["override_width"] == 0 or configuration["override_height"] == 0 or configuration["override_refresh_rate"] == 0: + for resolution in parsed_edid_file.resolutions: + if configuration["override_width"] == 0 or configuration["override_height"] == 0: + if resolution[0] > max_width and resolution[1] > max_height: + max_width = resolution[0] + max_height = resolution[1] + + if configuration["override_refresh_rate"] == 0: + max_refresh = max(max_refresh, int(resolution[2])) + + if max_width == 0 or max_height == 0: + raise ValueError("Could not determine maximum width and height from EDID file, and the ") + + if max_refresh == 0: + raise ValueError("Could not determine maximum refresh rate from EDID file, and the refresh rate overrides aren't set!") + else: + edid = libunreal.fetch_xr_glass_edid(configuration["allow_unsupported_devices"]) + + assert(edid is not None) + + logger.info("Got EDID file") + logger.info("Initializing virtual displays") + + cards = [] + + for i in range(int(configuration["display_count"])): + suitable_card_id = find_suitable_evdi_card() + card = PyEvdi.Card(suitable_card_id) + card.connect(edid.edid, len(edid.edid), edid.max_width*edid.max_height, edid.max_width*edid.max_height*edid.max_refresh_rate) + cards.append(card) + + logger.debug(f"Initialized card #{str(i)}") + + atexit.register(lambda: card.close()) + + logger.info("Initialized displays") + + time.sleep(10) +if __name__ == "__main__": + print("Welcome to UnrealXR!\n") + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d978da6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +setuptools==80.9.0 +pybind11==2.13.6 +pyedid==1.0.3 +loguru==0.7.3 +platformdirs==4.3.8 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..d448fde --- /dev/null +++ b/shell.nix @@ -0,0 +1,35 @@ +{ + pkgs ? import { }, +}: pkgs.mkShell { + buildInputs = with pkgs; [ + # Runtime dependencies + python3 + pciutils + + # evdi build dependencies + libdrm + linuxHeaders + + # raylib build dependencies + libGL + xorg.libXi + xorg.libXcursor + xorg.libXrandr + xorg.libXinerama + waylandpp + libxkbcommon + ]; + + shellHook = '' + export LD_LIBRARY_PATH="$PWD/evdi/library:$LD_LIBRARY_PATH" + mkdir -p "$PWD/data/config" "$PWD/data/data" + export UNREALXR_CONFIG_PATH="$PWD/data/config" + export UNREALXR_DATA_PATH="$PWD/data/data" + + if [ ! -d ".venv" ]; then + python3 -m venv .venv + fi + + source .venv/bin/activate + ''; +} diff --git a/unrealxr b/unrealxr new file mode 100755 index 0000000..672da8e --- /dev/null +++ b/unrealxr @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +sudo LD_LIBRARY_PATH=$LD_LIBRARY_PATH UNREALXR_CONFIG_PATH=$UNREALXR_CONFIG_PATH UNREALXR_DATA_PATH=$UNREALXR_DATA_PATH python3 main.py