chore: Implement display initialization

This commit is contained in:
Tera << 8 2025-06-04 22:53:38 -04:00
parent 5adf93a9d3
commit 0fd3b21d6d
Signed by: imterah
GPG key ID: 8FA7DD57BA6CEA37
12 changed files with 501 additions and 20 deletions

203
.gitignore vendored
View file

@ -1,29 +1,192 @@
# ---> Go # ---> Python
# If you prefer the allow list template instead of the deny list, see community template: # Byte-compiled / optimized / DLL files
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore __pycache__/
# *.py[cod]
# Binaries for programs and plugins *$py.class
*.exe
*.exe~ # C extensions
*.dll
*.so *.so
*.dylib
# Test binary, built with `go test -c` # Distribution / packaging
*.test .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 # PyInstaller
*.out # 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) # Installer logs
# vendor/ pip-log.txt
pip-delete-this-directory.txt
# Go workspace file # Unit test / coverage reports
go.work htmlcov/
go.work.sum .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 .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 # ---> C
# Prerequisites # Prerequisites
@ -113,3 +276,5 @@ dkms.conf
*.out *.out
*.app *.app
# ---> UnrealXR
data

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "evdi"]
path = evdi
url = https://github.com/DisplayLink/evdi

View file

@ -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 ../..`

1
evdi Submodule

@ -0,0 +1 @@
Subproject commit 600ed4342a7251d4575ee4810756649f9e77cdf0

8
libunreal/__init__.py Normal file
View file

@ -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")

8
libunreal/edid.py Normal file
View file

@ -0,0 +1,8 @@
import dataclasses
@dataclasses.dataclass
class EvdiDisplaySpec:
edid: bytes
max_width: int
max_height: int
max_refresh_rate: int

View file

@ -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.")

View file

@ -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
}
}
}

159
main.py Executable file
View file

@ -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()

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
setuptools==80.9.0
pybind11==2.13.6
pyedid==1.0.3
loguru==0.7.3
platformdirs==4.3.8

35
shell.nix Normal file
View file

@ -0,0 +1,35 @@
{
pkgs ? import <nixpkgs> { },
}: 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
'';
}

2
unrealxr Executable file
View file

@ -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