diff --git a/.gitignore b/.gitignore index 6479242..6d6cf83 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ __pycache__/ */.env wg_config/wg_confs/ +records/ +src/auracast/server/stream_settings.json +src/auracast/server/certs/per_device/ +src/auracast/.env diff --git a/Dockerfile b/Dockerfile index b518099..666ea45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # TODO: investigate using -alpine in the future -FROM python:3.11 +FROM python:3.11-bookworm # Install system dependencies and poetry RUN apt-get update && apt-get install -y \ - iputils-ping \ + iputils-ping portaudio19-dev\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..015f2b6 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +## Local HTTP/HTTPS Setup with Custom CA + +This project provides a dual-port Streamlit server setup for local networks: + +- **HTTP** available on port **8502** +- **HTTPS** (trusted with custom CA) available on port **8503** + +### How it works +- A custom Certificate Authority (CA) is generated for your organization. +- Each device/server is issued a certificate signed by this CA. +- Customers can import the CA certificate into their OS/browser trust store, so the device's HTTPS connection is fully trusted (no browser warnings). + +### Usage + +1. **Generate Certificates** + - Run `generate_ca_cert.sh` in `src/auracast/server/`. + - This creates: + - `certs/ca/ca_cert.pem` / `ca_key.pem` (CA cert/key) + - **Distribute `ca_cert.pem` or `ca_cert.crt` to customers** for installation in their trust store. + - This is a one-time operation for your organization. + +2. **Start the Server** + - Run `run_http_and_https.sh` in `src/auracast/server/`. + - This starts: + - HTTP Streamlit on port 8500 + - HTTPS Streamlit on port 8501 (using the signed device cert) + +3. **Client Trust Setup** + - Customers should install `ca_cert.pem` in their operating system or browser trust store to trust the HTTPS connection. + - After this, browsers will show a secure HTTPS connection to the device (no warnings). + +### Why this setup? +- **WebRTC and other browser features require HTTPS for local devices.** +- Using a local CA allows trusted HTTPS without needing a public certificate or exposing devices to the internet. +- HTTP is also available for compatibility/testing. + +### Advertise Hostname with mDNS +```bash +cd src/auracast/server +sudo ./provision_domain_hostname.sh +``` +- Example: + ```bash + sudo ./provision_domain_hostname.sh box1 auracast.local + ``` +- The script will: + - Validate your input (no dots in hostname) + - Set the system hostname + - Update `/etc/hosts` + - Set the Avahi domain in `/etc/avahi/avahi-daemon.conf` + - Restart Avahi + - Generate a unique per-device certificate and key signed by your CA, stored in `certs/per_device/./`. + - The certificate will have a SAN matching the device's mDNS name (e.g., `box1-summitwave.local`). +--- + +### Troubleshooting & Tips +- **Use .local domain** (e.g., `box1-summitwave.local`) - most clients will not resolve multi-label domains. +- **Hostnames must not contain dots** (`.`). Only use single-label names for the system hostname. +- **Avahi domain** can be multi-label (e.g., `auracast.local`). +- **Clients may need** `libnss-mdns` installed and `/etc/nsswitch.conf` configured with `mdns4_minimal` and `mdns4` for multi-label mDNS names. +- If you have issues with mDNS name resolution, check for conflicting mDNS stacks (e.g., systemd-resolved, Bonjour, or other daemons). +- Some Linux clients may not resolve multi-label mDNS names via NSS—test with `avahi-resolve-host-name` and try from another device if needed. + +--- + +After completing these steps, your device will be discoverable as `.` (e.g., `box1.auracast.local`) on the local network via mDNS. + +--- + +## Checking Advertised mDNS Services +Once your device is configured, you can verify that its mDNS advertisement is visible on the network: + +- **List all mDNS services:** + ```bash + avahi-browse -a + ``` + Look for your hostname and service (e.g., `box1.auracast.local`). +- **Check specific hostname resolution:** + ```bash + avahi-resolve-host-name box1.auracast.local + avahi-resolve-host-name -4 box1.auracast.local # IPv4 only + avahi-resolve-host-name -6 box1.auracast.local # IPv6 only + ``` + +## Run the application with local webui +- for microphone streaming via the browser, https is required +- poetry run multicast_server.py +- sudo -E PATH="$PATH" bash ./start_frontend_https.sh +- bash start_mdns.sh + + +## Managing Auracast systemd Services + +You can run the backend and frontend as systemd services for easier management and automatic startup on boot. + +### 1. Install the service files +Copy the provided service files to your systemd directory (requires sudo): + +```bash +sudo cp auracast-server.service /etc/systemd/system/ +sudo cp auracast-frontend.service /etc/systemd/system/ +``` + +### 2. Reload systemd +```bash +sudo systemctl daemon-reload +``` + +### 3. Enable services to start at boot +```bash +sudo systemctl enable auracast-server +sudo systemctl enable auracast-frontend +``` + +### 4. Start the services +```bash +sudo systemctl start auracast-server +sudo systemctl start auracast-frontend +``` + +### 5. Stop the services +```bash +sudo systemctl stop auracast-server +sudo systemctl stop auracast-frontend +``` + +### 6. Disable services to start at boot +```bash +sudo systemctl disable auracast-server +sudo systemctl disable auracast-frontend +``` + +### 7. Check service status +```bash +sudo systemctl status auracast-server +sudo systemctl status auracast-frontend +``` + +If you want to run the services as a specific user, edit the `User=` line in the service files accordingly. + +# Setup the audio system +sudo apt update + +sudo apt remove -y libportaudio2 portaudio19-dev libportaudiocpp0 +echo "y" | rpi-update stable + +sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries rtkit cpufrequtils +cp src/service/pipewire/99-lowlatency.conf ~/.config/pipewire/pipewire.conf.d/ +sudo cpufreq-set -g performance + +/etc/modprobe.d/usb-audio-lowlatency.conf +option snd_usb_audio nrpacks=1 + +sudo apt install -y --no-install-recommends \ + git build-essential cmake pkg-config \ + libasound2-dev libpulse-devpipewire ethtool linuxptp + +git clone https://github.com/PortAudio/portaudio.git +cd portaudio +git checkout 9abe5fe7db729280080a0bbc1397a528cd3ce658 +rm -rf build +cmake -S . -B build -G"Unix Makefiles" \ + -DBUILD_SHARED_LIBS=ON \ + -DPA_USE_ALSA=OFF \ + -DPA_USE_PULSEAUDIO=ON \ + -DPA_USE_JACK=OFF +cmake --build build -j$(nproc) +sudo cmake --install build # installs to /usr/local/lib +sudo ldconfig # refresh linker cache + + +# Device commisioning +- generate id_ed25519 keypair +- setup hostname + - sudo bash src/auracast/server/provision_domain_hostname.sh box7-summitwave local +- activate aes67 service + - install udev rule for ptp4l + - sudo cp src/service/aes67/90-pipewire-aes67-ptp.rules /etc/udev/rules.d/ + - sudo udevadm control --log-priority=debug --reload-rules + - sudo udevadm trigger + - bash src/service/update_and_run_aes67.sh + - poetry config virtualenvs.in-project true + - poetry install +- activate server and frontend + - bash srv/service/update_and_run_server_and_frontend.sh +- update to latest stable kernel + - echo "y" | rpi-update stable +- place cert +- disable pw login +- reboot + +# Known issues: +- When running on a laptop there might be issues switching between usb and browser audio input since they use the same audio device + diff --git a/docker-compose.yaml b/docker-compose-cloud.yaml similarity index 100% rename from docker-compose.yaml rename to docker-compose-cloud.yaml diff --git a/docker-compose-test.yaml b/docker-compose-test.yaml index 14fb30b..181a9ba 100644 --- a/docker-compose-test.yaml +++ b/docker-compose-test.yaml @@ -1,6 +1,6 @@ services: multicaster: - container_name: multicaster-test + container_name: multicaster privileged: true # Grants full access to all devices (for serial access) restart: unless-stopped ports: diff --git a/docker-compose-webui.yaml b/docker-compose-webui.yaml new file mode 100644 index 0000000..8c44d6b --- /dev/null +++ b/docker-compose-webui.yaml @@ -0,0 +1,24 @@ +services: + multicaster: + container_name: multicast-webapp + privileged: true # Grants full access to all devices (for serial access) + restart: unless-stopped + ports: + - "8501:8501" + build: + dockerfile: Dockerfile + ssh: + - default=~/.ssh/id_ed25519 #lappi + #- default=~/.ssh/id_rsa #raspi + volumes: + - "/dev/serial:/dev/serial" + - "/dev/snd:/dev/snd" + #devices: + # - /dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00 + environment: + LOG_LEVEL: INFO + + # start the server and the frontend + command: > + bash -c "python ./auracast/server/multicast_server.py & streamlit run ./auracast/server/multicast_frontend.py" + diff --git a/poetry.lock b/poetry.lock index b6f2319..895195e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -111,6 +111,50 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +[[package]] +name = "aioice" +version = "0.10.1" +description = "An implementation of Interactive Connectivity Establishment (RFC 5245)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aioice-0.10.1-py3-none-any.whl", hash = "sha256:f31ae2abc8608b1283ed5f21aebd7b6bd472b152ff9551e9b559b2d8efed79e9"}, + {file = "aioice-0.10.1.tar.gz", hash = "sha256:5c8e1422103448d171925c678fb39795e5fe13d79108bebb00aa75a899c2094a"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +ifaddr = ">=0.2.0" + +[package.extras] +dev = ["coverage[toml] (>=7.2.2)", "mypy", "pyopenssl", "ruff", "websockets"] + +[[package]] +name = "aiortc" +version = "1.13.0" +description = "An implementation of WebRTC and ORTC" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiortc-1.13.0-py3-none-any.whl", hash = "sha256:9ccccec98796f6a96bd1c3dd437a06da7e0f57521c96bd56e4b965a91b03a0a0"}, + {file = "aiortc-1.13.0.tar.gz", hash = "sha256:5d209975c22d0910fb5a0f0e2caa828f2da966c53580f7c7170ac3a16a871620"}, +] + +[package.dependencies] +aioice = ">=0.10.1,<1.0.0" +av = ">=14.0.0,<15.0.0" +cffi = ">=1.0.0" +cryptography = ">=44.0.0" +google-crc32c = ">=1.1" +pyee = ">=13.0.0" +pylibsrtp = ">=0.10.0" +pyopenssl = ">=25.0.0" + +[package.extras] +dev = ["aiohttp (>=3.7.0)", "coverage[toml] (>=7.2.2)", "numpy (>=1.19.0)"] + [[package]] name = "aiosignal" version = "1.3.2" @@ -126,6 +170,31 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "altair" +version = "5.5.0" +description = "Vega-Altair: A declarative statistical visualization library for Python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c"}, + {file = "altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d"}, +] + +[package.dependencies] +jinja2 = "*" +jsonschema = ">=3.0" +narwhals = ">=1.14.2" +packaging = "*" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.14\""} + +[package.extras] +all = ["altair-tiles (>=0.3.0)", "anywidget (>=0.9.0)", "numpy", "pandas (>=1.1.3)", "pyarrow (>=11)", "vega-datasets (>=0.9.0)", "vegafusion[embed] (>=1.6.6)", "vl-convert-python (>=1.7.0)"] +dev = ["duckdb (>=1.0)", "geopandas", "hatch (>=1.13.0)", "ipython[kernel]", "mistune", "mypy", "pandas (>=1.1.3)", "pandas-stubs", "polars (>=0.20.3)", "pyarrow-stubs", "pytest", "pytest-cov", "pytest-xdist[psutil] (>=3.5,<4.0)", "ruff (>=0.6.0)", "types-jsonschema", "types-setuptools"] +doc = ["docutils", "jinja2", "myst-parser", "numpydoc", "pillow (>=9,<10)", "pydata-sphinx-theme (>=0.14.1)", "scipy", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinxext-altair"] +save = ["vl-convert-python (>=1.7.0)"] + [[package]] name = "annotated-types" version = "0.7.0" @@ -193,6 +262,74 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] +[[package]] +name = "av" +version = "14.4.0" +description = "Pythonic bindings for FFmpeg's libraries." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c"}, + {file = "av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f"}, + {file = "av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef"}, + {file = "av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae"}, + {file = "av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311"}, + {file = "av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7"}, + {file = "av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2"}, + {file = "av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059"}, + {file = "av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e"}, + {file = "av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701"}, + {file = "av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835"}, + {file = "av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6"}, + {file = "av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e"}, + {file = "av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2"}, + {file = "av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3"}, + {file = "av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474"}, + {file = "av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4"}, + {file = "av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29"}, + {file = "av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94"}, + {file = "av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395"}, + {file = "av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de"}, + {file = "av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81"}, + {file = "av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30"}, + {file = "av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d"}, + {file = "av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09"}, + {file = "av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c"}, + {file = "av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad"}, + {file = "av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f"}, + {file = "av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae"}, + {file = "av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8"}, + {file = "av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115"}, + {file = "av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59"}, + {file = "av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7"}, + {file = "av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a"}, + {file = "av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8"}, + {file = "av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20"}, + {file = "av-14.4.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8ff683777e0bb3601f7cfb4545dca25db92817585330b773e897e1f6f9d612f7"}, + {file = "av-14.4.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:fe372acf7b1814bc2b16d89161609db63f81dad88684da76d26dd32cd1c16f92"}, + {file = "av-14.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de869030eb8acfdfe39f39965de3a899dcde9b08df2db41f183c6166ca6f6d09"}, + {file = "av-14.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9117ed91fba6299b7d5233dd3e471770bab829f97e5a157f182761e9fb59254c"}, + {file = "av-14.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54e8f9209184098b7755e6250be8ffa48a8aa5b554a02555406120583da17373"}, + {file = "av-14.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:38ea51e62a014663caec7f621d6601cf269ef450f3c8705f5e3225e5623fd15d"}, + {file = "av-14.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d1d89842efe913448482573a253bd6955ce30a77f8a4cd04a1a3537cc919896"}, + {file = "av-14.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c3048e333da1367a2bca47e69593e10bc70f027d876adee9d1582c8cb818f36a"}, + {file = "av-14.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d6f25570d0782dd05640c7e1f71cb29857d94d915b5521a1e757ecae78a5a50"}, + {file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"}, +] + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + [[package]] name = "bumble" version = "0.0.209.dev2+g12bcdb7" @@ -237,6 +374,30 @@ url = "ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git" reference = "12bcdb7770c0d57a094bc0a96cd52e701f97fece" resolved_reference = "12bcdb7770c0d57a094bc0a96cd52e701f97fece" +[[package]] +name = "cachetools" +version = "5.5.2" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, +] + [[package]] name = "cffi" version = "1.17.1" @@ -317,6 +478,108 @@ files = [ [package.dependencies] pycparser = "*" +[[package]] +name = "charset-normalizer" +version = "3.4.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + [[package]] name = "click" version = "8.1.8" @@ -403,6 +666,27 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + [[package]] name = "fastapi" version = "0.115.11" @@ -526,6 +810,87 @@ files = [ {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, ] +[[package]] +name = "gitdb" +version = "4.0.12" +description = "Git Object Database" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, + {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.44" +description = "GitPython is a Python library used to interact with Git repositories" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, + {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[package.extras] +doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] + +[[package]] +name = "google-crc32c" +version = "1.7.1" +description = "A python wrapper of the C library 'Google CRC32C'" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "google_crc32c-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76"}, + {file = "google_crc32c-1.7.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d"}, + {file = "google_crc32c-1.7.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c"}, + {file = "google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb"}, + {file = "google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603"}, + {file = "google_crc32c-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a"}, + {file = "google_crc32c-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06"}, + {file = "google_crc32c-1.7.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9"}, + {file = "google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77"}, + {file = "google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53"}, + {file = "google_crc32c-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d"}, + {file = "google_crc32c-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194"}, + {file = "google_crc32c-1.7.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e"}, + {file = "google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337"}, + {file = "google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65"}, + {file = "google_crc32c-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6"}, + {file = "google_crc32c-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35"}, + {file = "google_crc32c-1.7.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638"}, + {file = "google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb"}, + {file = "google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6"}, + {file = "google_crc32c-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db"}, + {file = "google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3"}, + {file = "google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9"}, + {file = "google_crc32c-1.7.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:9fc196f0b8d8bd2789352c6a522db03f89e83a0ed6b64315923c396d7a932315"}, + {file = "google_crc32c-1.7.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb5e35dcd8552f76eed9461a23de1030920a3c953c1982f324be8f97946e7127"}, + {file = "google_crc32c-1.7.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f2226b6a8da04f1d9e61d3e357f2460b9551c5e6950071437e122c958a18ae14"}, + {file = "google_crc32c-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f2b3522222746fff0e04a9bd0a23ea003ba3cccc8cf21385c564deb1f223242"}, + {file = "google_crc32c-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bda0fcb632d390e3ea8b6b07bf6b4f4a66c9d02dcd6fbf7ba00a197c143f582"}, + {file = "google_crc32c-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:713121af19f1a617054c41f952294764e0c5443d5a5d9034b2cd60f5dd7e0349"}, + {file = "google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589"}, + {file = "google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b"}, + {file = "google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48"}, + {file = "google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82"}, + {file = "google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472"}, +] + +[package.extras] +testing = ["pytest"] + [[package]] name = "grpcio" version = "1.71.0" @@ -634,6 +999,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "ifaddr" +version = "0.2.0" +description = "Cross-platform network interface and IP address enumeration library" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, + {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -647,6 +1024,61 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jsonschema" +version = "4.24.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d"}, + {file = "jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, + {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "lc3" version = "0.0.1" @@ -757,6 +1189,77 @@ files = [ {file = "libusb1-3.3.1.tar.gz", hash = "sha256:3951d360f2daf0e0eacf839e15d2d1d2f4f5e7830231eb3188eeffef2dd17bad"}, ] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "multidict" version = "6.2.0" @@ -859,19 +1362,294 @@ files = [ {file = "multidict-6.2.0.tar.gz", hash = "sha256:0085b0afb2446e57050140240a8595846ed64d1cbd26cef936bfab3192c673b8"}, ] +[[package]] +name = "narwhals" +version = "1.41.0" +description = "Extremely lightweight compatibility layer between dataframe libraries" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "narwhals-1.41.0-py3-none-any.whl", hash = "sha256:d958336b40952e4c4b7aeef259a7074851da0800cf902186a58f2faeff97be02"}, + {file = "narwhals-1.41.0.tar.gz", hash = "sha256:0ab2e5a1757a19b071e37ca74b53b0b5426789321d68939738337dfddea629b5"}, +] + +[package.extras] +cudf = ["cudf (>=24.10.0)"] +dask = ["dask[dataframe] (>=2024.8)"] +duckdb = ["duckdb (>=1.0)"] +ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] +modin = ["modin"] +pandas = ["pandas (>=0.25.3)"] +polars = ["polars (>=0.20.3)"] +pyarrow = ["pyarrow (>=11.0.0)"] +pyspark = ["pyspark (>=3.5.0)"] +pyspark-connect = ["pyspark[connect] (>=3.5.0)"] +sqlframe = ["sqlframe (>=3.22.0)"] + +[[package]] +name = "numpy" +version = "2.2.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + [[package]] name = "packaging" version = "24.2" description = "Core utilities for Python packages" -optional = true +optional = false python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"test\"" files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] +[[package]] +name = "pandas" +version = "2.3.0" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pandas-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:625466edd01d43b75b1883a64d859168e4556261a5035b32f9d743b67ef44634"}, + {file = "pandas-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6872d695c896f00df46b71648eea332279ef4077a409e2fe94220208b6bb675"}, + {file = "pandas-2.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4dd97c19bd06bc557ad787a15b6489d2614ddaab5d104a0310eb314c724b2d2"}, + {file = "pandas-2.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:034abd6f3db8b9880aaee98f4f5d4dbec7c4829938463ec046517220b2f8574e"}, + {file = "pandas-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23c2b2dc5213810208ca0b80b8666670eb4660bbfd9d45f58592cc4ddcfd62e1"}, + {file = "pandas-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:39ff73ec07be5e90330cc6ff5705c651ace83374189dcdcb46e6ff54b4a72cd6"}, + {file = "pandas-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:40cecc4ea5abd2921682b57532baea5588cc5f80f0231c624056b146887274d2"}, + {file = "pandas-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8adff9f138fc614347ff33812046787f7d43b3cef7c0f0171b3340cae333f6ca"}, + {file = "pandas-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e5f08eb9a445d07720776df6e641975665c9ea12c9d8a331e0f6890f2dcd76ef"}, + {file = "pandas-2.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa35c266c8cd1a67d75971a1912b185b492d257092bdd2709bbdebe574ed228d"}, + {file = "pandas-2.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a0cc77b0f089d2d2ffe3007db58f170dae9b9f54e569b299db871a3ab5bf46"}, + {file = "pandas-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c06f6f144ad0a1bf84699aeea7eff6068ca5c63ceb404798198af7eb86082e33"}, + {file = "pandas-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ed16339bc354a73e0a609df36d256672c7d296f3f767ac07257801aa064ff73c"}, + {file = "pandas-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:fa07e138b3f6c04addfeaf56cc7fdb96c3b68a3fe5e5401251f231fce40a0d7a"}, + {file = "pandas-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2eb4728a18dcd2908c7fccf74a982e241b467d178724545a48d0caf534b38ebf"}, + {file = "pandas-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9d8c3187be7479ea5c3d30c32a5d73d62a621166675063b2edd21bc47614027"}, + {file = "pandas-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ff730713d4c4f2f1c860e36c005c7cefc1c7c80c21c0688fd605aa43c9fcf09"}, + {file = "pandas-2.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba24af48643b12ffe49b27065d3babd52702d95ab70f50e1b34f71ca703e2c0d"}, + {file = "pandas-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:404d681c698e3c8a40a61d0cd9412cc7364ab9a9cc6e144ae2992e11a2e77a20"}, + {file = "pandas-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6021910b086b3ca756755e86ddc64e0ddafd5e58e076c72cb1585162e5ad259b"}, + {file = "pandas-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:094e271a15b579650ebf4c5155c05dcd2a14fd4fdd72cf4854b2f7ad31ea30be"}, + {file = "pandas-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c7e2fc25f89a49a11599ec1e76821322439d90820108309bf42130d2f36c983"}, + {file = "pandas-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6da97aeb6a6d233fb6b17986234cc723b396b50a3c6804776351994f2a658fd"}, + {file = "pandas-2.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb32dc743b52467d488e7a7c8039b821da2826a9ba4f85b89ea95274f863280f"}, + {file = "pandas-2.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:213cd63c43263dbb522c1f8a7c9d072e25900f6975596f883f4bebd77295d4f3"}, + {file = "pandas-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1d2b33e68d0ce64e26a4acc2e72d747292084f4e8db4c847c6f5f6cbe56ed6d8"}, + {file = "pandas-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:430a63bae10b5086995db1b02694996336e5a8ac9a96b4200572b413dfdfccb9"}, + {file = "pandas-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4930255e28ff5545e2ca404637bcc56f031893142773b3468dc021c6c32a1390"}, + {file = "pandas-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f925f1ef673b4bd0271b1809b72b3270384f2b7d9d14a189b12b7fc02574d575"}, + {file = "pandas-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78ad363ddb873a631e92a3c063ade1ecfb34cae71e9a2be6ad100f875ac1042"}, + {file = "pandas-2.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951805d146922aed8357e4cc5671b8b0b9be1027f0619cea132a9f3f65f2f09c"}, + {file = "pandas-2.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a881bc1309f3fce34696d07b00f13335c41f5f5a8770a33b09ebe23261cfc67"}, + {file = "pandas-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e1991bbb96f4050b09b5f811253c4f3cf05ee89a589379aa36cd623f21a31d6f"}, + {file = "pandas-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bb3be958022198531eb7ec2008cfc78c5b1eed51af8600c6c5d9160d89d8d249"}, + {file = "pandas-2.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9efc0acbbffb5236fbdf0409c04edce96bec4bdaa649d49985427bd1ec73e085"}, + {file = "pandas-2.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75651c14fde635e680496148a8526b328e09fe0572d9ae9b638648c46a544ba3"}, + {file = "pandas-2.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5be867a0541a9fb47a4be0c5790a4bccd5b77b92f0a59eeec9375fafc2aa14"}, + {file = "pandas-2.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84141f722d45d0c2a89544dd29d35b3abfc13d2250ed7e68394eda7564bd6324"}, + {file = "pandas-2.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f95a2aef32614ed86216d3c450ab12a4e82084e8102e355707a1d96e33d51c34"}, + {file = "pandas-2.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e0f51973ba93a9f97185049326d75b942b9aeb472bec616a129806facb129ebb"}, + {file = "pandas-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:b198687ca9c8529662213538a9bb1e60fa0bf0f6af89292eb68fea28743fcd5a"}, + {file = "pandas-2.3.0.tar.gz", hash = "sha256:34600ab34ebf1131a7613a260a61dbe8b62c188ec0ea4c296da7c9a06b004133"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pillow" +version = "11.2.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, + {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, + {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, + {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, + {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, + {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, + {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, + {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, + {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, + {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, + {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, + {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, + {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, + {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, + {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, + {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, + {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, + {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, + {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, + {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, + {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.7" @@ -1057,7 +1835,6 @@ description = "" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_system != \"Emscripten\"" files = [ {file = "protobuf-6.30.2-cp310-abi3-win32.whl", hash = "sha256:b12ef7df7b9329886e66404bef5e9ce6a26b54069d7f7436a0853ccdeb91c103"}, {file = "protobuf-6.30.2-cp310-abi3-win_amd64.whl", hash = "sha256:7653c99774f73fe6b9301b87da52af0e69783a2e371e8b599b3e9cb4da4b12b9"}, @@ -1070,6 +1847,74 @@ files = [ {file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"}, ] +[[package]] +name = "pyarrow" +version = "20.0.0" +description = "Python library for Apache Arrow" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:c7dd06fd7d7b410ca5dc839cc9d485d2bc4ae5240851bcd45d85105cc90a47d7"}, + {file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d5382de8dc34c943249b01c19110783d0d64b207167c728461add1ecc2db88e4"}, + {file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6415a0d0174487456ddc9beaead703d0ded5966129fa4fd3114d76b5d1c5ceae"}, + {file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15aa1b3b2587e74328a730457068dc6c89e6dcbf438d4369f572af9d320a25ee"}, + {file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5605919fbe67a7948c1f03b9f3727d82846c053cd2ce9303ace791855923fd20"}, + {file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a5704f29a74b81673d266e5ec1fe376f060627c2e42c5c7651288ed4b0db29e9"}, + {file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:00138f79ee1b5aca81e2bdedb91e3739b987245e11fa3c826f9e57c5d102fb75"}, + {file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f2d67ac28f57a362f1a2c1e6fa98bfe2f03230f7e15927aecd067433b1e70ce8"}, + {file = "pyarrow-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4a8b029a07956b8d7bd742ffca25374dd3f634b35e46cc7a7c3fa4c75b297191"}, + {file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:24ca380585444cb2a31324c546a9a56abbe87e26069189e14bdba19c86c049f0"}, + {file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:95b330059ddfdc591a3225f2d272123be26c8fa76e8c9ee1a77aad507361cfdb"}, + {file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f0fb1041267e9968c6d0d2ce3ff92e3928b243e2b6d11eeb84d9ac547308232"}, + {file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8ff87cc837601532cc8242d2f7e09b4e02404de1b797aee747dd4ba4bd6313f"}, + {file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7a3a5dcf54286e6141d5114522cf31dd67a9e7c9133d150799f30ee302a7a1ab"}, + {file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a6ad3e7758ecf559900261a4df985662df54fb7fdb55e8e3b3aa99b23d526b62"}, + {file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6bb830757103a6cb300a04610e08d9636f0cd223d32f388418ea893a3e655f1c"}, + {file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96e37f0766ecb4514a899d9a3554fadda770fb57ddf42b63d80f14bc20aa7db3"}, + {file = "pyarrow-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:3346babb516f4b6fd790da99b98bed9708e3f02e734c84971faccb20736848dc"}, + {file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:75a51a5b0eef32727a247707d4755322cb970be7e935172b6a3a9f9ae98404ba"}, + {file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:211d5e84cecc640c7a3ab900f930aaff5cd2702177e0d562d426fb7c4f737781"}, + {file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba3cf4182828be7a896cbd232aa8dd6a31bd1f9e32776cc3796c012855e1199"}, + {file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c3a01f313ffe27ac4126f4c2e5ea0f36a5fc6ab51f8726cf41fee4b256680bd"}, + {file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a2791f69ad72addd33510fec7bb14ee06c2a448e06b649e264c094c5b5f7ce28"}, + {file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4250e28a22302ce8692d3a0e8ec9d9dde54ec00d237cff4dfa9c1fbf79e472a8"}, + {file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89e030dc58fc760e4010148e6ff164d2f44441490280ef1e97a542375e41058e"}, + {file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6102b4864d77102dbbb72965618e204e550135a940c2534711d5ffa787df2a5a"}, + {file = "pyarrow-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:96d6a0a37d9c98be08f5ed6a10831d88d52cac7b13f5287f1e0f625a0de8062b"}, + {file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893"}, + {file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061"}, + {file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae"}, + {file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4"}, + {file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5"}, + {file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b"}, + {file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3"}, + {file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368"}, + {file = "pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031"}, + {file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63"}, + {file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c"}, + {file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70"}, + {file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b"}, + {file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122"}, + {file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6"}, + {file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c"}, + {file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a"}, + {file = "pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9"}, + {file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:1bcbe471ef3349be7714261dea28fe280db574f9d0f77eeccc195a2d161fd861"}, + {file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:a18a14baef7d7ae49247e75641fd8bcbb39f44ed49a9fc4ec2f65d5031aa3b96"}, + {file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb497649e505dc36542d0e68eca1a3c94ecbe9799cb67b578b55f2441a247fbc"}, + {file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11529a2283cb1f6271d7c23e4a8f9f8b7fd173f7360776b668e509d712a02eec"}, + {file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fc1499ed3b4b57ee4e090e1cea6eb3584793fe3d1b4297bbf53f09b434991a5"}, + {file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:db53390eaf8a4dab4dbd6d93c85c5cf002db24902dbff0ca7d988beb5c9dd15b"}, + {file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:851c6a8260ad387caf82d2bbf54759130534723e37083111d4ed481cb253cc0d"}, + {file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e22f80b97a271f0a7d9cd07394a7d348f80d3ac63ed7cc38b6d1b696ab3b2619"}, + {file = "pyarrow-20.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:9965a050048ab02409fb7cbbefeedba04d3d67f2cc899eff505cc084345959ca"}, + {file = "pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1"}, +] + +[package.extras] +test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"] + [[package]] name = "pycparser" version = "2.22" @@ -1216,6 +2061,26 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydeck" +version = "0.9.1" +description = "Widget for deck.gl maps" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038"}, + {file = "pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605"}, +] + +[package.dependencies] +jinja2 = ">=2.10.1" +numpy = ">=1.16.4" + +[package.extras] +carto = ["pydeck-carto"] +jupyter = ["ipykernel (>=5.1.2) ; python_version >= \"3.4\"", "ipython (>=5.8.0) ; python_version < \"3.4\"", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"] + [[package]] name = "pyee" version = "13.0.0" @@ -1234,6 +2099,53 @@ typing-extensions = "*" [package.extras] dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] +[[package]] +name = "pylibsrtp" +version = "0.12.0" +description = "Python wrapper around the libsrtp library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pylibsrtp-0.12.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5adde3cf9a5feef561d0eb7ed99dedb30b9bf1ce9a0c1770b2bf19fd0b98bc9a"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d2c81d152606721331ece87c80ed17159ba6da55c7c61a6b750cff67ab7f63a5"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:242fa3d44219846bf1734d5df595563a2c8fbb0fb00ccc79ab0f569fc0af2c1b"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b74aaf8fac1b119a3c762f54751c3d20e77227b84c26d85aae57c2c43129b49c"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e3e223102989b71f07e1deeb804170ed53fb4e1b283762eb031bd45bb425d4"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:36d07de64dbc82dbbb99fd77f36c8e23d6730bdbcccf09701945690a9a9a422a"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:ef03b4578577690f716fd023daed8914eee6de9a764fa128eda19a0e645cc032"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a8421e9fe4d20ce48d439430e55149f12b1bca1b0436741972c362c49948c0a"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-win32.whl", hash = "sha256:cbc9bfbfb2597e993a1aa16b832ba16a9dd4647f70815421bb78484f8b50b924"}, + {file = "pylibsrtp-0.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:061ef1dbb5f08079ac6d7515b7e67ca48a3163e16e5b820beea6b01cb31d7e54"}, + {file = "pylibsrtp-0.12.0.tar.gz", hash = "sha256:f5c3c0fb6954e7bb74dc7e6398352740ca67327e6759a199fe852dbc7b84b8ac"}, +] + +[package.dependencies] +cffi = ">=1.0.0" + +[package.extras] +dev = ["coverage[toml] (>=7.2.2)"] + +[[package]] +name = "pyopenssl" +version = "25.1.0" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab"}, + {file = "pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<46" +typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + [[package]] name = "pyserial" version = "3.5" @@ -1308,6 +2220,48 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.1.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytz" +version = "2025.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + [[package]] name = "pyusb" version = "1.3.1" @@ -1321,6 +2275,196 @@ files = [ {file = "pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e"}, ] +[[package]] +name = "referencing" +version = "0.36.2" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, + {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rpds-py" +version = "0.25.1" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rpds_py-0.25.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f4ad628b5174d5315761b67f212774a32f5bad5e61396d38108bd801c0a8f5d9"}, + {file = "rpds_py-0.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c742af695f7525e559c16f1562cf2323db0e3f0fbdcabdf6865b095256b2d40"}, + {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:605ffe7769e24b1800b4d024d24034405d9404f0bc2f55b6db3362cd34145a6f"}, + {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc6f3ddef93243538be76f8e47045b4aad7a66a212cd3a0f23e34469473d36b"}, + {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f70316f760174ca04492b5ab01be631a8ae30cadab1d1081035136ba12738cfa"}, + {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1dafef8df605fdb46edcc0bf1573dea0d6d7b01ba87f85cd04dc855b2b4479e"}, + {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0701942049095741a8aeb298a31b203e735d1c61f4423511d2b1a41dcd8a16da"}, + {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e87798852ae0b37c88babb7f7bbbb3e3fecc562a1c340195b44c7e24d403e380"}, + {file = "rpds_py-0.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bcce0edc1488906c2d4c75c94c70a0417e83920dd4c88fec1078c94843a6ce9"}, + {file = "rpds_py-0.25.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e2f6a2347d3440ae789505693a02836383426249d5293541cd712e07e7aecf54"}, + {file = "rpds_py-0.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4fd52d3455a0aa997734f3835cbc4c9f32571345143960e7d7ebfe7b5fbfa3b2"}, + {file = "rpds_py-0.25.1-cp310-cp310-win32.whl", hash = "sha256:3f0b1798cae2bbbc9b9db44ee068c556d4737911ad53a4e5093d09d04b3bbc24"}, + {file = "rpds_py-0.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3ebd879ab996537fc510a2be58c59915b5dd63bccb06d1ef514fee787e05984a"}, + {file = "rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d"}, + {file = "rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255"}, + {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2"}, + {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0"}, + {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f"}, + {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7"}, + {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd"}, + {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65"}, + {file = "rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f"}, + {file = "rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d"}, + {file = "rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042"}, + {file = "rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc"}, + {file = "rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4"}, + {file = "rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4"}, + {file = "rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c"}, + {file = "rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b"}, + {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa"}, + {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda"}, + {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309"}, + {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b"}, + {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea"}, + {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65"}, + {file = "rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c"}, + {file = "rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd"}, + {file = "rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb"}, + {file = "rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe"}, + {file = "rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192"}, + {file = "rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728"}, + {file = "rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559"}, + {file = "rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1"}, + {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c"}, + {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb"}, + {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40"}, + {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79"}, + {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325"}, + {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295"}, + {file = "rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b"}, + {file = "rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98"}, + {file = "rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd"}, + {file = "rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31"}, + {file = "rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500"}, + {file = "rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5"}, + {file = "rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129"}, + {file = "rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d"}, + {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72"}, + {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34"}, + {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9"}, + {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5"}, + {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194"}, + {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6"}, + {file = "rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78"}, + {file = "rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72"}, + {file = "rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66"}, + {file = "rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523"}, + {file = "rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763"}, + {file = "rpds_py-0.25.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ce4c8e485a3c59593f1a6f683cf0ea5ab1c1dc94d11eea5619e4fb5228b40fbd"}, + {file = "rpds_py-0.25.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8222acdb51a22929c3b2ddb236b69c59c72af4019d2cba961e2f9add9b6e634"}, + {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4593c4eae9b27d22df41cde518b4b9e4464d139e4322e2127daa9b5b981b76be"}, + {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd035756830c712b64725a76327ce80e82ed12ebab361d3a1cdc0f51ea21acb0"}, + {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:114a07e85f32b125404f28f2ed0ba431685151c037a26032b213c882f26eb908"}, + {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dec21e02e6cc932538b5203d3a8bd6aa1480c98c4914cb88eea064ecdbc6396a"}, + {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09eab132f41bf792c7a0ea1578e55df3f3e7f61888e340779b06050a9a3f16e9"}, + {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c98f126c4fc697b84c423e387337d5b07e4a61e9feac494362a59fd7a2d9ed80"}, + {file = "rpds_py-0.25.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0e6a327af8ebf6baba1c10fadd04964c1965d375d318f4435d5f3f9651550f4a"}, + {file = "rpds_py-0.25.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc120d1132cff853ff617754196d0ac0ae63befe7c8498bd67731ba368abe451"}, + {file = "rpds_py-0.25.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:140f61d9bed7839446bdd44852e30195c8e520f81329b4201ceead4d64eb3a9f"}, + {file = "rpds_py-0.25.1-cp39-cp39-win32.whl", hash = "sha256:9c006f3aadeda131b438c3092124bd196b66312f0caa5823ef09585a669cf449"}, + {file = "rpds_py-0.25.1-cp39-cp39-win_amd64.whl", hash = "sha256:a61d0b2c7c9a0ae45732a77844917b427ff16ad5464b4d4f5e4adb955f582890"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b24bf3cd93d5b6ecfbedec73b15f143596c88ee249fa98cefa9a9dc9d92c6f28"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:0eb90e94f43e5085623932b68840b6f379f26db7b5c2e6bcef3179bd83c9330f"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d50e4864498a9ab639d6d8854b25e80642bd362ff104312d9770b05d66e5fb13"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c9409b47ba0650544b0bb3c188243b83654dfe55dcc173a86832314e1a6a35d"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:796ad874c89127c91970652a4ee8b00d56368b7e00d3477f4415fe78164c8000"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85608eb70a659bf4c1142b2781083d4b7c0c4e2c90eff11856a9754e965b2540"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4feb9211d15d9160bc85fa72fed46432cdc143eb9cf6d5ca377335a921ac37b"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ccfa689b9246c48947d31dd9d8b16d89a0ecc8e0e26ea5253068efb6c542b76e"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3c5b317ecbd8226887994852e85de562f7177add602514d4ac40f87de3ae45a8"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:454601988aab2c6e8fd49e7634c65476b2b919647626208e376afcd22019eeb8"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1c0c434a53714358532d13539272db75a5ed9df75a4a090a753ac7173ec14e11"}, + {file = "rpds_py-0.25.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f73ce1512e04fbe2bc97836e89830d6b4314c171587a99688082d090f934d20a"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf"}, + {file = "rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:50f2c501a89c9a5f4e454b126193c5495b9fb441a75b298c60591d8a2eb92e1b"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7d779b325cc8238227c47fbc53964c8cc9a941d5dbae87aa007a1f08f2f77b23"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:036ded36bedb727beeabc16dc1dad7cb154b3fa444e936a03b67a86dc6a5066e"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245550f5a1ac98504147cba96ffec8fabc22b610742e9150138e5d60774686d7"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff7c23ba0a88cb7b104281a99476cccadf29de2a0ef5ce864959a52675b1ca83"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e37caa8cdb3b7cf24786451a0bdb853f6347b8b92005eeb64225ae1db54d1c2b"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2f48ab00181600ee266a095fe815134eb456163f7d6699f525dee471f312cf"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e5fc7484fa7dce57e25063b0ec9638ff02a908304f861d81ea49273e43838c1"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d3c10228d6cf6fe2b63d2e7985e94f6916fa46940df46b70449e9ff9297bd3d1"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:5d9e40f32745db28c1ef7aad23f6fc458dc1e29945bd6781060f0d15628b8ddf"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:35a8d1a24b5936b35c5003313bc177403d8bdef0f8b24f28b1c4a255f94ea992"}, + {file = "rpds_py-0.25.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6099263f526efff9cf3883dfef505518730f7a7a93049b1d90d42e50a22b4793"}, + {file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"}, +] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "smmap" +version = "5.0.2" +description = "A pure Python implementation of a sliding window memory map manager" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, + {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1335,17 +2479,17 @@ files = [ [[package]] name = "sounddevice" -version = "0.5.1" +version = "0.5.2" description = "Play and Record Sound with Python" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "sounddevice-0.5.1-py3-none-any.whl", hash = "sha256:e2017f182888c3f3c280d9fbac92e5dbddac024a7e3442f6e6116bd79dab8a9c"}, - {file = "sounddevice-0.5.1-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:d16cb23d92322526a86a9490c427bf8d49e273d9ccc0bd096feecd229cde6031"}, - {file = "sounddevice-0.5.1-py3-none-win32.whl", hash = "sha256:d84cc6231526e7a08e89beff229c37f762baefe5e0cc2747cbe8e3a565470055"}, - {file = "sounddevice-0.5.1-py3-none-win_amd64.whl", hash = "sha256:4313b63f2076552b23ac3e0abd3bcfc0c1c6a696fc356759a13bd113c9df90f1"}, - {file = "sounddevice-0.5.1.tar.gz", hash = "sha256:09ca991daeda8ce4be9ac91e15a9a81c8f81efa6b695a348c9171ea0c16cb041"}, + {file = "sounddevice-0.5.2-py3-none-any.whl", hash = "sha256:82375859fac2e73295a4ab3fc60bd4782743157adc339561c1f1142af472f505"}, + {file = "sounddevice-0.5.2-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:943f27e66037d41435bdd0293454072cdf657b594c9cde63cd01ee3daaac7ab3"}, + {file = "sounddevice-0.5.2-py3-none-win32.whl", hash = "sha256:3a113ce614a2c557f14737cb20123ae6298c91fc9301eb014ada0cba6d248c5f"}, + {file = "sounddevice-0.5.2-py3-none-win_amd64.whl", hash = "sha256:e18944b767d2dac3771a7771bdd7ff7d3acd7d334e72c4bedab17d1aed5dbc22"}, + {file = "sounddevice-0.5.2.tar.gz", hash = "sha256:c634d51bd4e922d6f0fa5e1a975cc897c947f61d31da9f79ba7ea34dff448b49"}, ] [package.dependencies] @@ -1372,6 +2516,91 @@ anyio = ">=3.6.2,<5" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "streamlit" +version = "1.45.1" +description = "A faster way to build and share data apps" +optional = false +python-versions = "!=3.9.7,>=3.9" +groups = ["main"] +files = [ + {file = "streamlit-1.45.1-py3-none-any.whl", hash = "sha256:9ab6951585e9444672dd650850f81767b01bba5d87c8dac9bc2e1c859d6cc254"}, + {file = "streamlit-1.45.1.tar.gz", hash = "sha256:e37d56c0af5240dbc240976880e81366689c290a559376417246f9b3f51b4217"}, +] + +[package.dependencies] +altair = ">=4.0,<6" +blinker = ">=1.5.0,<2" +cachetools = ">=4.0,<6" +click = ">=7.0,<9" +gitpython = ">=3.0.7,<3.1.19 || >3.1.19,<4" +numpy = ">=1.23,<3" +packaging = ">=20,<25" +pandas = ">=1.4.0,<3" +pillow = ">=7.1.0,<12" +protobuf = ">=3.20,<7" +pyarrow = ">=7.0" +pydeck = ">=0.8.0b4,<1" +requests = ">=2.27,<3" +tenacity = ">=8.1.0,<10" +toml = ">=0.10.1,<2" +tornado = ">=6.0.3,<7" +typing-extensions = ">=4.4.0,<5" +watchdog = {version = ">=2.1.5,<7", markers = "platform_system != \"Darwin\""} + +[package.extras] +snowflake = ["snowflake-connector-python (>=3.3.0) ; python_version < \"3.12\"", "snowflake-snowpark-python[modin] (>=1.17.0) ; python_version < \"3.12\""] + +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tornado" +version = "6.5.1" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7"}, + {file = "tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6"}, + {file = "tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888"}, + {file = "tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331"}, + {file = "tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e"}, + {file = "tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401"}, + {file = "tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692"}, + {file = "tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a"}, + {file = "tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365"}, + {file = "tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b"}, + {file = "tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7"}, + {file = "tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c"}, +] + [[package]] name = "typing-extensions" version = "4.13.0" @@ -1399,6 +2628,36 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, + {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "uvicorn" version = "0.34.0" @@ -1418,6 +2677,50 @@ h11 = ">=0.8" [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_system != \"Darwin\"" +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + [[package]] name = "wcwidth" version = "0.2.13" @@ -1631,4 +2934,4 @@ test = ["pytest", "pytest-asyncio"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "43fd58df49d1a116d9cacc305ee2dcee08521a6421aa4736916449c49ef42b2e" +content-hash = "723981c612b424756f0338604c35c9cef37fd44ffd208e15c7865b1f5e83bc05" diff --git a/pyproject.toml b/pyproject.toml index 72569fe..3587878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,16 @@ requires-python = ">=3.11" dependencies = [ "bumble @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/bumble_mirror.git@12bcdb7770c0d57a094bc0a96cd52e701f97fece", "lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc", - "sounddevice", "aioconsole", "fastapi==0.115.11", "uvicorn==0.34.0", "aiohttp==3.9.3", - "sounddevice (>=0.5.1,<0.6.0)", - "aioconsole (>=0.8.1,<0.9.0)" + "aioconsole (>=0.8.1,<0.9.0)", + "numpy (>=2.2.6,<3.0.0)", + "streamlit (>=1.45.1,<2.0.0)", + "aiortc (>=1.13.0,<2.0.0)", + "sounddevice (>=0.5.2,<0.6.0)", + "python-dotenv (>=1.1.1,<2.0.0)" ] [project.optional-dependencies] diff --git a/src/auracast/auracast_config.py b/src/auracast/auracast_config.py index a732393..dfeb1f9 100644 --- a/src/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -35,6 +35,10 @@ class AuracastGlobalConfig(BaseModel): presentation_delay_us: int = 40000 # TODO:pydantic does not support bytes serialization - use .hex and np.fromhex() manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None) + # LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09) + # When true, include a zero-length LTV with type 0x09 in the subgroup metadata + # so receivers may render earlier than the presentation delay for lower latency. + immediate_rendering: bool = False # "Audio input. " # "'device' -> use the host's default sound input device, " @@ -58,42 +62,52 @@ class AuracastBigConfig(BaseModel): class AuracastBigConfigDeu(AuracastBigConfig): id: int = 12 random_address: str = 'F1:F1:F2:F3:F4:F5' - name: str = 'Broadcast0' + name: str = 'Hörsaal A' language: str ='deu' - program_info: str = 'Announcements German' - audio_source: str = 'file:./testdata/announcement_de.wav' + program_info: str = 'Vorlesung DE' + audio_source: str = 'file:./testdata/wave_particle_5min_de.wav' class AuracastBigConfigEng(AuracastBigConfig): id: int = 123 random_address: str = 'F2:F1:F2:F3:F4:F5' - name: str = 'Broadcast1' + name: str = 'Lecture Hall A' language: str ='eng' - program_info: str = 'Announcements English' - audio_source: str = 'file:./testdata/announcement_en.wav' + program_info: str = 'Lecture EN' + audio_source: str = 'file:./testdata/wave_particle_5min_en.wav' class AuracastBigConfigFra(AuracastBigConfig): id: int = 1234 random_address: str = 'F3:F1:F2:F3:F4:F5' - name: str = 'Broadcast2' + # French + name: str = 'Auditoire A' language: str ='fra' - program_info: str = 'Announcements French' - audio_source: str = 'file:./testdata/announcement_fr.wav' + program_info: str = 'Auditoire FR' + audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav' class AuracastBigConfigSpa(AuracastBigConfig): id: int =12345 random_address: str = 'F4:F1:F2:F3:F4:F5' - name: str = 'Broadcast3' + name: str = 'Auditorio A' language: str ='spa' - program_info: str = 'Announcements Spanish' - audio_source: str = 'file:./testdata/announcement_es.wav' + program_info: str = 'Auditorio ES' + audio_source: str = 'file:./testdata/wave_particle_5min_es.wav' class AuracastBigConfigIta(AuracastBigConfig): id: int =1234567 random_address: str = 'F5:F1:F2:F3:F4:F5' - name: str = 'Broadcast4' + name: str = 'Aula A' language: str ='ita' - program_info: str = 'Announcements Italian' - audio_source: str = 'file:./testdata/announcement_it.wav' + program_info: str = 'Aula IT' + audio_source: str = 'file:./testdata/wave_particle_5min_it.wav' + + +class AuracastBigConfigPol(AuracastBigConfig): + id: int =12345678 + random_address: str = 'F6:F1:F2:F3:F4:F5' + name: str = 'Sala Wykładowa' + language: str ='pol' + program_info: str = 'Sala Wykładowa PL' + audio_source: str = 'file:./testdata/wave_particle_5min_pl.wav' class AuracastConfigGroup(AuracastGlobalConfig): diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 3542879..f5d694b 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -44,13 +44,18 @@ from bumble.profiles import bass import bumble.device import bumble.transport import bumble.utils +import numpy as np # for audio down-mix from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap from bumble.audio import io as audio_io from auracast import auracast_config from auracast.utils.read_lc3_file import read_lc3_file +from auracast.utils.network_audio_receiver import NetworkAudioReceiverUncoded +from auracast.utils.webrtc_audio_input import WebRTCAudioInput +# Instantiate WebRTC audio input for streaming (can be used per-BIG or globally) + # modified from bumble class ModWaveAudioInput(audio_io.ThreadedAudioInput): """Audio input that reads PCM samples from a .wav file.""" @@ -148,6 +153,30 @@ async def init_broadcast( bap_sampling_freq = getattr(bap.SamplingFrequency, f"FREQ_{global_config.auracast_sampling_rate_hz}") bigs = {} for i, conf in enumerate(big_config): + metadata=le_audio.Metadata( + [ + le_audio.Metadata.Entry( + tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode() + ), + le_audio.Metadata.Entry( + tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode() + ), + le_audio.Metadata.Entry( + tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode() + ), + ] + + ( + [ + # Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value + le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG, data=b"") + ] + if global_config.immediate_rendering #TODO: verify this + else [] + ) + ) + logging.info( + metadata.pretty_print("\n") + ) bigs[f'big{i}'] = {} # Config advertising set bigs[f'big{i}']['basic_audio_announcement'] = bap.BasicAudioAnnouncement( @@ -160,19 +189,7 @@ async def init_broadcast( frame_duration=bap.FrameDuration.DURATION_10000_US, octets_per_codec_frame=global_config.octets_per_frame, ), - metadata=le_audio.Metadata( - [ - le_audio.Metadata.Entry( - tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode() - ), - le_audio.Metadata.Entry( - tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode() - ), - le_audio.Metadata.Entry( - tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode() - ), - ] - ), + metadata=metadata, bis=[ bap.BasicAudioAnnouncement.BIS( index=1, @@ -211,7 +228,7 @@ async def init_broadcast( primary_advertising_interval_max=200, advertising_sid=i, primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported - secondary_advertising_phy=hci.Phy.LE_2M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising) + secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising) #advertising_tx_power= # tx power in dbm (max 20) #secondary_advertising_max_skip=10, ), @@ -272,7 +289,7 @@ async def init_broadcast( logging.debug(f'big{i} parameters are:') logging.debug('%s', pprint.pformat(vars(big))) - logging.debug(f'Finished setup of big{i}.') + logging.info(f'Finished setup of big{i}.') await asyncio.sleep(i+1) # Wait for advertising to set up @@ -322,26 +339,75 @@ class Streamer(): else: logging.warning('Streamer is already running') - def stop_streaming(self): - """Stops the background task if running.""" - if self.is_streaming: - self.is_streaming = False - if self.task: - self.task.cancel() # Cancel the task safely - self.task = None + async def stop_streaming(self): + """Gracefully stop streaming and release audio devices.""" + if not self.is_streaming and self.task is None: + return + + # Ask the streaming loop to finish + self.is_streaming = False + if self.task is not None: + self.task.cancel() + + self.task = None + + # Close audio inputs (await to ensure ALSA devices are released) + close_tasks = [] + for big in self.bigs.values(): + ai = big.get("audio_input") + if ai and hasattr(ai, "close"): + close_tasks.append(ai.close()) + # Remove reference so a fresh one is created next time + big.pop("audio_input", None) + if close_tasks: + await asyncio.gather(*close_tasks, return_exceptions=True) async def stream(self): + bigs = self.bigs big_config = self.big_config global_config = self.global_config - # init for i, big in enumerate(bigs.values()): audio_source = big_config[i].audio_source input_format = big_config[i].input_format + # --- New: network_uncoded mode using NetworkAudioReceiver --- + if isinstance(audio_source, NetworkAudioReceiverUncoded): + # Start the UDP receiver coroutine so packets are actually received + asyncio.create_task(audio_source.receive()) + encoder = lc3.Encoder( + frame_duration_us=global_config.frame_duration_us, + sample_rate_hz=global_config.auracast_sampling_rate_hz, + num_channels=1, + input_sample_rate_hz=audio_source.samplerate, + ) + lc3_frame_samples = encoder.get_frame_samples() + big['pcm_bit_depth'] = 16 + big['lc3_frame_samples'] = lc3_frame_samples + big['lc3_bytes_per_frame'] = global_config.octets_per_frame + big['audio_input'] = audio_source + big['encoder'] = encoder + big['precoded'] = False + + elif audio_source == 'webrtc': + big['audio_input'] = WebRTCAudioInput() + encoder = lc3.Encoder( + frame_duration_us=global_config.frame_duration_us, + sample_rate_hz=global_config.auracast_sampling_rate_hz, + num_channels=1, + input_sample_rate_hz=48000, # TODO: get samplerate from webrtc + ) + lc3_frame_samples = encoder.get_frame_samples() + big['pcm_bit_depth'] = 16 + big['lc3_frame_samples'] = lc3_frame_samples + big['lc3_bytes_per_frame'] = global_config.octets_per_frame + big['encoder'] = encoder + big['precoded'] = False + # precoded lc3 from ram - if isinstance(big_config[i].audio_source, bytes): + elif isinstance(big_config[i].audio_source, bytes): big['precoded'] = True + big['lc3_bytes_per_frame'] = global_config.octets_per_frame lc3_frames = iter(big_config[i].audio_source) @@ -352,6 +418,7 @@ class Streamer(): # precoded lc3 file elif big_config[i].audio_source.endswith('.lc3'): big['precoded'] = True + big['lc3_bytes_per_frame'] = global_config.octets_per_frame filename = big_config[i].audio_source.replace('file:', '') lc3_bytes = read_lc3_file(filename) @@ -363,21 +430,23 @@ class Streamer(): # use wav files and code them entirely before streaming elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'): + logging.info('Precoding wav file: %s, this may take a while', big_config[i].audio_source) big['precoded'] = True + big['lc3_bytes_per_frame'] = global_config.octets_per_frame audio_input = await audio_io.create_audio_input(audio_source, input_format) audio_input.rewind = False pcm_format = await audio_input.open() if pcm_format.channels != 1: - print("Only 1 channels PCM configurations are supported") + logging.error("Only 1 channels PCM configurations are supported") return if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16: pcm_bit_depth = 16 elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32: pcm_bit_depth = None else: - print("Only INT16 and FLOAT32 sample types are supported") + logging.error("Only INT16 and FLOAT32 sample types are supported") return encoder = lc3.Encoder( frame_duration_us=global_config.frame_duration_us, @@ -402,40 +471,92 @@ class Streamer(): # anything else, e.g. realtime stream from device (bumble) else: audio_input = await audio_io.create_audio_input(audio_source, input_format) - audio_input.rewind = big_config[i].loop - pcm_format = await audio_input.open() + # Store early so stop_streaming can close even if open() fails + big['audio_input'] = audio_input + # SoundDeviceAudioInput (used for `mic:` captures) has no `.rewind`. + if hasattr(audio_input, "rewind"): + audio_input.rewind = big_config[i].loop - #try: - if pcm_format.channels != 1: - print("Only 1 channels PCM configurations are supported") + # Retry logic – ALSA sometimes keeps the device busy for a short time after the + # previous stream has closed. Handle PortAudioError -9985 with back-off retries. + import sounddevice as _sd + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + pcm_format = await audio_input.open() + break # success + except _sd.PortAudioError as err: + # -9985 == paDeviceUnavailable + logging.error('Could not open audio device %s with error %s', audio_source, err) + code = None + if hasattr(err, 'errno'): + code = err.errno + elif len(err.args) > 1 and isinstance(err.args[1], int): + code = err.args[1] + if code == -9985 and attempt < max_attempts: + backoff_ms = 200 * attempt + logging.warning("PortAudio device busy (attempt %d/%d). Retrying in %.1f ms…", attempt, max_attempts, backoff_ms) + # ensure device handle and PortAudio context are closed before retrying + try: + if hasattr(audio_input, "aclose"): + await audio_input.aclose() + elif hasattr(audio_input, "close"): + audio_input.close() + except Exception: + pass + # Fully terminate PortAudio to drop lingering handles (sounddevice quirk) + if hasattr(_sd, "_terminate"): + try: + _sd._terminate() + except Exception: + pass + # Small pause then re-initialize PortAudio + await asyncio.sleep(0.1) + if hasattr(_sd, "_initialize"): + try: + _sd._initialize() + except Exception: + pass + + # Back-off before next attempt + await asyncio.sleep(backoff_ms / 1000) + # Recreate audio_input fresh for next attempt + audio_input = await audio_io.create_audio_input(audio_source, input_format) + continue + # Other errors or final attempt – re-raise so caller can abort gracefully + raise + else: + # Loop exhausted without break + logging.error("Unable to open audio device after %d attempts – giving up", max_attempts) return + + if pcm_format.channels != 1: + logging.info("Input device provides %d channels – will down-mix to mono for LC3", pcm_format.channels) if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16: pcm_bit_depth = 16 elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32: pcm_bit_depth = None else: - print("Only INT16 and FLOAT32 sample types are supported") + logging.error("Only INT16 and FLOAT32 sample types are supported") return + encoder = lc3.Encoder( frame_duration_us=global_config.frame_duration_us, sample_rate_hz=global_config.auracast_sampling_rate_hz, num_channels=1, input_sample_rate_hz=pcm_format.sample_rate, ) + lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame - big['pcm_bit_depth'] = pcm_bit_depth + big['channels'] = pcm_format.channels big['lc3_frame_samples'] = lc3_frame_samples + big['lc3_bytes_per_frame'] = global_config.octets_per_frame big['audio_input'] = audio_input big['encoder'] = encoder big['precoded'] = False - # Need for coded an uncoded audio - lc3_frame_size = global_config.octets_per_frame #encoder.get_frame_bytes(bitrate) - lc3_bytes_per_frame = lc3_frame_size #* 2 #multiplied by number of channels - big['lc3_bytes_per_frame'] = lc3_bytes_per_frame - # TODO: Maybe do some pre buffering so the stream is stable from the beginning. One half iso queue would be appropriate logging.info("Streaming audio...") bigs = self.bigs self.is_streaming = True @@ -443,7 +564,6 @@ class Streamer(): while self.is_streaming: stream_finished = [False for _ in range(len(bigs))] for i, big in enumerate(bigs.values()): - if big['precoded']:# everything was already lc3 coded beforehand lc3_frame = bytes( itertools.islice(big['lc3_frames'], big['lc3_bytes_per_frame']) @@ -452,13 +572,26 @@ class Streamer(): if lc3_frame == b'': # Not all streams may stop at the same time stream_finished[i] = True continue - else: + else: # code lc3 on the fly pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None) if pcm_frame is None: # Not all streams may stop at the same time stream_finished[i] = True continue + # Down-mix multi-channel PCM to mono for LC3 encoder if needed + if big.get('channels', 1) > 1: + if isinstance(pcm_frame, np.ndarray): + if pcm_frame.ndim > 1: + mono = pcm_frame.mean(axis=1).astype(pcm_frame.dtype) + pcm_frame = mono + else: + # Convert raw bytes to numpy, average channels, convert back + dtype = np.int16 if big['pcm_bit_depth'] == 16 else np.float32 + samples = np.frombuffer(pcm_frame, dtype=dtype) + samples = samples.reshape(-1, big['channels']).mean(axis=1) + pcm_frame = samples.astype(dtype).tobytes() + lc3_frame = big['encoder'].encode( pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth'] ) @@ -511,13 +644,12 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf: if __name__ == "__main__": import os - logging.basicConfig( #export LOG_LEVEL=INFO - level=os.environ.get('LOG_LEVEL', logging.DEBUG), + logging.basicConfig( #export LOG_LEVEL=DEBUG + level=os.environ.get('LOG_LEVEL', logging.INFO), format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' ) os.chdir(os.path.dirname(__file__)) - config = auracast_config.AuracastConfigGroup( bigs = [ auracast_config.AuracastBigConfigDeu(), @@ -537,15 +669,19 @@ if __name__ == "__main__": #config.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_95A087EADB030B24-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc #config.transport='usb:2fe3:000b' #nrf52dongle hci_usb # TODO: iso packet over usb not supported #config.transport= 'auto' - config.transport='serial:/dev/ttyAMA2,1000000,rtscts' # transport for raspberry pi + config.transport='serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi + # TODO: encrypted streams are not working - for big in config.bigs: # TODO: encrypted streams are not working + for big in config.bigs: #big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR #big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae' - big.precode_wav = True - big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files - big.audio_source = read_lc3_file(big.audio_source) # load files in advance + big.precode_wav = False + #big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files + #big.audio_source = read_lc3_file(big.audio_source) # load files in advance + + # --- Network_uncoded mode using NetworkAudioReceiver --- + #big.audio_source = NetworkAudioReceiverUncoded(port=50007, samplerate=16000, channels=1, chunk_size=1024) # 16kHz works reliably with 3 streams # 24kHz is only working with 2 streams - probably airtime constraint diff --git a/src/auracast/multicast_control.py b/src/auracast/multicast_control.py index 507eed3..ac9b540 100644 --- a/src/auracast/multicast_control.py +++ b/src/auracast/multicast_control.py @@ -52,13 +52,19 @@ class Multicaster: self.device = device self.is_auracast_init = True - def start_streaming(self): + async def start_streaming(self): + """Start streaming; if an old stream is running, stop it first to release audio devices.""" + if self.streamer is not None: + await self.stop_streaming() + # Brief pause to ensure ALSA/PortAudio fully releases the input device + await asyncio.sleep(0.5) self.streamer = multicast.Streamer(self.bigs, self.global_conf, self.big_conf) self.streamer.start_streaming() - def stop_streaming(self): + + async def stop_streaming(self): if self.streamer is not None: - self.streamer.stop_streaming() + await self.streamer.stop_streaming() self.streamer = None async def reset(self): @@ -66,18 +72,28 @@ class Multicaster: self.__init__(self.global_conf, self.big_conf) async def shutdown(self): + # Ensure streaming is fully stopped before tearing down Bluetooth resources + if self.streamer is not None: + await self.stop_streaming() + self.is_auracast_init = False - self. is_audio_init = False + self.is_audio_init = False + + if self.bigs: + for big in self.bigs.values(): + if big.get('audio_input'): + if hasattr(big['audio_input'], 'aclose'): + await big['audio_input'].aclose() + if self.device: await self.device.stop_advertising() if self.bigs: for big in self.bigs.values(): - if big['advertising_set']: + if big.get('advertising_set'): await big['advertising_set'].stop() await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown - # example commandline ui async def command_line_ui(caster: Multicaster): while True: diff --git a/src/auracast/multicast_script.py b/src/auracast/multicast_script.py new file mode 100644 index 0000000..dd0ead6 --- /dev/null +++ b/src/auracast/multicast_script.py @@ -0,0 +1,155 @@ +""" +multicast_script +================= + +Loads environment variables from a .env file located next to this script +and configures the multicast broadcast. Only UPPERCASE keys are read. + +Environment variables +--------------------- +- LOG_LEVEL: Logging level for the script. + Default: INFO. Examples: DEBUG, INFO, WARNING, ERROR. + +- INPUT: Select audio capture source. + Values: + - "usb" (default): first available USB input device. + - "aes67": select AES67 inputs. Two forms: + * INPUT=aes67 -> first available AES67 input. + * INPUT=aes67, -> case-insensitive substring match against + the device name, e.g. INPUT=aes67,8f6326. + +- BROADCAST_NAME: Name of the broadcast (Auracast BIG name). + Default: "Broadcast0". + +- PROGRAM_INFO: Free-text program/broadcast info. + Default: "Some Announcements". + +- LANGUATE: ISO 639-3 language code used by config (intentional key name). + Default: "deu". + +- PULSE_LATENCY_MSEC: Pulse/PipeWire latency hint in milliseconds. + Default: 3. + +Examples (.env) +--------------- +LOG_LEVEL=DEBUG +INPUT=aes67,8f6326 +BROADCAST_NAME=MyBroadcast +PROGRAM_INFO="Live announcements" +LANGUATE=deu +""" +import logging +import os +import time +from dotenv import load_dotenv +from auracast import multicast +from auracast import auracast_config +from auracast.utils.sounddevice_utils import list_usb_pw_inputs, list_network_pw_inputs + + +if __name__ == "__main__": + + logging.basicConfig( #export LOG_LEVEL=DEBUG + level=os.environ.get('LOG_LEVEL', logging.INFO), + format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' + ) + os.chdir(os.path.dirname(__file__)) + # Load .env located next to this script (only uppercase keys will be referenced) + load_dotenv(dotenv_path='.env') + + os.environ.setdefault("PULSE_LATENCY_MSEC", "3") + + usb_inputs = list_usb_pw_inputs() + logging.info("USB pw inputs:") + for i, d in usb_inputs: + logging.info(f"{i}: {d['name']} in={d['max_input_channels']}") + + aes67_inputs = list_network_pw_inputs() + logging.info("AES67 pw inputs:") + for i, d in aes67_inputs: + logging.info(f"{i}: {d['name']} in={d['max_input_channels']}") + + # Input selection (usb | aes67). Default to usb. + # Allows specifying an AES67 device by substring: INPUT=aes67, + # Example: INPUT=aes67,8f6326 will match a device name containing "8f6326". + input_env = os.environ.get('INPUT', 'usb') or 'usb' + parts = [p.strip() for p in input_env.split(',', 1)] + input_mode = (parts[0] or 'usb').lower() + iface_substr = (parts[1].lower() if len(parts) > 1 and parts[1] else None) + + selected_dev = None + if input_mode == 'aes67': + if not aes67_inputs and not iface_substr: + # No AES67 inputs and no specific target -> fail fast + raise RuntimeError("No AES67 audio inputs found.") + if iface_substr: + # Loop until a matching AES67 input becomes available + while True: + current = list_network_pw_inputs() + sel = next(((i, d) for i, d in current if iface_substr in (d.get('name','').lower())), None) + if sel: + input_sel = sel[0] + selected_dev = sel[1] + logging.info(f"Selected AES67 input by match '{iface_substr}': index={input_sel}") + break + logging.info(f"Waiting for AES67 input matching '{iface_substr}'... retrying in 2s") + time.sleep(2) + else: + input_sel, selected_dev = aes67_inputs[0] + logging.info(f"Selected first AES67 input: index={input_sel}, device={selected_dev['name']}") + else: + if usb_inputs: + input_sel, selected_dev = usb_inputs[0] + logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}") + else: + raise RuntimeError("No USB audio inputs found.") + + TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header + TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header + # Capture at 48 kHz to avoid PipeWire resampler latency; encode LC3 at 24 kHz + CAPTURE_SRATE = 48000 + LC3_SRATE = 24000 + OCTETS_PER_FRAME=60 + + # Read uppercase-only settings from environment/.env + broadcast_name = os.environ.get('BROADCAST_NAME', 'Broadcast0') + program_info = os.environ.get('PROGRAM_INFO', 'Some Announcements') + # Note: 'LANGUATE' (typo) is intentionally used as requested, maps to config.language + language = os.environ.get('LANGUATE', 'deu') + + # Determine capture channel count based on selected device (prefer up to 2) + try: + max_in = int((selected_dev or {}).get('max_input_channels', 1)) + except Exception: + max_in = 1 + channels = max(1, min(2, max_in)) + + config = auracast_config.AuracastConfigGroup( + bigs = [ + auracast_config.AuracastBigConfig( + name=broadcast_name, + program_info=program_info, + language=language, + iso_que_len=1, + audio_source=f'device:{input_sel}', + input_format=f"int16le,{CAPTURE_SRATE},{channels}", + sampling_frequency=LC3_SRATE, + octets_per_frame=OCTETS_PER_FRAME, + ), + #auracast_config.AuracastBigConfigEng(), + ], + immediate_rendering=True, + presentation_delay_us=40000, + qos_config=auracast_config.AuracastQosHigh(), + auracast_sampling_rate_hz = LC3_SRATE, + octets_per_frame = OCTETS_PER_FRAME, # 32kbps@16kHz + transport=TRANSPORT1 + ) + #config.debug = True + + multicast.run_async( + multicast.broadcast( + config, + config.bigs + ) + ) diff --git a/src/auracast/multicast_server.py b/src/auracast/multicast_server.py deleted file mode 100644 index e24b4f0..0000000 --- a/src/auracast/multicast_server.py +++ /dev/null @@ -1,104 +0,0 @@ -import glob -import logging as log -from fastapi import FastAPI, HTTPException -from auracast import multicast_control, auracast_config - -app = FastAPI() - -# Initialize global configuration -global_config_group = auracast_config.AuracastConfigGroup() - -# Create multicast controller -multicaster: multicast_control.Multicaster | None = None - - -@app.post("/init") -async def initialize(conf: auracast_config.AuracastConfigGroup): - """Initializes the broadcasters.""" - global global_config_group - global multicaster - try: - if conf.transport == 'auto': - serial_devices = glob.glob('/dev/serial/by-id/*') - log.info('Found serial devices: %s', serial_devices) - for device in serial_devices: - if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device: - log.info('Using: %s', device) - conf.transport = f'serial:{device},115200,rtscts' - break - - # check again if transport is still auto - if conf.transport == 'auto': - HTTPException(status_code=500, detail='No suitable transport found.') - - # initialize the streams dict - global_config_group = conf - log.info( - 'Initializing multicaster with config:\n %s', conf.model_dump_json(indent=2) - ) - multicaster = multicast_control.Multicaster( - conf, - conf.bigs, - ) - await multicaster.init_broadcast() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/stream_lc3") -async def send_audio(audio_data: dict[str, str]): - """Streams pre-coded LC3 audio.""" - if multicaster is None: - raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized') - try: - for big in global_config_group.bigs: - assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch') - log.info('Received a send audio request for %s', big.language) - big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding - - multicaster.big_conf = global_config_group.bigs - multicaster.start_streaming() - return {"status": "audio_sent"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/shutdown") -async def shutdown(): - """Stops broadcasting.""" - try: - await multicaster.reset() - return {"status": "stopped"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/stop_audio") -async def stop_audio(): - """Stops streaming.""" - try: - multicaster.stop_streaming() - return {"status": "stopped"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/status") -async def get_status(): - """Gets the current status of the multicaster.""" - if multicaster: - return multicaster.get_status() - else: - return { - 'is_initialized': False, - 'is_streaming': False, - } - - -if __name__ == '__main__': - import uvicorn - log.basicConfig( - level=log.INFO, - format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' - ) - uvicorn.run(app, host="0.0.0.0", port=5000) \ No newline at end of file diff --git a/src/auracast/server/certs/ca/ca_cert.crt b/src/auracast/server/certs/ca/ca_cert.crt new file mode 100644 index 0000000..b75680d --- /dev/null +++ b/src/auracast/server/certs/ca/ca_cert.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFDzCCAvegAwIBAgIUJkOMN61fArjxyeFLR1u2PnoUCogwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMB4XDTI1MDYyOTE0MzMyOVoXDTQ1 +MDYyNDE0MzMyOVowFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAnU1k3Yasc2mFCHNdBzf76Y7NTUIy50577fJL +kVVjwxUsUzimj6hkeTd16o5EsmTpW19/N8o/JJ1j3ne37EB8vm0q9H7yyN0fx+Gy +uJujmqu5ZG9+ow+kbqpxJUbRMmDkZqsF6/XHfNMUQLK5vVH219xmW/hgxdEB4o50 +jF25+jhUuolVYybhLT9AGtXhpqExmCn/o78I97+GtYNdkY8cwCt/khftM4DRDeEA +NdyVUWHG2sWqgx0BpgyL9gH/YwfeqBrjFmhh1VbgPCdgypwRV6YHVUqPtmSL7H7q +CmX8/ccyS6Cif9z/rsb1KwSeOgNKqV3D5DN3Qrboy9NmbWKXmhnF3Pl0EQ5f2/WS +xN+NKo8LNyZErQ27jZ6Xn9rVBRQ4rTw5oVf5hi6bOZcW2GNIQhQomQy83ohwFDnW +6aLsBag4/lGJFS+QpRAwIvFY4R559Ki3xndUQpvbt0KHIUNTlWddACm1tkcgXEGF +GJRZMBcKlyNdM5cRjhMtuZljoY2nHdfouiy4SETHgFFVvIZ2uOZLikljkL7cnWqF +0DZh9MxIZqZEoffSDRCRdlhmPITwuacGTFBNAmiGqg463rNmzcyc5JOoPUQrcSy1 +0F5Ig16tiGjpgNtqyBen0r0udEoU1bBF/kxhAQCbam/IqpTtR+ouRnnbE4ST2zV6 +IXc4mPcCAwEAAaNTMFEwHQYDVR0OBBYEFOsaKvMh7Lr+/O620X2uzHxlnvmzMB8G +A1UdIwQYMBaAFOsaKvMh7Lr+/O620X2uzHxlnvmzMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggIBAAxq7hy9xgDCxwZOQX8Z5iBIK3agXUads7m91TcH +/EzFfJsUDOpDsSi58wIXuCmiJ+qe2S+hJxghLNsqm01DosPuNLNI0gCDg+glx5z5 +ADtY0EJb7mRH+xuFC1GBdP7ve3REvfi7WC9snrqBUji/xL4VycaOyTDGOxWaHlyZ +u876I6/+xkj5hkhM1bsbEcGZ81QnTaJyeVtHTRYaORPAb2FP2V65MTn18Pu08i4T +bzh0KAsoDkwKvoEK24T5xFEUCuLexQ+6fabYXGro3It9VmAbrtkSyX8Z1eO7rVCu +hsUrA6UDzTerX1pWafeftpKiH7YiOaYYOAVcqDn+WKwYq3MPafNJp8x8HV1eeWYD +dx9HBKuvlOsoxnjJMnYusmQZyJk1EJR03najrV7HH8cyU2gfNyBwfsr6nU+FnDOX +qL2P0nWDjBkfjQRvmG59YLDVZYhw30+lishpmMLGZGwRFCjMCHD7rAdQTB3dtCP6 +NqaGogwitIdIITBtyV1ZABoE3vQuUAKZChU+DsSKniyFitKDQrXP+rwcX5Y5/pS1 +S1s6ITgllbErKqAoeelEVkJyiWykEtrtdcD0DXTr/QY4GzXeMi9u+dMXUOt95Md2 +lQVAaFIX8QxbmHXen6GsXeHhPpPw8sXtC6rh7aqSCqqB6EDS77mjrGHXbSeBS5aq +MklC +-----END CERTIFICATE----- diff --git a/src/auracast/server/certs/ca/ca_cert.der b/src/auracast/server/certs/ca/ca_cert.der new file mode 100644 index 0000000..dba767d Binary files /dev/null and b/src/auracast/server/certs/ca/ca_cert.der differ diff --git a/src/auracast/server/certs/ca/ca_cert.pem b/src/auracast/server/certs/ca/ca_cert.pem new file mode 100644 index 0000000..b75680d --- /dev/null +++ b/src/auracast/server/certs/ca/ca_cert.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFDzCCAvegAwIBAgIUJkOMN61fArjxyeFLR1u2PnoUCogwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMB4XDTI1MDYyOTE0MzMyOVoXDTQ1 +MDYyNDE0MzMyOVowFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAnU1k3Yasc2mFCHNdBzf76Y7NTUIy50577fJL +kVVjwxUsUzimj6hkeTd16o5EsmTpW19/N8o/JJ1j3ne37EB8vm0q9H7yyN0fx+Gy +uJujmqu5ZG9+ow+kbqpxJUbRMmDkZqsF6/XHfNMUQLK5vVH219xmW/hgxdEB4o50 +jF25+jhUuolVYybhLT9AGtXhpqExmCn/o78I97+GtYNdkY8cwCt/khftM4DRDeEA +NdyVUWHG2sWqgx0BpgyL9gH/YwfeqBrjFmhh1VbgPCdgypwRV6YHVUqPtmSL7H7q +CmX8/ccyS6Cif9z/rsb1KwSeOgNKqV3D5DN3Qrboy9NmbWKXmhnF3Pl0EQ5f2/WS +xN+NKo8LNyZErQ27jZ6Xn9rVBRQ4rTw5oVf5hi6bOZcW2GNIQhQomQy83ohwFDnW +6aLsBag4/lGJFS+QpRAwIvFY4R559Ki3xndUQpvbt0KHIUNTlWddACm1tkcgXEGF +GJRZMBcKlyNdM5cRjhMtuZljoY2nHdfouiy4SETHgFFVvIZ2uOZLikljkL7cnWqF +0DZh9MxIZqZEoffSDRCRdlhmPITwuacGTFBNAmiGqg463rNmzcyc5JOoPUQrcSy1 +0F5Ig16tiGjpgNtqyBen0r0udEoU1bBF/kxhAQCbam/IqpTtR+ouRnnbE4ST2zV6 +IXc4mPcCAwEAAaNTMFEwHQYDVR0OBBYEFOsaKvMh7Lr+/O620X2uzHxlnvmzMB8G +A1UdIwQYMBaAFOsaKvMh7Lr+/O620X2uzHxlnvmzMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggIBAAxq7hy9xgDCxwZOQX8Z5iBIK3agXUads7m91TcH +/EzFfJsUDOpDsSi58wIXuCmiJ+qe2S+hJxghLNsqm01DosPuNLNI0gCDg+glx5z5 +ADtY0EJb7mRH+xuFC1GBdP7ve3REvfi7WC9snrqBUji/xL4VycaOyTDGOxWaHlyZ +u876I6/+xkj5hkhM1bsbEcGZ81QnTaJyeVtHTRYaORPAb2FP2V65MTn18Pu08i4T +bzh0KAsoDkwKvoEK24T5xFEUCuLexQ+6fabYXGro3It9VmAbrtkSyX8Z1eO7rVCu +hsUrA6UDzTerX1pWafeftpKiH7YiOaYYOAVcqDn+WKwYq3MPafNJp8x8HV1eeWYD +dx9HBKuvlOsoxnjJMnYusmQZyJk1EJR03najrV7HH8cyU2gfNyBwfsr6nU+FnDOX +qL2P0nWDjBkfjQRvmG59YLDVZYhw30+lishpmMLGZGwRFCjMCHD7rAdQTB3dtCP6 +NqaGogwitIdIITBtyV1ZABoE3vQuUAKZChU+DsSKniyFitKDQrXP+rwcX5Y5/pS1 +S1s6ITgllbErKqAoeelEVkJyiWykEtrtdcD0DXTr/QY4GzXeMi9u+dMXUOt95Md2 +lQVAaFIX8QxbmHXen6GsXeHhPpPw8sXtC6rh7aqSCqqB6EDS77mjrGHXbSeBS5aq +MklC +-----END CERTIFICATE----- diff --git a/src/auracast/server/certs/ca/ca_cert.srl b/src/auracast/server/certs/ca/ca_cert.srl new file mode 100644 index 0000000..a29e13e --- /dev/null +++ b/src/auracast/server/certs/ca/ca_cert.srl @@ -0,0 +1 @@ +5078804E6FBCF893D5537715FD928E46AD576ECA diff --git a/src/auracast/server/certs/ca/ca_key.pem b/src/auracast/server/certs/ca/ca_key.pem new file mode 100644 index 0000000..033854f --- /dev/null +++ b/src/auracast/server/certs/ca/ca_key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQCdTWTdhqxzaYUI +c10HN/vpjs1NQjLnTnvt8kuRVWPDFSxTOKaPqGR5N3XqjkSyZOlbX383yj8knWPe +d7fsQHy+bSr0fvLI3R/H4bK4m6Oaq7lkb36jD6RuqnElRtEyYORmqwXr9cd80xRA +srm9UfbX3GZb+GDF0QHijnSMXbn6OFS6iVVjJuEtP0Aa1eGmoTGYKf+jvwj3v4a1 +g12RjxzAK3+SF+0zgNEN4QA13JVRYcbaxaqDHQGmDIv2Af9jB96oGuMWaGHVVuA8 +J2DKnBFXpgdVSo+2ZIvsfuoKZfz9xzJLoKJ/3P+uxvUrBJ46A0qpXcPkM3dCtujL +02ZtYpeaGcXc+XQRDl/b9ZLE340qjws3JkStDbuNnpef2tUFFDitPDmhV/mGLps5 +lxbYY0hCFCiZDLzeiHAUOdbpouwFqDj+UYkVL5ClEDAi8VjhHnn0qLfGd1RCm9u3 +QochQ1OVZ10AKbW2RyBcQYUYlFkwFwqXI10zlxGOEy25mWOhjacd1+i6LLhIRMeA +UVW8hna45kuKSWOQvtydaoXQNmH0zEhmpkSh99INEJF2WGY8hPC5pwZMUE0CaIaq +Djres2bNzJzkk6g9RCtxLLXQXkiDXq2IaOmA22rIF6fSvS50ShTVsEX+TGEBAJtq +b8iqlO1H6i5GedsThJPbNXohdziY9wIDAQABAoICAER+VSuyfve4HCGsXfcNNQcj +U5jO+OxH++WFqcrsJAbnesf39Gq8N5eigxkxfo8xKn1LbVElIu52C+zsMy1PfSHL +1jbk6iF1S2fVCmWg+5GXMaAefkVRQ9eeJqtFFUU69GkSEf+HIyhinsB3MjJR9MpU +YUutsLGiCxCT2ALgsuDV02rv7rrATK9PicHFnL5aFQa9Tt+FiMmb33O88iq15p50 +slUyTuosrpq8/ML3PBtWGGjdRhxWLogXkX/6qbH81MJdBsGUjPkAnZ4DxX0jjNed +5zaHw2D3kgfV0WHau9ji+i79EJTdbYW0gz+KgL0g/ssVlX0Rvd3SWDacY87AbeMQ +b1Tl3iOXqt6nqHupxgWthAnrc81bz0NrabmKCnWCQLlYiuvJ+hN945H4uzjVh5Tx +PS0Nf17zTZsrWQgkz/ei4SIQtg/3lBm70BSsSpu+JtFJ8P+SB64maqAhhaF4mlEk +SA5cNaY+TKTO9up3aUWnYi/GFV2R3l+wTuNiC4QDmFZRWA4RrM0EK1HrhE+5fnxJ +cPBU48QB+IrZOI0qoqd/8XxHyEe/qzJ7Ml7wLBMzPOyr9ST6PSmoDQrT4mxeHAVE +ogfjJ5LjaY4kyJp/u5LsvhzF6sS5InvME2YnXXAb4nvxohPFFKY9iWDZ3W+jN6xD +zQ40bdQDVZW6fXC+HbLBAoIBAQDQkmZYb6JhrZzSQbXoQOFevPi2odeqGCqgwLAD +fp7ZMQisQYpcXmZtyBOWX8ZO+1O5KtXEFsdf+97rwOqMWVDmd2Q2VMSmW++Ni4U8 +HZvV2gfYZISds2PXtWVLF9UNuXZ+a+HPPDpqKenyaLJtMvr1xX2kBRsi1CMk6yLI +tCIwh4rnDiYJYHrmIggP/w1YllCkM5k33OeFuzPnW2rY0z+Q260Cxr3ouktWJ4tz +U7vssrZh3LtvWXvkSh7mbotON6YUXpeX2WV/E/7Kh/bm8uLZGuYVhHctvjUmYpA2 +LFk6i3Mulh0OHab3WcOQV+Dpcut6QBvS6aJsxYh/tWIsn3M3AoIBAQDBEnAzEZ2S +cpOoXSKOaYpoQ7wnwRONckJ2FKYKA7anRX4FTW6d3E2Jb/+CmXqzcRWWSvNt7ab/ +N+fXVLi1Nc2fC5BI0hFEVvPwp9mnMH8HCG7QcHQAhjYaKS1QeCEyLCudzcNBXoR9 +OuKTQcJd9tX0oJj6GNuY76gmxH3Smgwim2fPsHX0A2kekpyqVS3zHo47oeUO0N/Q +WWNcQ49+9T2KZXF116rjL1TDZkUHvGi6p1wSAc/J5ixQ6EagfJ72PujGBkpRTTiR +Fl/Qp4Ldy7S7AzOeiP3/w/0j5qL0NN0ZjUnoOr8u+1WaUyxTxN4+TZG3ThIYIAK1 +UTs6VLz2gmhBAoIBABx2Dc89lIv9s+uhGeCSke5qnQnW9eX5HEAJazte2PBMV6Gh +4+6M1y9d4QZhFV+LvjYDWV5DuXsolJfZIGh8e6SnYB5l3NvSqdLH2iuE4tIAyZdG +yC3438P8tdDUdLdFupyvvgWYc2QvSgRRMx/hmAtXorhyFezfw9fy2jFHG29B37t9 +28TlzH+A31bHeBvBj0mI3PyZgWJnVELa366szPzIbUh2tE2Atm0QQmA/aeJ31Jlw +FIeyT0ysrKDHLu1CfMBE1CzddpMruFYMza1gMYJswD7pb5XnYbtWMdWioZ5yjwop +Y9ecRj90mVImG8PfcbCh9OoIBakQH3tF1hq+u2sCggEATdST/FJGlgmwMnfQ/V3Y +WK2thM0Vh7ieyCEMyg6zK/0cjyCmzeZIL3ZBpzEdwIZ+sEZomVDrOAkeYbSafRpC +WLH9qQ1dvpHa5pGTcQ1gt8ITgd1DNg7kcmlVBhJXN3WM46FV690hRaZePgSNSPm/ +SE0RPgiVRbKes3oUSrik2bKSB6xX8FULpDJwC04pJs+TgMCDqRRUlRXjswbdKs3L +0CWStnGJRuoGnnp0q2itQ0lCGVQ3omkyRi9MgVebcSLtDR7uCJY7jmlZmLBeVfDP +W3Av9+G7msY0HqvT1uQUmT9WotJDzbmtyXdr8Bz1hmIYsq87JhSJYvRrDtmoDyuE +wQKCAQBYY7G1HIhLEeS07UZ1yMxSCLZM3rLqEfpckzy68aA48HeiockwbtShky/8 +D4pvSwZnTF9VWxXKaK46FuIULSUXc8eMSx2pCShQ5nFHa04j4ZjPwsWRrUc98AiO +pkbSgfxmKwAHpRBlimMleG+kXz6Urr5CJVQyWMP1hXTpGR1HME1z9ZbaACwvfMJk +0xCytMv3/m7JYiCfHRsc09sjHZQZtou0JpRczkxustxXL2wylvAjI4hNwYIl7Oj8 +yzhhDzoqUGOA8uhyXZtG6NfPMr5pBo0J/pskaHco8UNV+gjOwewHrwd7K2NZmQQj +sKOYrVeRKuwd/MuNfkJTA8MOwLM4 +-----END PRIVATE KEY----- diff --git a/src/auracast/server/generate_ca_cert.sh b/src/auracast/server/generate_ca_cert.sh new file mode 100644 index 0000000..937b9d5 --- /dev/null +++ b/src/auracast/server/generate_ca_cert.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Script to generate a CA cert/key and a device/server cert signed by this CA +# Outputs: ca_cert.pem, ca_key.pem, device_cert.pem, device_key.pem + +CA_DIR=certs/ca +mkdir -p "$CA_DIR" +CA_CERT=$CA_DIR/ca_cert.pem +CA_KEY=$CA_DIR/ca_key.pem + +# Generate CA key and cert (20 year expiry) +echo "Generating CA key and certificate (20 year expiry)..." +openssl req -x509 -newkey rsa:4096 -days 7300 -nodes -subj "/CN=SummitWaveCA" -keyout "$CA_KEY" -out "$CA_CERT" + +# PEM version (for most browsers) +cp "$CA_CERT" "$CA_DIR/ca_cert.crt" +# DER version (for Windows) +openssl x509 -in "$CA_CERT" -outform der -out "$CA_DIR/ca_cert.der" + +# Output summary +echo "CA cert: $CA_CERT" +echo "CA cert (CRT for browser import): $CA_DIR/ca_cert.crt" +echo "CA key: $CA_KEY" +echo "Distribute $CA_CERT or $CA_DIR/ca_cert.crt to clients to trust this device." +echo "Keep $CA_KEY secret and offline except when signing device CSRs." +echo "CA cert: $CA_CERT" +echo "CA cert (CRT for browser import): $CERT_DIR/ca_cert.crt" +echo "CA key: $CA_KEY" +echo "Device cert: $DEVICE_CERT" +echo "Device key: $DEVICE_KEY" +echo "Distribute $CA_CERT or $CERT_DIR/ca_cert.crt to clients to trust this device." diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py new file mode 100644 index 0000000..2d383f7 --- /dev/null +++ b/src/auracast/server/multicast_frontend.py @@ -0,0 +1,399 @@ +# frontend/app.py +import os +import time +import streamlit as st +import requests +from auracast import auracast_config +import logging as log + +# Track whether WebRTC stream is active across Streamlit reruns +if 'stream_started' not in st.session_state: + st.session_state['stream_started'] = False + +# Global: desired packetization time in ms for Opus (should match backend) +PTIME = 40 +BACKEND_URL = "http://localhost:5000" +#TRANSPORT1 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_B53C372677E14460-if00,115200,rtscts" +#TRANSPORT2 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,115200,rtscts" + +TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header +TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header +QUALITY_MAP = { + "High (48kHz)": {"rate": 48000, "octets": 120}, + "Good (32kHz)": {"rate": 32000, "octets": 80}, + "Medium (24kHz)": {"rate": 24000, "octets": 60}, + "Fair (16kHz)": {"rate": 16000, "octets": 40}, +} + +# Try loading persisted settings from backend +saved_settings = {} +try: + resp = requests.get(f"{BACKEND_URL}/status", timeout=1) + if resp.status_code == 200: + saved_settings = resp.json() +except Exception: + saved_settings = {} + +st.title("🎙️ Auracast Audio Mode Control") + +# Audio mode selection with persisted default +options = ["Webapp", "USB/Network", "Demo"] +saved_audio_mode = saved_settings.get("audio_mode", "Webapp") +if saved_audio_mode not in options: + saved_audio_mode = "Webapp" + +audio_mode = st.selectbox( + "Audio Mode", + options, + index=options.index(saved_audio_mode), + help="Select the audio input source. Choose 'Webapp' for browser microphone, 'USB/Network' for a connected hardware device, or 'Demo' for a simulated stream." +) + +if audio_mode == "Demo": + demo_stream_map = { + "1 × 48kHz": {"quality": "High (48kHz)", "streams": 1}, + "2 × 24kHz": {"quality": "Medium (24kHz)", "streams": 2}, + "3 × 16kHz": {"quality": "Fair (16kHz)", "streams": 3}, + "2 × 48kHz": {"quality": "High (48kHz)", "streams": 2}, + "4 × 24kHz": {"quality": "Medium (24kHz)", "streams": 4}, + "6 × 16kHz": {"quality": "Fair (16kHz)", "streams": 6}, + } + demo_options = list(demo_stream_map.keys()) + default_demo = demo_options[0] + demo_selected = st.selectbox( + "Demo Stream Type", + demo_options, + index=0, + help="Select the demo stream configuration." + ) + #st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)") + # Start/Stop buttons for demo mode + if 'demo_stream_started' not in st.session_state: + st.session_state['demo_stream_started'] = False + col1, col2 = st.columns(2) + with col1: + start_demo = st.button("Start Demo Stream") + with col2: + stop_demo = st.button("Stop Demo Stream") + if start_demo: + # Always stop any running stream for clean state + try: + requests.post(f"{BACKEND_URL}/stop_audio").json() + except Exception: + pass + time.sleep(1) + demo_cfg = demo_stream_map[demo_selected] + # Octets per frame logic matches quality_map + q = QUALITY_MAP[demo_cfg['quality']] + + # Language configs and test files + lang_cfgs = [ + (auracast_config.AuracastBigConfigDeu, 'de'), + (auracast_config.AuracastBigConfigEng, 'en'), + (auracast_config.AuracastBigConfigFra, 'fr'), + (auracast_config.AuracastBigConfigSpa, 'es'), + (auracast_config.AuracastBigConfigIta, 'it'), + (auracast_config.AuracastBigConfigPol, 'pl'), + ] + bigs1 = [] + for i in range(demo_cfg['streams']): + cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)] + bigs1.append(cfg_cls( + audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav', + iso_que_len=32, + sampling_frequency=q['rate'], + octets_per_frame=q['octets'], + )) + + # Split bigs into two configs if needed + max_per_mc = {48000: 1, 24000: 2, 16000: 3} + max_streams = max_per_mc.get(q['rate'], 3) + bigs2 = [] + if len(bigs1) > max_streams: + bigs2 = bigs1[max_streams:] + bigs1 = bigs1[:max_streams] + config1 = auracast_config.AuracastConfigGroup( + auracast_sampling_rate_hz=q['rate'], + octets_per_frame=q['octets'], + transport=TRANSPORT1, + bigs=bigs1 + ) + config2 = None + if bigs2: + config2 = auracast_config.AuracastConfigGroup( + auracast_sampling_rate_hz=q['rate'], + octets_per_frame=q['octets'], + transport=TRANSPORT2, + bigs=bigs2 + ) + # Call /init and /init2 + try: + r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump()) + if r1.status_code == 200: + msg = f"Demo stream started on multicaster 1 ({len(bigs1)} streams)" + st.session_state['demo_stream_started'] = True + st.success(msg) + else: + st.session_state['demo_stream_started'] = False + st.error(f"Failed to initialize multicaster 1: {r1.text}") + if config2: + r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump()) + if r2.status_code == 200: + st.success(f"Demo stream started on multicaster 2 ({len(bigs2)} streams)") + else: + st.error(f"Failed to initialize multicaster 2: {r2.text}") + except Exception as e: + st.session_state['demo_stream_started'] = False + st.error(f"Error: {e}") + elif stop_demo: + try: + r = requests.post(f"{BACKEND_URL}/stop_audio").json() + st.session_state['demo_stream_started'] = False + if r.get('was_running'): + st.info("Demo stream stopped.") + else: + st.info("Demo stream was not running.") + except Exception as e: + st.error(f"Error: {e}") + elif st.session_state['demo_stream_started']: + st.success(f"Demo stream running: {demo_selected}") + else: + st.info("Demo stream not running.") + quality = None # Not used in demo mode +else: + # Stream quality selection (now enabled) + + quality_options = list(QUALITY_MAP.keys()) + default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] + quality = st.selectbox( + "Stream Quality (Sampling Rate)", + quality_options, + index=quality_options.index(default_quality), + help="Select the audio sampling rate for the stream. Lower rates may improve compatibility." + ) + default_name = saved_settings.get('channel_names', ["Broadcast0"])[0] + default_lang = saved_settings.get('languages', ["deu"])[0] + default_input = saved_settings.get('input_device') or 'default' + stream_name = st.text_input( + "Channel Name", + value=default_name, + help="The primary name for your broadcast. Like the SSID of a WLAN, it identifies your stream for receivers." + ) + raw_program_info = saved_settings.get('program_info', default_name) + if isinstance(raw_program_info, list) and raw_program_info: + default_program_info = raw_program_info[0] + else: + default_program_info = raw_program_info + program_info = st.text_input( + "Program Info", + value=default_program_info, + help="Additional details about the broadcast program, such as its content or purpose. Shown to receivers for more context." + ) + language = st.text_input( + "Language (ISO 639-3)", + value=default_lang, + help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes" + ) + # Gain slider for Webapp mode + if audio_mode == "Webapp": + mic_gain = st.slider("Microphone Gain", 0.0, 2.0, 1.0, 0.1, help="Adjust microphone volume sent to Auracast") + else: + mic_gain = 1.0 + + # Input device selection for USB mode + if audio_mode == "USB/Network": + resp = requests.get(f"{BACKEND_URL}/audio_inputs") + device_list = resp.json().get('inputs', []) + # Display "name [id]" but use name as value + input_options = [f"{d['name']} [{d['id']}]" for d in device_list] + option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list} + device_names = [d['name'] for d in device_list] + + # Determine default input by name + default_input_name = saved_settings.get('input_device') + if default_input_name not in device_names and device_names: + default_input_name = device_names[0] + default_input_label = None + for label, name in option_name_map.items(): + if name == default_input_name: + default_input_label = label + break + if not input_options: + st.warning("No hardware audio input devices found. Plug in a USB input device and click Refresh.") + if st.button("Refresh"): + try: + requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3) + except Exception as e: + st.error(f"Failed to refresh devices: {e}") + st.rerun() + input_device = None + else: + col1, col2 = st.columns([3, 1], vertical_alignment="bottom") + with col1: + selected_option = st.selectbox( + "Input Device", + input_options, + index=input_options.index(default_input_label) if default_input_label in input_options else 0 + ) + with col2: + if st.button("Refresh"): + try: + requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3) + except Exception as e: + st.error(f"Failed to refresh devices: {e}") + st.rerun() + # Send only the device name to backend + input_device = option_name_map[selected_option] if selected_option in option_name_map else None + else: + input_device = None + + start_stream = st.button("Start Auracast") + stop_stream = st.button("Stop Auracast") + + # If gain slider moved while streaming, send update to JS without restarting + if audio_mode == "Webapp" and st.session_state.get('stream_started'): + update_js = f""" + + """ + st.components.v1.html(update_js, height=0) + + if stop_stream: + st.session_state['stream_started'] = False + try: + r = requests.post(f"{BACKEND_URL}/stop_audio").json() + if r['was_running']: + st.success("Stream Stopped!") + else: + st.success("Stream was not running.") + except Exception as e: + st.error(f"Error: {e}") + # Ensure existing WebRTC connection is fully closed so that a fresh + # connection is created the next time we start the stream. + if audio_mode == "Webapp": + cleanup_js = """ + + """ + st.components.v1.html(cleanup_js, height=0) + + if start_stream: + # Always send stop to ensure backend is in a clean state, regardless of current status + r = requests.post(f"{BACKEND_URL}/stop_audio").json() + if r['was_running']: + st.success("Stream Stopped!") + + # Small pause lets backend fully release audio devices before re-init + time.sleep(1) + # Prepare config using the model (do NOT send qos_config, only relevant fields) + q = QUALITY_MAP[quality] + config = auracast_config.AuracastConfigGroup( + auracast_sampling_rate_hz=q['rate'], + octets_per_frame=q['octets'], + transport=TRANSPORT1, # transport for raspberry pi gpio header + bigs = [ + auracast_config.AuracastBigConfig( + name=stream_name, + program_info=program_info, + language=language, + audio_source=( + f"device:{input_device}" if audio_mode == "USB/Network" else ( + "webrtc" if audio_mode == "Webapp" else "network" + ) + ), + input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"), + iso_que_len=1, + sampling_frequency=q['rate'], + octets_per_frame=q['octets'], + ), + ] + ) + + try: + r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump()) + if r.status_code == 200: + st.success("Stream Started!") + else: + st.error(f"Failed to initialize: {r.text}") + except Exception as e: + st.error(f"Error: {e}") + + # Render / maintain WebRTC component + if audio_mode == "Webapp" and (start_stream or st.session_state.get('stream_started')): + st.markdown("Starting microphone; allow access if prompted and speak.") + component = f""" + + """ + st.components.v1.html(component, height=0) + st.session_state['stream_started'] = True +#else: +# st.header("Advertised Streams (Cloud Announcements)") +# st.info("This feature requires backend support to list advertised streams.") + # Placeholder for future implementation + # Example: r = requests.get(f"{BACKEND_URL}/advertised_streams") + # if r.status_code == 200: + # streams = r.json() + # for s in streams: + # st.write(s) + # else: + # st.error("Could not fetch advertised streams.") + +log.basicConfig( + level=os.environ.get('LOG_LEVEL', log.DEBUG), + format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' +) \ No newline at end of file diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py new file mode 100644 index 0000000..7e4e588 --- /dev/null +++ b/src/auracast/server/multicast_server.py @@ -0,0 +1,421 @@ +import glob +import os +import logging as log +import uuid +import json +import sys +from datetime import datetime +import asyncio +import numpy as np +from pydantic import BaseModel +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from auracast import multicast_control, auracast_config +from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack +import av +import av.audio.layout +import sounddevice as sd # type: ignore +from typing import Set, List, Dict, Any +import traceback + + +PTIME = 40 # TODO: seems to have no effect at all +pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early +AUDIO_INPUT_DEVICES_CACHE: List[Dict[str, Any]] = [] + +class Offer(BaseModel): + sdp: str + type: str + +def get_device_index_by_name(name: str): + """Return the device index for a given device name, or None if not found.""" + for d in AUDIO_INPUT_DEVICES_CACHE: + if d["name"] == name: + return d["id"] + return None + + +# Path to persist stream settings +STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json') + +def load_stream_settings() -> dict: + """Load persisted stream settings if available.""" + if os.path.exists(STREAM_SETTINGS_FILE): + try: + with open(STREAM_SETTINGS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {} + return {} + +def save_stream_settings(settings: dict): + """Save stream settings to disk.""" + try: + with open(STREAM_SETTINGS_FILE, 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=2) + except Exception as e: + log.error('Unable to persist stream settings: %s', e) + + +app = FastAPI() + +# Allow CORS for frontend on localhost +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # You can restrict this to ["http://localhost:8501"] if you want + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize global configuration +global_config_group = auracast_config.AuracastConfigGroup() + +# Create multicast controller +multicaster1: multicast_control.Multicaster | None = None +multicaster2: multicast_control.Multicaster | None = None + +@app.post("/init") +async def initialize(conf: auracast_config.AuracastConfigGroup): + """Initializes the primary broadcaster (multicaster1).""" + global global_config_group + global multicaster1 + try: + if conf.transport == 'auto': + serial_devices = glob.glob('/dev/serial/by-id/*') + log.info('Found serial devices: %s', serial_devices) + for device in serial_devices: + if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device: + log.info('Using: %s', device) + conf.transport = f'serial:{device},115200,rtscts' + break + if conf.transport == 'auto': + raise HTTPException(status_code=500, detail='No suitable transport found.') + # Derive audio_mode and input_device from first BIG audio_source + first_source = conf.bigs[0].audio_source if conf.bigs else '' + if first_source.startswith('device:'): + audio_mode_persist = 'USB' + input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None + # Map device name to current index for use with sounddevice + device_index = get_device_index_by_name(input_device_name) if input_device_name else None + # Patch config to use index for sounddevice (but persist name) + if device_index is not None: + for big in conf.bigs: + if big.audio_source.startswith('device:'): + big.audio_source = f'device:{device_index}' + else: + log.error(f"Device name '{input_device_name}' not found in current device list.") + raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.") + elif first_source == 'webrtc': + audio_mode_persist = 'Webapp' + input_device_name = None + elif first_source.startswith('file:'): + audio_mode_persist = 'Demo' + input_device_name = None + else: + audio_mode_persist = 'Network' + input_device_name = None + save_stream_settings({ + 'channel_names': [big.name for big in conf.bigs], + 'languages': [big.language for big in conf.bigs], + 'audio_mode': audio_mode_persist, + 'input_device': input_device_name, + 'program_info': [getattr(big, 'program_info', None) for big in conf.bigs], + 'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs], + 'timestamp': datetime.utcnow().isoformat() + }) + global_config_group = conf + if multicaster1 is not None: + try: + await multicaster1.shutdown() + except Exception: + log.warning("Failed to shutdown previous multicaster", exc_info=True) + log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2)) + multicaster1 = multicast_control.Multicaster(conf, conf.bigs) + await multicaster1.init_broadcast() + if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs): + log.info("Auto-starting streaming on multicaster1") + await multicaster1.start_streaming() + except Exception as e: + log.error("Exception in /init: %s", traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/init2") +async def initialize2(conf: auracast_config.AuracastConfigGroup): + """Initializes the secondary broadcaster (multicaster2). Does NOT persist stream settings.""" + global multicaster2 + try: + if conf.transport == 'auto': + serial_devices = glob.glob('/dev/serial/by-id/*') + log.info('Found serial devices: %s', serial_devices) + for device in serial_devices: + if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device: + log.info('Using: %s', device) + conf.transport = f'serial:{device},115200,rtscts' + break + if conf.transport == 'auto': + raise HTTPException(status_code=500, detail='No suitable transport found.') + # Patch device name to index for sounddevice + for big in conf.bigs: + if big.audio_source.startswith('device:'): + device_name = big.audio_source.split(':', 1)[1] + device_index = get_device_index_by_name(device_name) + if device_index is not None: + big.audio_source = f'device:{device_index}' + else: + log.error(f"Device name '{device_name}' not found in current device list.") + raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.") + log.info('Initializing multicaster2 with config:\n %s', conf.model_dump_json(indent=2)) + multicaster2 = multicast_control.Multicaster(conf, conf.bigs) + await multicaster2.init_broadcast() + if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs): + log.info("Auto-starting streaming on multicaster2") + await multicaster2.start_streaming() + except Exception as e: + log.error("Exception in /init2: %s", traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/stream_lc3") +async def send_audio(audio_data: dict[str, str]): + """Sends a block of pre-coded LC3 audio.""" + if multicaster1 is None: + raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized') + try: + for big in global_config_group.bigs: + assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch') + log.info('Received a send audio request for %s', big.language) + big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding + + multicaster1.big_conf = global_config_group.bigs + await multicaster1.start_streaming() + return {"status": "audio_sent"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/stop_audio") +async def stop_audio(): + """Stops streaming on both multicaster1 and multicaster2.""" + try: + # First close any active WebRTC peer connections so their track loops finish cleanly + close_tasks = [pc.close() for pc in list(pcs)] + pcs.clear() + if close_tasks: + await asyncio.gather(*close_tasks, return_exceptions=True) + + # Now shut down both multicasters and release audio devices + running = False + if multicaster1 is not None: + await multicaster1.stop_streaming() + await multicaster1.reset() # Fully reset controller and advertising + running = True + if multicaster2 is not None: + await multicaster2.stop_streaming() + await multicaster2.reset() # Fully reset controller and advertising + running = True + + return {"status": "stopped", "was_running": running} + except Exception as e: + log.error("Exception in /stop_audio: %s", traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/status") +async def get_status(): + """Gets the current status of the multicaster together with persisted stream info.""" + status = multicaster1.get_status() if multicaster1 else { + 'is_initialized': False, + 'is_streaming': False, + } + status.update(load_stream_settings()) + return status + + +async def scan_audio_devices(): + """Scans for available audio devices and updates the cache.""" + global AUDIO_INPUT_DEVICES_CACHE + log.info("Scanning for audio input devices...") + try: + if sys.platform == 'linux': + log.info("Re-initializing sounddevice to scan for new devices") + sd._terminate() + sd._initialize() + + devs = sd.query_devices() + inputs = [ + dict(d, id=idx) + for idx, d in enumerate(devs) + if d.get("max_input_channels", 0) > 0 + ] + log.info('Found %d audio input devices: %s', len(inputs), inputs) + AUDIO_INPUT_DEVICES_CACHE = inputs + except Exception: + log.error("Exception while scanning audio devices:", exc_info=True) + # Do not clear cache on error, keep the last known good list + + +@app.on_event("startup") +async def startup_event(): + """Pre-scans audio devices on startup.""" + await scan_audio_devices() + + +@app.get("/audio_inputs") +async def list_audio_inputs(): + """Return available hardware audio input devices from cache (by name, for selection).""" + # Only expose name and id for frontend + return {"inputs": AUDIO_INPUT_DEVICES_CACHE} + + +@app.post("/refresh_audio_inputs") +async def refresh_audio_inputs(): + """Triggers a re-scan of audio devices.""" + await scan_audio_devices() + return {"status": "ok", "inputs": AUDIO_INPUT_DEVICES_CACHE} + + +@app.post("/offer") +async def offer(offer: Offer): + log.info("/offer endpoint called") + + # If a previous PeerConnection is still alive, close it so we only ever keep one active. + if pcs: + log.info("Closing %d existing PeerConnection(s) before creating a new one", len(pcs)) + close_tasks = [p.close() for p in list(pcs)] + await asyncio.gather(*close_tasks, return_exceptions=True) + pcs.clear() + + pc = RTCPeerConnection() # No STUN needed for localhost + pcs.add(pc) + id_ = uuid.uuid4().hex[:8] + log.info(f"{id_}: new PeerConnection") + + # create directory for records - only for testing + os.makedirs("./records", exist_ok=True) + + # Do NOT start the streamer yet – we'll start it lazily once we actually + # receive the first audio frame, ensuring WebRTCAudioInput is ready and + # avoiding race-conditions on restarts. + @pc.on("track") + async def on_track(track: MediaStreamTrack): + log.info(f"{id_}: track {track.kind} received") + try: + first = True + while True: + frame: av.audio.frame.AudioFrame = await track.recv() # RTP audio frame (already decrypted) + if first: + log.info(f"{id_}: frame layout={frame.layout}") + log.info(f"{id_}: frame format={frame.format}") + log.info( + f"{id_}: frame sample_rate={frame.sample_rate}, samples_per_channel={frame.samples}, planes={frame.planes}" + ) + # Lazily start the streamer now that we know a track exists. + if multicaster1.streamer is None: + await multicaster1.start_streaming() + # Yield control so the Streamer coroutine has a chance to + # create the WebRTCAudioInput before we push samples. + await asyncio.sleep(0) + first = False + # in stereo case this is interleaved data format + frame_array = frame.to_ndarray() + log.info(f"array.shape{frame_array.shape}") + log.info(f"array.dtype{frame_array.dtype}") + log.info(f"frame.to_ndarray(){frame_array}") + + samples = frame_array.reshape(-1) + log.info(f"samples.shape: {samples.shape}") + + if frame.layout.name == 'stereo': + # Interleaved stereo: [L0, R0, L1, R1, ...] + mono_array = samples[::2] # Take left channel + else: + mono_array = samples + + log.info(f"mono_array.shape: {mono_array.shape}") + + + frame_array = frame.to_ndarray() + + # Flatten in case it's (1, N) or (N,) + samples = frame_array.reshape(-1) + + if frame.layout.name == 'stereo': + # Interleaved stereo: [L0, R0, L1, R1, ...] + mono_array = samples[::2] # Take left channel + else: + mono_array = samples + + # Get current WebRTC audio input (streamer may have been restarted) + big0 = list(multicaster1.bigs.values())[0] + audio_input = big0.get('audio_input') + # Wait until the streamer has instantiated the WebRTCAudioInput + if audio_input is None or getattr(audio_input, 'closed', False): + continue + # Feed mono PCM samples to the global WebRTC audio input + await audio_input.put_samples(mono_array.astype(np.int16)) + + # Save to WAV file - only for testing + # if not hasattr(pc, 'wav_writer'): + # import wave + # wav_path = f"./records/auracast_{id_}.wav" + # pc.wav_writer = wave.open(wav_path, "wb") + # pc.wav_writer.setnchannels(1) # mono + # pc.wav_writer.setsampwidth(2) # 16-bit PCM + # pc.wav_writer.setframerate(frame.sample_rate) + + # pcm_data = mono_array.astype(np.int16).tobytes() + # pc.wav_writer.writeframes(pcm_data) + + + except Exception as e: + log.error(f"{id_}: Exception in on_track: {e}") + finally: + # Always close the wav file when the track ends or on error + if hasattr(pc, 'wav_writer'): + try: + pc.wav_writer.close() + except Exception: + pass + del pc.wav_writer + + # --- SDP negotiation --- + log.info(f"{id_}: setting remote description") + await pc.setRemoteDescription(RTCSessionDescription(**offer.model_dump())) + + log.info(f"{id_}: creating answer") + answer = await pc.createAnswer() + sdp = answer.sdp + # Insert a=ptime using the global PTIME variable + ptime_line = f"a=ptime:{PTIME}" + if "a=sendrecv" in sdp: + sdp = sdp.replace("a=sendrecv", f"a=sendrecv\n{ptime_line}") + else: + sdp += f"\n{ptime_line}" + new_answer = RTCSessionDescription(sdp=sdp, type=answer.type) + await pc.setLocalDescription(new_answer) + log.info(f"{id_}: sending answer with {ptime_line}") + return {"sdp": pc.localDescription.sdp, + "type": pc.localDescription.type} + + +@app.post("/shutdown") +async def shutdown(): + """Stops broadcasting and releases all audio/Bluetooth resources.""" + try: + await multicaster1.shutdown() + return {"status": "stopped"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == '__main__': + import os + os.chdir(os.path.dirname(__file__)) + import uvicorn + log.basicConfig( # for debug log level export LOG_LEVEL=DEBUG + level=os.environ.get('LOG_LEVEL', log.INFO), + format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s' + ) + # Bind to localhost only for security: prevents network access, only frontend on same machine can connect + uvicorn.run(app, host="127.0.0.1", port=5000) \ No newline at end of file diff --git a/src/auracast/server/provision_domain_hostname.sh b/src/auracast/server/provision_domain_hostname.sh new file mode 100644 index 0000000..c7f0e8f --- /dev/null +++ b/src/auracast/server/provision_domain_hostname.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# change_domain_hostname.sh +# Safely change the system hostname and Avahi mDNS domain name, update /etc/hosts, restart Avahi, +# and generate a per-device certificate signed by the CA. +# Usage: sudo ./change_domain_hostname.sh [--force] + +set -e + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root." + exit 1 +fi + +if [ $# -lt 2 ]; then + echo "Usage: sudo $0 [--force]" + exit 1 +fi + +NEW_HOSTNAME="$1" +NEW_DOMAIN="$2" +FORCE=0 +if [ "$3" == "--force" ]; then + FORCE=1 +fi + +# Validate hostname: single label, no dots +if [[ "$NEW_HOSTNAME" == *.* ]]; then + echo "ERROR: Hostname must not contain dots." + exit 1 +fi + +# Set system hostname +hostnamectl set-hostname "$NEW_HOSTNAME" +echo "/etc/hostname set to $NEW_HOSTNAME." + +# Update /etc/hosts +if grep -q '^127.0.1.1' /etc/hosts; then + sed -i "s/^127.0.1.1.*/127.0.1.1 $NEW_HOSTNAME/" /etc/hosts +else + echo "127.0.1.1 $NEW_HOSTNAME" >> /etc/hosts +fi +echo "/etc/hosts updated." + +# Set Avahi domain name +AVAHI_CONF="/etc/avahi/avahi-daemon.conf" +sed -i "/^\[server\]/,/^\s*\[/{s/^\s*domain-name\s*=.*/domain-name=$NEW_DOMAIN/}" "$AVAHI_CONF" +echo "Set Avahi domain name to $NEW_DOMAIN." + +# Restart Avahi +echo "Restarting avahi-daemon..." +systemctl restart avahi-daemon + +echo "Done. Hostname: $NEW_HOSTNAME, Avahi domain: $NEW_DOMAIN" + +# --- Per-device certificate logic --- +CA_DIR="$(dirname "$0")/certs/ca" +PER_DEVICE_DIR="$(dirname "$0")/certs/per_device/$NEW_HOSTNAME.$NEW_DOMAIN" +mkdir -p "$PER_DEVICE_DIR" +CA_CERT="$CA_DIR/ca_cert.pem" +CA_KEY="$CA_DIR/ca_key.pem" +DEVICE_CERT="$PER_DEVICE_DIR/device_cert.pem" +DEVICE_KEY="$PER_DEVICE_DIR/device_key.pem" +DEVICE_CSR="$PER_DEVICE_DIR/device.csr" +SAN_CNF="$PER_DEVICE_DIR/san.cnf" + +if [ -f "$DEVICE_CERT" ] && [ $FORCE -eq 0 ]; then + echo "Per-device certificate already exists at $DEVICE_CERT. Use --force to regenerate." +else + echo "Generating per-device key/cert for $NEW_HOSTNAME.$NEW_DOMAIN..." + openssl genrsa -out "$DEVICE_KEY" 4096 + cat > "$SAN_CNF" <HTTPS redirector + +# Dynamically select per-device cert and key based on hostname and Avahi domain +DEVICE_HOSTNAME=$(hostname) +AVAHI_CONF="/etc/avahi/avahi-daemon.conf" +AVAHI_DOMAIN=$(awk -F= '/^\s*domain-name\s*=/{gsub(/ /, "", $2); print $2}' "$AVAHI_CONF") +if [ -z "$AVAHI_DOMAIN" ]; then + AVAHI_DOMAIN=local +fi +CERT_DIR="certs/per_device/${DEVICE_HOSTNAME}.${AVAHI_DOMAIN}" +CERT="$CERT_DIR/device_cert.pem" +KEY="$CERT_DIR/device_key.pem" +CA_CERT="certs/ca/ca_cert.pem" + +if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then + echo "ERROR: Device certificate or key not found in $CERT_DIR. Run provision_domain_hostname.sh first." + exit 1 +fi + +if [ ! -f "$CA_CERT" ]; then + echo "WARNING: CA certificate not found at $CA_CERT. HTTPS will work, but clients may not be able to import the CA." +fi + +echo "CA cert: $CA_CERT" +echo "Device cert: $CERT" +echo "Device key: $KEY" +echo "Using hostname: $DEVICE_HOSTNAME" +echo "Using Avahi domain: $AVAHI_DOMAIN" + +# Path to poetry binary +POETRY_BIN="/home/caster/.local/bin/poetry" + +# Start Streamlit HTTPS server (port 443) +$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false diff --git a/src/auracast/server/start_mdns.sh b/src/auracast/server/start_mdns.sh new file mode 100755 index 0000000..a6c0944 --- /dev/null +++ b/src/auracast/server/start_mdns.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Script to advertise the local device via mDNS for an HTTPS service. +# This allows other clients on the network to discover this device +# using its mDNS hostname (e.g., your-hostname.local) on the specified port. + +# Update: Advertise HTTPS service on port 443 (default) +SERVICE_NAME="Auracast HTTPS Service" # You can customize this name +SERVICE_TYPE="_https._tcp" # Standard type for HTTPS services +SERVICE_PORT="443" # Port must match your HTTPS server (default 443) + +echo "Starting mDNS advertisement..." +echo "Command: avahi-publish-service -v \"$SERVICE_NAME\" \"$SERVICE_TYPE\" \"$SERVICE_PORT\"" + +avahi-publish-service -v "$SERVICE_NAME" "$SERVICE_TYPE" "$SERVICE_PORT" +EXIT_STATUS=$? + +# This part will be reached if avahi-publish-service exits. +if [ $EXIT_STATUS -eq 0 ]; then + echo "mDNS advertisement command finished with status 0." + echo "This might indicate an issue connecting to the avahi-daemon or a configuration problem." + echo "Please check for any messages above from avahi-publish-service itself." +else + echo "mDNS advertisement command exited with status $EXIT_STATUS." + echo "This might be due to an error, or if you pressed Ctrl+C (which typically results in a non-zero status from signal termination)." +fi diff --git a/src/auracast/testdata/wave_particle_5min_de.wav b/src/auracast/testdata/wave_particle_5min_de.wav new file mode 100644 index 0000000..3d2b575 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_de.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_de_16kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_de_16kHz_mono.wav new file mode 100644 index 0000000..d4cbb61 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_de_16kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_de_24kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_de_24kHz_mono.wav new file mode 100644 index 0000000..3a1ab31 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_de_24kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_de_48kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_de_48kHz_mono.wav new file mode 100644 index 0000000..ae7d35e Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_de_48kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_en.wav b/src/auracast/testdata/wave_particle_5min_en.wav new file mode 100644 index 0000000..bc451c9 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_en.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_en_16kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_en_16kHz_mono.wav new file mode 100644 index 0000000..592bd42 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_en_16kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_en_24kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_en_24kHz_mono.wav new file mode 100644 index 0000000..8ebacf5 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_en_24kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_en_48kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_en_48kHz_mono.wav new file mode 100644 index 0000000..b203240 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_en_48kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_es.wav b/src/auracast/testdata/wave_particle_5min_es.wav new file mode 100644 index 0000000..0d67ef1 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_es.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_es_16kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_es_16kHz_mono.wav new file mode 100644 index 0000000..22905a5 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_es_16kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_es_24kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_es_24kHz_mono.wav new file mode 100644 index 0000000..acad61a Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_es_24kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_es_48kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_es_48kHz_mono.wav new file mode 100644 index 0000000..9d1773f Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_es_48kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_fr.wav b/src/auracast/testdata/wave_particle_5min_fr.wav new file mode 100644 index 0000000..7a5a2f3 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_fr.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_fr_16kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_fr_16kHz_mono.wav new file mode 100644 index 0000000..7f16d51 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_fr_16kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_fr_24kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_fr_24kHz_mono.wav new file mode 100644 index 0000000..dcfe9ca Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_fr_24kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_fr_48kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_fr_48kHz_mono.wav new file mode 100644 index 0000000..c558003 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_fr_48kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_it.wav b/src/auracast/testdata/wave_particle_5min_it.wav new file mode 100644 index 0000000..0e4389b Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_it.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_it_16kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_it_16kHz_mono.wav new file mode 100644 index 0000000..a6f6db3 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_it_16kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_it_24kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_it_24kHz_mono.wav new file mode 100644 index 0000000..605fd17 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_it_24kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_it_48kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_it_48kHz_mono.wav new file mode 100644 index 0000000..3b100d1 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_it_48kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_pl_16kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_pl_16kHz_mono.wav new file mode 100644 index 0000000..498eea0 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_pl_16kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_pl_24kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_pl_24kHz_mono.wav new file mode 100644 index 0000000..a0fbe15 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_pl_24kHz_mono.wav differ diff --git a/src/auracast/testdata/wave_particle_5min_pl_48kHz_mono.wav b/src/auracast/testdata/wave_particle_5min_pl_48kHz_mono.wav new file mode 100644 index 0000000..c290724 Binary files /dev/null and b/src/auracast/testdata/wave_particle_5min_pl_48kHz_mono.wav differ diff --git a/src/auracast/utils/network_audio_receiver.py b/src/auracast/utils/network_audio_receiver.py new file mode 100644 index 0000000..ce54b00 --- /dev/null +++ b/src/auracast/utils/network_audio_receiver.py @@ -0,0 +1,67 @@ +import asyncio +import socket +import logging +import numpy as np +from typing import AsyncGenerator + +class NetworkAudioReceiverUncoded: + """ + Receives PCM audio over UDP and provides an async generator interface for uncoded PCM frames. + Combines network receiving and input logic for use with Auracast streamer. + """ + def __init__(self, port: int = 50007, samplerate: int = 16000, channels: int = 1, chunk_size: int = 1024): + self.port = port + self.samplerate = samplerate + self.channels = channels + self.chunk_size = chunk_size + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind(('0.0.0.0', self.port)) + self.sock.setblocking(False) + self._running = False + # Reduce queue size for lower latency (less buffering) + self._queue = asyncio.Queue(maxsize=2) # Was 20 + + async def receive(self): + self._running = True + logging.info(f"NetworkAudioReceiver listening on UDP port {self.port}") + try: + while self._running: + try: + data, _ = await asyncio.get_event_loop().sock_recvfrom(self.sock, self.chunk_size * 2) + await self._queue.put(data) + except Exception: + await asyncio.sleep(0.01) + finally: + self.sock.close() + logging.info("NetworkAudioReceiver stopped.") + + def stop(self): + self._running = False + + async def open(self): + # Dummy PCM format object + class PCMFormat: + channels = self.channels + sample_type = 'int16' + sample_rate = self.samplerate + return PCMFormat() + + def rewind(self): + pass # Not supported for live network input + + async def frames(self, samples_per_frame: int) -> AsyncGenerator[np.ndarray, None]: + bytes_per_frame = samples_per_frame * 2 * self.channels # 2 bytes for int16 + buf = bytearray() + while True: + data = await self._queue.get() + # Optional: log queue size for latency debugging + # logging.debug(f'NetworkAudioReceiver queue size: {self._queue.qsize()}') + if data is None: + break + buf.extend(data) + while len(buf) >= bytes_per_frame: + frame = np.frombuffer(buf[:bytes_per_frame], dtype=np.int16).reshape(-1, self.channels) + # Optional: log when a frame is yielded + # logging.debug(f'Yielding frame of shape {frame.shape}') + yield frame + buf = buf[bytes_per_frame:] diff --git a/src/auracast/utils/sounddevice_utils.py b/src/auracast/utils/sounddevice_utils.py new file mode 100644 index 0000000..f1f4f05 --- /dev/null +++ b/src/auracast/utils/sounddevice_utils.py @@ -0,0 +1,141 @@ +import sounddevice as sd +import os, re, json, subprocess + +def devices_by_backend(backend_name: str): + hostapis = sd.query_hostapis() # list of host APIs + # find the host API index by (case-insensitive) name match + try: + hostapi_idx = next( + i for i, ha in enumerate(hostapis) + if backend_name.lower() in ha['name'].lower() + ) + except StopIteration: + raise ValueError(f"No host API matching {backend_name!r}. " + f"Available: {[ha['name'] for ha in hostapis]}") + # return (global_index, device_dict) pairs filtered by that host API + return [(i, d) for i, d in enumerate(sd.query_devices()) + if d['hostapi'] == hostapi_idx] + +def _pa_like_hostapi_index(): + for i, ha in enumerate(sd.query_hostapis()): + if any(k in ha["name"] for k in ("PipeWire", "PulseAudio")): + return i + raise RuntimeError("PipeWire/PulseAudio host API not present in PortAudio.") + +def _pw_dump(): + return json.loads(subprocess.check_output(["pw-dump"])) + +def _sd_refresh(): + """Force PortAudio to re-enumerate devices on next query. + + sounddevice/PortAudio keeps a static device list after initialization. + Terminating here ensures that subsequent sd.query_* calls re-initialize + and see newly added devices (e.g., AES67 nodes created after start). + """ + sd._terminate() # private API, acceptable for runtime refresh + sd._initialize() + +def _sd_matches_from_names(pa_idx, names): + names_l = {n.lower() for n in names if n} + out = [] + for i, d in enumerate(sd.query_devices()): + if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0: + continue + dn = d["name"].lower() + if any(n in dn for n in names_l): + out.append((i, d)) + return out + +def list_usb_pw_inputs(): + """ + Return [(device_index, device_dict), ...] for PipeWire **input** nodes + backed by **USB** devices (excludes monitor sources). + """ + # Refresh PortAudio so we see newly added nodes before mapping + _sd_refresh() + pa_idx = _pa_like_hostapi_index() + pw = _pw_dump() + + # Map device.id -> device.bus ("usb"/"pci"/"platform"/"network"/...) + device_bus = {} + for obj in pw: + if obj.get("type") == "PipeWire:Interface:Device": + props = (obj.get("info") or {}).get("props") or {} + device_bus[obj["id"]] = (props.get("device.bus") or "").lower() + + # Collect names/descriptions of USB input nodes + usb_input_names = set() + for obj in pw: + if obj.get("type") != "PipeWire:Interface:Node": + continue + props = (obj.get("info") or {}).get("props") or {} + media = (props.get("media.class") or "").lower() + if "source" not in media and "stream/input" not in media: + continue + # skip monitor sources ("Monitor of ..." or *.monitor) + nname = (props.get("node.name") or "").lower() + ndesc = (props.get("node.description") or "").lower() + if ".monitor" in nname or "monitor" in ndesc: + continue + bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower() + if bus == "usb": + usb_input_names.add(props.get("node.description") or props.get("node.name")) + + # Map to sounddevice devices on PipeWire host API + return _sd_matches_from_names(pa_idx, usb_input_names) + +def list_network_pw_inputs(): + """ + Return [(device_index, device_dict), ...] for PipeWire **input** nodes that + look like network/AES67/RTP sources (excludes monitor sources). + """ + # Refresh PortAudio so we see newly added nodes before mapping + _sd_refresh() + pa_idx = _pa_like_hostapi_index() + pw = _pw_dump() + + network_input_names = set() + for obj in pw: + if obj.get("type") != "PipeWire:Interface:Node": + continue + props = (obj.get("info") or {}).get("props") or {} + media = (props.get("media.class") or "").lower() + if "source" not in media and "stream/input" not in media: + continue + nname = (props.get("node.name") or "") + ndesc = (props.get("node.description") or "") + # skip monitor sources + if ".monitor" in nname.lower() or "monitor" in ndesc.lower(): + continue + + # Heuristics for network/AES67/RTP + text = (nname + " " + ndesc).lower() + media_name = (props.get("media.name") or "").lower() + node_group = (props.get("node.group") or "").lower() + # Presence flags/keys that strongly indicate network RTP/AES67 sources + node_network_flag = bool(props.get("node.network")) + has_rtp_keys = any(k in props for k in ( + "rtp.session", "rtp.source.ip", "rtp.source.port", "rtp.fmtp", "rtp.rate" + )) + has_sess_keys = any(k in props for k in ( + "sess.name", "sess.media", "sess.latency.msec" + )) + is_network = ( + (props.get("device.bus") or "").lower() == "network" or + node_network_flag or + "rtp" in media_name or + any(k in text for k in ("rtp", "sap", "aes67", "network", "raop", "airplay")) or + has_rtp_keys or + has_sess_keys or + ("pipewire.ptp" in node_group) + ) + if is_network: + network_input_names.add(ndesc or nname) + + return _sd_matches_from_names(pa_idx, network_input_names) + +# Example usage: +# for i, d in list_usb_pw_inputs(): +# print(f"USB IN {i}: {d['name']} in={d['max_input_channels']}") +# for i, d in list_network_pw_inputs(): +# print(f"NET IN {i}: {d['name']} in={d['max_input_channels']}") diff --git a/src/auracast/utils/webrtc_audio_input.py b/src/auracast/utils/webrtc_audio_input.py new file mode 100644 index 0000000..d13f267 --- /dev/null +++ b/src/auracast/utils/webrtc_audio_input.py @@ -0,0 +1,42 @@ +import asyncio +import numpy as np +import logging + +class WebRTCAudioInput: + """ + Buffer PCM samples from WebRTC and provide an async generator interface for chunked frames. + """ + def __init__(self): + self.buffer = np.array([], dtype=np.int16) + self.lock = asyncio.Lock() + self.data_available = asyncio.Event() + self.closed = False + + async def frames(self, frame_size: int): + """ + Async generator yielding exactly frame_size samples as numpy arrays. + """ + while not self.closed: + async with self.lock: + if len(self.buffer) >= frame_size: + chunk = self.buffer[:frame_size] + self.buffer = self.buffer[frame_size:] + logging.debug(f"WebRTCAudioInput: Yielding {frame_size} samples, buffer now has {len(self.buffer)} samples remaining.") + yield chunk + else: + self.data_available.clear() + await self.data_available.wait() + + async def put_samples(self, samples: np.ndarray): + """ + Add new PCM samples (1D np.int16 array, mono) to the buffer. + """ + async with self.lock: + self.buffer = np.concatenate([self.buffer, samples]) + logging.debug(f"WebRTCAudioInput: Added {len(samples)} samples, buffer now has {len(self.buffer)} samples.") + self.data_available.set() + + async def close(self): + """Mark the input closed so frames() stops yielding.""" + self.closed = True + self.data_available.set() diff --git a/src/scripts/list_pw_nodes.py b/src/scripts/list_pw_nodes.py new file mode 100644 index 0000000..7c9bc85 --- /dev/null +++ b/src/scripts/list_pw_nodes.py @@ -0,0 +1,24 @@ +import sounddevice as sd, pprint +from auracast.utils.sounddevice_utils import devices_by_backend, list_usb_pw_inputs, list_network_pw_inputs + + +print("PortAudio library:", sd._libname) +print("PortAudio version:", sd.get_portaudio_version()) +print("\nHost APIs:") +pprint.pprint(sd.query_hostapis()) +print("\nDevices:") +pprint.pprint(sd.query_devices()) + +# Example: only PulseAudio devices on Linux +print("\nOnly PulseAudio devices:") +for i, d in devices_by_backend("PulseAudio"): + print(f"{i}: {d['name']} in={d['max_input_channels']} out={d['max_output_channels']}") + + +print("Network pw inputs:") +for i, d in list_network_pw_inputs(): + print(f"{i}: {d['name']} in={d['max_input_channels']}") + +print("USB pw inputs:") +for i, d in list_usb_pw_inputs(): + print(f"{i}: {d['name']} in={d['max_input_channels']}") diff --git a/src/service/aes67/90-pipewire-aes67-ptp.rules b/src/service/aes67/90-pipewire-aes67-ptp.rules new file mode 100644 index 0000000..22f39ff --- /dev/null +++ b/src/service/aes67/90-pipewire-aes67-ptp.rules @@ -0,0 +1,6 @@ +# This file was installed by PipeWire project for its pipewire-aes67 +# +# This is used to give readonly access to the PTP hardware clock. +# PipeWire uses this to follow PTP grandmaster time. It should be synced by another service +# +KERNEL=="ptp[0-9]*", MODE="0644" diff --git a/src/service/aes67/pipewire-aes67.conf b/src/service/aes67/pipewire-aes67.conf new file mode 100644 index 0000000..001c24a --- /dev/null +++ b/src/service/aes67/pipewire-aes67.conf @@ -0,0 +1,114 @@ +# AES67 config file for PipeWire version "1.2.7" # +# +# Copy and edit this file in /etc/pipewire for system-wide changes +# or in ~/.config/pipewire for local changes. +# +# It is also possible to place a file with an updated section in +# /etc/pipewire/pipewire-aes67.conf.d/ for system-wide changes or in +# ~/.config/pipewire/pipewire-aes67.conf.d/ for local changes. +# + +context.properties = { + ## Configure properties in the system. + default.clock.rate = 48000 + default.clock.allowed-rates = [ 48000 ] + # Enforce 3ms quantum on this AES67 PipeWire instance + clock.force-quantum = 144 + default.clock.quantum = 144 + #mem.warn-mlock = false + #mem.allow-mlock = true + #mem.mlock-all = false + #log.level = 2 + + #default.clock.quantum-limit = 8192 +} + +context.spa-libs = { + support.* = support/libspa-support +} + +context.objects = [ + # An example clock reading from /dev/ptp0. You can also specify the network interface name, + # pipewire will query the interface for the current active PHC index. Another option is to + # sync the ptp clock to CLOCK_TAI and then set clock.id = tai, keep in mind that tai may + # also be synced by a NTP client. + # The precedence is: device, interface, id + { factory = spa-node-factory + args = { + factory.name = support.node.driver + node.name = PTP0-Driver + node.group = pipewire.ptp0 + # This driver should only be used for network nodes marked with group + priority.driver = 100000 + clock.name = "clock.system.ptp0" + ### Please select the PTP hardware clock here + # Interface name is the preferred method of specifying the PHC + clock.interface = "eth0" + #clock.device = "/dev/ptp0" + #clock.id = tai + # Lower this in case of periodic out-of-sync + resync.ms = 1.5 + object.export = true + } + } +] + +context.modules = [ + { name = libpipewire-module-rt + args = { + nice.level = -11 + #rt.prio = 83 + #rt.time.soft = -1 + #rt.time.hard = -1 + } + flags = [ ifexists nofail ] + } + { name = libpipewire-module-protocol-native } + { name = libpipewire-module-client-node } + { name = libpipewire-module-spa-node-factory } + { name = libpipewire-module-adapter } + { name = libpipewire-module-rtp-sap + args = { + ### Please select the interface here + local.ifname = eth0 + sap.ip = 239.255.255.255 + sap.port = 9875 + net.ttl = 32 + net.loop = false + # If you use another PTPv2 daemon supporting management + # messages over a UNIX socket, specify its path here + ptp.management-socket = "/var/run/ptp4lro" + + stream.rules = [ + { + matches = [ + { + rtp.session = "~.*" + } + ] + actions = { + create-stream = { + node.virtual = false + media.class = "Audio/Source" + device.api = aes67 + # You can adjust the latency buffering here. Use integer values only + sess.latency.msec = 6 + node.latency = "144/48000" + node.group = pipewire.ptp0 + } + } + }, + { + matches = [ + { + sess.sap.announce = true + } + ] + actions = { + announce-stream = {} + } + } + ] + } + }, +] diff --git a/src/service/aes67/ptp_aes67_1.conf b/src/service/aes67/ptp_aes67_1.conf new file mode 100644 index 0000000..c4c1283 --- /dev/null +++ b/src/service/aes67/ptp_aes67_1.conf @@ -0,0 +1,19 @@ +[global] +priority1 255 +priority2 254 +# Lower = more likely to become Grandmaster. Keep the same on both for "either can be master". +domainNumber 0 +# Default domain +logSyncInterval -3 +# AES67 profile: Sync messages every 125ms +logAnnounceInterval 1 +# Announce messages every 2s (AES67 default) +logMinDelayReqInterval 0 +dscp_event 46 +# QoS for event messages +dscp_general 0 +# QoS for general messages +step_threshold 1 +# Fast convergence on time jumps + +tx_timestamp_timeout 20 \ No newline at end of file diff --git a/src/service/auracast-frontend.service b/src/service/auracast-frontend.service new file mode 100644 index 0000000..dca1b9e --- /dev/null +++ b/src/service/auracast-frontend.service @@ -0,0 +1,15 @@ +[Unit] +Description=Auracast Frontend HTTPS Server +# Ensure backend is running as a user service before starting frontend +After=auracast-server.service network.target +Wants=auracast-server.service + +[Service] +Type=simple +WorkingDirectory=/home/caster/bumble-auracast/src/auracast/server +ExecStart=/home/caster/bumble-auracast/src/auracast/server/start_frontend_https.sh +Restart=on-failure +Environment=LOG_LEVEL=INFO + +[Install] +WantedBy=multi-user.target diff --git a/src/service/auracast-script.service b/src/service/auracast-script.service new file mode 100644 index 0000000..b979a62 --- /dev/null +++ b/src/service/auracast-script.service @@ -0,0 +1,14 @@ +[Unit] +Description=Auracast Multicast Script +After=network.target + +[Service] +Type=simple +WorkingDirectory=/home/caster/bumble-auracast +ExecStart=/home/caster/.local/bin/poetry run python src/auracast/multicast_script.py +Restart=on-failure +Environment=PYTHONUNBUFFERED=1 +Environment=LOG_LEVEL=INFO + +[Install] +WantedBy=default.target diff --git a/src/service/auracast-server.service b/src/service/auracast-server.service new file mode 100644 index 0000000..23f8735 --- /dev/null +++ b/src/service/auracast-server.service @@ -0,0 +1,14 @@ +[Unit] +Description=Auracast Backend Server +After=network.target + +[Service] +Type=simple +WorkingDirectory=/home/caster/bumble-auracast +ExecStart=/home/caster/.local/bin/poetry run python src/auracast/server/multicast_server.py +Restart=on-failure +Environment=PYTHONUNBUFFERED=1 +Environment=LOG_LEVEL=INFO + +[Install] +WantedBy=default.target diff --git a/src/service/pipewire-aes67.service b/src/service/pipewire-aes67.service new file mode 100644 index 0000000..b800101 --- /dev/null +++ b/src/service/pipewire-aes67.service @@ -0,0 +1,11 @@ +[Unit] +Description=PipeWire AES67 Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/pipewire-aes67 -c /home/caster/bumble-auracast/src/service/aes67/pipewire-aes67.conf +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/src/service/pipewire/99-lowlatency.conf b/src/service/pipewire/99-lowlatency.conf new file mode 100644 index 0000000..ffdcfaa --- /dev/null +++ b/src/service/pipewire/99-lowlatency.conf @@ -0,0 +1,13 @@ +context.properties = { + default.clock.rate = 48000 + default.clock.allowed-rates = [ 48000 ] + default.clock.quantum = 144 # 144/48000 = 3.0 ms + default.clock.min-quantum = 32 + default.clock.max-quantum = 256 +} + +stream.properties = { + # Prefer to let specific nodes (e.g. AES67) or clients set node.latency. + node.latency = "144/48000" + resample.quality = 0 +} \ No newline at end of file diff --git a/src/service/ptp_aes67.service b/src/service/ptp_aes67.service new file mode 100644 index 0000000..99097bf --- /dev/null +++ b/src/service/ptp_aes67.service @@ -0,0 +1,13 @@ +[Unit] +Description=PTP AES67 Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/sbin/ptp4l -i eth0 -f /home/caster/bumble-auracast/src/service/aes67/ptp_aes67_1.conf +Restart=on-failure +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/src/service/status_auracast_services.sh b/src/service/status_auracast_services.sh new file mode 100755 index 0000000..c7f6b17 --- /dev/null +++ b/src/service/status_auracast_services.sh @@ -0,0 +1,84 @@ +#!/bin/bash +set -euo pipefail + +# Print status of relevant services and warn if mutually exclusive ones are running. +# Utilities (network audio): +# - ptp_aes67.service (system) +# - pipewire-aes67.service (user) +# App services (mutually exclusive groups): +# - auracast-script.service (user) +# - auracast-server.service (prefer user; also check system) +# - auracast-frontend.service (system) + +print_status() { + local scope="$1" # "system" or "user" + local unit="$2" + local act="unknown" + local ena="unknown" + if [[ "$scope" == "user" ]]; then + act=$(systemctl --user is-active "$unit" 2>/dev/null || true) + ena=$(systemctl --user is-enabled "$unit" 2>/dev/null || true) + else + act=$(systemctl is-active "$unit" 2>/dev/null || true) + ena=$(systemctl is-enabled "$unit" 2>/dev/null || true) + fi + act="${act:-unknown}" + ena="${ena:-unknown}" + printf " - %-24s [%s] active=%-10s enabled=%s\n" "$unit" "$scope" "$act" "$ena" +} + +is_active() { + local scope="$1" unit="$2" + if [[ "$scope" == "user" ]]; then + systemctl --user is-active "$unit" &>/dev/null && echo yes || echo no + else + systemctl is-active "$unit" &>/dev/null && echo yes || echo no + fi +} + +hr() { printf '\n%s\n' "----------------------------------------"; } + +printf "AURACAST SERVICE STATUS\n" +hr + +printf "Utilities (required for network audio)\n" +print_status system ptp_aes67.service +print_status user pipewire-aes67.service + +PTP_ACTIVE=$(is_active system ptp_aes67.service) +PW_ACTIVE=$(is_active user pipewire-aes67.service) + +if [[ "$PTP_ACTIVE" == "yes" && "$PW_ACTIVE" == "yes" ]]; then + echo " ✓ Utilities ready for AES67/network audio" +else + echo " ! Utilities not fully active (AES67/network audio may not work)" +fi + +hr +printf "Application services (mutually exclusive)\n" +print_status user auracast-script.service +print_status user auracast-server.service +print_status system auracast-server.service +print_status system auracast-frontend.service + +SCRIPT_ACTIVE=$(is_active user auracast-script.service) +SERVER_USER_ACTIVE=$(is_active user auracast-server.service) +SERVER_SYS_ACTIVE=$(is_active system auracast-server.service) +FRONT_ACTIVE=$(is_active system auracast-frontend.service) + +# Consider server active if either user or system instance is active +if [[ "$SERVER_USER_ACTIVE" == "yes" || "$SERVER_SYS_ACTIVE" == "yes" ]]; then + SERVER_ACTIVE=yes +else + SERVER_ACTIVE=no +fi + +if [[ "$SCRIPT_ACTIVE" == "yes" && ( "$SERVER_ACTIVE" == "yes" || "$FRONT_ACTIVE" == "yes" ) ]]; then + echo " ! WARNING: 'auracast-script' and 'server/frontend' are running together (mutually exclusive)." +fi + +hr +printf "Hints\n" +echo " - Follow logs (user): journalctl --user -u auracast-script.service -f" +echo " - Follow logs (server): journalctl --user -u auracast-server.service -f || journalctl -u auracast-server.service -f" +echo " - Frontend logs: journalctl -u auracast-frontend.service -f" diff --git a/src/service/stop_aes67.sh b/src/service/stop_aes67.sh new file mode 100644 index 0000000..37a2fbf --- /dev/null +++ b/src/service/stop_aes67.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# This script stops and disables the AES67 services +# Requires sudo privileges + +# Stop services +sudo systemctl stop ptp_aes67.service +systemctl --user stop pipewire-aes67.service + +# Disable services from starting on boot +sudo systemctl disable ptp_aes67.service +systemctl --user disable pipewire-aes67.service + +echo "\n--- ptp_aes67.service status ---" +sudo systemctl status ptp_aes67.service --no-pager + +echo "\n--- pipewire-aes67.service status (user) ---" +systemctl --user status pipewire-aes67.service --no-pager + +echo "AES67 services stopped, disabled, and status printed successfully." diff --git a/src/service/stop_auracast_script.sh b/src/service/stop_auracast_script.sh new file mode 100644 index 0000000..a95ab26 --- /dev/null +++ b/src/service/stop_auracast_script.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +# This script stops and disables the auracast-script user service + +# Stop service +systemctl --user stop auracast-script.service || true + +# Disable service from starting on login +systemctl --user disable auracast-script.service || true + +echo "\n--- auracast-script.service status (user) ---" +systemctl --user status auracast-script.service --no-pager || true + +echo "auracast-script service stopped, disabled, and status printed successfully." diff --git a/src/service/stop_server_and_frontend.sh b/src/service/stop_server_and_frontend.sh new file mode 100644 index 0000000..e6cf15b --- /dev/null +++ b/src/service/stop_server_and_frontend.sh @@ -0,0 +1,22 @@ +# This script stops and disables the auracast-server and auracast-frontend services +# Requires sudo privileges + +echo "Stopping auracast-server.service..." +systemctl --user stop auracast-server.service + +echo "Disabling auracast-server.service (user)..." +systemctl --user disable auracast-server.service + +echo "Stopping auracast-frontend.service ..." +sudo systemctl stop auracast-frontend.service + +echo "Disabling auracast-frontend.service ..." +sudo systemctl disable auracast-frontend.service + +echo "\n--- auracast-server.service status ---" +systemctl --user status auracast-server.service --no-pager + +echo "\n--- auracast-frontend.service status ---" +sudo systemctl status auracast-frontend.service --no-pager + +echo "auracast-server and auracast-frontend services stopped, disabled, and status printed successfully." diff --git a/src/service/update_and_run_auracast_script.sh b/src/service/update_and_run_auracast_script.sh new file mode 100644 index 0000000..83bfbbc --- /dev/null +++ b/src/service/update_and_run_auracast_script.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +# This script installs, enables, and restarts the auracast-script user service +# No sudo is required as it is a user service + +# Copy user service file for auracast-script +mkdir -p /home/caster/.config/systemd/user +cp /home/caster/bumble-auracast/src/service/auracast-script.service /home/caster/.config/systemd/user/auracast-script.service + +# Reload systemd to recognize new/updated services +systemctl --user daemon-reload + +# Enable service to start on user login +systemctl --user enable auracast-script.service + +# Restart service +systemctl --user restart auracast-script.service + +echo "\n--- auracast-script.service status (user) ---" +systemctl --user status auracast-script.service --no-pager + +echo "auracast-script service updated, enabled, restarted, and status printed successfully." diff --git a/src/service/update_and_run_pw_aes67.sh b/src/service/update_and_run_pw_aes67.sh new file mode 100644 index 0000000..30da008 --- /dev/null +++ b/src/service/update_and_run_pw_aes67.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +# This script installs, enables, and restarts the AES67 services +# Requires sudo privileges + +# Copy system service file for ptp_aes67 +sudo cp /home/caster/bumble-auracast/src/service/ptp_aes67.service /etc/systemd/system/ptp_aes67.service + +# Copy user service file for pipewire-aes67 +mkdir -p /home/caster/.config/systemd/user +cp /home/caster/bumble-auracast/src/service/pipewire-aes67.service /home/caster/.config/systemd/user/pipewire-aes67.service + +# Install PipeWire user config to persist 3ms@48kHz (default.clock.quantum=144) +mkdir -p /home/caster/.config/pipewire/pipewire.conf.d +cp /home/caster/bumble-auracast/src/service/pipewire/99-lowlatency.conf /home/caster/.config/pipewire/pipewire.conf.d/99-lowlatency.conf + +# Reload systemd to recognize new/updated services +sudo systemctl daemon-reload +systemctl --user daemon-reload + +# Enable services to start on boot +sudo systemctl enable ptp_aes67.service +systemctl --user enable pipewire-aes67.service + +# Restart services +systemctl --user restart pipewire.service pipewire-pulse.service +sudo systemctl restart ptp_aes67.service +systemctl --user restart pipewire-aes67.service + +echo "\n--- pipewire.service status (user) ---" +systemctl --user status pipewire.service --no-pager + +echo "\n--- ptp_aes67.service status ---" +sudo systemctl status ptp_aes67.service --no-pager + +echo "\n--- pipewire-aes67.service status (user) ---" +systemctl --user status pipewire-aes67.service --no-pager + + +echo "AES67 services updated, enabled, restarted, and status printed successfully." diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh new file mode 100644 index 0000000..5130bb2 --- /dev/null +++ b/src/service/update_and_run_server_and_frontend.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +# This script installs, enables, and restarts the auracast-server and auracast-frontend services +# Requires sudo privileges + +# Copy system service file for frontend +sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service + +# Copy user service file for backend (now using WantedBy=default.target) +mkdir -p /home/caster/.config/systemd/user +cp /home/caster/bumble-auracast/src/service/auracast-server.service /home/caster/.config/systemd/user/auracast-server.service + +# Reload systemd for frontend +sudo systemctl daemon-reload +# Reload user systemd for server +systemctl --user daemon-reload + +# Enable frontend to start on boot (system) +sudo systemctl enable auracast-frontend.service +# Enable server to start on boot (user) +systemctl --user enable auracast-server.service + +# Restart both +sudo systemctl restart auracast-frontend.service +systemctl --user restart auracast-server.service + +echo "\n--- auracast-frontend.service status ---" +sudo systemctl status auracast-frontend.service --no-pager + +echo "\n--- auracast-server.service status---" +systemctl --user status auracast-server.service --no-pager +echo "auracast-server and auracast-frontend services updated, enabled, restarted, and status printed successfully." diff --git a/tests/test_audio_device_io.py b/tests/test_audio_device_io.py new file mode 100644 index 0000000..c5059c4 --- /dev/null +++ b/tests/test_audio_device_io.py @@ -0,0 +1,44 @@ +"""Utility to diagnose Bumble SoundDeviceAudioInput compatibility. + +Run inside the project venv: + python -m tests.usb_audio_diag [rate] +It enumerates all PortAudio input devices and tries to open each with Bumble's +create_audio_input using the URI pattern `device:` with an explicit input_format of `int16le,,1`. +""" +from __future__ import annotations +import asyncio +import sys + +import sounddevice as sd # type: ignore +from bumble.audio import io as audio_io # type: ignore + +RATE = int(sys.argv[1]) if len(sys.argv) > 1 else 48000 + + +aSYNC = asyncio.run + + +async def try_device(index: int, rate: int = 48000) -> None: + input_uri = f"device:{index}" + try: + audio_input = await audio_io.create_audio_input(input_uri, f"int16le,{rate},1") + fmt = await audio_input.open() + print(f"\033[32m✔︎ {input_uri} -> {fmt.channels}ch @ {fmt.sample_rate}Hz\033[0m") + if hasattr(audio_input, "aclose"): + await audio_input.aclose() + except Exception as exc: # pylint: disable=broad-except + print(f"\033[31m✗ {input_uri}: {exc}\033[0m") + + +async def main() -> None: + print(f"Trying PortAudio input devices with rate {RATE} Hz\n") + for idx, dev in enumerate(sd.query_devices()): + if dev["max_input_channels"] > 0 and "(hw:" in dev["name"].lower(): + name = dev["name"] + print(f"[{idx}] {name}") + await try_device(idx, RATE) + print() + + +if __name__ == "__main__": + aSYNC(main())