diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index f05bf3f5..ae2bb3cc 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -111,7 +111,7 @@ jobs: run: | dnf install -y git rpmdevtools meson ninja-build python3-devel \ systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \ - gcc gettext + gcc gettext mingw64-gcc - name: Setup rpmbuild environment run: | @@ -150,7 +150,7 @@ jobs: run: | apt-get update apt-get install -y build-essential debhelper dpkg-dev gettext git \ - libvulkan-dev meson ninja-build nodejs npm \ + libvulkan-dev meson mingw-w64 ninja-build nodejs npm \ pkgconf python3 - name: Checkout diff --git a/.gitea/workflows/package-build.yml b/.gitea/workflows/package-build.yml index 5cf2e21c..303361ae 100644 --- a/.gitea/workflows/package-build.yml +++ b/.gitea/workflows/package-build.yml @@ -137,7 +137,7 @@ jobs: run: | dnf install -y git rpmdevtools meson ninja-build python3-devel \ systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \ - gcc gettext + gcc gettext mingw64-gcc - name: Setup rpmbuild environment run: | @@ -257,7 +257,7 @@ jobs: run: | apt-get update apt-get install -y build-essential debhelper dpkg-dev gettext git \ - libvulkan-dev meson ninja-build nodejs npm \ + libvulkan-dev meson mingw-w64 ninja-build nodejs npm \ pkgconf python3 - name: Checkout PR diff --git a/.gitignore b/.gitignore index 2304ffa2..61938e02 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,8 @@ Thumbs.db # Compiled gettext catalogs portprotonqt/locales/*/LC_MESSAGES/*.mo +# Local MinGW toolchain and built DLL (CI builds these) +.toolchain/ +data/NPClient64.dll # AI .codex diff --git a/build-aux/PKGBUILD b/build-aux/PKGBUILD index 96f0f701..2cbe149a 100644 --- a/build-aux/PKGBUILD +++ b/build-aux/PKGBUILD @@ -6,8 +6,8 @@ arch=('any') url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt" license=('GPL-3.0') depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson' - 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3') -makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext') + 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'python-opencv' 'python-numpy' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3') +makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'mingw-w64-gcc') conflicts=("portprotonqt-steam-compat") provides=('portprotonqt-steam-compat') replaces=('portprotonqt-steam-compat') diff --git a/build-aux/PKGBUILD-git b/build-aux/PKGBUILD-git index 60c26738..de0cc240 100644 --- a/build-aux/PKGBUILD-git +++ b/build-aux/PKGBUILD-git @@ -6,7 +6,7 @@ arch=('any') url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt" license=('GPL-3.0') depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson' - 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3') + 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'python-opencv' 'python-numpy' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3') makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext') conflicts=("portprotonqt-steam-compat") provides=('portprotonqt-steam-compat') diff --git a/build-aux/fedora-git.spec b/build-aux/fedora-git.spec index 7fa1a11d..5bf76702 100644 --- a/build-aux/fedora-git.spec +++ b/build-aux/fedora-git.spec @@ -23,6 +23,7 @@ BuildRequires: gettext BuildRequires: systemd-rpm-macros BuildRequires: vulkan-loader-devel BuildRequires: gcc +BuildRequires: mingw64-gcc Obsoletes: python3-%{pypi_name}-git < %{version}-%{release} Provides: python3-%{pypi_name}-git = %{version}-%{release} @@ -42,6 +43,8 @@ Requires: python3-pillow Requires: python3-pillow-qt Requires: python3-rapidfuzz Requires: python3-libarchive-c +Requires: python3-opencv +Requires: python3-numpy Requires: perl-Image-ExifTool Requires: SDL3 Requires: qt6-qtsvg @@ -118,6 +121,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d %{_datadir}/portproton/scripts/ %{_datadir}/portproton/conf/ %{_datadir}/portproton/img/ +%{_datadir}/%{pypi_name}/ %{bash_completions_dir}/portprotonqt %{fish_completions_dir}/portprotonqt.fish %{zsh_completions_dir}/_portprotonqt diff --git a/build-aux/fedora.spec b/build-aux/fedora.spec index 9f8ceb8b..343a7bd9 100644 --- a/build-aux/fedora.spec +++ b/build-aux/fedora.spec @@ -20,6 +20,7 @@ BuildRequires: gettext BuildRequires: systemd-rpm-macros BuildRequires: vulkan-loader-devel BuildRequires: gcc +BuildRequires: mingw64-gcc Obsoletes: python3-%{pypi_name} < %{version}-%{release} Provides: python3-%{pypi_name} = %{version}-%{release} @@ -39,6 +40,8 @@ Requires: python3-pillow Requires: python3-pillow-qt Requires: python3-rapidfuzz Requires: python3-libarchive-c +Requires: python3-opencv +Requires: python3-numpy Requires: perl-Image-ExifTool Requires: SDL3 Requires: qt6-qtsvg @@ -116,6 +119,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d %{_datadir}/portproton/scripts/ %{_datadir}/portproton/conf/ %{_datadir}/portproton/img/ +%{_datadir}/%{pypi_name}/ %{bash_completions_dir}/portprotonqt %{fish_completions_dir}/portprotonqt.fish %{zsh_completions_dir}/_portprotonqt diff --git a/build-aux/npclient/LICENSE b/build-aux/npclient/LICENSE new file mode 100644 index 00000000..80a942ce --- /dev/null +++ b/build-aux/npclient/LICENSE @@ -0,0 +1,26 @@ +NPClient64.dll for PortProtonQt +Copyright (C) 2026 PortProtonQt contributors + +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 . + +--- + +The NPClient API implemented in this DLL is based on publicly +available documentation and open-source implementations, including: + +- linuxtrack-wine by John Tsiombikas (GPLv3) + https://github.com/jtsiomb/linuxtrack-wine + +- opentrack by Stanislaw Halik and contributors (ISC) + https://github.com/opentrack/opentrack diff --git a/build-aux/npclient/Makefile b/build-aux/npclient/Makefile new file mode 100644 index 00000000..5d1de24e --- /dev/null +++ b/build-aux/npclient/Makefile @@ -0,0 +1,20 @@ +# Makefile for NPClient64.dll +# Build with: make NPClient64.dll +# Requires mingw-w64 toolchain (x86_64-w64-mingw32-gcc) + +CC = x86_64-w64-mingw32-gcc +CFLAGS = -O2 -Wall -shared +LDFLAGS = -Wl,--subsystem,windows + +TARGET = NPClient64.dll +SRC = npclient64.c + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ + +clean: + rm -f $(TARGET) diff --git a/build-aux/npclient/npclient64.c b/build-aux/npclient/npclient64.c new file mode 100644 index 00000000..f0deee2c --- /dev/null +++ b/build-aux/npclient/npclient64.c @@ -0,0 +1,210 @@ +/* + * NPClient64.dll - TrackIR-compatible head tracking client for Wine. + * + * Implements the NaturalPoint TrackIR NPClient API, reading head tracking + * data from the Wine shared memory file "facetracknoir-wine-shm" written + * by PortProtonQt. + * + * The NPClient API was reverse-engineered from publicly available + * documentation and open-source implementations. This file is released + * under the GPL-3.0 license as part of PortProtonQt. + */ + +#include +#include +#include + +#define WINE_SHM_NAME "facetracknoir-wine-shm" +#define WINE_MTX_NAME "facetracknoir-wine-mtx" + +#pragma pack(push, 1) +struct WineSHM { + double data[6]; + int32_t gameid; + int32_t gameid2; + unsigned char table[8]; + BOOL stop; +}; + +struct NPData { + int16_t status; + int16_t frame; + uint32_t checksum; + float roll, pitch, yaw; + float tx, ty, tz; + float padding[9]; +}; + +struct NPSignature { + char dllsig[200]; + char appsig[200]; +}; +#pragma pack(pop) + +static HANDLE hShm = NULL; +static struct WineSHM *shm = NULL; +static HANDLE hMtx = NULL; +static int16_t frame_counter = 0; +static int initialized = 0; +static int transmitting = 0; + +static struct NPSignature sigdata = { + "precise head tracking\n put your head into the game\n now go look around\n\n " + "Copyright EyeControl Technologies", + + "hardware camera\n software processing data\n track user movement\n\n " + "Copyright EyeControl Technologies" +}; + +static BOOL impl_create_mapping(void) +{ + if (shm != NULL) + return TRUE; + + hShm = CreateFileMappingA(INVALID_HANDLE_VALUE, + NULL, + PAGE_READWRITE, + 0, + sizeof(struct WineSHM), + (LPCSTR)WINE_SHM_NAME); + if (hShm == NULL) + return FALSE; + + shm = (struct WineSHM *)MapViewOfFile(hShm, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(struct WineSHM)); + hMtx = CreateMutexA(NULL, FALSE, WINE_MTX_NAME); + + return shm != NULL; +} + +__declspec(dllexport) int __stdcall NP_GetData(struct NPData *data) +{ + if (data == NULL) + return 1; + + memset(data, 0, sizeof(struct NPData)); + + if (!initialized || !transmitting) + return 0; + + if (impl_create_mapping() == FALSE || shm == NULL) + return 1; + + if (hMtx && WaitForSingleObject(hMtx, 16) == WAIT_OBJECT_0) { + double x = shm->data[0]; + double y = shm->data[1]; + double z = shm->data[2]; + double yaw = shm->data[3]; + double pitch = shm->data[4]; + double roll = shm->data[5]; + ReleaseMutex(hMtx); + + data->yaw = (float)(-(yaw * 180.0 / 3.14159265358979323846) / 180.0 * 16384.0); + data->pitch = (float)(-(pitch * 180.0 / 3.14159265358979323846) / 180.0 * 16384.0); + data->roll = (float)(-(roll * 180.0 / 3.14159265358979323846) / 180.0 * 16384.0); + + data->tx = (float)(-x * 64.0f / 10.0f); + data->ty = (float)(y * 64.0f / 10.0f); + data->tz = (float)(z * 64.0f / 10.0f); + } + + data->status = 0; + data->frame = frame_counter++; + data->checksum = 0; + + return 0; +} + +__declspec(dllexport) int __stdcall NP_GetSignature(struct NPSignature *sig) +{ + if (sig == NULL) + return 1; + memcpy(sig, &sigdata, sizeof(sigdata)); + return 0; +} + +__declspec(dllexport) int __stdcall NP_QueryVersion(short *ver) +{ + if (ver == NULL) + return 1; + *ver = 0x0400; + return 0; +} + +__declspec(dllexport) int __stdcall NP_ReCenter(void) +{ + return 0; +} + +__declspec(dllexport) int __stdcall NP_RegisterWindowHandle(void *handle) +{ + (void)handle; + return 0; +} + +__declspec(dllexport) int __stdcall NP_UnregisterWindowHandle(void) +{ + return 0; +} + +__declspec(dllexport) int __stdcall NP_RegisterProgramProfileID(short id) +{ + (void)id; + initialized = 1; + return 0; +} + +__declspec(dllexport) int __stdcall NP_RequestData(short data) +{ + (void)data; + return 0; +} + +__declspec(dllexport) int __stdcall NP_StopCursor(void) +{ + return 0; +} + +__declspec(dllexport) int __stdcall NP_StartCursor(void) +{ + return 0; +} + +__declspec(dllexport) int __stdcall NP_StartDataTransmission(void) +{ + transmitting = 1; + return 0; +} + +__declspec(dllexport) int __stdcall NP_StopDataTransmission(void) +{ + transmitting = 0; + return 0; +} + +BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) +{ + (void)hinstDLL; + (void)lpvReserved; + + switch (fdwReason) { + case DLL_PROCESS_ATTACH: + break; + case DLL_PROCESS_DETACH: + if (shm) { + UnmapViewOfFile(shm); + shm = NULL; + } + if (hShm) { + CloseHandle(hShm); + hShm = NULL; + } + if (hMtx) { + CloseHandle(hMtx); + hMtx = NULL; + } + break; + default: + break; + } + return TRUE; +} diff --git a/data/face_detection_yunet_2023mar.onnx b/data/face_detection_yunet_2023mar.onnx new file mode 100644 index 00000000..f9beb304 Binary files /dev/null and b/data/face_detection_yunet_2023mar.onnx differ diff --git a/debian/control b/debian/control index 8b576a97..8eb1b1d9 100644 --- a/debian/control +++ b/debian/control @@ -7,6 +7,7 @@ Build-Depends: gettext, libvulkan-dev, meson, + mingw-w64, ninja-build, pkgconf, python3, @@ -34,6 +35,8 @@ Depends: python3-dbus-fast, python3-evdev, python3-libarchive-c, + python3-numpy, + python3-opencv, python3-orjson, python3-pefile, python3-pil, diff --git a/debian/copyright b/debian/copyright index 5142d332..40481c2a 100644 --- a/debian/copyright +++ b/debian/copyright @@ -12,6 +12,11 @@ Copyright: 2026 PortProtonQt contributors License: GPL-3.0 +Files: data/face_detection_yunet_2023mar.onnx +Copyright: + 2023 OpenCV team +License: Apache-2.0 + License: GPL-3.0 This package is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License version 3. diff --git a/debian/portprotonqt.install b/debian/portprotonqt.install index 137259fe..06a012d4 100644 --- a/debian/portprotonqt.install +++ b/debian/portprotonqt.install @@ -11,4 +11,5 @@ usr/share/metainfo/ru.linux_gaming.PortProtonQt.metainfo.xml usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml usr/share/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules usr/share/portproton/ +usr/share/portprotonqt/ usr/share/zsh/site-functions/_portprotonqt diff --git a/meson.build b/meson.build index 16f98074..eb9c5519 100644 --- a/meson.build +++ b/meson.build @@ -102,6 +102,27 @@ install_data( install_dir: datadir / 'metainfo', ) +# Install YuNet face detection model for head tracking +install_data( + 'data/face_detection_yunet_2023mar.onnx', + install_dir: datadir / meson.project_name(), +) + +# Build NPClient64.dll for TrackIR-compatible games (optional, requires mingw-w64) +mingw_cc = find_program('x86_64-w64-mingw32-gcc', required: false) +if mingw_cc.found() + npclient64_dll = custom_target('npclient64_dll', + input: 'build-aux/npclient/npclient64.c', + output: 'NPClient64.dll', + command: [mingw_cc, '-O2', '-Wall', '-shared', '-Wl,--subsystem,windows', + '@INPUT@', '-o', '@OUTPUT@'], + install: true, + install_dir: datadir / meson.project_name(), + ) +else + warning('mingw-w64 not found, skipping NPClient64.dll build. Install mingw-w64 to enable TrackIR game support.') +endif + # Install PortProton scripts install_subdir( 'build-aux/share/portproton/scripts', diff --git a/portprotonqt/dialogs/settings_dialog.py b/portprotonqt/dialogs/settings_dialog.py index a2a45887..d1116de7 100644 --- a/portprotonqt/dialogs/settings_dialog.py +++ b/portprotonqt/dialogs/settings_dialog.py @@ -3,7 +3,7 @@ import os import re import subprocess -from typing import cast, TYPE_CHECKING +from typing import cast from PySide6.QtCore import Qt, QObject, QEvent, QPoint, QProcess, QTimer, QUrl from PySide6.QtGui import QColor, QContextMenuEvent, QDesktopServices, QGuiApplication, QIcon @@ -26,9 +26,6 @@ from PySide6.QtWidgets import ( QWidget, ) -if TYPE_CHECKING: - from portprotonqt.main_window import MainWindow - from portprotonqt.config import ( get_portproton_location, get_portproton_scripts_path, @@ -42,6 +39,7 @@ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin +from portprotonqt.dialogs.settings_headtracking import HeadTrackingSettingsMixin, HT_ALL_KEYS from portprotonqt.localization import _, format_setting_name_for_display from portprotonqt.logger import get_logger from portprotonqt.preloader import Preloader @@ -132,6 +130,7 @@ class ExeSettingsDialog( MangoHudSettingsMixin, GamescopeSettingsMixin, VkBasaltSettingsMixin, + HeadTrackingSettingsMixin, ): """Dialog for configuring executable-specific settings.""" @@ -179,11 +178,13 @@ class ExeSettingsDialog( self.init_mangohud_state() self.init_gamescope_state() self.init_vkbasalt_state() + self.init_headtracking_state() self.blocked_keys = set() self.numa_nodes = {} self.locale_options = [] self.logical_core_options = [] self._gamepad_tooltip_map = {} + self._closing = False self.setWindowTitle(_("Exe Settings")) self.setModal(True) @@ -203,7 +204,7 @@ class ExeSettingsDialog( parent_obj = self.parent() while parent_obj: if hasattr(parent_obj, 'input_manager'): - self.input_manager = cast("MainWindow", parent_obj).input_manager + self.input_manager = parent_obj.input_manager # type: ignore[union-attr] self.main_window = parent_obj parent_obj = parent_obj.parent() @@ -212,6 +213,7 @@ class ExeSettingsDialog( if self.input_manager: self.input_manager.enable_settings_mode(self) self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility) + self.input_manager.gamepad_hotplug.connect(self._check_gamepad_and_block_headtracking) self.hints_widget, self.hints_labels = create_dialog_hints_widget( self.theme, self.main_window, self.input_manager, context='settings' @@ -290,6 +292,8 @@ class ExeSettingsDialog( self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab) self.gamescope_tab = QWidget() self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab) + self.headtracking_tab = QWidget() + self.headtracking_tab_layout = QVBoxLayout(self.headtracking_tab) self.tab_widget.addTab(self.main_tab, _("Main")) self.tab_widget.addTab(self.advanced_tab, _("Advanced")) @@ -297,7 +301,9 @@ class ExeSettingsDialog( self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt") if self.gamescope_available: self.tab_widget.addTab(self.gamescope_tab, "Gamescope") + self.tab_widget.addTab(self.headtracking_tab, _("Head Tracking")) self.tab_widget.currentChanged.connect(self.on_table_selection_changed) + self.tab_widget.currentChanged.connect(lambda: self._on_headtracking_tab_changed()) self.settings_table = QTableWidget() self.settings_table.setAlternatingRowColors(True) @@ -421,6 +427,7 @@ class ExeSettingsDialog( self.setup_vkbasalt_tab() if self.gamescope_available: self.setup_gamescope_tab() + self.setup_headtracking_tab() self.main_layout.addWidget(self.tab_widget) @@ -473,6 +480,8 @@ class ExeSettingsDialog( def on_show_ppdb_finished(self, exit_code, exit_status): """Handle --show-ppdb output.""" + if self._closing: + return process = cast(QProcess, self.sender()) self.current_settings = {} self.blocked_keys = set() @@ -504,6 +513,7 @@ class ExeSettingsDialog( or key in GAMESCOPE_ENV_KEYS or key in VKBASALT_ENV_KEYS or key in TOGGLE_BOOL_KEYS + or key in HT_ALL_KEYS ): if val.startswith('"') and val.endswith('"') and len(val) >= 2: val = val[1:-1] @@ -564,6 +574,8 @@ class ExeSettingsDialog( self.populate_vkbasalt() if self.gamescope_available: self.populate_gamescope() + self.populate_headtracking() + self._on_headtracking_tab_changed() self.populate_favorites(select_tab=True) self.settings_container.setCurrentIndex(1) @@ -1228,9 +1240,11 @@ class ExeSettingsDialog( gamescope_changes = [] if self.gamescope_available: gamescope_changes = self._collect_gamescope_changes() + headtracking_changes = self._collect_headtracking_changes() changes.extend(mangohud_changes) changes.extend(self._collect_vkbasalt_changes()) changes.extend(gamescope_changes) + changes.extend(headtracking_changes) # Check if PW_GAMESCOPE toggle changes are already in the list has_gamescope_toggle = any(change.startswith("PW_GAMESCOPE=") for change in gamescope_changes) @@ -1267,14 +1281,17 @@ class ExeSettingsDialog( QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs.")) logger.error(f"Failed to apply changes: {error_output}") else: - self.load_current_settings() + self._closing = True + self.cleanup_headtracking() self.close() def closeEvent(self, event): + self._closing = True if hasattr(self, 'keyboard') and self.keyboard.isVisible(): self.keyboard.hide() if self.input_manager: self.input_manager.disable_settings_mode() + self.cleanup_headtracking() super().closeEvent(event) def eventFilter(self, obj: QObject, event: QEvent) -> bool: @@ -1474,17 +1491,21 @@ class ExeSettingsDialog( self.show_gamepad_tooltip(show=False) def reject(self): + self._closing = True if hasattr(self, 'keyboard') and self.keyboard.isVisible(): self.keyboard.hide() self.gamepad_tooltip.setVisible(False) if self.input_manager: self.input_manager.disable_settings_mode() + self.cleanup_headtracking() super().reject() def accept(self): + self._closing = True if hasattr(self, 'keyboard') and self.keyboard.isVisible(): self.keyboard.hide() self.gamepad_tooltip.setVisible(False) if self.input_manager: self.input_manager.disable_settings_mode() + self.cleanup_headtracking() super().accept() diff --git a/portprotonqt/dialogs/settings_headtracking.py b/portprotonqt/dialogs/settings_headtracking.py new file mode 100644 index 00000000..680c76cf --- /dev/null +++ b/portprotonqt/dialogs/settings_headtracking.py @@ -0,0 +1,1017 @@ +"""Head Tracking settings mixin for executable settings dialog.""" + +import os +import subprocess +from typing import Any + +import cv2 +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QImage, QPixmap +from PySide6.QtWidgets import ( + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSlider, + QVBoxLayout, + QWidget, +) + +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.head_tracking.tracker_camera import CameraTracker +from portprotonqt.head_tracking.tracker_manager import TrackerManager +from portprotonqt.localization import _ +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +# Settings keys +HT_ENABLED_KEY = "PW_HEADTRACKING" +HT_CAMERA_KEY = "PW_HEADTRACKING_CAMERA" +HT_SENS_X_KEY = "PW_HEADTRACKING_SENS_X" +HT_SENS_Y_KEY = "PW_HEADTRACKING_SENS_Y" +HT_SENS_Z_KEY = "PW_HEADTRACKING_SENS_Z" +HT_SENS_YAW_KEY = "PW_HEADTRACKING_SENS_YAW" +HT_SENS_PITCH_KEY = "PW_HEADTRACKING_SENS_PITCH" +HT_SENS_ROLL_KEY = "PW_HEADTRACKING_SENS_ROLL" +HT_OUTPUT_KEY = "PW_HEADTRACKING_OUTPUT" +HT_MOUSE_SENS_X_KEY = "PW_HEADTRACKING_MOUSE_SENS_X" +HT_MOUSE_SENS_Y_KEY = "PW_HEADTRACKING_MOUSE_SENS_Y" +HT_MOUSE_DEADZONE_KEY = "PW_HEADTRACKING_MOUSE_DEADZONE" +HT_UDP_HOST_KEY = "PW_HEADTRACKING_UDP_HOST" +HT_UDP_PORT_KEY = "PW_HEADTRACKING_UDP_PORT" + +DEFAULT_SENSITIVITY = 1.0 +DEFAULT_MOUSE_SENS = 10.0 +DEFAULT_MOUSE_DEADZONE = 0.05 +DEFAULT_UDP_HOST = "127.0.0.1" +DEFAULT_UDP_PORT = 4242 + +OUTPUT_MODES = [ + ("shm", _("Wine SHM (TrackIR/FreeTrack)")), + ("mouse", _("Mouse emulation")), + ("joystick", _("Joystick emulation")), + ("npclient", _("TrackIR / FreeTrack (NPClient64)")), + ("udp", _("UDP over network")), + ("flightgear", _("FlightGear UDP")), +] + +# All head tracking setting keys for PPDB parsing +HT_ALL_KEYS = [ + HT_ENABLED_KEY, + HT_CAMERA_KEY, + HT_SENS_X_KEY, + HT_SENS_Y_KEY, + HT_SENS_Z_KEY, + HT_SENS_YAW_KEY, + HT_SENS_PITCH_KEY, + HT_SENS_ROLL_KEY, + HT_OUTPUT_KEY, + HT_MOUSE_SENS_X_KEY, + HT_MOUSE_SENS_Y_KEY, + HT_MOUSE_DEADZONE_KEY, + HT_UDP_HOST_KEY, + HT_UDP_PORT_KEY, +] + + +def _get_camera_name(index: int) -> str | None: + """Read camera name from sysfs V4L2 and udev.""" + # Try udev vendor + model first (more descriptive) + try: + result = subprocess.run( + ["udevadm", "info", "-q", "all", "-n", f"/dev/video{index}"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + vendor = "" + model = "" + for line in result.stdout.splitlines(): + if line.startswith("E: ID_VENDOR="): + vendor = line.split("=", 1)[1].strip() + elif line.startswith("E: ID_MODEL="): + model = line.split("=", 1)[1].strip() + if vendor and model: + # Clean up vendor string (replace underscores with spaces) + vendor_clean = vendor.replace("_", " ") + return f"{vendor_clean} {model}" + elif model: + return model + except (OSError, subprocess.TimeoutExpired): + pass + + # Fallback to sysfs V4L2 name + sysfs_path = f"/sys/class/video4linux/video{index}/name" + try: + with open(sysfs_path, encoding="utf-8") as f: + return f.read().strip() + except (OSError, UnicodeDecodeError): + return None + + +def _is_capture_device(index: int) -> bool: + """Check if /dev/video{index} is a video capture device (not metadata).""" + try: + result = subprocess.run( + ["v4l2-ctl", "-d", f"/dev/video{index}", "--all"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + output = result.stdout + # Must have "Format Video Capture" and NOT "Format Metadata Capture" + has_video_capture = "Format Video Capture" in output + has_metadata = "Format Metadata Capture" in output + return has_video_capture and not has_metadata + except (OSError, subprocess.TimeoutExpired): + pass + # Fallback: try to open with OpenCV + return True + + +# Module-level cache for camera list +_camera_cache: list[tuple[int, str]] | None = None + + +def _count_v4l_devices() -> int: + """Count available V4L2 video devices.""" + try: + return len(os.listdir("/sys/class/video4linux")) + except OSError: + return 10 + + +def _list_cameras(max_check: int = 10) -> list[tuple[int, str]]: + """Enumerate available camera devices with real names.""" + global _camera_cache + if _camera_cache is not None: + return _camera_cache + + # Limit to actual number of V4L devices to avoid OpenCV warnings + max_check = min(max_check, _count_v4l_devices()) + + cameras = [] + seen_names: set[str] = set() + for i in range(max_check): + if not _is_capture_device(i): + continue + cap = cv2.VideoCapture(i) + if cap.isOpened(): + name = _get_camera_name(i) + if not name: + backend = ( + cap.getBackendName() + if hasattr(cap, "getBackendName") + else "Camera" + ) + name = f"{backend} {i}" + # Deduplicate: same physical camera exposes multiple /dev/video* + if name not in seen_names: + seen_names.add(name) + cameras.append((i, name)) + cap.release() + _camera_cache = cameras + return cameras + + +class HeadTrackingSettingsMixin: + """Mixin with Head Tracking settings UI and logic.""" + + theme: Any + tab_widget: Any + current_settings: dict[str, str] + start_sh: list[str] | None + + def init_headtracking_state(self) -> None: + """Initialize head tracking state.""" + self.headtracking_tab: QWidget | None = None + self.headtracking_tab_layout: QVBoxLayout | None = None + self.headtracking_enable_button: QPushButton | None = None + self.headtracking_warning_group: QGroupBox | None = None + self.headtracking_gamepad_warning_label: QLabel | None = None + self.headtracking_output_group: QGroupBox | None = None + self.headtracking_preview_group: QGroupBox | None = None + self.headtracking_camera_combo: QComboBox | None = None + self.headtracking_preview_label: QLabel | None = None + self.headtracking_pose_label: QLabel | None = None + self.headtracking_sens_sliders: dict[str, QSlider] = {} + self.headtracking_sens_labels: dict[str, QLabel] = {} + self.headtracking_output_combo: QComboBox | None = None + self.headtracking_mouse_sens_x_slider: QSlider | None = None + self.headtracking_mouse_sens_x_label: QLabel | None = None + self.headtracking_mouse_sens_x_name_label: QLabel | None = None + self.headtracking_mouse_sens_y_slider: QSlider | None = None + self.headtracking_mouse_sens_y_label: QLabel | None = None + self.headtracking_mouse_sens_y_name_label: QLabel | None = None + self.headtracking_mouse_deadzone_slider: QSlider | None = None + self.headtracking_mouse_deadzone_label: QLabel | None = None + self.headtracking_mouse_deadzone_name_label: QLabel | None = None + self.headtracking_udp_host_input: Any | None = None + self.headtracking_udp_port_input: Any | None = None + self.headtracking_udp_host_label: QLabel | None = None + self.headtracking_udp_port_label: QLabel | None = None + self.headtracking_preview_timer: QTimer | None = None + self.headtracking_tracker_manager = TrackerManager() + self.headtracking_preview_active = False + + def setup_headtracking_tab(self) -> None: + """Create Head Tracking tab widgets.""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus) + scroll.setStyleSheet( + self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE + ) + + container = QWidget() + container.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE) + layout = QVBoxLayout(container) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing) + + self._add_headtracking_enable_group(layout) + self._add_headtracking_warning_group(layout) + self._add_headtracking_output_group(layout) + + # Preview group is created but hidden until tracking is enabled + self._add_headtracking_preview_group(layout) + + layout.addStretch() + scroll.setWidget(container) + + if self.headtracking_tab_layout: + self.headtracking_tab_layout.addWidget(scroll) + + def _add_headtracking_enable_group(self, parent_layout: QVBoxLayout) -> None: + """Add enable/disable button group.""" + group = QGroupBox(_("Tracking")) + group.setStyleSheet(self.theme.QGROUP_BOX_STYLE) + layout = QVBoxLayout(group) + + self.headtracking_enable_button = QPushButton() + self.headtracking_enable_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + self.headtracking_enable_button.setCheckable(True) + self.headtracking_enable_button.clicked.connect( + self._on_headtracking_toggle + ) + + layout.addWidget(self.headtracking_enable_button) + parent_layout.addWidget(group) + + def _add_headtracking_warning_group(self, parent_layout: QVBoxLayout) -> None: + """Add gamepad incompatibility warning group.""" + self.headtracking_warning_group = QGroupBox(_("Warning")) + self.headtracking_warning_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE) + layout = QVBoxLayout(self.headtracking_warning_group) + + self.headtracking_gamepad_warning_label = QLabel() + self.headtracking_gamepad_warning_label.setStyleSheet( + getattr(self.theme, "WARNING_LABEL_STYLE", "") + ) + self.headtracking_gamepad_warning_label.setWordWrap(True) + layout.addWidget(self.headtracking_gamepad_warning_label) + + parent_layout.addWidget(self.headtracking_warning_group) + + def _add_headtracking_output_group(self, parent_layout: QVBoxLayout) -> None: + """Add output method selection group.""" + self.headtracking_output_group = QGroupBox(_("Output Method")) + self.headtracking_output_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE) + layout = QVBoxLayout(self.headtracking_output_group) + + # Output mode selector + mode_row = QHBoxLayout() + mode_lbl = QLabel(_("Mode:")) + mode_lbl.setStyleSheet(self.theme.CONTENT_STYLE) + + self.headtracking_output_combo = QComboBox() + self.headtracking_output_combo.setStyleSheet( + getattr(self.theme, "COMBOBOX_STYLE", "") + ) + for value, label in OUTPUT_MODES: + self.headtracking_output_combo.addItem(label, value) + self.headtracking_output_combo.currentIndexChanged.connect( + self._on_output_mode_changed + ) + + mode_row.addWidget(mode_lbl) + mode_row.addWidget(self.headtracking_output_combo, stretch=1) + layout.addLayout(mode_row) + + # Mouse sensitivity X + ( + self.headtracking_mouse_sens_x_name_label, + self.headtracking_mouse_sens_x_slider, + self.headtracking_mouse_sens_x_label, + ) = self._add_mouse_slider(layout, _("Mouse X speed"), 500, DEFAULT_MOUSE_SENS) + self.headtracking_mouse_sens_x_slider.valueChanged.connect( + lambda v: self._on_mouse_sens_changed( + HT_MOUSE_SENS_X_KEY, v, self.headtracking_mouse_sens_x_label + ) + ) + + # Mouse sensitivity Y + ( + self.headtracking_mouse_sens_y_name_label, + self.headtracking_mouse_sens_y_slider, + self.headtracking_mouse_sens_y_label, + ) = self._add_mouse_slider(layout, _("Mouse Y speed"), 500, DEFAULT_MOUSE_SENS) + self.headtracking_mouse_sens_y_slider.valueChanged.connect( + lambda v: self._on_mouse_sens_changed( + HT_MOUSE_SENS_Y_KEY, v, self.headtracking_mouse_sens_y_label + ) + ) + + # Mouse deadzone + ( + self.headtracking_mouse_deadzone_name_label, + self.headtracking_mouse_deadzone_slider, + self.headtracking_mouse_deadzone_label, + ) = self._add_mouse_slider(layout, _("Mouse deadzone"), 100, DEFAULT_MOUSE_DEADZONE * 100) + self.headtracking_mouse_deadzone_slider.valueChanged.connect( + lambda v: self._on_mouse_deadzone_changed(v) + ) + + # UDP host input + udp_host_row = QHBoxLayout() + udp_host_lbl = QLabel(_("UDP host:")) + udp_host_lbl.setStyleSheet(self.theme.CONTENT_STYLE) + udp_host_lbl.setMinimumWidth(160) + self.headtracking_udp_host_label = udp_host_lbl + + self.headtracking_udp_host_input = QLineEdit(DEFAULT_UDP_HOST) + self.headtracking_udp_host_input.setStyleSheet( + getattr(self.theme, "LINEEDIT_STYLE", "") + ) + self.headtracking_udp_host_input.textChanged.connect( + lambda: self._update_tracker_output_settings() + ) + + udp_host_row.addWidget(udp_host_lbl) + udp_host_row.addWidget(self.headtracking_udp_host_input, stretch=1) + layout.addLayout(udp_host_row) + + # UDP port input + udp_port_row = QHBoxLayout() + udp_port_lbl = QLabel(_("UDP port:")) + udp_port_lbl.setStyleSheet(self.theme.CONTENT_STYLE) + udp_port_lbl.setMinimumWidth(160) + self.headtracking_udp_port_label = udp_port_lbl + + self.headtracking_udp_port_input = QLineEdit(str(DEFAULT_UDP_PORT)) + self.headtracking_udp_port_input.setStyleSheet( + getattr(self.theme, "LINEEDIT_STYLE", "") + ) + self.headtracking_udp_port_input.textChanged.connect( + lambda: self._update_tracker_output_settings() + ) + + udp_port_row.addWidget(udp_port_lbl) + udp_port_row.addWidget(self.headtracking_udp_port_input, stretch=1) + layout.addLayout(udp_port_row) + + parent_layout.addWidget(self.headtracking_output_group) + + def _add_mouse_slider( + self, + parent_layout: QVBoxLayout, + label: str, + max_value: int, + default_value: float, + ) -> tuple[QLabel, QSlider, QLabel]: + """Add a slider row for mouse settings.""" + row = QHBoxLayout() + lbl = QLabel(label) + lbl.setStyleSheet(self.theme.CONTENT_STYLE) + lbl.setMinimumWidth(160) + + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(0, max_value) + slider.setValue(int(default_value)) + slider.setStyleSheet( + getattr(self.theme, "SLIDER_STYLE", getattr(self.theme, "SLIDER_SIZE_STYLE", "")) + ) + + val_lbl = QLabel(f"{default_value:.0f}") + val_lbl.setStyleSheet(self.theme.CONTENT_STYLE) + val_lbl.setMinimumWidth(50) + + row.addWidget(lbl) + row.addWidget(slider) + row.addWidget(val_lbl) + parent_layout.addLayout(row) + return lbl, slider, val_lbl + + def _on_output_mode_changed(self) -> None: + """Show/hide mouse settings based on selected output mode.""" + self._update_output_settings_visibility() + + def _update_output_settings_visibility(self) -> None: + """Update visibility of output-specific settings.""" + mode = self._get_selected_output_mode() + mouse_visible = mode == "mouse" + udp_visible = mode in ("udp", "flightgear") + for widget in ( + self.headtracking_mouse_sens_x_slider, + self.headtracking_mouse_sens_y_slider, + self.headtracking_mouse_deadzone_slider, + ): + if widget: + widget.setVisible(mouse_visible) + for widget in ( + self.headtracking_mouse_sens_x_label, + self.headtracking_mouse_sens_y_label, + self.headtracking_mouse_deadzone_label, + self.headtracking_mouse_sens_x_name_label, + self.headtracking_mouse_sens_y_name_label, + self.headtracking_mouse_deadzone_name_label, + ): + if widget: + widget.setVisible(mouse_visible) + for widget in ( + self.headtracking_udp_host_input, + self.headtracking_udp_port_input, + self.headtracking_udp_host_label, + self.headtracking_udp_port_label, + ): + if widget: + widget.setVisible(udp_visible) + + def _get_selected_output_mode(self) -> str: + """Get currently selected output mode.""" + if self.headtracking_output_combo is None: + return "shm" + mode = self.headtracking_output_combo.currentData() + return mode if mode is not None else "shm" + + def _on_mouse_sens_changed(self, key: str, value: int, label: QLabel | None) -> None: + """Handle mouse sensitivity slider change.""" + if label: + label.setText(f"{value:.0f}") + self._update_tracker_output_settings() + + def _on_mouse_deadzone_changed(self, value: int) -> None: + """Handle mouse deadzone slider change.""" + if self.headtracking_mouse_deadzone_label: + self.headtracking_mouse_deadzone_label.setText(f"{value / 100.0:.2f}") + self._update_tracker_output_settings() + + def _update_tracker_output_settings(self) -> None: + """Apply output settings to tracker manager.""" + mouse_sens_x = ( + self.headtracking_mouse_sens_x_slider.value() + if self.headtracking_mouse_sens_x_slider + else int(DEFAULT_MOUSE_SENS) + ) + mouse_sens_y = ( + self.headtracking_mouse_sens_y_slider.value() + if self.headtracking_mouse_sens_y_slider + else int(DEFAULT_MOUSE_SENS) + ) + mouse_deadzone = ( + self.headtracking_mouse_deadzone_slider.value() / 100.0 + if self.headtracking_mouse_deadzone_slider + else DEFAULT_MOUSE_DEADZONE + ) + udp_host = ( + self.headtracking_udp_host_input.text() + if self.headtracking_udp_host_input + else DEFAULT_UDP_HOST + ) + udp_port = DEFAULT_UDP_PORT + if self.headtracking_udp_port_input: + try: + udp_port = int(self.headtracking_udp_port_input.text()) + except ValueError: + udp_port = DEFAULT_UDP_PORT + # Preview always uses SHM output to avoid interfering with real mouse/joystick + self.headtracking_tracker_manager.set_output_mode( + "shm", + mouse_sens_yaw=float(mouse_sens_x), + mouse_sens_pitch=float(mouse_sens_y), + mouse_deadzone=mouse_deadzone, + udp_host=udp_host, + udp_port=udp_port, + ) + + def populate_headtracking(self) -> None: + """Update head tracking widgets from current_settings.""" + if self.headtracking_enable_button is None: + return + + # Stop any active preview before repopulating + self._stop_preview() + + # Enable button + enabled = self.current_settings.get(HT_ENABLED_KEY) == "1" + self.headtracking_enable_button.setChecked(enabled) + self._update_enable_button_text(enabled) + + # Camera combo + if self.headtracking_camera_combo is not None: + self._populate_camera_combo() + saved_camera = self.current_settings.get(HT_CAMERA_KEY, "0") + idx = self.headtracking_camera_combo.findData(int(saved_camera)) + if idx >= 0: + self.headtracking_camera_combo.setCurrentIndex(idx) + + # Sensitivity sliders + for key, slider in self.headtracking_sens_sliders.items(): + saved = self.current_settings.get(key, str(DEFAULT_SENSITIVITY)) + try: + val = int(float(saved) * 100) + slider.setValue(max(0, min(200, val))) + except ValueError: + slider.setValue(100) + + # Output mode + if self.headtracking_output_combo is not None: + saved_output = self.current_settings.get(HT_OUTPUT_KEY, "shm") + idx = self.headtracking_output_combo.findData(saved_output) + if idx >= 0: + self.headtracking_output_combo.setCurrentIndex(idx) + + # Mouse settings + if self.headtracking_mouse_sens_x_slider is not None: + saved = self.current_settings.get( + HT_MOUSE_SENS_X_KEY, str(DEFAULT_MOUSE_SENS) + ) + try: + val = int(float(saved)) + except ValueError: + val = int(DEFAULT_MOUSE_SENS) + self.headtracking_mouse_sens_x_slider.setValue(val) + if self.headtracking_mouse_sens_x_label: + self.headtracking_mouse_sens_x_label.setText(f"{val:.0f}") + + if self.headtracking_mouse_sens_y_slider is not None: + saved = self.current_settings.get( + HT_MOUSE_SENS_Y_KEY, str(DEFAULT_MOUSE_SENS) + ) + try: + val = int(float(saved)) + except ValueError: + val = int(DEFAULT_MOUSE_SENS) + self.headtracking_mouse_sens_y_slider.setValue(val) + if self.headtracking_mouse_sens_y_label: + self.headtracking_mouse_sens_y_label.setText(f"{val:.0f}") + + if self.headtracking_mouse_deadzone_slider is not None: + saved = self.current_settings.get( + HT_MOUSE_DEADZONE_KEY, str(DEFAULT_MOUSE_DEADZONE) + ) + try: + val = int(float(saved) * 100) + val = max(0, min(100, val)) + except ValueError: + val = int(DEFAULT_MOUSE_DEADZONE * 100) + self.headtracking_mouse_deadzone_slider.setValue(val) + if self.headtracking_mouse_deadzone_label: + self.headtracking_mouse_deadzone_label.setText(f"{val / 100.0:.2f}") + + # UDP settings + if self.headtracking_udp_host_input is not None: + saved_host = self.current_settings.get(HT_UDP_HOST_KEY, DEFAULT_UDP_HOST) + self.headtracking_udp_host_input.setText(saved_host) + + if self.headtracking_udp_port_input is not None: + saved_port = self.current_settings.get(HT_UDP_PORT_KEY, str(DEFAULT_UDP_PORT)) + self.headtracking_udp_port_input.setText(saved_port) + + self._update_output_settings_visibility() + self._update_tracker_output_settings() + + # Output and preview group visibility + if self.headtracking_output_group is not None: + self.headtracking_output_group.setVisible(enabled) + if self.headtracking_preview_group is not None: + self.headtracking_preview_group.setVisible(enabled) + + def _update_enable_button_text(self, enabled: bool) -> None: + """Update button text based on state.""" + if self.headtracking_enable_button: + text = _("Head tracking ON") if enabled else _("Head tracking OFF") + self.headtracking_enable_button.setText(text) + + def _add_headtracking_preview_group(self, parent_layout: QVBoxLayout) -> None: + """Add camera preview group with camera selector and sensitivity on the left.""" + self.headtracking_preview_group = QGroupBox(_("Camera Preview")) + self.headtracking_preview_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE) + layout = QVBoxLayout(self.headtracking_preview_group) + + # Main area: left = camera list + sensitivity, right = preview + pose + preview_row = QHBoxLayout() + + # Left side: camera selector + sensitivity sliders + left_layout = QVBoxLayout() + + # Camera label + combo on one row + camera_row = QHBoxLayout() + camera_lbl = QLabel(_("Camera:")) + camera_lbl.setStyleSheet(self.theme.CONTENT_STYLE) + + self.headtracking_camera_combo = QComboBox() + self.headtracking_camera_combo.setStyleSheet( + getattr(self.theme, "COMBOBOX_STYLE", "") + ) + self._populate_camera_combo() + + saved_camera = self.current_settings.get(HT_CAMERA_KEY, "0") + idx = self.headtracking_camera_combo.findData(int(saved_camera)) + if idx >= 0: + self.headtracking_camera_combo.setCurrentIndex(idx) + + camera_row.addWidget(camera_lbl) + camera_row.addWidget(self.headtracking_camera_combo, stretch=1) + left_layout.addLayout(camera_row) + + # Camera change restarts preview if active + self.headtracking_camera_combo.currentIndexChanged.connect( + self._on_camera_changed + ) + + # Spacer between camera selector and sensitivity sliders + left_layout.addSpacing(10) + + # Sensitivity sliders under camera selector + self._add_sensitivity_sliders(left_layout) + + left_layout.addStretch() + preview_row.addLayout(left_layout, stretch=1) + + # Right side: preview + pose + calibrate button + right_layout = QVBoxLayout() + + self.headtracking_preview_label = QLabel() + self.headtracking_preview_label.setMinimumSize(280, 210) + self.headtracking_preview_label.setStyleSheet( + f"background-color: {self.theme.color_disabled_bg};" + ) + self.headtracking_preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.headtracking_preview_label.setText(_("Camera off")) + + self.headtracking_pose_label = QLabel(_("Pose: —")) + self.headtracking_pose_label.setStyleSheet(self.theme.CONTENT_STYLE) + + btn_layout = QHBoxLayout() + self.headtracking_calib_button = QPushButton(_("Calibrate center")) + self.headtracking_calib_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + self.headtracking_calib_button.clicked.connect( + self._on_headtracking_calibrate + ) + + btn_layout.addWidget(self.headtracking_calib_button) + btn_layout.addStretch() + + right_layout.addWidget(self.headtracking_preview_label) + right_layout.addWidget(self.headtracking_pose_label) + right_layout.addLayout(btn_layout) + + preview_row.addLayout(right_layout, stretch=1) + layout.addLayout(preview_row) + + parent_layout.addWidget(self.headtracking_preview_group) + + # Hide by default if tracking is disabled + enabled = self.current_settings.get(HT_ENABLED_KEY) == "1" + self.headtracking_preview_group.setVisible(enabled) + + def _populate_camera_combo(self) -> None: + """Fill camera dropdown with available devices.""" + if self.headtracking_camera_combo is None: + return + self.headtracking_camera_combo.clear() + cameras = _list_cameras() + if not cameras: + self.headtracking_camera_combo.addItem(_("No cameras found"), -1) + return + for idx, name in cameras: + self.headtracking_camera_combo.addItem(name, idx) + + def _add_sensitivity_sliders(self, parent_layout: QVBoxLayout) -> None: + """Add sensitivity sliders directly to the given layout.""" + axes = [ + (HT_SENS_X_KEY, _("X (left/right)")), + (HT_SENS_Y_KEY, _("Y (up/down)")), + (HT_SENS_Z_KEY, _("Z (forward/back)")), + (HT_SENS_YAW_KEY, _("Yaw (turn left/right)")), + (HT_SENS_PITCH_KEY, _("Pitch (look up/down)")), + (HT_SENS_ROLL_KEY, _("Roll (tilt head)")), + ] + + for key, label in axes: + row = QHBoxLayout() + lbl = QLabel(label) + lbl.setStyleSheet(self.theme.CONTENT_STYLE) + lbl.setMinimumWidth(160) + + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(0, 200) + slider.setValue(100) + slider.setStyleSheet( + getattr(self.theme, "SLIDER_STYLE", getattr(self.theme, "SLIDER_SIZE_STYLE", "")) + ) + slider.valueChanged.connect( + lambda v, k=key: self._on_sens_changed(k, v) + ) + + val_lbl = QLabel("1.00x") + val_lbl.setStyleSheet(self.theme.CONTENT_STYLE) + val_lbl.setMinimumWidth(50) + + row.addWidget(lbl) + row.addWidget(slider) + row.addWidget(val_lbl) + parent_layout.addLayout(row) + + self.headtracking_sens_sliders[key] = slider + self.headtracking_sens_labels[key] = val_lbl + + # Load saved value + saved = self.current_settings.get(key, str(DEFAULT_SENSITIVITY)) + try: + val = int(float(saved) * 100) + slider.setValue(max(0, min(200, val))) + except ValueError: + pass + + def _on_headtracking_toggle(self) -> None: + """Handle enable/disable button click.""" + if self.headtracking_enable_button is None: + return + enabled = self.headtracking_enable_button.isChecked() + self._update_enable_button_text(enabled) + if self.headtracking_output_group: + self.headtracking_output_group.setVisible(enabled) + if self.headtracking_preview_group: + self.headtracking_preview_group.setVisible(enabled) + if enabled: + self._start_preview() + else: + self._stop_preview() + + def _on_camera_changed(self) -> None: + """Restart preview with newly selected camera.""" + if not self.headtracking_preview_active: + return + self._stop_preview() + self._start_preview() + + def _get_selected_camera_index(self) -> int: + """Get currently selected camera index from combo box.""" + if self.headtracking_camera_combo is None: + return 0 + idx = self.headtracking_camera_combo.currentData() + return idx if idx is not None else 0 + + def _start_preview(self) -> None: + """Start camera preview.""" + if self.headtracking_preview_active: + return + + camera_idx = self._get_selected_camera_index() + if camera_idx < 0: + logger.error("No camera selected") + if self.headtracking_enable_button: + self.headtracking_enable_button.setChecked(False) + self._update_enable_button_text(False) + return + + tracker = CameraTracker(camera_index=camera_idx) + if not self.headtracking_tracker_manager.start(tracker): + logger.error("Failed to start head tracking preview") + if self.headtracking_enable_button: + self.headtracking_enable_button.setChecked(False) + self._update_enable_button_text(False) + return + + self.headtracking_preview_active = True + + # Apply current sensitivity values to preview tracker + self._update_tracker_sensitivity() + + self.headtracking_preview_timer = QTimer() # type: ignore[arg-type] + self.headtracking_preview_timer.timeout.connect(self._update_preview) + self.headtracking_preview_timer.start(100) # ~10 FPS + + def _stop_preview(self) -> None: + """Stop camera preview.""" + self.headtracking_preview_active = False + + if self.headtracking_preview_timer: + self.headtracking_preview_timer.stop() + self.headtracking_preview_timer = None + + self.headtracking_tracker_manager.stop() + + if self.headtracking_preview_label: + self.headtracking_preview_label.setPixmap(QPixmap()) + self.headtracking_preview_label.setText(_("Camera off")) + + if self.headtracking_pose_label: + self.headtracking_pose_label.setText(_("Pose: —")) + + def _update_preview(self) -> None: + """Update preview frame and pose display.""" + frame, pose = self.headtracking_tracker_manager.get_preview_frame() + + if frame is not None and self.headtracking_preview_label: + # Convert BGR to RGB + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + h, w, ch = rgb.shape + bytes_per_line = ch * w + qt_image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) + pixmap = QPixmap.fromImage(qt_image) + # Scale to fill label width, crop vertically to remove side bars + label_w = self.headtracking_preview_label.width() + label_h = self.headtracking_preview_label.height() + scaled = pixmap.scaled( + label_w, + label_h, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + # Center-crop to label size + if scaled.width() > label_w: + x = (scaled.width() - label_w) // 2 + scaled = scaled.copy(x, 0, label_w, scaled.height()) + if scaled.height() > label_h: + y = (scaled.height() - label_h) // 2 + scaled = scaled.copy(0, y, scaled.width(), label_h) + self.headtracking_preview_label.setPixmap(scaled) + + if pose and self.headtracking_pose_label: + self.headtracking_pose_label.setText( + f"X:{pose.x:.1f} Y:{pose.y:.1f} Z:{pose.z:.1f} " + f"Y:{pose.yaw:.1f} P:{pose.pitch:.1f} R:{pose.roll:.1f}" + ) + + def _on_sens_changed(self, key: str, value: int) -> None: + """Handle sensitivity slider change.""" + sens = value / 100.0 + if key in self.headtracking_sens_labels: + self.headtracking_sens_labels[key].setText(f"{sens:.2f}x") + + # Update live sensitivity on preview tracker + self._update_tracker_sensitivity() + + def _update_tracker_sensitivity(self) -> None: + """Apply current slider values as sensitivity to preview tracker.""" + sens = PoseData( + x=self._get_sens_slider_value(HT_SENS_X_KEY), + y=self._get_sens_slider_value(HT_SENS_Y_KEY), + z=self._get_sens_slider_value(HT_SENS_Z_KEY), + yaw=self._get_sens_slider_value(HT_SENS_YAW_KEY), + pitch=self._get_sens_slider_value(HT_SENS_PITCH_KEY), + roll=self._get_sens_slider_value(HT_SENS_ROLL_KEY), + ) + self.headtracking_tracker_manager.set_sensitivity(sens) + + def _get_sens_slider_value(self, key: str) -> float: + """Get slider value for sensitivity key, defaulting to 1.0.""" + slider = self.headtracking_sens_sliders.get(key) + if slider is None: + return 1.0 + return slider.value() / 100.0 + + def _on_headtracking_calibrate(self) -> None: + """Calibrate center position.""" + logger.info("Head tracking calibration requested") + self.headtracking_tracker_manager.calibrate() + if self.headtracking_pose_label: + self.headtracking_pose_label.setText( + self.headtracking_pose_label.text() + " [CALIBRATED]" + ) + + def _collect_headtracking_changes(self) -> list[str]: + """Collect head tracking setting changes.""" + changes = [] + + if not self.headtracking_enable_button: + return changes + + enabled = "1" if self.headtracking_enable_button.isChecked() else "0" + orig_enabled = self.current_settings.get(HT_ENABLED_KEY, "0") + if enabled != orig_enabled: + changes.append(f"{HT_ENABLED_KEY}={enabled}") + + # Camera selection + if self.headtracking_camera_combo: + camera_idx = self.headtracking_camera_combo.currentData() + if camera_idx is not None: + orig_camera = self.current_settings.get(HT_CAMERA_KEY, "0") + if str(camera_idx) != orig_camera: + changes.append(f"{HT_CAMERA_KEY}={camera_idx}") + + # Sensitivity: always write all values so they exist in PPDB + for key, slider in self.headtracking_sens_sliders.items(): + sens = slider.value() / 100.0 + orig = self.current_settings.get(key) + if orig is not None: + try: + orig_val = float(orig) + except ValueError: + orig_val = DEFAULT_SENSITIVITY + if abs(sens - orig_val) > 0.001: + changes.append(f"{key}={sens:.2f}") + else: + # Key not in PPDB yet, write it + changes.append(f"{key}={sens:.2f}") + + # Output mode + if self.headtracking_output_combo: + mode = self.headtracking_output_combo.currentData() + if mode is not None: + orig_mode = self.current_settings.get(HT_OUTPUT_KEY, "shm") + if mode != orig_mode: + changes.append(f"{HT_OUTPUT_KEY}={mode}") + + # Mouse sensitivity X + if self.headtracking_mouse_sens_x_slider: + val = self.headtracking_mouse_sens_x_slider.value() + orig = self.current_settings.get(HT_MOUSE_SENS_X_KEY) + if orig is None or abs(float(orig) - val) > 0.001: + changes.append(f"{HT_MOUSE_SENS_X_KEY}={val}") + + # Mouse sensitivity Y + if self.headtracking_mouse_sens_y_slider: + val = self.headtracking_mouse_sens_y_slider.value() + orig = self.current_settings.get(HT_MOUSE_SENS_Y_KEY) + if orig is None or abs(float(orig) - val) > 0.001: + changes.append(f"{HT_MOUSE_SENS_Y_KEY}={val}") + + # Mouse deadzone + if self.headtracking_mouse_deadzone_slider: + val = self.headtracking_mouse_deadzone_slider.value() / 100.0 + orig = self.current_settings.get(HT_MOUSE_DEADZONE_KEY) + if orig is None or abs(float(orig) - val) > 0.001: + changes.append(f"{HT_MOUSE_DEADZONE_KEY}={val:.2f}") + + # UDP host + if self.headtracking_udp_host_input: + val = self.headtracking_udp_host_input.text() + orig = self.current_settings.get(HT_UDP_HOST_KEY, DEFAULT_UDP_HOST) + if val != orig: + changes.append(f"{HT_UDP_HOST_KEY}={val}") + + # UDP port + if self.headtracking_udp_port_input: + val = self.headtracking_udp_port_input.text() + orig = self.current_settings.get(HT_UDP_PORT_KEY, str(DEFAULT_UDP_PORT)) + if val != orig: + changes.append(f"{HT_UDP_PORT_KEY}={val}") + + return changes + + def _check_gamepad_and_block_headtracking(self, _action: str | None = None) -> None: + """Block head tracking if a gamepad is connected.""" + input_manager = getattr(self, "input_manager", None) + has_gamepad = input_manager is not None and getattr(input_manager, "gamepad", None) is not None + + if self.headtracking_enable_button is None: + return + + if has_gamepad: + self.headtracking_enable_button.setEnabled(False) + self.headtracking_enable_button.setChecked(False) + self._update_enable_button_text(False) + if self.headtracking_output_group is not None: + self.headtracking_output_group.setVisible(False) + if self.headtracking_preview_group is not None: + self.headtracking_preview_group.setVisible(False) + if self.headtracking_warning_group is not None: + self.headtracking_warning_group.setVisible(True) + if self.headtracking_gamepad_warning_label is not None: + self.headtracking_gamepad_warning_label.setText( + _("Head tracking is disabled because a gamepad is connected. The two modes are incompatible.") + ) + else: + self.headtracking_enable_button.setEnabled(True) + if self.headtracking_output_group is not None: + self.headtracking_output_group.setVisible(self.headtracking_enable_button.isChecked()) + if self.headtracking_preview_group is not None: + self.headtracking_preview_group.setVisible(self.headtracking_enable_button.isChecked()) + if self.headtracking_warning_group is not None: + self.headtracking_warning_group.setVisible(False) + + def _on_headtracking_tab_changed(self) -> None: + """Start or stop preview when switching to/from head tracking tab.""" + if self.headtracking_enable_button is None: + return + if self.tab_widget.currentWidget() == self.headtracking_tab: + self._check_gamepad_and_block_headtracking() + enabled = self.headtracking_enable_button.isChecked() + if enabled: + self._start_preview() + else: + self._stop_preview() + + def cleanup_headtracking(self) -> None: + """Cleanup resources on dialog close.""" + self._stop_preview() diff --git a/portprotonqt/global_hotkey.py b/portprotonqt/global_hotkey.py new file mode 100644 index 00000000..248824f9 --- /dev/null +++ b/portprotonqt/global_hotkey.py @@ -0,0 +1,209 @@ +"""Global hotkey support via evdev without grab (Linux). + +Reads keyboard events at the kernel level without grabbing devices, +so all keys (including the hotkey) pass through to games normally. +Works on Wayland, X11, and in games. +""" + +import select +import threading +import time +from collections.abc import Callable + +from PySide6.QtCore import QObject, Signal +from evdev import InputDevice, ecodes, list_devices + +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +# Keys that can be used as global hotkeys +SUPPORTED_HOTKEYS = { + "scrolllock": ecodes.KEY_SCROLLLOCK, + "insert": ecodes.KEY_INSERT, +} + +DEFAULT_HOTKEY = "insert" + + +class GlobalHotkeyEvdev(QObject): + """Global hotkey using evdev without grab. Works on Wayland and X11.""" + + activated = Signal(str) + + def __init__(self) -> None: + super().__init__() + self._callbacks: dict[str, Callable[[], None]] = {} + self._devices: list[InputDevice] = [] + self._thread: threading.Thread | None = None + self._running = False + self._pressed: set[int] = set() + self._hotkey_codes: set[int] = {SUPPORTED_HOTKEYS[DEFAULT_HOTKEY]} + + def _find_keyboard_devices(self) -> list[InputDevice]: + """Find all keyboard input devices.""" + devices: list[InputDevice] = [] + for path in list_devices(): + try: + dev = InputDevice(path) + caps = dev.capabilities() + if ecodes.EV_KEY not in caps: + continue + keys = caps[ecodes.EV_KEY] + # Must have at least one hotkey and basic letter keys + if ecodes.KEY_A in keys and any(h in keys for h in self._hotkey_codes): + devices.append(dev) + logger.debug("Keyboard device: %s (%s)", path, dev.name) + except (OSError, PermissionError): + continue + logger.info("Found %d keyboard device(s)", len(devices)) + return devices + + def _scan_devices(self) -> None: + """Close old devices and open new ones.""" + for dev in self._devices: + try: + dev.close() + except Exception: + pass + self._devices = self._find_keyboard_devices() + + def register( + self, + shortcut_id: str, + _description: str, + callback: Callable[[], None], + hotkey: str | None = None, + ) -> bool: + """Register a global hotkey and start monitoring.""" + self._callbacks[shortcut_id] = callback + + if hotkey and hotkey.lower() in SUPPORTED_HOTKEYS: + self._hotkey_codes.add(SUPPORTED_HOTKEYS[hotkey.lower()]) + + # Already running — just updated hotkey codes + if self._running and self._thread is not None and self._thread.is_alive(): + logger.info("Global hotkey '%s' (Insert) active", shortcut_id) + return True + + self._devices = self._find_keyboard_devices() + if not self._devices: + logger.warning("No keyboard devices found for hotkey") + return False + + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + logger.info( + "Global hotkey '%s' (Insert) registered on %d device(s)", + shortcut_id, + len(self._devices), + ) + return True + + def _run(self) -> None: + """Main loop: wait for events with select() and dispatch.""" + scan_interval = 30.0 # Rescan devices every 30 seconds + min_scan_interval = 5.0 # Minimum time between rescans + last_scan = time.time() + + while self._running: + try: + now = time.time() + if now - last_scan > scan_interval: + self._scan_devices() + last_scan = now + + if not self._devices: + time.sleep(1.0) + if now - last_scan > min_scan_interval: + self._scan_devices() + last_scan = time.time() + continue + + # Build readable file descriptors + readable: list[int] = [] + fd_map: dict[int, InputDevice] = {} + for dev in list(self._devices): + try: + fd = dev.fd + if fd is not None and fd >= 0: + readable.append(fd) + fd_map[fd] = dev + except Exception: + continue + + if not readable: + time.sleep(0.5) + continue + + # Wait for events with timeout + ready, _, _ = select.select(readable, [], [], 0.5) + for fd in ready: + dev = fd_map.get(fd) + if dev is None: + continue + try: + for event in dev.read(): + self._handle_event(event) + except (OSError, PermissionError): + # Device disconnected or permission lost + logger.debug("Device %s unreadable, removing", dev.path) + try: + dev.close() + except Exception: + pass + if dev in self._devices: + self._devices.remove(dev) + except Exception as e: + logger.debug("Read error on %s: %s", dev.path, e) + + except Exception as e: + logger.error("Hotkey monitor error: %s", e) + time.sleep(1.0) + + def _handle_event(self, event) -> None: + """Process a single input event.""" + if event.type != ecodes.EV_KEY: + return + + code = event.code + value = event.value + + # value: 0 = release, 1 = press, 2 = repeat + if value == 1 and code in self._hotkey_codes: + # Find hotkey name from code and call all registered callbacks + hotkey_name = None + for name, key_code in SUPPORTED_HOTKEYS.items(): + if key_code == code: + hotkey_name = name + break + if hotkey_name is None: + return + logger.info("Hotkey %s pressed", hotkey_name) + for shortcut_id, cb in self._callbacks.items(): + try: + cb() + except Exception as e: + logger.error("Hotkey callback error for '%s': %s", shortcut_id, e) + for shortcut_id in self._callbacks: + self.activated.emit(shortcut_id) + + def unregister(self, shortcut_id: str) -> None: + """Unregister a global hotkey.""" + self._callbacks.pop(shortcut_id, None) + + def stop(self) -> None: + """Stop and cleanup.""" + self._running = False + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + for dev in self._devices: + try: + dev.close() + except Exception: + pass + self._devices = [] + logger.info("Global hotkey stopped") diff --git a/portprotonqt/head_tracking/__init__.py b/portprotonqt/head_tracking/__init__.py new file mode 100644 index 00000000..52ac032c --- /dev/null +++ b/portprotonqt/head_tracking/__init__.py @@ -0,0 +1,14 @@ +"""Head tracking module for PortProtonQt.""" + +from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData +from portprotonqt.head_tracking.tracker_camera import CameraTracker +from portprotonqt.head_tracking.tracker_manager import TrackerManager +from portprotonqt.head_tracking.tracking_worker import TrackingWorker + +__all__ = [ + "BaseTracker", + "CameraTracker", + "PoseData", + "TrackerManager", + "TrackingWorker", +] diff --git a/portprotonqt/head_tracking/outputs/__init__.py b/portprotonqt/head_tracking/outputs/__init__.py new file mode 100644 index 00000000..3c8dc831 --- /dev/null +++ b/portprotonqt/head_tracking/outputs/__init__.py @@ -0,0 +1,10 @@ +"""Output methods for head tracking data.""" + +from portprotonqt.head_tracking.outputs.flightgear_output import FlightGearOutput +from portprotonqt.head_tracking.outputs.joystick_output import JoystickOutput +from portprotonqt.head_tracking.outputs.mouse_output import MouseOutput +from portprotonqt.head_tracking.outputs.npclient_output import NpclientOutput +from portprotonqt.head_tracking.outputs.shm_output import ShmOutput +from portprotonqt.head_tracking.outputs.udp_output import UdpOutput + +__all__ = ["FlightGearOutput", "JoystickOutput", "MouseOutput", "NpclientOutput", "ShmOutput", "UdpOutput"] diff --git a/portprotonqt/head_tracking/outputs/base_output.py b/portprotonqt/head_tracking/outputs/base_output.py new file mode 100644 index 00000000..0e9191d3 --- /dev/null +++ b/portprotonqt/head_tracking/outputs/base_output.py @@ -0,0 +1,21 @@ +"""Base class for head tracking outputs.""" + +from abc import ABC, abstractmethod + +from portprotonqt.head_tracking.tracker_base import PoseData + + +class BaseOutput(ABC): + """Abstract base for head tracking output methods.""" + + @abstractmethod + def open(self) -> bool: + """Initialize output. Returns True on success.""" + + @abstractmethod + def close(self) -> None: + """Release output resources.""" + + @abstractmethod + def write(self, pose: PoseData) -> None: + """Write pose data to output.""" diff --git a/portprotonqt/head_tracking/outputs/flightgear_output.py b/portprotonqt/head_tracking/outputs/flightgear_output.py new file mode 100644 index 00000000..315b00ef --- /dev/null +++ b/portprotonqt/head_tracking/outputs/flightgear_output.py @@ -0,0 +1,75 @@ +"""FlightGear UDP output for head tracking. + +Sends pose data in FlightGear native protocol format. +Compatible with opentrack FlightGear protocol. +""" + +import socket +import struct + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +# FlightGear datagram format: +# x, y, z in meters (pose * 1e-2, with sign flips) +# h, p, r in degrees (with sign flips) +# status int32 = 1 +_FG_FORMAT = "<6dI" +_DEFAULT_HOST = "127.0.0.1" +_DEFAULT_PORT = 5542 + + +class FlightGearOutput(BaseOutput): + """Send head tracking data to FlightGear via UDP.""" + + def __init__(self, host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> None: + self.host = host + self.port = port + self._sock: socket.socket | None = None + + def open(self) -> bool: + """Open UDP socket.""" + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + logger.info("FlightGear output opened: %s:%d", self.host, self.port) + return True + except Exception as exc: + logger.error("Failed to open FlightGear UDP socket: %s", exc) + return False + + def close(self) -> None: + """Close UDP socket.""" + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + logger.info("FlightGear output closed") + + def write(self, pose: PoseData) -> None: + """Send pose data in FlightGear format.""" + if self._sock is None: + return + + # opentrack conversion: + # x = -headpose[TX] * 1e-2, y = headpose[TY] * 1e-2, z = headpose[TZ] * 1e-2 + # h = -headpose[Yaw], p = headpose[Pitch], r = -headpose[Roll] + data = ( + -pose.x * 1e-2, + pose.y * 1e-2, + pose.z * 1e-2, + -pose.yaw, + pose.pitch, + -pose.roll, + 1, # status + ) + packet = struct.pack(_FG_FORMAT, *data) + + try: + self._sock.sendto(packet, (self.host, self.port)) + except Exception as exc: + logger.warning("FlightGear UDP send failed: %s", exc) diff --git a/portprotonqt/head_tracking/outputs/joystick_output.py b/portprotonqt/head_tracking/outputs/joystick_output.py new file mode 100644 index 00000000..541be200 --- /dev/null +++ b/portprotonqt/head_tracking/outputs/joystick_output.py @@ -0,0 +1,136 @@ +"""Joystick emulation output for head tracking.""" + +import threading +import time + +import evdev + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +# Linux input axis range for absolute axes +_AXIS_MIN = -32768 +_AXIS_MAX = 32767 + + +def _degrees_to_axis(degrees: float, max_degrees: float = 45.0) -> int: + """Map degrees to joystick axis value.""" + clamped = max(-max_degrees, min(max_degrees, degrees)) + return int((clamped / max_degrees) * _AXIS_MAX) + + +class JoystickOutput(BaseOutput): + """Emulates joystick axes from head tracking yaw/pitch/roll.""" + + def __init__( + self, + sens_yaw: float = 1.0, + sens_pitch: float = 1.0, + sens_roll: float = 1.0, + max_angle: float = 45.0, + ) -> None: + self.sens_yaw = sens_yaw + self.sens_pitch = sens_pitch + self.sens_roll = sens_roll + self.max_angle = max_angle + self._ui = None + self._running = False + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + self._pose = PoseData() + + def open(self) -> bool: + """Start joystick emulation thread.""" + try: + import evdev.uinput + except ImportError: + logger.error("evdev not installed. Install: uv add evdev") + return False + + with self._lock: + if self._running: + return True + + try: + caps = { + evdev.ecodes.EV_ABS: [ + (evdev.ecodes.ABS_X, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)), + (evdev.ecodes.ABS_Y, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)), + (evdev.ecodes.ABS_Z, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)), + (evdev.ecodes.ABS_RX, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)), + (evdev.ecodes.ABS_RY, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)), + (evdev.ecodes.ABS_RZ, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)), + ], + evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_TRIGGER], + } + self._ui = evdev.uinput.UInput( + caps, name="PortProtonQt Head Tracking", version=0x3 + ) + except Exception as exc: + logger.error("Failed to create uinput joystick: %s", exc) + return False + + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + logger.info("Joystick output started") + return True + + def close(self) -> None: + """Stop joystick emulation.""" + with self._lock: + self._running = False + if self._ui: + try: + self._ui.close() + except Exception: + pass + self._ui = None + + if self._thread: + self._thread.join(timeout=1.0) + self._thread = None + logger.info("Joystick output stopped") + + def write(self, pose: PoseData) -> None: + """Update current head pose.""" + with self._lock: + self._pose = pose + + def _run(self) -> None: + """Main emulation loop.""" + last_values: dict[int, int] = {} + + while True: + with self._lock: + if not self._running: + break + pose = self._pose + ui = self._ui + + if ui is None: + time.sleep(0.016) + continue + + values = { + evdev.ecodes.ABS_X: _degrees_to_axis(pose.yaw * self.sens_yaw, self.max_angle), + evdev.ecodes.ABS_Y: _degrees_to_axis(pose.pitch * self.sens_pitch, self.max_angle), + evdev.ecodes.ABS_Z: _degrees_to_axis(pose.roll * self.sens_roll, self.max_angle), + evdev.ecodes.ABS_RX: _degrees_to_axis(pose.x * self.sens_yaw * 10, self.max_angle), + evdev.ecodes.ABS_RY: _degrees_to_axis(pose.y * self.sens_pitch * 10, self.max_angle), + evdev.ecodes.ABS_RZ: _degrees_to_axis(pose.z * self.sens_roll * 10, self.max_angle), + } + + try: + for axis, val in values.items(): + if last_values.get(axis) != val: + ui.write(evdev.ecodes.EV_ABS, axis, val) + last_values[axis] = val + ui.syn() + except Exception as exc: + logger.warning("Joystick output write failed: %s", exc) + + time.sleep(0.016) diff --git a/portprotonqt/head_tracking/outputs/mouse_output.py b/portprotonqt/head_tracking/outputs/mouse_output.py new file mode 100644 index 00000000..dd9e3900 --- /dev/null +++ b/portprotonqt/head_tracking/outputs/mouse_output.py @@ -0,0 +1,128 @@ +"""Mouse emulation output for head tracking.""" + +import math +import threading +import time +from collections.abc import Sequence + +import evdev + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + + +class MouseOutput(BaseOutput): + """Emulates mouse movement from head tracking yaw/pitch.""" + + def __init__( + self, + sens_yaw: float = 10.0, + sens_pitch: float = 10.0, + deadzone: float = 0.05, + ) -> None: + self.sens_yaw = sens_yaw + self.sens_pitch = sens_pitch + self.deadzone = deadzone + self._ui = None + self._running = False + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + self._pose = PoseData() + + def open(self) -> bool: + """Start mouse emulation thread.""" + try: + import evdev.uinput + except ImportError: + logger.error("evdev not installed. Install: uv add evdev") + return False + + with self._lock: + if self._running: + return True + + try: + caps: dict[int, Sequence[int]] = { + evdev.ecodes.EV_REL: [evdev.ecodes.REL_X, evdev.ecodes.REL_Y], + evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT], + } + self._ui = evdev.uinput.UInput( + caps, name="PortProtonQt Head Tracking" + ) + except Exception as exc: + logger.error("Failed to create uinput mouse: %s", exc) + return False + + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + logger.info("Mouse output started") + return True + + def close(self) -> None: + """Stop mouse emulation.""" + with self._lock: + self._running = False + if self._ui: + try: + self._ui.close() + except Exception: + pass + self._ui = None + + if self._thread: + self._thread.join(timeout=1.0) + self._thread = None + logger.info("Mouse output stopped") + + def write(self, pose: PoseData) -> None: + """Update current head pose.""" + with self._lock: + self._pose = pose + + def _run(self) -> None: + """Main emulation loop.""" + accum_x = 0.0 + accum_y = 0.0 + + while True: + with self._lock: + if not self._running: + break + pose = self._pose + ui = self._ui + + if ui is None: + time.sleep(0.016) + continue + + yaw = pose.yaw if abs(pose.yaw) > self.deadzone else 0.0 + pitch = pose.pitch if abs(pose.pitch) > self.deadzone else 0.0 + + # Reset accumulation when head is centered to prevent drift + if yaw == 0.0: + accum_x = 0.0 + if pitch == 0.0: + accum_y = 0.0 + + # Accumulate fractional movement to avoid jitter at low speeds + accum_x += -math.radians(yaw) * self.sens_yaw + accum_y += math.radians(pitch) * self.sens_pitch + + dx = int(accum_x) + dy = int(accum_y) + + if dx != 0 or dy != 0: + try: + ui.write(evdev.ecodes.EV_REL, evdev.ecodes.REL_X, dx) + ui.write(evdev.ecodes.EV_REL, evdev.ecodes.REL_Y, dy) + ui.syn() + accum_x -= dx + accum_y -= dy + except Exception as exc: + logger.warning("Mouse output write failed: %s", exc) + + time.sleep(0.016) diff --git a/portprotonqt/head_tracking/outputs/npclient_output.py b/portprotonqt/head_tracking/outputs/npclient_output.py new file mode 100644 index 00000000..d21e5d0d --- /dev/null +++ b/portprotonqt/head_tracking/outputs/npclient_output.py @@ -0,0 +1,124 @@ +"""NPClient64 DLL output for native TrackIR SDK games. + +This output writes head tracking data to the Wine SHM file and ensures +that a compatible NPClient64.dll is present in the game's Wine prefix. +Games using the TrackIR SDK (e.g., DCS World) load this DLL to receive +tracking data. + +The actual DLL implementation is provided as a separate MinGW-built +binary placed next to the PortProtonQt data files. +""" + +import os +import shutil +import subprocess + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.outputs.shm_output import ShmOutput +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +_DLL_NAME = "NPClient64.dll" +_DLL_SOURCE_CANDIDATES = [ + # Development / user data + "data/NPClient64.dll", + # Build output + "builddir/build-aux/npclient/NPClient64.dll", + # Installed system paths + "/usr/share/portprotonqt/NPClient64.dll", + "/usr/local/share/portprotonqt/NPClient64.dll", +] + + +class NpclientOutput(BaseOutput): + """Output via NPClient64.dll for TrackIR-native games.""" + + def __init__(self, prefix_path: str | None = None) -> None: + self.prefix_path = prefix_path + self._shm = ShmOutput() + + def open(self) -> bool: + """Open SHM and deploy DLL if prefix is known.""" + if not self._shm.open(): + return False + if self.prefix_path: + self._deploy_dll(self.prefix_path) + return True + + def close(self) -> None: + """Close SHM output.""" + self._shm.close() + + def write(self, pose: PoseData) -> None: + """Write pose to Wine SHM (DLL reads from there).""" + self._shm.write(pose) + + def _deploy_dll(self, prefix_path: str) -> None: + """Copy NPClient64.dll into Wine prefix and set registry keys.""" + source = self._find_dll_source() + if not source: + logger.warning("NPClient64.dll not found; game may not detect tracker") + return + + target = os.path.join(prefix_path, "drive_c", "windows", "system32", _DLL_NAME) + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.copy2(source, target) + logger.info("Deployed %s to %s", _DLL_NAME, target) + except Exception as exc: + logger.warning("Failed to deploy %s to %s: %s", _DLL_NAME, target, exc) + return + + self._setup_registry(prefix_path, os.path.dirname(target)) + + def _setup_registry(self, prefix_path: str, dll_dir: str) -> None: + """Set Wine registry keys so games find the DLL.""" + wine_path = self._find_wine(prefix_path) + if not wine_path: + logger.warning("Wine not found; cannot set NPClient registry keys") + return + + env = os.environ.copy() + env["WINEPREFIX"] = prefix_path + + # Use forward slashes and add trailing slash as expected by games + dll_dir_fwd = dll_dir.replace("\\", "/") + if not dll_dir_fwd.endswith("/"): + dll_dir_fwd += "/" + + keys = [ + (r"HKCU\Software\NaturalPoint\NATURALPOINT\NPClient Location", "Path", dll_dir_fwd), + (r"HKCU\Software\Freetrack\FreeTrackClient", "Path", dll_dir_fwd), + ] + + for key, value_name, value in keys: + try: + subprocess.run( + [wine_path, "reg", "add", key, "/v", value_name, "/d", value, "/f"], + env=env, + check=False, + capture_output=True, + timeout=30, + ) + logger.info("Set registry key %s\\%s", key, value_name) + except Exception as exc: + logger.warning("Failed to set registry key %s: %s", key, exc) + + @staticmethod + def _find_wine(prefix_path: str) -> str | None: + """Locate wine binary for the given prefix.""" + candidates = ["wine", "/usr/bin/wine", "/usr/local/bin/wine"] + for candidate in candidates: + if shutil.which(candidate): + return candidate + return None + + @staticmethod + def _find_dll_source() -> str | None: + """Locate bundled NPClient64.dll.""" + for candidate in _DLL_SOURCE_CANDIDATES: + if os.path.exists(candidate): + return candidate + return None diff --git a/portprotonqt/head_tracking/outputs/shm_output.py b/portprotonqt/head_tracking/outputs/shm_output.py new file mode 100644 index 00000000..15f8940a --- /dev/null +++ b/portprotonqt/head_tracking/outputs/shm_output.py @@ -0,0 +1,89 @@ +"""Wine SHM output for head tracking.""" + +import math +import mmap +import os +import struct + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +# Wine SHM protocol constants (TrackIR/FreeTrack compatible) +WINE_SHM_NAME = "/facetracknoir-wine-shm" +_SHM_FORMAT = "6d2i8b?" +_SHM_SIZE = struct.calcsize(_SHM_FORMAT) + + +class ShmOutput(BaseOutput): + """Writes head tracking data to Wine shared memory file.""" + + def __init__(self) -> None: + self._shm: mmap.mmap | None = None + + def open(self) -> bool: + """Open shared memory file compatible with Wine.""" + if self._shm is not None: + return True + + wine_shm_path = "/dev/shm/facetracknoir-wine-shm" + try: + fd = os.open(wine_shm_path, os.O_CREAT | os.O_RDWR, 0o666) + os.ftruncate(fd, _SHM_SIZE) + self._shm = mmap.mmap(fd, _SHM_SIZE) + os.close(fd) + self._write_pose(PoseData(), gameid=0, stop=False) + logger.info("Wine SHM opened: %s", wine_shm_path) + return True + except Exception as exc: + logger.error("Failed to open Wine SHM: %s", exc) + return False + + def close(self) -> None: + """Close shared memory.""" + if self._shm: + try: + self._write_pose(PoseData(), gameid=0, stop=True) + except Exception: + pass + self._shm.close() + self._shm = None + + wine_shm_path = "/dev/shm/facetracknoir-wine-shm" + try: + if os.path.exists(wine_shm_path): + os.unlink(wine_shm_path) + except Exception: + pass + logger.info("Wine SHM closed") + + def write(self, pose: PoseData, gameid: int = 0) -> None: + """Write pose data to shared memory.""" + self._write_pose(pose, gameid, stop=False) + + def _write_pose( + self, + pose: PoseData, + gameid: int, + stop: bool, + ) -> None: + """Internal write.""" + if self._shm is None: + return + + data = ( + pose.x * 10.0, + pose.y * 10.0, + pose.z * 10.0, + math.radians(pose.yaw), + math.radians(pose.pitch), + math.radians(pose.roll), + gameid, + 0, + 0, 0, 0, 0, 0, 0, 0, 0, + stop, + ) + self._shm.seek(0) + self._shm.write(struct.pack(_SHM_FORMAT, *data)) diff --git a/portprotonqt/head_tracking/outputs/udp_output.py b/portprotonqt/head_tracking/outputs/udp_output.py new file mode 100644 index 00000000..2b0c892c --- /dev/null +++ b/portprotonqt/head_tracking/outputs/udp_output.py @@ -0,0 +1,71 @@ +"""UDP output for head tracking. + +Sends 6-DOF pose data as 6 doubles over UDP. +Compatible with opentrack UDP protocol. +""" + +import math +import socket +import struct + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + +# opentrack UDP format: 6 doubles (little-endian) +# x, y, z in mm (pose * 10) +# yaw, pitch, roll in radians +_UDP_FORMAT = "<6d" +_DEFAULT_HOST = "127.0.0.1" +_DEFAULT_PORT = 4242 + + +class UdpOutput(BaseOutput): + """Send head tracking data over UDP.""" + + def __init__(self, host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> None: + self.host = host + self.port = port + self._sock: socket.socket | None = None + + def open(self) -> bool: + """Open UDP socket.""" + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + logger.info("UDP output opened: %s:%d", self.host, self.port) + return True + except Exception as exc: + logger.error("Failed to open UDP socket: %s", exc) + return False + + def close(self) -> None: + """Close UDP socket.""" + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + logger.info("UDP output closed") + + def write(self, pose: PoseData) -> None: + """Send pose data over UDP.""" + if self._sock is None: + return + + data = ( + pose.x * 10.0, + pose.y * 10.0, + pose.z * 10.0, + math.radians(pose.yaw), + math.radians(pose.pitch), + math.radians(pose.roll), + ) + packet = struct.pack(_UDP_FORMAT, *data) + + try: + self._sock.sendto(packet, (self.host, self.port)) + except Exception as exc: + logger.warning("UDP send failed: %s", exc) diff --git a/portprotonqt/head_tracking/tracker_base.py b/portprotonqt/head_tracking/tracker_base.py new file mode 100644 index 00000000..e978cf0f --- /dev/null +++ b/portprotonqt/head_tracking/tracker_base.py @@ -0,0 +1,69 @@ +"""Base tracker interface for head tracking.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import numpy as np + + +@dataclass +class PoseData: + """6-DOF head pose data.""" + + x: float = 0.0 # Translation X (cm) + y: float = 0.0 # Translation Y (cm) + z: float = 0.0 # Translation Z (cm) + yaw: float = 0.0 # Rotation Yaw (degrees) + pitch: float = 0.0 # Rotation Pitch (degrees) + roll: float = 0.0 # Rotation Roll (degrees) + + def apply_offset(self, offset: "PoseData") -> "PoseData": + """Subtract offset from this pose.""" + return PoseData( + x=self.x - offset.x, + y=self.y - offset.y, + z=self.z - offset.z, + yaw=self.yaw - offset.yaw, + pitch=self.pitch - offset.pitch, + roll=self.roll - offset.roll, + ) + + def apply_sensitivity(self, sens: "PoseData") -> "PoseData": + """Apply per-axis sensitivity multipliers.""" + return PoseData( + x=self.x * sens.x, + y=self.y * sens.y, + z=self.z * sens.z, + yaw=self.yaw * sens.yaw, + pitch=self.pitch * sens.pitch, + roll=self.roll * sens.roll, + ) + + +class BaseTracker(ABC): + """Abstract base class for head trackers.""" + + @abstractmethod + def start(self) -> bool: + """Start tracking. Returns True on success.""" + + @abstractmethod + def stop(self) -> None: + """Stop tracking and release resources.""" + + @abstractmethod + def get_pose(self) -> PoseData | None: + """Get current head pose. Returns None if tracking lost.""" + + def get_frame(self) -> tuple["np.ndarray | None", PoseData | None]: + """Get current camera frame with landmarks drawn and pose. + + Default implementation returns no frame; subclasses may override. + """ + return None, self.get_pose() + + @abstractmethod + def is_running(self) -> bool: + """Check if tracker is active.""" diff --git a/portprotonqt/head_tracking/tracker_camera.py b/portprotonqt/head_tracking/tracker_camera.py new file mode 100644 index 00000000..01966cfd --- /dev/null +++ b/portprotonqt/head_tracking/tracker_camera.py @@ -0,0 +1,350 @@ +"""Camera-based head tracker using OpenCV YuNet DNN.""" + +import math +import os +import time + +import cv2 +import numpy as np + +from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData +from portprotonqt.logger import get_logger + +# Module-level shared VideoCapture to avoid V4L2 device busy issues +_shared_cap: cv2.VideoCapture | None = None +_shared_cap_index: int = -1 + +logger = get_logger(__name__) + + +def _find_yunet_model() -> str | None: + """Find YuNet model file in known install locations.""" + try: + import portprotonqt + module_dir = os.path.dirname(portprotonqt.__file__) + except ImportError: + module_dir = "" + + project_root = os.path.dirname(module_dir) if module_dir else "" + + candidates = [] + + if project_root: + candidates.append( + os.path.join(project_root, "data", "face_detection_yunet_2023mar.onnx") + ) + + xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share") + for d in xdg_data_dirs.split(":"): + candidates.append( + os.path.join(d, "portprotonqt", "face_detection_yunet_2023mar.onnx") + ) + + candidates.append("/usr/share/portprotonqt/face_detection_yunet_2023mar.onnx") + candidates.append("/usr/local/share/portprotonqt/face_detection_yunet_2023mar.onnx") + + for c in candidates: + if os.path.exists(c): + return c + + return None + + +def _create_detector(model_path: str) -> cv2.FaceDetectorYN | None: + """Create YuNet face detector from model file.""" + try: + detector = cv2.FaceDetectorYN.create( + model=model_path, + config="", + input_size=(320, 320), + score_threshold=0.3, + nms_threshold=0.3, + top_k=5000, + ) + return detector + except Exception as exc: + logger.error("Failed to create YuNet detector: %s", exc) + return None + + +class CameraTracker(BaseTracker): + """Head tracker using webcam and OpenCV YuNet DNN.""" + + def __init__(self, camera_index: int = 0) -> None: + self.camera_index = camera_index + self._cap: cv2.VideoCapture | None = None + self._detector: cv2.FaceDetectorYN | None = None + self._running = False + self._last_pose = PoseData() + self._last_face: tuple[int, int, int, int] | None = None + self._last_landmarks: list[tuple[int, int]] | None = None + self._pose_history: list[PoseData] = [] + self._history_size = 5 + self._lost_frames = 0 + self._max_lost_frames = 5 + # Calibration: neutral pose at start + self._calibrated = False + self._neutral_yaw = 0.0 + self._neutral_pitch = 0.0 + self._neutral_roll = 0.0 + self._neutral_x = 0.0 + self._neutral_y = 0.0 + + def _get_or_create_detector(self) -> cv2.FaceDetectorYN | None: + """Get or create shared YuNet detector.""" + if self._detector is not None: + return self._detector + + model_path = _find_yunet_model() + if not model_path: + logger.error("YuNet model not found") + return None + + self._detector = _create_detector(model_path) + return self._detector + + def start(self) -> bool: + """Start camera and initialize YuNet detector.""" + global _shared_cap, _shared_cap_index + + detector = self._get_or_create_detector() + if detector is None: + return False + + if ( + _shared_cap is not None + and _shared_cap_index == self.camera_index + and _shared_cap.isOpened() + ): + self._cap = _shared_cap + self._running = True + logger.info("Camera tracker reused existing capture") + return True + + if _shared_cap is not None: + _shared_cap.release() + _shared_cap = None + + for attempt in range(5): + _shared_cap = cv2.VideoCapture(self.camera_index) + if _shared_cap.isOpened(): + break + if _shared_cap: + _shared_cap.release() + _shared_cap = None + if attempt < 4: + time.sleep(0.5) + else: + logger.error("Cannot open camera %s after retries", self.camera_index) + return False + + _shared_cap_index = self.camera_index + self._cap = _shared_cap + self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) + self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) + + self._running = True + self._calibrated = False + logger.info("Camera tracker started") + return True + + def stop(self) -> None: + """Stop tracking and release camera capture.""" + global _shared_cap, _shared_cap_index + self._running = False + self._detector = None + self._cap = None + self._calibrated = False + if _shared_cap is not None: + _shared_cap.release() + _shared_cap = None + _shared_cap_index = -1 + logger.info("Camera tracker stopped (capture released)") + + def is_running(self) -> bool: + return self._running and self._cap is not None and self._cap.isOpened() + + def get_pose(self) -> PoseData | None: + """Capture frame and compute head pose.""" + if not self.is_running() or self._cap is None: + return None + + ret, frame = self._cap.read() + if not ret or frame is None: + return None + + pose = self._process_frame(frame) + if pose: + self._last_pose = pose + return pose + + def get_frame(self) -> tuple[np.ndarray | None, PoseData | None]: + """Get current camera frame with landmarks drawn and pose.""" + if not self.is_running() or self._cap is None: + return None, None + + ret, frame = self._cap.read() + if not ret or frame is None: + return None, None + + pose = self._process_frame(frame) + if pose: + self._last_pose = pose + + if self._last_landmarks: + for x, y in self._last_landmarks: + cv2.circle(frame, (x, y), 2, (0, 255, 0), -1) + + return frame, pose + + def _detect_face(self, frame: np.ndarray) -> np.ndarray | None: + """Detect face using YuNet, return face data array.""" + if self._detector is None: + return None + + h, w = frame.shape[:2] + self._detector.setInputSize((w, h)) + retval, faces = self._detector.detect(frame) + + if not retval or faces is None or len(faces) == 0: + return None + + # Pick face closest to previous position, or highest score + if self._last_face is not None: + lx, ly, lw, lh = self._last_face + lcx = lx + lw / 2 + lcy = ly + lh / 2 + best = min( + faces, + key=lambda f: (f[0] + f[2] / 2 - lcx) ** 2 + + (f[1] + f[3] / 2 - lcy) ** 2, + ) + else: + best = max(faces, key=lambda f: f[14]) + + return best + + def _estimate_pose( + self, frame_w: int, frame_h: int, face: np.ndarray + ) -> PoseData | None: + """Estimate head pose from YuNet face landmarks. + + Uses geometric relationships between facial landmarks + rather than solvePnP for stability. + """ + # YuNet: [x, y, w, h, re_x, re_y, le_x, le_y, nt_x, nt_y, + # rcm_x, rcm_y, lcm_x, lcm_y, score] + re_x, re_y = face[4], face[5] # right eye + le_x, le_y = face[6], face[7] # left eye + nt_x = face[8] # nose tip x + + # Face center + face_cx = face[0] + face[2] / 2 + face_cy = face[1] + face[3] / 2 + + # Eye center + eye_cx = (re_x + le_x) / 2 + + # Inter-ocular distance (distance between eyes) + iod = math.sqrt((le_x - re_x) ** 2 + (le_y - re_y) ** 2) + if iod < 1: + return None + + # Roll from eye line angle + roll = math.degrees(math.atan2(le_y - re_y, le_x - re_x)) + + # Yaw from nose position relative to eye center + # When looking right, nose is to the right of eye center + nose_offset_x = nt_x - eye_cx + yaw = math.degrees(math.asin(max(-1.0, min(1.0, nose_offset_x / (iod * 0.8))))) + + # Pitch from vertical shift of face center relative to image center + # When looking up, face center moves down on image (nose goes up) + face_center_y = face_cy + image_center_y = frame_h / 2 + pitch = (face_center_y - image_center_y) / (frame_h / 2) * 30.0 + + # X, Y translation from face center offset + dx = face_cx - frame_w / 2 + dy = face_cy - frame_h / 2 + # Z from face size (inverse of inter-ocular distance) + z = 500.0 / max(iod, 1.0) + # Scale X/Y by Z + fov_half = 30.0 + fx_len = frame_w / (2 * math.tan(math.radians(fov_half))) + x = dx * z / fx_len + y = dy * z / fx_len + + return PoseData(x=x, y=y, z=z, yaw=yaw, pitch=pitch, roll=roll) + + def _process_frame(self, frame: np.ndarray) -> PoseData | None: + """Process frame and return smoothed pose.""" + if self._detector is None: + return None + + face = self._detect_face(frame) + if face is None: + self._lost_frames += 1 + if self._lost_frames >= self._max_lost_frames: + self._last_face = None + self._last_landmarks = None + return None + + self._lost_frames = 0 + self._last_face = (int(face[0]), int(face[1]), int(face[2]), int(face[3])) + self._last_landmarks = [ + (int(face[i]), int(face[i + 1])) for i in range(4, 14, 2) + ] + + pose = self._estimate_pose(frame.shape[1], frame.shape[0], face) + if pose is None: + return None + + # Auto-calibrate on first valid frame + if not self._calibrated: + self._neutral_yaw = pose.yaw + self._neutral_pitch = pose.pitch + self._neutral_roll = pose.roll + self._neutral_x = pose.x + self._neutral_y = pose.y + self._calibrated = True + logger.info( + "Head tracking calibrated: yaw=%.1f, pitch=%.1f, roll=%.1f", + self._neutral_yaw, + self._neutral_pitch, + self._neutral_roll, + ) + + # Subtract neutral pose + pose = PoseData( + x=pose.x - self._neutral_x, + y=pose.y - self._neutral_y, + z=pose.z, + yaw=pose.yaw - self._neutral_yaw, + pitch=pose.pitch - self._neutral_pitch, + roll=pose.roll - self._neutral_roll, + ) + + self._pose_history.append(pose) + if len(self._pose_history) > self._history_size: + self._pose_history.pop(0) + + return self._smooth_poses(self._pose_history) + + @staticmethod + def _smooth_poses(history: list[PoseData]) -> PoseData: + """Compute moving average of pose history.""" + n = len(history) + if n == 0: + return PoseData() + if n == 1: + return history[0] + + return PoseData( + x=sum(p.x for p in history) / n, + y=sum(p.y for p in history) / n, + z=sum(p.z for p in history) / n, + yaw=sum(p.yaw for p in history) / n, + pitch=sum(p.pitch for p in history) / n, + roll=sum(p.roll for p in history) / n, + ) diff --git a/portprotonqt/head_tracking/tracker_manager.py b/portprotonqt/head_tracking/tracker_manager.py new file mode 100644 index 00000000..8e535e3f --- /dev/null +++ b/portprotonqt/head_tracking/tracker_manager.py @@ -0,0 +1,190 @@ +"""Manager for head tracking lifecycle.""" + +import threading + +import numpy as np + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.outputs.flightgear_output import FlightGearOutput +from portprotonqt.head_tracking.outputs.joystick_output import JoystickOutput +from portprotonqt.head_tracking.outputs.mouse_output import MouseOutput +from portprotonqt.head_tracking.outputs.npclient_output import NpclientOutput +from portprotonqt.head_tracking.outputs.shm_output import ShmOutput +from portprotonqt.head_tracking.outputs.udp_output import UdpOutput +from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData +from portprotonqt.head_tracking.tracking_worker import TrackingWorker +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + + +class TrackerManager: + """Manages tracker and output lifecycle.""" + + def __init__(self, prefix_path: str | None = None) -> None: + self.tracker: BaseTracker | None = None + self.output: BaseOutput | None = None + self.worker: TrackingWorker | None = None + self._lock = threading.Lock() + self.calibration_offset: PoseData | None = None + self.sensitivity: PoseData = PoseData(1.0, 1.0, 1.0, 1.0, 1.0, 1.0) + self.output_mode: str = "shm" + self.mouse_sens_yaw: float = 10.0 + self.mouse_sens_pitch: float = 10.0 + self.mouse_deadzone: float = 0.05 + self.udp_host: str = "127.0.0.1" + self.udp_port: int = 4242 + self.prefix_path = prefix_path + + def _create_output(self) -> BaseOutput | None: + """Create output based on current mode.""" + if self.output_mode == "mouse": + return MouseOutput( + sens_yaw=self.mouse_sens_yaw, + sens_pitch=self.mouse_sens_pitch, + deadzone=self.mouse_deadzone, + ) + if self.output_mode == "joystick": + return JoystickOutput() + if self.output_mode == "npclient": + return NpclientOutput(prefix_path=self.prefix_path) + if self.output_mode == "udp": + return UdpOutput(host=self.udp_host, port=self.udp_port) + if self.output_mode == "flightgear": + return FlightGearOutput(host=self.udp_host, port=self.udp_port) + return ShmOutput() + + def start(self, tracker: BaseTracker) -> bool: + """Start tracking with given tracker.""" + with self._lock: + if self.is_running(): + logger.warning("Tracker already running") + return False + + if not tracker.start(): + return False + + output = self._create_output() + if output is None or not output.open(): + tracker.stop() + return False + + self.tracker = tracker + self.output = output + self.worker = TrackingWorker( + tracker, + output, + calibration_offset=self.calibration_offset, + sensitivity=self.sensitivity, + ) + self.worker.start() + logger.info("Tracker manager started (mode=%s)", self.output_mode) + return True + + def stop(self) -> None: + """Stop tracking and cleanup.""" + with self._lock: + if self.tracker: + self.tracker.stop() + + if self.worker: + self.worker.stop() + self.worker = None + + self.tracker = None + if self.output: + self.output.close() + self.output = None + + logger.info("Tracker manager stopped") + + def is_running(self) -> bool: + """Check if tracking is active.""" + return self.worker is not None and self.worker.is_alive() + + def get_preview_frame(self) -> tuple[np.ndarray | None, PoseData | None]: + """Get preview frame and calibrated pose for UI.""" + if self.tracker is None: + return None, None + + frame, pose = self.tracker.get_frame() + + if pose: + pose = self._apply_calibration_and_sensitivity(pose) + + return frame, pose + + def _apply_calibration_and_sensitivity(self, pose: PoseData) -> PoseData: + """Apply calibration offset and sensitivity to pose.""" + if self.calibration_offset: + pose = pose.apply_offset(self.calibration_offset) + return pose.apply_sensitivity(self.sensitivity) + + def calibrate(self) -> None: + """Set current pose as zero offset.""" + if self.tracker is None: + return + raw = self.tracker.get_pose() + if raw: + self.calibration_offset = raw + if self.worker is not None: + self.worker.calibration_offset = raw + logger.info("Head tracking calibrated: %s", raw) + + def set_sensitivity(self, sens: PoseData) -> None: + """Set per-axis sensitivity multipliers.""" + self.sensitivity = sens + if self.worker is not None: + self.worker.sensitivity = sens + + def set_output_mode( + self, + mode: str, + mouse_sens_yaw: float = 10.0, + mouse_sens_pitch: float = 10.0, + mouse_deadzone: float = 0.05, + udp_host: str = "127.0.0.1", + udp_port: int = 4242, + ) -> None: + """Set output mode and parameters; restart output if running.""" + with self._lock: + changed = ( + self.output_mode != mode + or self.mouse_sens_yaw != mouse_sens_yaw + or self.mouse_sens_pitch != mouse_sens_pitch + or self.mouse_deadzone != mouse_deadzone + or self.udp_host != udp_host + or self.udp_port != udp_port + ) + self.output_mode = mode + self.mouse_sens_yaw = mouse_sens_yaw + self.mouse_sens_pitch = mouse_sens_pitch + self.mouse_deadzone = mouse_deadzone + self.udp_host = udp_host + self.udp_port = udp_port + + if changed and self.is_running() and self.tracker is not None: + # Restart output with new settings + if self.worker: + self.worker.stop() + self.worker = None + if self.output: + self.output.close() + self.output = None + + output = self._create_output() + if output is None or not output.open(): + logger.error("Failed to restart output in mode %s", mode) + self.tracker.stop() + self.tracker = None + return + + self.output = output + self.worker = TrackingWorker( + self.tracker, + output, + calibration_offset=self.calibration_offset, + sensitivity=self.sensitivity, + ) + self.worker.start() + logger.info("Tracker output restarted (mode=%s)", mode) diff --git a/portprotonqt/head_tracking/tracking_worker.py b/portprotonqt/head_tracking/tracking_worker.py new file mode 100644 index 00000000..e3607ac9 --- /dev/null +++ b/portprotonqt/head_tracking/tracking_worker.py @@ -0,0 +1,51 @@ +"""Background worker for head tracking outputs.""" + +import threading + +from portprotonqt.head_tracking.outputs.base_output import BaseOutput +from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData +from portprotonqt.logger import get_logger + +logger = get_logger(__name__) + + +class TrackingWorker(threading.Thread): + """Background worker that reads tracker and writes to active output.""" + + def __init__( + self, + tracker: BaseTracker, + output: BaseOutput, + update_interval: float = 0.016, # ~60 FPS + calibration_offset: PoseData | None = None, + sensitivity: PoseData | None = None, + ) -> None: + super().__init__(daemon=True) + self.tracker = tracker + self.output = output + self.update_interval = update_interval + self.calibration_offset = calibration_offset + self.sensitivity = sensitivity or PoseData(1.0, 1.0, 1.0, 1.0, 1.0, 1.0) + self._stop_event = threading.Event() + + def stop(self) -> None: + """Request worker to stop.""" + self._stop_event.set() + self.join(timeout=2.0) + + def _apply_calibration_and_sensitivity(self, pose: PoseData) -> PoseData: + """Apply calibration offset and sensitivity.""" + if self.calibration_offset: + pose = pose.apply_offset(self.calibration_offset) + return pose.apply_sensitivity(self.sensitivity) + + def run(self) -> None: + """Main worker loop.""" + logger.info("Tracking worker started") + while not self._stop_event.is_set(): + pose = self.tracker.get_pose() + if pose: + pose = self._apply_calibration_and_sensitivity(pose) + self.output.write(pose) + self._stop_event.wait(self.update_interval) + logger.info("Tracking worker stopped") diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index 7a5d6faa..613cde43 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -59,6 +59,8 @@ class MainWindowProtocol(Protocol): current_add_game_dialog: AddGameDialog | None game_library_manager: Any # GameLibraryManager - using Any to avoid circular import auto_size_slider: QSlider | None + def toggleHeadtracking(self) -> None: + ... PAD_BUTTON_SOUTH = 1000 PAD_BUTTON_EAST = 1001 @@ -3705,6 +3707,9 @@ class InputManager(QObject): if key == pygame.K_F11 and not self._is_gamescope_session: self.toggle_fullscreen.emit(not self._is_fullscreen) return True + if key == pygame.K_SCROLLLOCK: + self._parent.toggleHeadtracking() + return True return False def _handle_pygame_file_explorer_key(self, key: int) -> bool: diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 62257b65..467b66a8 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -9,7 +9,11 @@ from queue import Empty, Queue from threading import Thread from portprotonqt.logger import get_logger from portprotonqt.icon_extractor import generate_thumbnail -from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog +from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog +from portprotonqt.head_tracking.tracker_base import PoseData +from portprotonqt.head_tracking.tracker_camera import CameraTracker +from portprotonqt.head_tracking.tracker_manager import TrackerManager +from portprotonqt.global_hotkey import GlobalHotkeyEvdev from portprotonqt.game_card import GameCard from portprotonqt.animations import DetailPageAnimations from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel @@ -60,9 +64,9 @@ from portprotonqt.tabs.wine_tab import MainWindowWineTabMixin from portprotonqt.tabs.workers import MainWindowWorkersMixin from PySide6.QtWidgets import (QLineEdit, QMainWindow, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, - QMessageBox, QApplication, QPushButton, QCheckBox) -from PySide6.QtCore import Qt, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QFileSystemWatcher, QObject -from PySide6.QtGui import QColor, QDesktopServices, QHideEvent, QShowEvent, QGuiApplication + QDialog, QFormLayout, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider, QFrame) +from PySide6.QtCore import Qt, QAbstractAnimation, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QProcessEnvironment, QFileSystemWatcher, QStandardPaths, QObject +from PySide6.QtGui import QIcon, QColor, QDesktopServices, QHideEvent, QShowEvent, QKeySequence, QShortcut, QGuiApplication from typing import cast from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor @@ -248,6 +252,27 @@ class MainWindow( self.portproton_location = get_portproton_location() self.start_sh = get_portproton_start_command() self.launch_exe = launch_exe # Store launch_exe path + self.headtracking_manager = None + self._headtracking_settings: dict[str, str] | None = None + self._headtracking_exe_path: str | None = None + + # Hotkey for toggling head tracking (Insert or Scroll Lock) + self._headtracking_shortcut = QShortcut(QKeySequence("Insert"), self) + self._headtracking_shortcut.activated.connect(self._toggle_headtracking) + + # Global hotkey via evdev without grab (works in games on Wayland/X11) + self._global_hotkey = GlobalHotkeyEvdev() + registered = False + for key in ("insert", "scrolllock"): + if self._global_hotkey.register("toggle_headtracking", "Toggle Head Tracking", + self.toggleHeadtracking, + hotkey=key, + ): + registered = True + if registered: + logger.info("Global hotkey registered via evdev (Insert, Scroll Lock)") + else: + logger.warning("Failed to register global hotkey via evdev") self.game_library_manager = GameLibraryManager(self, self.theme, None) @@ -1893,6 +1918,11 @@ class MainWindow( self.game_library_manager.update_game_grid(focus_first_card=False) self._refresh_current_detail_time() + # Stop head tracking + self._stop_headtracking() + self._headtracking_settings = None + self._headtracking_exe_path = None + def _has_running_game_process(self) -> bool: return any(proc.poll() is None for proc in self.game_processes) @@ -1953,8 +1983,132 @@ class MainWindow( self.checkProcessTimer.deleteLater() self.checkProcessTimer = None self.resetPlayButton() + # Stop head tracking on manual stop + self._stop_headtracking() + self._headtracking_settings = None + self._headtracking_exe_path = None + return True + def _start_headtracking_if_enabled(self, exe_path: str) -> None: + """Read head tracking settings for this executable.""" + if not self.portproton_location or not self.start_sh: + logger.info("Cannot read HT settings: portproton_location=%s, start_sh=%s", + bool(self.portproton_location), bool(self.start_sh)) + return + + try: + result = subprocess.run( + self.start_sh + ["cli", "--show-ppdb", exe_path], + capture_output=True, + text=True, + timeout=5, + ) + logger.info("PPDB query for %s: rc=%s, stdout=%s", exe_path, result.returncode, result.stdout[:200]) + if result.returncode != 0: + return + + settings = {} + for line in result.stdout.strip().splitlines(): + if "=" in line: + key, value = line.split("=", 1) + settings[key.strip()] = value.strip().strip('"\'') + + self._headtracking_settings = settings + self._headtracking_exe_path = exe_path + logger.info("HT settings saved for %s: PW_HEADTRACKING=%s", exe_path, settings.get("PW_HEADTRACKING")) + except Exception as exc: + logger.error("Error reading head tracking settings: %s", exc) + + def _gamepad_blocks_headtracking(self) -> bool: + """Check if a gamepad is connected and should block head tracking.""" + return ( + getattr(self, "input_manager", None) is not None + and getattr(self.input_manager, "gamepad", None) is not None + ) + + def _start_headtracking_with_settings( + self, exe_path: str, settings: dict[str, str] + ) -> None: + """Start head tracking with parsed settings.""" + if self._gamepad_blocks_headtracking(): + logger.info("Head tracking blocked: gamepad is connected") + return + self._stop_headtracking() + mode = settings.get("PW_HEADTRACKING_OUTPUT", "shm") + mouse_sens_x = float(settings.get("PW_HEADTRACKING_MOUSE_SENS_X", "10")) + mouse_sens_y = float(settings.get("PW_HEADTRACKING_MOUSE_SENS_Y", "10")) + mouse_deadzone = float(settings.get("PW_HEADTRACKING_MOUSE_DEADZONE", "0.05")) + + prefix_path = None + if self.portproton_location: + prefix_name = get_prefix_name(exe_path) + if prefix_name: + candidate = os.path.join( + self.portproton_location, "data", "prefixes", prefix_name + ) + if os.path.isdir(candidate): + prefix_path = candidate + + self.headtracking_manager = TrackerManager(prefix_path=prefix_path) + self.headtracking_manager.set_output_mode( + mode, + mouse_sens_yaw=mouse_sens_x, + mouse_sens_pitch=mouse_sens_y, + mouse_deadzone=mouse_deadzone, + ) + + camera_idx = int(settings.get("PW_HEADTRACKING_CAMERA", "0")) + sens = PoseData( + x=float(settings.get("PW_HEADTRACKING_SENS_X", "1.0")), + y=float(settings.get("PW_HEADTRACKING_SENS_Y", "1.0")), + z=float(settings.get("PW_HEADTRACKING_SENS_Z", "1.0")), + yaw=float(settings.get("PW_HEADTRACKING_SENS_YAW", "1.0")), + pitch=float(settings.get("PW_HEADTRACKING_SENS_PITCH", "1.0")), + roll=float(settings.get("PW_HEADTRACKING_SENS_ROLL", "1.0")), + ) + self.headtracking_manager.set_sensitivity(sens) + + tracker = CameraTracker(camera_index=camera_idx) + if self.headtracking_manager.start(tracker): + logger.info("Head tracking started for %s (mode=%s)", exe_path, mode) + else: + logger.warning("Failed to start head tracking for %s", exe_path) + self.headtracking_manager = None + + def _stop_headtracking(self) -> None: + """Stop head tracking if running.""" + if self.headtracking_manager: + self.headtracking_manager.stop() + self.headtracking_manager = None + logger.info("Head tracking stopped") + + def toggleHeadtracking(self) -> None: + """Toggle head tracking on/off via hotkey (public for InputManager).""" + self._toggle_headtracking() + + def _toggle_headtracking(self) -> None: + """Toggle head tracking on/off via Scroll Lock hotkey.""" + logger.info("Toggle headtracking called, manager=%s, settings=%s, exe=%s", + self.headtracking_manager, bool(self._headtracking_settings), self._headtracking_exe_path) + if self.headtracking_manager and self.headtracking_manager.is_running(): + self._stop_headtracking() + logger.info("Head tracking toggled off") + return + + if not self._headtracking_settings or not self._headtracking_exe_path: + logger.info("Cannot toggle: no settings or exe path") + return + + if self._headtracking_settings.get("PW_HEADTRACKING") != "1": + logger.info("Cannot toggle: head tracking is disabled in settings") + return + + self._start_headtracking_with_settings( + self._headtracking_exe_path, self._headtracking_settings + ) + logger.info("Head tracking toggled on") + def _check_missing_prefix_by_name_before_launch(self, prefix_name: str, env_vars: dict[str, str]) -> None: """Check prefix presence and optionally disable default recommended libs.""" if not prefix_name or not self.portproton_location: @@ -2116,6 +2270,12 @@ class MainWindow( self.checkProcessTimer = QTimer(self) self.checkProcessTimer.timeout.connect(self.checkTargetExe) self.checkProcessTimer.start(500) + + # Start head tracking if enabled for this exe + self._headtracking_exe_path = file_to_check + self._start_headtracking_if_enabled(file_to_check) + + except Exception as e: logger.error(f"Failed to launch game {exe_name}: {e}") QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e))) @@ -2162,6 +2322,13 @@ class MainWindow( self.disc_image_manager.cleanup_iso_rw_paths() self._stopBackgroundWorkers() + self._stop_headtracking() + self._headtracking_settings = None + self._headtracking_exe_path = None + + # Stop global hotkey + if hasattr(self, "_global_hotkey") and self._global_hotkey: + self._global_hotkey.stop() # Universal stop and delete timers timers = [ diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index c8db989a..061ca3e3 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -10,6 +10,7 @@ install_data( 'downloader.py', 'game_card.py', 'game_library_manager.py', + 'global_hotkey.py', 'howlongtobeat_api.py', 'image_utils.py', 'icon_extractor.py', @@ -71,6 +72,7 @@ install_data( 'dialogs/settings_mangohud.py', 'dialogs/settings_gamescope.py', 'dialogs/settings_vkbasalt.py', + 'dialogs/settings_headtracking.py', 'dialogs/proton_manager.py', 'dialogs/prefix_backup.py', 'dialogs/wine_loader.py', @@ -165,6 +167,25 @@ install_data( install_dir: pythondir / meson.project_name() / 'config', ) +# Install head_tracking package +install_data( + ['head_tracking/__init__.py', + 'head_tracking/tracker_base.py', + 'head_tracking/tracker_camera.py', + 'head_tracking/tracker_manager.py', + 'head_tracking/tracking_worker.py', + 'head_tracking/outputs/__init__.py', + 'head_tracking/outputs/base_output.py', + 'head_tracking/outputs/shm_output.py', + 'head_tracking/outputs/mouse_output.py', + 'head_tracking/outputs/joystick_output.py', + 'head_tracking/outputs/npclient_output.py', + 'head_tracking/outputs/udp_output.py', + 'head_tracking/outputs/flightgear_output.py', + ], + install_dir: pythondir / meson.project_name() / 'head_tracking', +) + # Install themes install_subdir( 'themes', diff --git a/portprotonqt/port_data_path_selector.py b/portprotonqt/port_data_path_selector.py index 98c8c606..0b768481 100644 --- a/portprotonqt/port_data_path_selector.py +++ b/portprotonqt/port_data_path_selector.py @@ -168,6 +168,9 @@ class _BootstrapInputHost(MainWindowControlHintsMixin, QWidget): def handleSystemGamepadAction(self, action: str) -> bool: return False + def toggleHeadtracking(self) -> None: + return + def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose: bool = False) -> bool: message_box = QMessageBox(parent) message_box.setIcon(QMessageBox.Icon.Warning) diff --git a/portprotonqt/themes/standart/styles/base.py b/portprotonqt/themes/standart/styles/base.py index 764ec375..2251377b 100644 --- a/portprotonqt/themes/standart/styles/base.py +++ b/portprotonqt/themes/standart/styles/base.py @@ -284,6 +284,14 @@ CONTENT_STYLE = f""" padding-bottom: 15px; }} """ +WARNING_LABEL_STYLE = f""" + QLabel {{ + font-family: '{font_family}'; + font-size: {font_size_a}; + color: {color_warning}; + background-color: none; + }} +""" PREVIEW_WIDGET_STYLE = f""" QWidget {{ margin-top: 3px; diff --git a/portprotonqt/themes/standart/styles/constants.py b/portprotonqt/themes/standart/styles/constants.py index 16dc653f..bc934a3b 100644 --- a/portprotonqt/themes/standart/styles/constants.py +++ b/portprotonqt/themes/standart/styles/constants.py @@ -75,6 +75,7 @@ color_accent_blue = "#007AFF" color_preloader = "#007AFF" color_gamepad_supported = "#00ff00" color_white = "#ffffff" +color_warning = "#FFD700" missing_exe_cover_opacity = 0.45 # === Widget State Colors === diff --git a/pyproject.toml b/pyproject.toml index f0c539cd..61c24c19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "babel>=2.18.0", "evdev>=1.9.3", "libarchive-c>=5.3", + "numpy>=2.2.0", + "opencv-python>=4.11.0", "orjson>=3.11.8", "pillow>=12.2.0", "psutil>=7.2.2", diff --git a/uv.lock b/uv.lock index 100d5b70..69b83236 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] [[package]] name = "babel" @@ -13,11 +18,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] @@ -145,62 +150,62 @@ wheels = [ [[package]] name = "dbus-fast" -version = "5.0.4" +version = "5.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/c7/490f9ca9da84d1d47e021585cfc6888519f5c12ae48db90cdb4ba631be93/dbus_fast-5.0.4.tar.gz", hash = "sha256:ef372607f967870780c2063e5d39037fdd27e253680aef76687a8dc6cd9413f3", size = 80559, upload-time = "2026-05-23T18:31:31.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/e7/35d25d8652ded1787c198d87a3771a24c71ca04d544365e77e0babd019ba/dbus_fast-5.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7339765555cc40586fa8ee25b5a9b51c6f397c420fd9d42833ee8aec42c86bc", size = 694169, upload-time = "2026-05-23T18:38:34.461Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/dfef781f2de8c5f97c4360cba5d8abbccf617628d2feae031642e003946d/dbus_fast-5.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8cffe3f31d9e0108c4f0cf59e104037630fddbcf410954d821259e0cd71acf8", size = 842006, upload-time = "2026-05-23T18:38:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f8/39d2f6a72cd572ccf857ce7567db881113785655a5c25fd95cdf50d32121/dbus_fast-5.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f01e98f38215e1abbb320e0f6e0875c2ed3c17891eef3176fed2c0de8ffb7954", size = 885667, upload-time = "2026-05-23T18:38:38.939Z" }, - { url = "https://files.pythonhosted.org/packages/f7/20/73f8c0b2b17fab681680065a3a3853f992d26dbe00ab966241d114989aed/dbus_fast-5.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe8dd98f504af78f92ea4377839d1ca22dca9b1b7242e86a7655e866173b5df8", size = 884493, upload-time = "2026-05-23T18:38:41.528Z" }, - { url = "https://files.pythonhosted.org/packages/fd/75/d46e40d7308599a3c1df7a5b60f6e557f91cd2fbe56efc0ab2acf5a63236/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f819880a12e31f489fa81beedea14c87dd14fcb92817e9b88d2527a8989ac9f", size = 848095, upload-time = "2026-05-23T18:38:43.468Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b5/fa9f4399c78c237dbfdbe3e71eaccd1049e027fba1da312d3612533a96c2/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bf8ff7ceb5cac93c0c6869f8dc2ce5a84200337503d66bf83a3b88a9346ea76e", size = 882021, upload-time = "2026-05-23T18:38:45.479Z" }, - { url = "https://files.pythonhosted.org/packages/c0/9d/321c26379acfbfb605a68550a404c829b3498f01563e86091e2408ab1c68/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fe088a16d33054607021400043f3daeff566fc0cdd45e789d331d24e99f13e93", size = 891620, upload-time = "2026-05-23T18:38:47.113Z" }, - { url = "https://files.pythonhosted.org/packages/f6/17/9297c320ab6363cbb3c0f349565f78e7e1adc0e6fb356bc37dba16bcfd91/dbus_fast-5.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c2d83950f2a3b4536f51da0ad51dfa063ffc92dec084f80e9c7661582edeb9", size = 692498, upload-time = "2026-05-23T18:38:49.267Z" }, - { url = "https://files.pythonhosted.org/packages/31/b6/c63d13b45bff8619e3479be45429459728870d697fe037367ef78bb208fd/dbus_fast-5.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aca56a03b2d46263c1a0b6483677dbf1fa84f6516db9e1c032b4d57ad757cfc2", size = 842099, upload-time = "2026-05-23T18:38:51.425Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/5f9162efc2c1e6f9a9ed6732d4e4778a62f851482fb847afc82726d97f6a/dbus_fast-5.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:864b659724fb011356d4a5439551e17e03fa8420e5e14b1ae7cf5baf541c61e4", size = 885967, upload-time = "2026-05-23T18:38:53.591Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c6/704494d8bf28e4b5126a43c21d600af13b3e075c1c53e122e4f95a832174/dbus_fast-5.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d00901d51ba4e5f2eb421f83fb6e4faf9466788fc7d7c4cfaf464bd97737bc52", size = 885377, upload-time = "2026-05-23T18:38:55.236Z" }, - { url = "https://files.pythonhosted.org/packages/e8/82/8dd56dad8a32050a74261ead6bf8536823f2bf35a95cd5e2d50b4e8e585e/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4d4a0f108555f94ce76c2d9be58a1a0d72f3881b83ce919e418ece575e24e88", size = 848562, upload-time = "2026-05-23T18:38:57.86Z" }, - { url = "https://files.pythonhosted.org/packages/83/aa/b12b378005538f4355a8a376783fa7477a60adb111947a565edf12101675/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0a56f1b933d6f0333653b7968cb5b38ff27e42f50c60a35801f6d27a94cc1fb9", size = 882106, upload-time = "2026-05-23T18:38:59.994Z" }, - { url = "https://files.pythonhosted.org/packages/2c/12/61c28663229b2fa3a8833d74d086c8eb7bad57efa03d9b719a4a72b6f675/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6e28e4d08361697afe0be0815d2e649bdf5bc96bf012db61b3e5c6aa991bd7c3", size = 892440, upload-time = "2026-05-23T18:39:02.345Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/8a9dae63f6e68eafed65c2fc23aa1172bd840b93cb564f52035fe5bde409/dbus_fast-5.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06fdd44811c938bf8d10bb8a5d1d09831e337eeff325b17ad7ef45640c2c88c2", size = 689936, upload-time = "2026-05-23T18:39:04.404Z" }, - { url = "https://files.pythonhosted.org/packages/d0/2a/67d7a011dfe5054bca33ff7faee6d60daae343e5d07e3b6f4182e5cbcecf/dbus_fast-5.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51094e6a80a7bfa6e67c59826a5604c7824e166958e6a029d0020f98287c00e", size = 803359, upload-time = "2026-05-23T18:39:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/bb/15/3ed2ab01ac5fd025378e6528ca4b3067333fcaa56f2f7338111fc879c702/dbus_fast-5.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a3d8f0aa43d34f19588ccdfd12e332258ab6c234b419166fa938bbae8afd52", size = 855303, upload-time = "2026-05-23T18:39:08.041Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ca/88b86f5f065311eee071f30559538acd39b5dfb3b1298c4d7e599accbe8d/dbus_fast-5.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff5f30056c3a68944729b88686551b56b79ec50fe27657e4e172afef4856458", size = 836412, upload-time = "2026-05-23T18:39:09.995Z" }, - { url = "https://files.pythonhosted.org/packages/0b/74/13de0346943bed77e322479a6823293712d3525c953bb130476069a4f6da/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1582355810e2224736a008e277bab8780bd3e6a7bbf865dd9d78fe6cd4aab961", size = 810524, upload-time = "2026-05-23T18:39:12.109Z" }, - { url = "https://files.pythonhosted.org/packages/c6/58/515cafb4ecae652ac8d289f9e7e73fff26e3f4052751f014ccff0c761742/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:176ea1a9c736f65262d923f9f8303824f1d1029d80bbfbdefe37a80e8a50b242", size = 833967, upload-time = "2026-05-23T18:39:13.899Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/5f369f6f388a19b662785c8e156795dd8f4df08cb49a3e2220464b69d7ee/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:17ff032d12dc4f3dbc3b29cd3756ce9094fd4cfc8f8984efa214072f680f70b1", size = 863709, upload-time = "2026-05-23T18:39:15.799Z" }, - { url = "https://files.pythonhosted.org/packages/43/be/0e88b7c1263583fda6213ef3370e259d19735b2f7f6e8746b77b08fb0080/dbus_fast-5.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00fe9b762c9974304e50f73abfd75210ae769207b78d8f3a97783d7281108200", size = 685726, upload-time = "2026-05-23T18:39:18.176Z" }, - { url = "https://files.pythonhosted.org/packages/15/e5/609bbd7e8e59753236a06838b5d561498695c457f08883348a7b034399c0/dbus_fast-5.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527edcecb06a6a1676c8e7eb0c167a234701715913c66431377d38da7acaa5b3", size = 800170, upload-time = "2026-05-23T18:39:19.968Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/9542cd0fbf86be24aa4c0865667cf8bf742df90c93b5063c3364d10f8dc3/dbus_fast-5.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:556ffba0fd56e9e872ed031dab579dab99c537c702e7c3d28f460c39e686ad8a", size = 850999, upload-time = "2026-05-23T18:39:21.808Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8e/aaf3ff0e66df530807af0381e7a9ec92a8ebd9783d54f00c1cbfaab35dc5/dbus_fast-5.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7bb1465c2d06ddb6c38f9c7344c5a70beb09adfee8a00344322c7b2a90a5f4", size = 830440, upload-time = "2026-05-23T18:39:23.662Z" }, - { url = "https://files.pythonhosted.org/packages/29/ca/1cbf6e7928134fa642857222c4a684f4ffd5cc0d16fca51416a659cdc9be/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ca2a8e94610705fae090aa1c318b810da9f9df47b990da9b6e06a8005a52fb91", size = 807074, upload-time = "2026-05-23T18:39:25.667Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/a5f8e20fdef07a3afdf6e50fb98df5dc266c6ff43346dd66d71cde300dd8/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f4e47a01c1e196d0aac0654850911eade720e9e571f2e68ee363dfeb69699e4e", size = 830159, upload-time = "2026-05-23T18:39:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/01/d7/a2307c0b32c297728d325c05b9978caf7e9a1787d259d5172cfb90946b73/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c3587f50a3089e5cc1a09b9a72cad964b88565f3d6dcfab3681981bca81a3fac", size = 859531, upload-time = "2026-05-23T18:39:29.755Z" }, - { url = "https://files.pythonhosted.org/packages/10/33/1dabccf932cee9408f189306bd6efdfab8cc063fddebcb218fd36d991ce1/dbus_fast-5.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:aa519fd8536ea8d100347f15af8a9d1a2d209fa5d353dadb64dfa13ecf707b3c", size = 692866, upload-time = "2026-05-23T18:39:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/5d/46/a56b19ef369e55fe723c8b33fcdd18b730d77442585bd325972d2c07780f/dbus_fast-5.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6139737eda772a9a5de13368862a15e486d61570be7d3a5e6bd64c7a23131d36", size = 814255, upload-time = "2026-05-23T18:39:33.423Z" }, - { url = "https://files.pythonhosted.org/packages/01/f2/e0397cdfa0aefb5b4c44658bd6ba8ce532b997e81bff3d9714bbd36ee858/dbus_fast-5.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ce788791929923dbcb3eee889c61b8898eed41c77e334ac73bacd24d6e5c68a", size = 856800, upload-time = "2026-05-23T18:39:35.352Z" }, - { url = "https://files.pythonhosted.org/packages/ef/49/d11afde6602cefb2fdb43dd15a1f6b6f36a0e86cd046c99b4bafb071eb30/dbus_fast-5.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e36da263ce03faf48c5f40b9cc33949a8bbb67db1ef09b7cdb89a8b807aaaf7", size = 836628, upload-time = "2026-05-23T18:39:37.152Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d8/0d711a54e4523cad9a4b08753c7c49a32580fe90a2a0949109fa8cdccc1b/dbus_fast-5.0.4-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:2eeb8dc52aa377074133fd8cd15733e8de27c4fef62f9c66be90fe0f4822aaa3", size = 854900, upload-time = "2026-05-23T18:31:27.897Z" }, - { url = "https://files.pythonhosted.org/packages/e6/38/59d700c6540136740b102f66c94147dc707e917d8136e32fc7fcef649032/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d3d3bddcec3df4e213dce8de6e456e8bf4053a5e6dc9544fa13d2803c7ade3e8", size = 820912, upload-time = "2026-05-23T18:39:38.941Z" }, - { url = "https://files.pythonhosted.org/packages/34/58/961d5d6d7c71347bfc6d6e6fdf4501b8773eab46bad02dc0f57d1005ab66/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e2c53a20bc6756d2d70b5a189b2adc76bb5d9856a1abd9564f160cd03f5d63bb", size = 837066, upload-time = "2026-05-23T18:39:40.754Z" }, - { url = "https://files.pythonhosted.org/packages/47/e6/49122a2b58ceca179627b2cae1c6f536336303f00cff2d6ad37c08ce0094/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b0f0d98fcabc838589eb05b29322d20378bd3c98999ace909ed63bed28a92e8", size = 864390, upload-time = "2026-05-23T18:39:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/17/9b/55aed4a5e2905c3767dfa92b052272fa3459e2a7c46b1a97ca968163c2e9/dbus_fast-5.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2142c9be443711c8e185de57ad19781f6ff231fa2f6ebbe9312734efd7a72762", size = 1356783, upload-time = "2026-05-23T18:39:45.079Z" }, - { url = "https://files.pythonhosted.org/packages/45/8d/df4488525e22a8e843e12656f0718d760528f56709401af979c27a6c151c/dbus_fast-5.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a80a3114dc11fc463ef5b5562de6fa7fb5615acd488efa31a157dd83d510a247", size = 1545147, upload-time = "2026-05-23T18:39:46.933Z" }, - { url = "https://files.pythonhosted.org/packages/fd/75/c18803c88e85164ce60c28f59a7fdb7819901b5e5971bbc505bcd438d02d/dbus_fast-5.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340d36759a18d8b67deeb6bbd60647e0294f92950778a02e753832840b93f899", size = 1620877, upload-time = "2026-05-23T18:39:48.703Z" }, - { url = "https://files.pythonhosted.org/packages/c0/5c/a8154e4ba68c822c96233302551c7280198fd56ad6709baacc804a51f7e8/dbus_fast-5.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbbd41b9a641fc47e92d923331750a28c3a5001947bc1271d2392247c8fba327", size = 829639, upload-time = "2026-05-23T18:39:50.734Z" }, - { url = "https://files.pythonhosted.org/packages/54/6b/13ef6c4fddcd7beeb15e8425e8d90dd5f06d973e959397153d53e12ec385/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00ef02cfdc2423b50756e32e90af8f1c3d63ad41f3af8a3d0f7cea9315e0005", size = 1560002, upload-time = "2026-05-23T18:39:52.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/54/010f9a9673ca7bf67a393486278a617b9930fead5c04563bf710bb6dc21d/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2ba075dc3c12af888ecae09226dbc2225c75b0e105666e1f0630f95d2ca6ab84", size = 831734, upload-time = "2026-05-23T18:39:54.736Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/14b5ed792988db43b257b9c262e39b43d8b1fdb361b96da005a5cd3e5b48/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:210ba9eef1f63d256111207f98c5e2264b946ff8db1bf3c82ab064c7d1fb2dae", size = 1639471, upload-time = "2026-05-23T18:39:57.115Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5c/fa3b060e06e0322ddd5c21a4f63e6c3e59a427a92db1553b930816d40e97/dbus_fast-5.0.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:027b358d55164f722d7432d6734506751c89b5cee8d516e7282eff3eaa763f05", size = 694683, upload-time = "2026-06-05T18:55:38.241Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/9235af850e2ee0cec23b4e863612492964a9c40b65f3002e4a9f32e1ffa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8244561e503cffcd5e6b1ff6544a44aa0b65b6856f2f64d04345ebf82f8ac48", size = 840236, upload-time = "2026-06-05T18:55:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d1/08ed17331dd23a8b91b80db9acca48301c4879352024dc7882e2c5893fa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32869678240f233ece5a1137960c0337699dbb39acc02c155929f52543f03fb", size = 884309, upload-time = "2026-06-05T18:55:42.447Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/02458af54460e55cf26fdf0a778aa9b3217ac8d37e6280aa68e19f176650/dbus_fast-5.0.22-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f74700b1b4fd58acb76f8790e2cd475084d0e59149e0cc035abd239435a339b7", size = 885179, upload-time = "2026-06-05T18:55:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/e9/79/4958c807e59106558a4415212e6be019775f5be9775ffc79c6bc7248b388/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:246780dcda59d26dd2fb1d9a15991bc8483e9c241cee8713920a39a363ad4d39", size = 847693, upload-time = "2026-06-05T18:55:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/a5322fbc172ee099f98186224cf7e3bf48fc8bcb08270951da8006b556ba/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:58e4d41f9fea22cfd6dacdf03d53b932bbe15a825af5abad3ca79b91493a15c3", size = 882773, upload-time = "2026-06-05T18:55:46.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/e068899c1d9a034ed9d5fc8519b8e2247aa8c28a6a67229162db64163b59/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8ca39f3f2ab34f88bb343db87c7cf8b07eefa046b23d8c38b3387f150ac609e", size = 891573, upload-time = "2026-06-05T18:55:48.34Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1d/f6020228675338b7184ce5491e4535fb64c7badc0197151b9776f345a1a1/dbus_fast-5.0.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0926e4cf49989b4ec8233e8fd462eb35a640fcfe81bb75d91675dd47489022b", size = 693158, upload-time = "2026-06-05T18:55:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" }, + { url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" }, + { url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/52/81/ffc155f700c45191673e7f7620a28cbbbf5f116ff74a99f765895baa6f9c/dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c", size = 690171, upload-time = "2026-06-05T18:56:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" }, + { url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/25/1d/ebd02ae707328286c24582ac9189e4ed9c344399bc3b6a4b74e9687088eb/dbus_fast-5.0.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26e26b409e1e6edf5e2a4df8d192625fb38876b074fb5c7d0a5b15c5792e549d", size = 685921, upload-time = "2026-06-05T18:56:12.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b6/171f8e92775254e2f42afe9e7501a57ace47f32de2969cb694a375dc9dce/dbus_fast-5.0.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:511655d915692f55b8c5f5a535acf80cca9c6d1a35384db7fcfdabaae05dd837", size = 692986, upload-time = "2026-06-05T18:56:24.661Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/7c/dd/61086156a1c2d8ffd04d61a232debbaff8ca9fc1cf598476999e1f06164d/dbus_fast-5.0.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69a077c296eaab8c30160e861b5514c33d99d67d41d17dbf02e89aae44543b11", size = 1353993, upload-time = "2026-06-05T18:56:36.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" }, + { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" }, ] [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] @@ -214,7 +219,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -223,11 +228,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] [[package]] @@ -241,11 +246,11 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -275,6 +280,227 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -474,11 +700,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -499,6 +725,10 @@ dependencies = [ { name = "dbus-fast" }, { name = "evdev" }, { name = "libarchive-c" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "opencv-python" }, { name = "orjson" }, { name = "pefile" }, { name = "pillow" }, @@ -527,6 +757,8 @@ requires-dist = [ { name = "dbus-fast", specifier = ">=5.0.4" }, { name = "evdev", specifier = ">=1.9.3" }, { name = "libarchive-c", specifier = ">=5.3" }, + { name = "numpy", specifier = ">=2.2.0" }, + { name = "opencv-python", specifier = ">=4.11.0" }, { name = "orjson", specifier = ">=3.11.8" }, { name = "pefile", specifier = ">=2024.8.26" }, { name = "pillow", specifier = ">=12.2.0" }, @@ -640,15 +872,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.409" +version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [[package]] @@ -702,7 +934,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -713,9 +945,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -732,15 +964,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, ] [[package]] @@ -992,14 +1224,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.3" +version = "4.68.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] [[package]] @@ -1031,7 +1263,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -1040,9 +1272,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] [[package]]