From f4ac71c6d49088f828bbc2dc0071de712ff3cc7d Mon Sep 17 00:00:00 2001 From: Renan Prata Date: Mon, 31 Mar 2025 16:08:36 -0300 Subject: [PATCH 1/3] feat: adding trickle ice support --- examples/full-trickle-ice-client.py | 155 ++++++++++++++++++++++++++++ examples/ice-client.py | 6 +- examples/signaling-server.py | 16 ++- src/aioice/ice.py | 31 ++++-- 4 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 examples/full-trickle-ice-client.py diff --git a/examples/full-trickle-ice-client.py b/examples/full-trickle-ice-client.py new file mode 100644 index 0000000..a4986c4 --- /dev/null +++ b/examples/full-trickle-ice-client.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python + +import argparse +import asyncio +import json +import logging +import time +from typing import Optional + +import aioice +import websockets + +STUN_SERVER = ("stun.l.google.com", 19302) +WEBSOCKET_URI = "ws://127.0.0.1:8765" + + +async def offer(options): + websocket = await websockets.connect(WEBSOCKET_URI) + + connected = False + start_time = None + + async def signal_candidate(candidate: Optional[aioice.Candidate]): + message = { + "type": "candidate", + "candidate": candidate.to_sdp() if candidate else None, + } + await websocket.send(json.dumps(message)) + print("Sent candidate:", message["candidate"]) + + connection = aioice.Connection( + ice_controlling=True, + components=options.components, + stun_server=STUN_SERVER, + signal_candidate=signal_candidate, + ) + + start_time = time.time() + await websocket.send( + json.dumps({ + "type": "offer", + "username": connection.local_username, + "password": connection.local_password, + }) + ) + + await connection.gather_candidates() + + async for raw in websocket: + message = json.loads(raw) + + if message["type"] == "answer": + connection.remote_username = message["username"] + connection.remote_password = message["password"] + print("Received answer.") + elif message["type"] == "candidate": + candidate = message["candidate"] + await connection.add_remote_candidate( + aioice.Candidate.from_sdp(candidate) if candidate else None + ) + print("Received remote candidate:", candidate) + else: + print("Unknown message type:", message) + + if not connected and connection.remote_username and connection.remote_password: + try: + await connection.connect() + connected = True + elapsed = time.time() - start_time + print(f"✅ connected in {elapsed:.2f} seconds") + + data = b"hello" + await connection.sendto(data, 1) + data, component = await connection.recvfrom() + print("Received:", data) + + await asyncio.sleep(2) + await connection.close() + await websocket.close() + break + except Exception as e: + print("Connection error:", e) + + +async def answer(options): + websocket = await websockets.connect(WEBSOCKET_URI) + + connected = False + + async def signal_candidate(candidate: Optional[aioice.Candidate]): + message = { + "type": "candidate", + "candidate": candidate.to_sdp() if candidate else None, + } + await websocket.send(json.dumps(message)) + print("Sent candidate:", message["candidate"]) + + connection = aioice.Connection( + ice_controlling=False, + components=options.components, + stun_server=STUN_SERVER, + signal_candidate=signal_candidate, + ) + + async for raw in websocket: + message = json.loads(raw) + + if message["type"] == "offer": + connection.remote_username = message["username"] + connection.remote_password = message["password"] + + await websocket.send(json.dumps({ + "type": "answer", + "username": connection.local_username, + "password": connection.local_password, + })) + + await connection.gather_candidates() + + elif message["type"] == "candidate": + candidate = message["candidate"] + await connection.add_remote_candidate( + aioice.Candidate.from_sdp(candidate) if candidate else None + ) + print("Received remote candidate:", candidate) + + if not connected and candidate is None and connection.remote_username: + try: + await connection.connect() + connected = True + print("✅ Connected via ICE.") + + data, component = await connection.recvfrom() + print("Echoing:", data) + await connection.sendto(data, component) + + await asyncio.sleep(2) + await connection.close() + await websocket.close() + break + except Exception as e: + print("Connection error:", e) + + +parser = argparse.ArgumentParser(description="ICE trickle demo") +parser.add_argument("action", choices=["offer", "answer"]) +parser.add_argument("--components", type=int, default=1) +options = parser.parse_args() + +logging.basicConfig(level=logging.DEBUG) + +if options.action == "offer": + asyncio.run(offer(options)) +else: + asyncio.run(answer(options)) diff --git a/examples/ice-client.py b/examples/ice-client.py index 6e40135..d7ed207 100644 --- a/examples/ice-client.py +++ b/examples/ice-client.py @@ -4,6 +4,8 @@ import asyncio import json import logging +import time +from typing import Optional import websockets @@ -21,6 +23,7 @@ async def offer(components: int) -> None: websocket = await websockets.connect(WEBSOCKET_URI) + start_time = time.time() # send offer await websocket.send( json.dumps( @@ -44,7 +47,8 @@ async def offer(components: int) -> None: await websocket.close() await connection.connect() - print("connected") + elapsed = time.time() - start_time + print(f"✅ connected in {elapsed:.2f} seconds") # send data data = b"hello" diff --git a/examples/signaling-server.py b/examples/signaling-server.py index b3c1dc4..bc59f80 100644 --- a/examples/signaling-server.py +++ b/examples/signaling-server.py @@ -1,19 +1,17 @@ #!/usr/bin/env python -# -# Simple websocket server to perform signaling. -# import asyncio import binascii import os -from websockets.asyncio.server import ServerConnection, serve +from websockets.asyncio.server import ServerConnection +import websockets clients: dict[bytes, ServerConnection] = {} -async def echo(websocket: ServerConnection) -> None: - client_id = binascii.hexlify(os.urandom(8)) +async def echo(websocket): + client_id = binascii.hexlify(os.urandom(8)).decode() clients[client_id] = websocket try: @@ -25,9 +23,9 @@ async def echo(websocket: ServerConnection) -> None: clients.pop(client_id) -async def main() -> None: - async with serve(echo, "0.0.0.0", 8765) as server: - await server.serve_forever() +async def main(): + async with websockets.serve(echo, "0.0.0.0", 8765): + await asyncio.Future() if __name__ == "__main__": diff --git a/src/aioice/ice.py b/src/aioice/ice.py index 3316b47..c1a2826 100644 --- a/src/aioice/ice.py +++ b/src/aioice/ice.py @@ -9,7 +9,7 @@ import secrets import socket import threading -from typing import Optional, Union, cast +from typing import Callable, Optional, Union, cast import ifaddr @@ -320,6 +320,7 @@ class Connection: will be generated. :param local_password: An optional local password, otherwise a random one will be generated. + :param signal_candidate: Callback to signal a candidate. """ def __init__( @@ -337,6 +338,7 @@ def __init__( transport_policy: TransportPolicy = TransportPolicy.ALL, local_username: Optional[str] = None, local_password: Optional[str] = None, + signal_candidate: Optional[Callable[[Optional[Candidate]], None]] = None, ) -> None: self.ice_controlling = ice_controlling @@ -363,6 +365,7 @@ def __init__( self.turn_password = turn_password self.turn_ssl = turn_ssl self.turn_transport = turn_transport + self.signal_candidate = signal_candidate # private self._closed = False @@ -370,9 +373,9 @@ def __init__( self._check_list: list[CandidatePair] = [] self._check_list_done = False self._check_list_state: asyncio.Queue = asyncio.Queue() - self._early_checks: list[ - tuple[stun.Message, tuple[str, int], StunProtocol] - ] = [] + self._early_checks: list[tuple[stun.Message, tuple[str, int], StunProtocol]] = ( + [] + ) self._early_checks_done = False self._event_waiter: Optional[asyncio.Future[ConnectionEvent]] = None self._id = next(connection_id) @@ -499,13 +502,21 @@ async def gather_candidates(self) -> None: addresses = get_host_addresses( use_ipv4=self._use_ipv4, use_ipv6=self._use_ipv6 ) - coros = [ - self.get_component_candidates(component=component, addresses=addresses) - for component in self._components - ] - for candidates in await asyncio.gather(*coros): - self._local_candidates += candidates + + async def gather_and_signal(component): + candidates = await self.get_component_candidates( + component=component, addresses=addresses + ) + for candidate in candidates: + self._local_candidates.append(candidate) + if self.signal_candidate: + await self.signal_candidate(candidate) + + coros = [gather_and_signal(component) for component in self._components] + await asyncio.gather(*coros) self._local_candidates_end = True + if self.signal_candidate: + await self.signal_candidate(None) def get_default_candidate(self, component: int) -> Optional[Candidate]: """ From ffbd2459f54ed535f950a64f840462416186a2f2 Mon Sep 17 00:00:00 2001 From: Renan Prata Date: Mon, 31 Mar 2025 16:27:48 -0300 Subject: [PATCH 2/3] chore: updating python version --- .github/workflows/tests.yml | 11 +++++------ examples/full-trickle-ice-client.py | 30 +++++++++++++++++------------ examples/ice-client.py | 2 +- pyproject.toml | 27 ++++++++------------------ 4 files changed, 32 insertions(+), 38 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b988629..86019d4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,7 +3,6 @@ name: tests on: [push, pull_request] jobs: - lint: runs-on: ubuntu-latest steps: @@ -25,11 +24,11 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: - - '3.13' - - '3.12' - - '3.11' - - '3.10' - - '3.9' + - "3.13" + - "3.12" + - "3.11" + - "3.10" + - "3.9" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/examples/full-trickle-ice-client.py b/examples/full-trickle-ice-client.py index a4986c4..3dd6b53 100644 --- a/examples/full-trickle-ice-client.py +++ b/examples/full-trickle-ice-client.py @@ -37,11 +37,13 @@ async def signal_candidate(candidate: Optional[aioice.Candidate]): start_time = time.time() await websocket.send( - json.dumps({ - "type": "offer", - "username": connection.local_username, - "password": connection.local_password, - }) + json.dumps( + { + "type": "offer", + "username": connection.local_username, + "password": connection.local_password, + } + ) ) await connection.gather_candidates() @@ -67,7 +69,7 @@ async def signal_candidate(candidate: Optional[aioice.Candidate]): await connection.connect() connected = True elapsed = time.time() - start_time - print(f"✅ connected in {elapsed:.2f} seconds") + print(f"connected in {elapsed:.2f} seconds") data = b"hello" await connection.sendto(data, 1) @@ -109,11 +111,15 @@ async def signal_candidate(candidate: Optional[aioice.Candidate]): connection.remote_username = message["username"] connection.remote_password = message["password"] - await websocket.send(json.dumps({ - "type": "answer", - "username": connection.local_username, - "password": connection.local_password, - })) + await websocket.send( + json.dumps( + { + "type": "answer", + "username": connection.local_username, + "password": connection.local_password, + } + ) + ) await connection.gather_candidates() @@ -128,7 +134,7 @@ async def signal_candidate(candidate: Optional[aioice.Candidate]): try: await connection.connect() connected = True - print("✅ Connected via ICE.") + print("Connected via ICE.") data, component = await connection.recvfrom() print("Echoing:", data) diff --git a/examples/ice-client.py b/examples/ice-client.py index d7ed207..cab43cb 100644 --- a/examples/ice-client.py +++ b/examples/ice-client.py @@ -48,7 +48,7 @@ async def offer(components: int) -> None: await connection.connect() elapsed = time.time() - start_time - print(f"✅ connected in {elapsed:.2f} seconds") + print(f"connected in {elapsed:.2f} seconds") # send data data = b"hello" diff --git a/pyproject.toml b/pyproject.toml index 7632d93..cd74f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,7 @@ description = "An implementation of Interactive Connectivity Establishment (RFC readme = "README.rst" requires-python = ">=3.9" license = "BSD-3-Clause" -authors = [ - { name = "Jeremy Lainé", email = "jeremy.laine@m4x.org" }, -] +authors = [{ name = "Jeremy Lainé", email = "jeremy.laine@m4x.org" }] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", @@ -24,20 +22,11 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] -dependencies = [ - "dnspython>=2.0.0", - "ifaddr>=0.2.0", -] +dependencies = ["dnspython>=2.0.0", "ifaddr>=0.2.0"] dynamic = ["version"] [project.optional-dependencies] -dev = [ - "coverage[toml]>=7.2.2", - "mypy", - "pyopenssl", - "ruff", - "websockets", -] +dev = ["coverage[toml]>=7.2.2", "mypy", "pyopenssl", "ruff", "websockets"] [project.urls] homepage = "https://github.com/aiortc/aioice" @@ -57,11 +46,11 @@ warn_unused_ignores = true [tool.ruff.lint] select = [ - "E", # pycodestyle - "F", # Pyflakes - "W", # pycodestyle - "I", # isort + "E", # pycodestyle + "F", # Pyflakes + "W", # pycodestyle + "I", # isort ] [tool.setuptools.dynamic] -version = {attr = "aioice.__version__"} +version = { attr = "aioice.__version__" } From b36da7136a86950fbf953ac955bff781f236fd1a Mon Sep 17 00:00:00 2001 From: Renan Prata Date: Thu, 10 Apr 2025 17:39:55 -0300 Subject: [PATCH 3/3] revert: config files --- .github/workflows/tests.yml | 11 ++++++----- examples/full-trickle-ice-client.py | 3 ++- examples/ice-client.py | 1 - examples/signaling-server.py | 5 ++++- pyproject.toml | 27 +++++++++++++++++++-------- src/aioice/ice.py | 6 +++--- 6 files changed, 34 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86019d4..b988629 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,6 +3,7 @@ name: tests on: [push, pull_request] jobs: + lint: runs-on: ubuntu-latest steps: @@ -24,11 +25,11 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: - - "3.13" - - "3.12" - - "3.11" - - "3.10" - - "3.9" + - '3.13' + - '3.12' + - '3.11' + - '3.10' + - '3.9' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/examples/full-trickle-ice-client.py b/examples/full-trickle-ice-client.py index 3dd6b53..00f193e 100644 --- a/examples/full-trickle-ice-client.py +++ b/examples/full-trickle-ice-client.py @@ -7,9 +7,10 @@ import time from typing import Optional -import aioice import websockets +import aioice + STUN_SERVER = ("stun.l.google.com", 19302) WEBSOCKET_URI = "ws://127.0.0.1:8765" diff --git a/examples/ice-client.py b/examples/ice-client.py index cab43cb..bd34ab6 100644 --- a/examples/ice-client.py +++ b/examples/ice-client.py @@ -5,7 +5,6 @@ import json import logging import time -from typing import Optional import websockets diff --git a/examples/signaling-server.py b/examples/signaling-server.py index bc59f80..0357703 100644 --- a/examples/signaling-server.py +++ b/examples/signaling-server.py @@ -1,11 +1,14 @@ #!/usr/bin/env python +# +# Simple websocket server to perform signaling. +# import asyncio import binascii import os -from websockets.asyncio.server import ServerConnection import websockets +from websockets.asyncio.server import ServerConnection clients: dict[bytes, ServerConnection] = {} diff --git a/pyproject.toml b/pyproject.toml index cd74f3f..7632d93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ description = "An implementation of Interactive Connectivity Establishment (RFC readme = "README.rst" requires-python = ">=3.9" license = "BSD-3-Clause" -authors = [{ name = "Jeremy Lainé", email = "jeremy.laine@m4x.org" }] +authors = [ + { name = "Jeremy Lainé", email = "jeremy.laine@m4x.org" }, +] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", @@ -22,11 +24,20 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] -dependencies = ["dnspython>=2.0.0", "ifaddr>=0.2.0"] +dependencies = [ + "dnspython>=2.0.0", + "ifaddr>=0.2.0", +] dynamic = ["version"] [project.optional-dependencies] -dev = ["coverage[toml]>=7.2.2", "mypy", "pyopenssl", "ruff", "websockets"] +dev = [ + "coverage[toml]>=7.2.2", + "mypy", + "pyopenssl", + "ruff", + "websockets", +] [project.urls] homepage = "https://github.com/aiortc/aioice" @@ -46,11 +57,11 @@ warn_unused_ignores = true [tool.ruff.lint] select = [ - "E", # pycodestyle - "F", # Pyflakes - "W", # pycodestyle - "I", # isort + "E", # pycodestyle + "F", # Pyflakes + "W", # pycodestyle + "I", # isort ] [tool.setuptools.dynamic] -version = { attr = "aioice.__version__" } +version = {attr = "aioice.__version__"} diff --git a/src/aioice/ice.py b/src/aioice/ice.py index c1a2826..c6a54eb 100644 --- a/src/aioice/ice.py +++ b/src/aioice/ice.py @@ -373,9 +373,9 @@ def __init__( self._check_list: list[CandidatePair] = [] self._check_list_done = False self._check_list_state: asyncio.Queue = asyncio.Queue() - self._early_checks: list[tuple[stun.Message, tuple[str, int], StunProtocol]] = ( - [] - ) + self._early_checks: list[ + tuple[stun.Message, tuple[str, int], StunProtocol] + ] = [] self._early_checks_done = False self._event_waiter: Optional[asyncio.Future[ConnectionEvent]] = None self._id = next(connection_id)