From 4ff24430a0c28066ba3a13df503cd44aeeaf291f Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 28 Oct 2026 22:18:47 +0100 Subject: [PATCH 1/8] Write out the config flow strings that used common references Custom integrations ship their translations/*.json as they are: only core's compile step replaces [%key:...%] references, so users saw the raw reference for unknown errors, an already configured gateway and a successful reauth. Write the text out, using the same wording as core's common strings. Co-Authored-By: Claude Opus 5.5 --- custom_components/sg_150/translations/de.json | 6 +++--- custom_components/sg_150/translations/en.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/sg_150/translations/de.json b/custom_components/sg_150/translations/de.json index f1f638a..fb0c0e6 100644 --- a/custom_components/sg_150/translations/de.json +++ b/custom_components/sg_150/translations/de.json @@ -51,11 +51,11 @@ "error": { "auth": "Benutzername/Passwort falsch.", "connection": "Die Verbindung zum Server konnte nicht hergestellt werden.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Unerwarteter Fehler" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "Konto ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" } }, "options": { diff --git a/custom_components/sg_150/translations/en.json b/custom_components/sg_150/translations/en.json index 4b1af8d..6ab91f8 100644 --- a/custom_components/sg_150/translations/en.json +++ b/custom_components/sg_150/translations/en.json @@ -51,11 +51,11 @@ "error": { "auth": "Username/Password is wrong.", "connection": "Unable to connect to the server.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Unexpected error" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" } }, "options": { From 1ec09a6387a99d267806c2152764bcd59d54101b Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 28 Oct 2026 21:29:26 +0100 Subject: [PATCH 2/8] Test with Home Assistant's pytest-home-assistant on 2026.11 pytest-homeassistant-custom-component does not load on Home Assistant 2026.11, which now ships its own test package, pytest-home-assistant (home-assistant/core#187272). Switch to it and target 2026.11.0b0: - Import the test helpers from pytest_homeassistant, and drop the enable_custom_integrations wrapper; custom integrations are enabled by default now. - Disable the respx pytest plugin, so Home Assistant aliases httpx to httpx2 before respx imports it. - Install pytest-home-assistant from the 2026.11.0b0 core tag until it is published on PyPI. media_source now loads platforms lazily, so the image proxy view was only registered once media had been browsed, and stored image URLs returned 404 after a restart. Register it in async_setup instead. Declare pydantic as a requirement. api.py and types.py use it, but it was never declared: Home Assistant only constrains its version and doesn't install it, and the old test package happened to depend on it, while pytest-home-assistant doesn't. Require it in the manifest for installs, and pin Home Assistant's constrained version in requirements.txt for CI. Add pytest-cov to the test requirements for the same reason: scripts/test uses its --cov options and CI's coverage report needs coverage, which the old test package brought along and pytest-home-assistant doesn't. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 2 +- .pytest.toml | 2 ++ custom_components/sg_150/__init__.py | 4 +++- custom_components/sg_150/manifest.json | 3 ++- custom_components/sg_150/media_source.py | 8 ++++++-- requirements.txt | 4 +++- requirements_test.txt | 6 +++++- tests/conftest.py | 9 ++------- tests/test_config_flow.py | 4 ++-- tests/test_entities.py | 2 +- tests/test_init.py | 4 ++-- tests/test_media_source.py | 6 +++--- tests/test_services.py | 4 ++-- 13 files changed, 34 insertions(+), 24 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1cd854b..3278cdb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,4 +19,4 @@ updates: # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json - dependency-name: "homeassistant" # The test plugin pins one exact Home Assistant release, so it moves with it - - dependency-name: "pytest-homeassistant-custom-component" \ No newline at end of file + - dependency-name: "pytest-home-assistant" \ No newline at end of file diff --git a/.pytest.toml b/.pytest.toml index 511f742..033a34c 100644 --- a/.pytest.toml +++ b/.pytest.toml @@ -2,3 +2,5 @@ filterwarnings = ['ignore:DeprecationWarning'] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +# Let Home Assistant install its httpx alias before respx is imported. +addopts = ["-p", "no:respx"] diff --git a/custom_components/sg_150/__init__.py b/custom_components/sg_150/__init__.py index f7f56be..87c864b 100644 --- a/custom_components/sg_150/__init__.py +++ b/custom_components/sg_150/__init__.py @@ -29,6 +29,7 @@ from .coordinator import SG150Coordinator from .data import SG150RuntimeData from .line import GatewayLine +from .media_source import SG150ImageView from .services import async_setup_services from .sip.minisip import MiniSIPServer @@ -50,8 +51,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: - """Register the services once per Home Assistant instance.""" + """Register the services and the image view once per Home Assistant instance.""" async_setup_services(hass) + hass.http.register_view(SG150ImageView(hass)) return True diff --git a/custom_components/sg_150/manifest.json b/custom_components/sg_150/manifest.json index f46e708..03a0ec5 100644 --- a/custom_components/sg_150/manifest.json +++ b/custom_components/sg_150/manifest.json @@ -14,7 +14,8 @@ "issue_tracker": "https://github.com/fweinb/ha-sg-150/issues", "requirements": [ "soundfile==0.13.1", - "numpy>=2.3.2" + "numpy>=2.3.2", + "pydantic>=2.0" ], "version": "0.1.0" } \ No newline at end of file diff --git a/custom_components/sg_150/media_source.py b/custom_components/sg_150/media_source.py index e1fa50e..44f8ed9 100644 --- a/custom_components/sg_150/media_source.py +++ b/custom_components/sg_150/media_source.py @@ -40,8 +40,12 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: - """Set up the SG-150 media source.""" - hass.http.register_view(SG150ImageView(hass)) + """ + Set up the SG-150 media source. + + The image view is registered in async_setup instead: media_source loads + this platform lazily, so image URLs would 404 until media was browsed. + """ return SG150MediaSource(hass) diff --git a/requirements.txt b/requirements.txt index 287a20b..5d27161 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ colorlog==6.12.0 -homeassistant==2026.7.0 +homeassistant==2026.11.0b0 +# Home Assistant constrains pydantic to this version but doesn't install it. +pydantic==2.14.1 pip>=26.2.2 ruff==0.17.2 soundfile==0.14.0 diff --git a/requirements_test.txt b/requirements_test.txt index 60e6239..c29cc3c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,2 +1,6 @@ -r requirements.txt -pytest-homeassistant-custom-component==0.13.344 +# Home Assistant's own test package, released with Home Assistant itself. Until +# its 2026.11 release is on PyPI, install it from the core release tag. +pytest-home-assistant @ https://github.com/home-assistant/core/archive/refs/tags/2026.11.0b0.zip#subdirectory=packages/pytest-home-assistant +# Coverage for scripts/test; pytest-home-assistant doesn't bring it. +pytest-cov==7.1.0 diff --git a/tests/conftest.py b/tests/conftest.py index ead17f4..b63532f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant.common import MockConfigEntry from custom_components.sg_150.const import ( API_PORT, @@ -27,7 +27,7 @@ from collections.abc import Generator from homeassistant.core import HomeAssistant - from pytest_homeassistant_custom_component.test_util.aiohttp import ( + from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) @@ -53,11 +53,6 @@ } -@pytest.fixture(autouse=True) -def auto_enable_custom_integrations(enable_custom_integrations: None) -> None: - """Load the integration from custom_components in every test.""" - - @pytest.fixture def config_entry() -> MockConfigEntry: """Return a config entry for one gateway.""" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 88c4f7d..72f49e8 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -23,8 +23,8 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant - from pytest_homeassistant_custom_component.common import MockConfigEntry - from pytest_homeassistant_custom_component.test_util.aiohttp import ( + from pytest_homeassistant.common import MockConfigEntry + from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) diff --git a/tests/test_entities.py b/tests/test_entities.py index 6fe1f92..ce69e09 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant - from pytest_homeassistant_custom_component.test_util.aiohttp import ( + from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) diff --git a/tests/test_init.py b/tests/test_init.py index 4d2c615..62b994e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -12,8 +12,8 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant - from pytest_homeassistant_custom_component.common import MockConfigEntry - from pytest_homeassistant_custom_component.test_util.aiohttp import ( + from pytest_homeassistant.common import MockConfigEntry + from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) diff --git a/tests/test_media_source.py b/tests/test_media_source.py index 239ed21..2786c34 100644 --- a/tests/test_media_source.py +++ b/tests/test_media_source.py @@ -19,11 +19,11 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant - from pytest_homeassistant_custom_component.common import MockConfigEntry - from pytest_homeassistant_custom_component.test_util.aiohttp import ( + from pytest_homeassistant.common import MockConfigEntry + from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) - from pytest_homeassistant_custom_component.typing import ClientSessionGenerator + from pytest_homeassistant.typing import ClientSessionGenerator RESPONSE = json.loads((FIXTURES / "image_store_images.json").read_text()) IMAGES = RESPONSE["images"] diff --git a/tests/test_services.py b/tests/test_services.py index 8fba3da..ceb8443 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant.common import MockConfigEntry from custom_components.sg_150.const import API_PORT, CONF_HOST_ADDRESS, DOMAIN from tests.conftest import MACHINE_INFO, TOKEN @@ -25,7 +25,7 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant, ServiceResponse - from pytest_homeassistant_custom_component.test_util.aiohttp import ( + from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) From 5a4b82c1d4b86a4d6a67547792200e8a9e50d3a8 Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 28 Oct 2026 21:29:40 +0100 Subject: [PATCH 3/8] Add two-way audio to the door station camera Talk to the person at the door from the camera's live view, using Home Assistant 2026.11's two-way audio preview feature. Opt in with the new Two-way audio option; without it the camera stays MJPEG. - sip/rtp.py: an RtpSession per call on a configurable UDP port range (default 5004-5013). It sends a continuous, clock-paced G.711 stream from the advertised port, plays received audio out through a small jitter buffer, and mixes live audio over play_audio clips. - minisip: offer sendrecv on incoming and outgoing calls, accept PCMU, handle CANCEL, resend our 200 OK until the ACK, and let a call ring until it is answered or rejected later. - GatewayLine: with auto answer off and two-way audio on, ring along with the handsets and answer when talk is pressed; calls nobody answers are rejected as busy after a minute. - talkback.py: native WebRTC through aiortc. One VP8 encoder per camera feeds every viewer, door audio goes to every session, and the newest talking session's microphone goes into the call. The call is hung up when the last session that talked closes. - A Hang Up button next to each camera, ending the calls its device is on. Pressing it with no call shows an error. aiortc needs an av<20 release (main has it; PyPI 1.15.0 pins av<18), so requirements.txt pins an aiortc commit until then. Co-Authored-By: Claude Opus 5.5 --- .devcontainer.json | 6 +- CONTRIBUTING.md | 112 ++-- README.md | 66 ++- custom_components/sg_150/__init__.py | 11 + custom_components/sg_150/button.py | 51 +- custom_components/sg_150/camera.py | 158 ++++-- custom_components/sg_150/config_flow.py | 43 +- custom_components/sg_150/const.py | 6 + custom_components/sg_150/line.py | 79 ++- custom_components/sg_150/manifest.json | 1 + custom_components/sg_150/sip/minisip.py | 496 ++++++++++++----- custom_components/sg_150/sip/rtp.py | 442 +++++++++++++++ custom_components/sg_150/talkback.py | 523 ++++++++++++++++++ custom_components/sg_150/translations/de.json | 38 +- custom_components/sg_150/translations/en.json | 38 +- custom_components/sg_150/types.py | 8 + hacs.json | 2 +- requirements.txt | 3 + tests/conftest.py | 5 +- tests/sip_messages.py | 14 + tests/test_config_flow.py | 28 +- tests/test_entities.py | 68 +++ tests/test_line.py | 91 ++- tests/test_minisip_audio.py | 114 ++-- tests/test_minisip_behavior.py | 201 ++++++- tests/test_options.py | 34 ++ tests/test_rtp.py | 369 ++++++++++++ tests/test_talkback.py | 326 +++++++++++ tests/test_two_way_audio.py | 83 +++ tests/utils.py | 8 + 30 files changed, 3095 insertions(+), 329 deletions(-) create mode 100644 custom_components/sg_150/sip/rtp.py create mode 100644 custom_components/sg_150/talkback.py create mode 100644 tests/test_options.py create mode 100644 tests/test_rtp.py create mode 100644 tests/test_talkback.py create mode 100644 tests/test_two_way_audio.py diff --git a/.devcontainer.json b/.devcontainer.json index 1cd3f13..1ef99fc 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -2,7 +2,7 @@ "name": "fweinb/ha-sg-150", "image": "mcr.microsoft.com/devcontainers/python:3.14-trixie", "postCreateCommand": "scripts/setup", - "appPort": ["8123", "50600:50600/udp"], + "appPort": ["8123", "50600:50600/udp", "5004-5013:5004-5013/udp"], "portsAttributes": { "8123": { "label": "Home Assistant", @@ -11,6 +11,10 @@ "50600": { "label": "SG-150 SIP Port", "onAutoForward": "notify" + }, + "5004-5013": { + "label": "SG-150 RTP Ports", + "onAutoForward": "silent" } }, "customizations": { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6709beb..615155c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,45 +1,67 @@ -# Contribution guidelines - -Contributing to this project should be as easy and transparent as possible, whether it's: - -- Reporting a bug -- Discussing the current state of the code -- Submitting a fix -- Proposing new features - -## Github is used for everything - -Github is used to host code, to track issues and feature requests, as well as accept pull requests. - -Pull requests are the best way to propose changes to the codebase. - -1. Fork the repo and create your branch from `main`. -2. If you've changed something, update the documentation. -3. Make sure your code lints (using `scripts/lint`). -4. Test you contribution. -5. Issue that pull request! - -## Any contributions you make will be under the MIT Software License - -In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. - -## Report bugs using Github's [issues](../../issues) - -GitHub issues are used to track public bugs. -Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! - -## Write bug reports with detail, background, and sample code - -**Great Bug Reports** tend to have: - -- A quick summary and/or background -- Steps to reproduce - - Be specific! - - Give sample code if you can. -- What you expected would happen -- What actually happens -- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - -## License - -By contributing, you agree that your contributions will be licensed under its MIT License. +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +4. Test you contribution. +5. Issue that pull request! + +## Testing two-way audio on a real gateway + +The tests cover the SIP, RTP and WebRTC code on fake transports and an in-process WebRTC peer, but not the real SG-150 or a real browser. Before releasing a change to call audio, go through this with a gateway: + +1. Prepare: + - Home Assistant opened over HTTPS. + - **Two-way audio** turned on under Settings → System → Labs. + - **Two-way audio** turned on in the integration options. + - The RTP port range reachable from the gateway. In the devcontainer, 5004–5013/udp are forwarded. +2. With **SIP Auto Answer** off: + - Ring the door station. Every handset rings, and the camera's live view shows the talk button. + - Open the live view without pressing talk. The call keeps ringing. + - Press talk. The handsets stop ringing, and you hear the door station and it hears you. + - Close the live view. The call ends at the door station. +3. Ring again, and answer on a Siedle handset first. Pressing talk afterwards must not take over the call. +4. Ring again, and let nobody answer. After a minute the call is left to the handsets. +5. With **SIP Auto Answer** on, ring the door station: + - Audio from the door station plays in the live view. + - Closing the live view without having talked leaves the call up. + - The **Hang Up** button next to the camera ends it. +6. Run `play_audio` during a call. The clip plays at normal speed, without gaps. + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/README.md b/README.md index 3459dd3..1552e11 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,19 @@ This integration will run a minimal SIP Server that can be configured in the SG- Currently the integration exposes: -- Gateway Information (Mac Adresse, SIP Connection Status) -- All phone devices know to the SG-150 - - Phone number sensor -- All video extractors - - Camera for the live stream -- Media source with the pictures the gateway stored during calls, per PBX phone (Media → Siedle Gateway) +- Gateway information: MAC address, SIP connection status and a light switch button +- All phone devices known to the SG-150, with their phone number +- Door stations, with a door opener button +- A camera for each video stream, with optional two-way audio (see [Two-way audio](#two-way-audio)) +- A bell that rings when the door station calls Home Assistant +- A Hang Up button next to each camera +- A media source with the pictures the gateway stored during calls, per PBX phone (Media → Siedle Gateway) - Actions to control the SG-150: - start_call: Start a phone call within the In-Home system - stop_call: Stop a phone call within the In-Home system - send_sip_info_dtmf: Sends a DTMF signal to the Siedle Gateway during an In-Home phone call - get_active_calls: Returns a list of active call IDs (`active_calls`) and, in `calls`, each call's direction and the caller/callee device (`id`, `name`) when known. - - play_audio: Play a media_source_id to an active call (Supporting wav/mp3, see [Tips & Tricks](#tips--tricks)) + - play_audio: Play a media_source_id to an active call (Supporting wav/mp3, see [Tips & Tricks](#tips--tricks)) - Every action takes an optional `config_entry_id` to pick the gateway; it can be omitted when only one gateway is set up. # Disclaimer @@ -38,16 +39,51 @@ While setting up the integration within Home Assistant you will be asked to prov - Username (in most cases `admin`) - Password (the one used for the web interface) -In addition to that the Home Assistant instance will also configure a SIP-Server, please make sure to setup the following values: +## Options -- SIP Caller IP: `` -- SIP Username: `` -- SIP Password: `` -- SIP Number: `` -- SIP Port: `` (default: 5060) -- SIP Auto Answer: `` (default: True, determines if HA will automatically answer incoming calls from the SG-150 blocking the In-Home bus for other devices, needed so the video becomes available) +Home Assistant then sets up its own SIP server, which the SG-150 connects to like a telephone. You choose these options during setup, and can change them later with **Configure** on the integration: -These values will be used to configure the SG-150 to connect to the SIP-Server provided by this integration. +| Option | Default | What it means | +|---|---|---| +| SIP Caller IP | | The IP address of your Home Assistant. The SG-150 uses it to reach Home Assistant. | +| SIP Username | | A name of your choosing. You enter the same name in the SG-150. | +| SIP Password | | A password of your choosing. You enter the same password in the SG-150. | +| SIP Number | | The phone number Home Assistant has in your In-Home system. | +| SIP Port | 5060 | The port the SG-150 connects to. | +| SIP Auto Answer | On | Whether Home Assistant answers when the door station calls. The camera only gets a picture during a call. While Home Assistant holds a call, your other indoor stations can't take it (see [Limitations](#limitations)). With two-way audio on, you can turn this off and answer from the camera instead. | +| Two-way audio | Off | Lets you hear and talk to the person at the door from the camera. See [Two-way audio](#two-way-audio). | +| First RTP Port / Last RTP Port | 5004 / 5013 | The ports used for the sound of calls, one per call at the same time. Only change them if they clash with something else. The SG-150 must be able to reach them, so allow them in your firewall if you have one. | + +## Camera + +Each video stream of the SG-150 becomes a camera in Home Assistant. In most setups it belongs to the TC phone you create for Home Assistant (see [below](#create-a-tc-phone-connected-to-the-sip-gateway)). The camera shows **Streaming** while there is a call with its device, and **Idle** otherwise. + +Without two-way audio, the camera shows the picture only. With two-way audio on, its live view also plays the sound from the door station and has a talk button. + +## Buttons + +| Button | Where | What it does | +|---|---|---| +| Lightswitch | The gateway | Triggers the light switch of your In-Home system. | +| Door Opener | Each door station | Opens the door. | +| Hang Up | Next to each camera | Ends the call shown by that camera. If there is no call, Home Assistant shows an error. | + +## Two-way audio + +With **Two-way audio** turned on, you can hear the door station and talk back from the camera's live view, like on an indoor station. This, as of writing, needs Home Assistant 2026.11 or later, and the following configuration: + +1. Turn on the Labs feature under **Settings → System → Labs → Two-way audio**. Two-way audio is a preview feature, and Home Assistant only shows the talk button once it is turned on there. +2. Open Home Assistant over HTTPS. Browsers only let a page use your microphone on a secure connection. +3. Turn on **Two-way audio** in this integration's options. + +The camera's live view then shows a talk button. + +What happens when someone rings depends on the integration's **SIP Auto Answer** option: + +- **On:** Home Assistant answers straight away, as before. You hear the door station when you open the live view, and pressing talk lets them hear you. +- **Off:** Home Assistant rings along with your indoor stations. Pressing talk answers the call. If someone answers on an indoor station first, or nobody answers within a minute, Home Assistant leaves the call to them. + +The call ends when you close the live view you talked from, or when you press **Hang Up**. Just watching the live view never ends a call, except when the door station hangs up. ## Configure the SG-150 to connect to the SIP-Gateway diff --git a/custom_components/sg_150/__init__.py b/custom_components/sg_150/__init__.py index 87c864b..179b9e8 100644 --- a/custom_components/sg_150/__init__.py +++ b/custom_components/sg_150/__init__.py @@ -18,12 +18,17 @@ from .const import ( API_PORT, CONF_HOST_ADDRESS, + CONF_RTP_PORT_MAX, + CONF_RTP_PORT_MIN, CONF_SIP_AUTO_ANSWER, CONF_SIP_CALLER_IP, CONF_SIP_NUMBER, CONF_SIP_PASSWORD, CONF_SIP_PORT, CONF_SIP_USER, + CONF_TWO_WAY_AUDIO, + DEFAULT_RTP_PORT_MAX, + DEFAULT_RTP_PORT_MIN, DOMAIN, ) from .coordinator import SG150Coordinator @@ -78,6 +83,10 @@ async def async_setup_entry( call_host=entry.options.get(CONF_SIP_CALLER_IP), users=users, port=int(entry.options.get(CONF_SIP_PORT)), + rtp_ports=range( + int(entry.options.get(CONF_RTP_PORT_MIN, DEFAULT_RTP_PORT_MIN)), + int(entry.options.get(CONF_RTP_PORT_MAX, DEFAULT_RTP_PORT_MAX)) + 1, + ), ) line = GatewayLine( @@ -86,6 +95,8 @@ async def async_setup_entry( number=entry.options.get(CONF_SIP_NUMBER), auto_answer=entry.options.get(CONF_SIP_AUTO_ANSWER, True), devices=lambda: coordinator.data.devices if coordinator.data else [], + # Without auto answer, ring along so the camera's talk button can answer. + ring_along=entry.options.get(CONF_TWO_WAY_AUDIO, False), ) entry.runtime_data = SG150RuntimeData( diff --git a/custom_components/sg_150/button.py b/custom_components/sg_150/button.py index a742609..35f9afd 100644 --- a/custom_components/sg_150/button.py +++ b/custom_components/sg_150/button.py @@ -2,10 +2,13 @@ from __future__ import annotations +import contextlib from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity +from homeassistant.exceptions import ServiceValidationError +from .const import DOMAIN from .entity import SG150DeviceEntity, SG150GatewayEntity if TYPE_CHECKING: @@ -14,6 +17,7 @@ from .coordinator import SG150Coordinator from .data import SG150ConfigEntry + from .line import GatewayLine from .types import SG150Device @@ -23,16 +27,23 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the button platform.""" + coordinator = entry.runtime_data.coordinator + devices = coordinator.data.devices async_add_entities( - [LightSwitchButton(coordinator=entry.runtime_data.coordinator)] + [LightSwitchButton(coordinator=coordinator)] + [ - DoorOpenerButton( - coordinator=entry.runtime_data.coordinator, - device=device, - ) - for device in entry.runtime_data.coordinator.data.devices + DoorOpenerButton(coordinator=coordinator, device=device) + for device in devices if device.is_door ] + # Next to the camera, whose live view talks on the same calls. + + [ + HangUpButton( + coordinator=coordinator, device=device, line=entry.runtime_data.line + ) + for device in devices + if device.mjpeg_stream_url is not None + ] ) @@ -59,6 +70,34 @@ async def async_press(self) -> None: ) +class HangUpButton(SG150DeviceEntity, ButtonEntity): + """Button Entity ending the calls of a camera's device when pressed.""" + + icon = "mdi:phone-hangup" + + def __init__( + self, coordinator: SG150Coordinator, device: SG150Device, line: GatewayLine + ) -> None: + """Initialize the hang up button.""" + super().__init__(coordinator, device, key="hang_up") + self._line = line + + def press(self) -> None: + """Do not support the synchronous button API.""" + raise NotImplementedError + + async def async_press(self) -> None: + """Hang up every call this device is on, or refuse if there is none.""" + calls = [c for c in self._line.calls.values() if c.involves(self._device_id)] + if not calls: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="no_call_to_hang_up" + ) + for call in calls: + with contextlib.suppress(ValueError): # it ended meanwhile + await call.hangup() + + class LightSwitchButton(SG150GatewayEntity, ButtonEntity): """Button Entity triggering the light switch when pressed.""" diff --git a/custom_components/sg_150/camera.py b/custom_components/sg_150/camera.py index f15c1f2..0423ae4 100644 --- a/custom_components/sg_150/camera.py +++ b/custom_components/sg_150/camera.py @@ -5,23 +5,33 @@ from typing import TYPE_CHECKING import httpx -from homeassistant.components.camera import Camera +from aiortc import RTCIceServer +from homeassistant.components.camera import ( + Camera, + CameraEntityCapabilities, + CameraEntityFeature, +) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_web, async_get_clientsession, ) from homeassistant.helpers.httpx_client import get_async_client -from .const import LOGGER +from .const import CONF_TWO_WAY_AUDIO, LOGGER from .entity import SG150DeviceEntity from .line import CallEnded, CallStarted +from .talkback import Talkback if TYPE_CHECKING: from collections.abc import AsyncIterator from aiohttp import web + from homeassistant.components.camera import WebRTCSendMessage from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback + from webrtc_models import RTCIceCandidateInit from .coordinator import SG150Coordinator from .data import SG150ConfigEntry @@ -46,35 +56,23 @@ async def async_setup_entry( LOGGER.warning("Coordinator data is None, skipping binary_sensor setup") return - entites = [] - - # Create an entity for each device in `data` - for device in entry.runtime_data.coordinator.data.devices: - video_extraction = device.video_extraction - if video_extraction is not None and video_extraction.type == "MJPEG_STREAM": - entites.append( - SG150Camera( - coordinator=entry.runtime_data.coordinator, - device=device, - url=video_extraction.url, - line=entry.runtime_data.line, - ) - ) - - async_add_entities(entites) - + # Create an entity for each device with an MJPEG stream + async_add_entities( + SG150Camera( + coordinator=entry.runtime_data.coordinator, + device=device, + url=url, + line=entry.runtime_data.line, + two_way_audio=entry.options.get(CONF_TWO_WAY_AUDIO, False), + ) + for device in entry.runtime_data.coordinator.data.devices + if (url := device.mjpeg_stream_url) is not None + ) -async def _async_extract_image_from_mjpeg( - stream: AsyncIterator[bytes], -) -> bytes | None: - """ - Take in MJPEG stream and extract a frame from it. - The first FRAME_WAIT_COUNT frames are ignored, as they are usually the "no stream" - image. - """ +async def _mjpeg_frames(stream: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Split an MJPEG stream into its JPEG frames.""" data = b"" - frame_count = 0 async for chunk in stream: data += chunk @@ -91,19 +89,35 @@ async def _async_extract_image_from_mjpeg( data = data[jpg_start:] break - frame = data[jpg_start : jpg_end + 2] + yield data[jpg_start : jpg_end + 2] data = data[jpg_end + 2 :] - frame_count += 1 - if frame_count >= FRAME_WAIT_COUNT: - return frame +async def _async_extract_image_from_mjpeg( + stream: AsyncIterator[bytes], +) -> bytes | None: + """ + Take in MJPEG stream and extract a frame from it. + + The first FRAME_WAIT_COUNT frames are ignored, as they are usually the "no stream" + image. + """ + frame_count = 0 + async for frame in _mjpeg_frames(stream): + frame_count += 1 + if frame_count >= FRAME_WAIT_COUNT: + return frame return None class SG150Camera(SG150DeviceEntity, Camera): # pylint: disable=abstract-method - """Camera Entity for SG150 Stream.""" + """ + Camera Entity for SG150 Stream. + + With two-way audio enabled, the live view is WebRTC (see talkback.py) and + carries the door station's call audio both ways; otherwise it is MJPEG. + """ # This is how the camera entity is calculating this value: # https://github.com/home-assistant/core/blob/7bddee43e85bc13ea3218be8b34848c77ade4969/homeassistant/components/generic/camera.py#L106 @@ -115,6 +129,8 @@ def __init__( device: SG150Device, url: str, line: GatewayLine, + *, + two_way_audio: bool = False, ) -> None: """Initialize the camera entity.""" # CoordinatorEntity does not chain to Camera.__init__, so call both. @@ -123,6 +139,15 @@ def __init__( self._stream_url = url self._line = line self._attr_is_streaming = False + self._two_way_audio = two_way_audio + self._talkback: Talkback | None = None + if two_way_audio: + # Fixed for the entity's lifetime: the frontend reads capabilities + # once when the live view opens, so they can't follow call state. + self._attr_supported_features = CameraEntityFeature.STREAM + self._attr_camera_entity_capabilities = CameraEntityCapabilities( + two_way_audio=True + ) def _update_call_state(self) -> None: """Stream while a call to or from this device is established.""" @@ -141,6 +166,73 @@ async def async_added_to_hass(self) -> None: self.async_on_remove(self._line.subscribe(self._on_line_event)) await super().async_added_to_hass() self._update_call_state() + if self._two_way_audio: + self._talkback = Talkback(self._line, self._device_id, self._jpegs) + + async def async_will_remove_from_hass(self) -> None: + """Close the WebRTC sessions.""" + await super().async_will_remove_from_hass() + if self._talkback is not None: + await self._talkback.close() + self._talkback = None + + async def _jpegs(self) -> AsyncIterator[bytes]: + """Yield the JPEG frames of the camera's MJPEG stream.""" + client = get_async_client(self.hass) + async with client.stream("get", self._stream_url, timeout=TIMEOUT) as stream: + async for frame in _mjpeg_frames(stream.aiter_raw()): + yield frame + + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + """Answer a WebRTC offer from the frontend.""" + if self._talkback is None: + msg = "Two-way audio is not enabled for this gateway" + raise HomeAssistantError(msg) + configuration = self.async_get_webrtc_client_configuration().configuration + ice_servers = [ + RTCIceServer( + urls=server.urls, + username=server.username, + credential=server.credential, + ) + for server in configuration.ice_servers + ] + try: + await self._talkback.handle_offer( + session_id, offer_sdp, send_message, ice_servers + ) + except Exception as err: + msg = f"Could not answer the WebRTC offer: {err}" + raise HomeAssistantError(msg) from err + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidateInit + ) -> None: + """Handle a WebRTC candidate from the frontend.""" + if self._talkback is None: + msg = "Two-way audio is not enabled for this gateway" + raise HomeAssistantError(msg) + try: + await self._talkback.add_candidate( + session_id, + candidate.candidate, + candidate.sdp_mid, + candidate.sdp_m_line_index, + ) + except (ValueError, TimeoutError) as err: + raise HomeAssistantError(str(err)) from err + + @callback + def close_webrtc_session(self, session_id: str) -> None: + """Close a WebRTC session, hanging up if it was the last one talking.""" + super().close_webrtc_session(session_id) + if self._talkback is not None: + self.hass.async_create_task( + self._talkback.close_session(session_id), + f"sg_150 camera {self.entity_id} close WebRTC session", + ) async def stream_source(self) -> str: """Return the stream source, also used by HomeKit to support MJEPG streams.""" diff --git a/custom_components/sg_150/config_flow.py b/custom_components/sg_150/config_flow.py index a58862f..08f8d05 100644 --- a/custom_components/sg_150/config_flow.py +++ b/custom_components/sg_150/config_flow.py @@ -23,16 +23,24 @@ from .const import ( API_PORT, CONF_HOST_ADDRESS, + CONF_RTP_PORT_MAX, + CONF_RTP_PORT_MIN, CONF_SIP_AUTO_ANSWER, CONF_SIP_CALLER_IP, CONF_SIP_NUMBER, CONF_SIP_PASSWORD, CONF_SIP_PORT, CONF_SIP_USER, + CONF_TWO_WAY_AUDIO, + DEFAULT_RTP_PORT_MAX, + DEFAULT_RTP_PORT_MIN, DOMAIN, LOGGER, ) +MIN_RTP_PORT = 1024 +MAX_RTP_PORT = 65535 + if TYPE_CHECKING: from collections.abc import Mapping @@ -88,7 +96,8 @@ async def async_step_user_options( self, user_options: dict[str, any] | None = None ) -> config_entries.FlowResult: """Configure user optons.""" - if user_options: + errors = validate_options(user_options) if user_options else {} + if user_options and not errors: return self.async_create_entry( title=self._data[CONF_HOST_ADDRESS].lower(), data=self._data, @@ -98,6 +107,7 @@ async def async_step_user_options( return self.async_show_form( step_id="user_options", data_schema=vol.Schema(await get_options_schema(user_options)), + errors=errors, ) async def get_user_config_schema(self, entry: dict | None = None) -> dict: @@ -209,8 +219,9 @@ async def async_step_init( ) -> config_entries.FlowResult: """Configure options.""" existing_options = self.config_entry.options.copy() + errors = validate_options(user_input) if user_input else {} - if user_input: + if user_input and not errors: return self.async_create_entry(title="", data=existing_options | user_input) return self.async_show_form( @@ -218,9 +229,22 @@ async def async_step_init( data_schema=vol.Schema( await get_options_schema(user_input or self.config_entry.options) ), + errors=errors, ) +def validate_options(options: Mapping[str, Any]) -> dict[str, str]: + """Return form errors for the options, if any.""" + try: + low = int(options.get(CONF_RTP_PORT_MIN, DEFAULT_RTP_PORT_MIN)) + high = int(options.get(CONF_RTP_PORT_MAX, DEFAULT_RTP_PORT_MAX)) + except ValueError: + return {"base": "rtp_ports"} + if not MIN_RTP_PORT <= low <= high <= MAX_RTP_PORT: + return {"base": "rtp_ports"} + return {} + + async def get_options_schema(entry: dict | None = None) -> dict: """Create the options schema dictionary.""" return { @@ -260,4 +284,19 @@ async def get_options_schema(entry: dict | None = None) -> dict: vol.Optional( CONF_SIP_AUTO_ANSWER, default=(entry or {}).get(CONF_SIP_AUTO_ANSWER, True) ): selector.BooleanSelector(), + vol.Optional( + CONF_TWO_WAY_AUDIO, default=(entry or {}).get(CONF_TWO_WAY_AUDIO, False) + ): selector.BooleanSelector(), + vol.Optional( + CONF_RTP_PORT_MIN, + default=str((entry or {}).get(CONF_RTP_PORT_MIN, DEFAULT_RTP_PORT_MIN)), + ): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.NUMBER), + ), + vol.Optional( + CONF_RTP_PORT_MAX, + default=str((entry or {}).get(CONF_RTP_PORT_MAX, DEFAULT_RTP_PORT_MAX)), + ): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.NUMBER), + ), } diff --git a/custom_components/sg_150/const.py b/custom_components/sg_150/const.py index 2eca1af..d3f542b 100644 --- a/custom_components/sg_150/const.py +++ b/custom_components/sg_150/const.py @@ -11,6 +11,12 @@ CONF_SIP_PORT = "sip-port" CONF_SIP_NUMBER = "sip-number" CONF_SIP_AUTO_ANSWER = "sip-auto-answer" +CONF_TWO_WAY_AUDIO = "two-way-audio" +CONF_RTP_PORT_MIN = "rtp-port-min" +CONF_RTP_PORT_MAX = "rtp-port-max" + +DEFAULT_RTP_PORT_MIN = 5004 +DEFAULT_RTP_PORT_MAX = 5013 API_PORT = 8080 DOMAIN = "sg_150" diff --git a/custom_components/sg_150/line.py b/custom_components/sg_150/line.py index 4808018..d4bd030 100644 --- a/custom_components/sg_150/line.py +++ b/custom_components/sg_150/line.py @@ -8,18 +8,23 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal from .const import LOGGER -from .sip.minisip import sip_user +from .sip.minisip import IncomingCallDecision, sip_user if TYPE_CHECKING: from collections.abc import Callable, Iterable, Mapping from .sip.minisip import CallContext, MiniSIPServer, SIPDialog, SIPRegistration + from .sip.rtp import RtpSession from .types import SG150Device +# A ringing call nobody answers is rejected as busy after this many seconds. +RING_TIMEOUT = 60 + type Direction = Literal["incoming", "outgoing"] @@ -35,6 +40,10 @@ class CallFailedError(LineError): """The gateway did not accept the call.""" +class NotRingingError(LineError): + """The call to answer is not ringing (any more).""" + + @dataclass(frozen=True, eq=False) class Call: """A call on the line, with the devices on either end if known.""" @@ -67,6 +76,10 @@ async def play(self, mime_type: str, audio_data: bytes) -> None: """Play audio into the call.""" await self._server.play_audio(self.id, mime_type, audio_data) + def rtp_session(self) -> RtpSession: + """Return the audio stream of the established call.""" + return self._server.rtp_session(self.id) + @dataclass(frozen=True) class Registered: @@ -141,7 +154,7 @@ def match_device(header: str, devices: Iterable[SG150Device]) -> SG150Device | N class GatewayLine: """The SIP line the Smart Gateway registers on.""" - def __init__( + def __init__( # noqa: PLR0913 self, server: MiniSIPServer, *, @@ -149,27 +162,38 @@ def __init__( number: str, auto_answer: bool, devices: Callable[[], Iterable[SG150Device]], + ring_along: bool = False, + ring_timeout: float = RING_TIMEOUT, ) -> None: """ Create a line in front of `server`. user: the SIP user the gateway registers as. number: the number Home Assistant calls from. - auto_answer: answer incoming calls (True) or reject them as busy (False). + auto_answer: answer incoming calls (True) or not (False). devices: returns the current gateway devices, used to match calls. + ring_along: without auto answer, let incoming calls ring until answer() + is called, instead of rejecting them as busy straight away. Calls + nobody answers are rejected as busy after `ring_timeout` seconds. """ self._server = server self.user = user self._number = number self._auto_answer = auto_answer + self._ring_along = ring_along + self._ring_timeout = ring_timeout self._devices = devices self._calls: dict[str, Call] = {} + self._ringing: dict[str, Call] = {} + self._ring_timers: dict[str, asyncio.TimerHandle] = {} + self._tasks: set[asyncio.Task] = set() self._subscribers: list[Callable[[LineEvent], None]] = [] server.add_listener("on_register", self._on_register) server.add_listener("on_incoming_call", self._on_incoming_call) server.add_listener("on_call_established", self._on_call_established) server.add_listener("on_call_ended", self._on_call_ended) + server.add_listener("on_call_cancelled", self._on_call_cancelled) @property def registration(self) -> SIPRegistration | None: @@ -181,14 +205,48 @@ def calls(self) -> Mapping[str, Call]: """Return the established calls by call id.""" return self._calls + @property + def ringing(self) -> Mapping[str, Call]: + """Return the incoming calls waiting for answer(), by call id.""" + return self._ringing + async def start(self) -> None: """Start listening for SIP traffic.""" await self._server.start() async def stop(self) -> None: """Stop listening for SIP traffic.""" + for call_id in list(self._ringing): + self._stop_ringing(call_id) await self._server.stop() + async def answer(self, call_id: str) -> None: + """ + Answer a ringing incoming call. + + Raises NotRingingError if it is not ringing, for example because a + handset answered first. The call starts once the gateway ACKs. + """ + if self._stop_ringing(call_id) is None: + msg = f"Call {call_id} is not ringing" + raise NotRingingError(msg) + await self._server.answer_call(call_id) + + def _stop_ringing(self, call_id: str) -> Call | None: + if (timer := self._ring_timers.pop(call_id, None)) is not None: + timer.cancel() + return self._ringing.pop(call_id, None) + + def _on_ring_timeout(self, call_id: str) -> None: + task = asyncio.create_task(self._reject_unanswered(call_id)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _reject_unanswered(self, call_id: str) -> None: + if self._stop_ringing(call_id) is not None: + LOGGER.debug("Nobody answered call %s, rejecting it", call_id) + await self._server.reject_call(call_id) + def subscribe(self, callback: Callable[[LineEvent], None]) -> Callable[[], None]: """ Call `callback` with every LineEvent until the returned function is called. @@ -275,8 +333,18 @@ async def _on_incoming_call(self, context: CallContext) -> bool: context.called_to, context.addr, ) + if self._auto_answer: + decision = IncomingCallDecision.ANSWER + elif self._ring_along: + decision = IncomingCallDecision.RING + self._ringing[call.id] = call + self._ring_timers[call.id] = asyncio.get_running_loop().call_later( + self._ring_timeout, self._on_ring_timeout, call.id + ) + else: + decision = IncomingCallDecision.REJECT self._emit(IncomingCall(call, answered=self._auto_answer)) - return self._auto_answer + return decision async def _on_call_established(self, dialog: SIPDialog) -> None: call = self._new_call( @@ -293,3 +361,6 @@ async def _on_call_ended(self, dialog: SIPDialog) -> None: call = self._calls.pop(dialog.call_id, None) if call is not None: self._emit(CallEnded(call)) + + async def _on_call_cancelled(self, call_id: str) -> None: + self._stop_ringing(call_id) diff --git a/custom_components/sg_150/manifest.json b/custom_components/sg_150/manifest.json index 03a0ec5..75483c6 100644 --- a/custom_components/sg_150/manifest.json +++ b/custom_components/sg_150/manifest.json @@ -13,6 +13,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/fweinb/ha-sg-150/issues", "requirements": [ + "aiortc>=1.16.0", "soundfile==0.13.1", "numpy>=2.3.2", "pydantic>=2.0" diff --git a/custom_components/sg_150/sip/minisip.py b/custom_components/sg_150/sip/minisip.py index 54e76bc..f36ec15 100644 --- a/custom_components/sg_150/sip/minisip.py +++ b/custom_components/sg_150/sip/minisip.py @@ -2,9 +2,7 @@ MiniSIP: Simple SIP Server. Very minimal SIP Server that is "good enough" to connect to the Siedle Gateway to -initiate calls. - -Currently this is not supporting audio transmission. +initiate calls. Each call's audio runs through an RtpSession (see rtp.py). """ import asyncio @@ -19,11 +17,22 @@ import time from collections.abc import Callable from dataclasses import dataclass, field +from enum import Enum from typing import Literal import numpy as np import soundfile as sf +from .rtp import ( + CODEC_NAMES, + PCMA, + PCMU, + RtpEndpointFactory, + RtpPortPool, + RtpSession, + open_udp_endpoint, +) + logger = logging.getLogger("sip-server") @@ -69,6 +78,13 @@ def generate_opaque() -> str: STATUS_RINGING = 180 STATUS_BUSY = 486 +# RFC 3261 timers: our 200 OK to an INVITE is resent from T1, doubling up to +# T2, until the ACK arrives or 64 * T1 have passed. +TIMER_T1 = 0.5 +TIMER_T2 = 4.0 + +DEFAULT_RTP_PORTS = range(5004, 5014) + COMPACT_HEADERS = { "v": "Via", "f": "From", @@ -129,6 +145,11 @@ def _resample_pcm(pcm: np.ndarray, sample_rate: int) -> np.ndarray: def convert_audio_to_pcma(audio_data: bytes, mime_type: str) -> bytes: """Convert a media payload to 8 kHz mono G.711 a-law data.""" + return _pcm_to_pcma(convert_audio_to_pcm(audio_data, mime_type)) + + +def convert_audio_to_pcm(audio_data: bytes, mime_type: str) -> np.ndarray: + """Convert a media payload to 8 kHz mono int16 PCM samples.""" if not audio_data: msg = "No audio data provided." raise ValueError(msg) @@ -150,8 +171,7 @@ def convert_audio_to_pcma(audio_data: bytes, mime_type: str) -> bytes: samples, sample_rate = sf.read(wav_buffer, dtype="int16", always_2d=False) if np.asarray(samples).ndim > 1: samples = samples[:, 0] - pcm = _resample_pcm(samples, int(sample_rate)) - return _pcm_to_pcma(pcm) + return _resample_pcm(samples, int(sample_rate)) _msg = f"Unsupported audio media type: {mime_type}" raise ValueError(_msg) @@ -267,6 +287,51 @@ def new_branch_id() -> str: return f"z{random.randint(10000, 99999)}" # noqa: S311 not crypto relevant +def build_sdp( + host: str, + port: int, + payload_types: list[int], + telephone_event: int | None = None, +) -> str: + """Create an SDP body offering or answering sendrecv audio on host:port.""" + formats = [*payload_types] + ([telephone_event] if telephone_event else []) + lines = [ + "v=0", + f"o=- 123456789 123456789 IN IP4 {host}", + "s=MiniSIP Call", + f"c=IN IP4 {host}", + "t=0 0", + f"m=audio {port} RTP/AVP {' '.join(str(pt) for pt in formats)}", + *(f"a=rtpmap:{pt} {CODEC_NAMES[pt]}/8000" for pt in payload_types), + ] + if telephone_event: + lines += [ + f"a=rtpmap:{telephone_event} telephone-event/8000", + f"a=fmtp:{telephone_event} 0-16", + ] + lines += ["a=ptime:20", "a=sendrecv"] + return "\r\n".join(lines) + + +class IncomingCallDecision(Enum): + """What to do with an incoming call.""" + + ANSWER = "answer" + REJECT = "reject" + RING = "ring" # send 180 Ringing and answer or reject it later + + +def _decision(result: object) -> IncomingCallDecision: + """Read an on_incoming_call result; True and None answer, False rejects.""" + if isinstance(result, IncomingCallDecision): + return result + return ( + IncomingCallDecision.REJECT + if result is False + else (IncomingCallDecision.ANSWER) + ) + + @dataclass class CallContext: """Representation of a call context.""" @@ -298,6 +363,16 @@ class SIPDialog: media: dict[str, any] = field(default_factory=dict) +@dataclass +class _IncomingInvite: + """An incoming INVITE that has been sent 180 Ringing but not answered yet.""" + + message: dict[str, str] + addr: tuple[str, int] + remote_contact: str | None + media: dict[str, any] + + # ============================================================ # SIP Server Class # ============================================================ @@ -314,8 +389,15 @@ def __init__( # noqa: PLR0913, PLR0917 port: int = 5060, expires: int = 3600, realm: str = "", + rtp_ports: range = DEFAULT_RTP_PORTS, + rtp_endpoint: RtpEndpointFactory = open_udp_endpoint, ) -> None: - """Create a new instance of MiniSIPServer.""" + """ + Create a new instance of MiniSIPServer. + + rtp_ports: the local UDP ports for call audio, one per concurrent call. + rtp_endpoint: binds an RtpSession to a port; replaced in tests. + """ self.host = host self.call_host = call_host or host self.port: int = port @@ -326,10 +408,16 @@ def __init__( # noqa: PLR0913, PLR0917 self.registrations: dict[str, SIPRegistration] = {} self.challenges = {} self.pending_invites = {} + # Incoming calls that are ringing, waiting for answer_call or reject_call. + self.ringing: dict[str, _IncomingInvite] = {} # Accepted incoming calls waiting for the caller's ACK, by call id. self.pending_acks: dict[str, SIPDialog] = {} + self._ack_waits: dict[str, asyncio.Task] = {} self.active_calls: dict[str, SIPDialog] = {} - self.rtp_sessions = {} + self.rtp_sessions: dict[str, RtpSession] = {} + self._rtp_ports: dict[str, int] = {} + self._port_pool = RtpPortPool(rtp_ports) + self._rtp_endpoint = rtp_endpoint self.last_cseq = {} self.transport = None @@ -342,6 +430,7 @@ def __init__( # noqa: PLR0913, PLR0917 self.on_call_ended: list[Callable[..., any]] = [] self.on_call_ringing: list[Callable[..., any]] = [] self.on_call_busy: list[Callable[..., any]] = [] + self.on_call_cancelled: list[Callable[..., any]] = [] # Cleanup loop management self._cleanup_task: asyncio.Task | None = None @@ -498,7 +587,7 @@ def parse_cseq(self, cseq_header: str) -> (int, str): return num, method @staticmethod - def parse_sdp_media(sdp: str | None) -> dict[str, any] | None: # noqa: PLR0912 + def parse_sdp_media(sdp: str | None) -> dict[str, any] | None: # noqa: PLR0912, PLR0915 """Parse SDP media metadata from a SIP SDP body.""" if not sdp: return None @@ -510,8 +599,10 @@ def parse_sdp_media(sdp: str | None) -> dict[str, any] | None: # noqa: PLR0912 "codec": None, "ptime": None, "direction": "sendrecv", + "telephone_event": None, } - codec_by_pt: dict[int, str] = {} + # Static payload types need no rtpmap line. + codec_by_pt: dict[int, str] = dict(CODEC_NAMES) payload_types: list[int] = [] for line in sdp.splitlines(): @@ -529,11 +620,7 @@ def parse_sdp_media(sdp: str | None) -> dict[str, any] | None: # noqa: PLR0912 media["port"] = int(parts[1]) except ValueError: media["port"] = None - payload_types = [ - int(part) - for part in parts[3:] - if part.isdigit() and part != "0" - ] + payload_types = [int(part) for part in parts[3:] if part.isdigit()] elif line.startswith("a=rtpmap:"): parts = line.split() if len(parts) >= 2: # noqa: PLR2004 @@ -558,12 +645,18 @@ def parse_sdp_media(sdp: str | None) -> dict[str, any] | None: # noqa: PLR0912 media["direction"] = line[2:].strip().lower() for payload_type in payload_types: - if ( - payload_type in codec_by_pt - and codec_by_pt[payload_type].upper() == "PCMA" - ): - media["payload_type"] = payload_type - media["codec"] = codec_by_pt[payload_type].upper() + if "event" in codec_by_pt.get(payload_type, "").lower(): + media["telephone_event"] = payload_type + break + + # Prefer PCMA, then PCMU: the codecs RtpSession can send. + for codec in ("PCMA", "PCMU"): + for payload_type in payload_types: + if codec_by_pt.get(payload_type, "").upper() == codec: + media["payload_type"] = payload_type + media["codec"] = codec + break + if media["payload_type"] is not None: break if media["payload_type"] is None and payload_types: @@ -663,6 +756,15 @@ async def handle_register(self, message: dict[str, str], addr: (str, int)) -> No async def handle_invite(self, message: dict[str, str], addr: (str, int)) -> None: """Handle the INVITE method.""" call_id = message.get("Call-ID") + if ( + call_id in self.ringing + or call_id in self.pending_acks + or call_id in self.active_calls + ): + # A retransmission, or a re-INVITE we don't support: keep the call. + log_sip_event("INVITE_IGNORED", addr=f"{addr}", call_id=call_id) + return + call_context = CallContext( call_id=call_id, called_from=message.get("From"), @@ -670,69 +772,162 @@ async def handle_invite(self, message: dict[str, str], addr: (str, int)) -> None addr=addr, ) - accept = True - for cb in self.on_incoming_call: - result = await cb(call_context) - if result is False: - accept = False - - if accept: - # Answer with a tag in the To header, and a Contact pointing at our own - # host and port so the caller's ACK and later requests reach us. - remote_contact = message.get("Contact") - message["Contact"] = sip_address( - sip_user(message.get("To")) or "", self.server_address() - ) - message["To"] = f"{message.get('To')};tag=minisip" - - ringing_resp = build_response(180, "Ringing", message) - self.transport.sendto(ringing_resp, addr) - await self._fire_event("on_call_ringing", call_id, addr) - log_sip_event("CALL_RINGING_SENT", addr=f"{addr}", call_id=call_id) - - sdp = ( - "v=0\r\n" - f"o=- 123456789 123456789 IN IP4 {self.call_host}\r\n" - "s=MiniSIP Call\r\n" - f"c=IN IP4 {self.call_host}\r\n" - "t=0 0\r\n" - "m=audio 5004 RTP/AVP 8 101\r\n" - "a=rtpmap:101 telephone-event/8000\r\n" - "a=fmtp:101 0-16\r\n" - "a=ptime:20\r\n" - "a=maxptime:150\r\n" - "a=sendrecv" + decisions = [_decision(await cb(call_context)) for cb in self.on_incoming_call] + if IncomingCallDecision.REJECT in decisions: + resp = build_response(486, "Busy Here", message) + self.transport.sendto(resp, addr) + log_sip_event("INCOMING_CALL_REJECTED", addr=f"{addr}", call_id=call_id) + await self._fire_event("on_call_busy", call_id, addr) + return + + # Answer with a tag in the To header, and a Contact pointing at our own + # host and port so the caller's ACK and later requests reach us. + invite = _IncomingInvite( + message=message, + addr=addr, + remote_contact=message.get("Contact"), + media=self.parse_sdp_media(message.get(":body")) or {}, + ) + message["Contact"] = sip_address( + sip_user(message.get("To")) or "", self.server_address() + ) + message["To"] = f"{message.get('To')};tag=minisip" + + ringing_resp = build_response(180, "Ringing", message) + self.transport.sendto(ringing_resp, addr) + await self._fire_event("on_call_ringing", call_id, addr) + log_sip_event("CALL_RINGING_SENT", addr=f"{addr}", call_id=call_id) + + if IncomingCallDecision.RING in decisions: + self.ringing[call_id] = invite + return + + await self._answer_invite(invite) + + async def answer_call(self, call_id: str) -> None: + """Answer an incoming call that is ringing.""" + invite = self.ringing.pop(call_id, None) + if invite is None: + _msg = f"There is currently no ringing call with call id: {call_id}" + raise ValueError(_msg) + await self._answer_invite(invite) + + async def reject_call(self, call_id: str) -> None: + """Reject an incoming call that is ringing as busy.""" + invite = self.ringing.pop(call_id, None) + if invite is None: + _msg = f"There is currently no ringing call with call id: {call_id}" + raise ValueError(_msg) + self.transport.sendto( + build_response(486, "Busy Here", invite.message), invite.addr + ) + log_sip_event("INCOMING_CALL_REJECTED", addr=f"{invite.addr}", call_id=call_id) + await self._fire_event("on_call_busy", call_id, invite.addr) + + async def _answer_invite(self, invite: _IncomingInvite) -> None: + """Open the call's RTP session and send 200 OK with our SDP answer.""" + message, addr, media = invite.message, invite.addr, invite.media + call_id = message.get("Call-ID") + + payload_type = media.get("payload_type", PCMA) if media else PCMA + if payload_type not in CODEC_NAMES: + self.transport.sendto( + build_response(488, "Not Acceptable Here", message), addr ) + log_sip_event("INCOMING_CALL_NO_CODEC", addr=f"{addr}", call_id=call_id) + await self._fire_event("on_call_failed", call_id, addr, 488) + return - resp = build_response( - 200, - "OK", - message, - extra_headers=[ - "Content-Type: application/sdp", - ], - body=sdp, + remote = ( + (media["ip"], media["port"]) + if media.get("ip") and media.get("port") + else None + ) + port = await self._open_rtp( + call_id, RtpSession(payload_type=payload_type, remote=remote) + ) + if port is None: + self.transport.sendto( + build_response(503, "Service Unavailable", message), addr ) + await self._fire_event("on_call_failed", call_id, addr, 503) + return - self.transport.sendto(resp, addr) + sdp = build_sdp( + self.call_host, port, [payload_type], media.get("telephone_event") + ) + resp = build_response( + 200, + "OK", + message, + extra_headers=[ + "Content-Type: application/sdp", + ], + body=sdp, + ) + self.transport.sendto(resp, addr) - media = self.parse_sdp_media(message.get(":body")) - # The call is established once the caller ACKs our 200 OK. - self.pending_acks[call_id] = SIPDialog( - call_id=call_id, - direction="incoming", - addr=addr, - from_header=message.get("From"), - to_header=message.get("To"), - contact=remote_contact, - media=media or {}, - ) - log_sip_event("INCOMING_CALL_ACCEPTED", addr=f"{addr}", call_id=call_id) - else: - resp = build_response(486, "Busy Here", message) - self.transport.sendto(resp, addr) - log_sip_event("INCOMING_CALL_REJECTED", addr=f"{addr}", call_id=call_id) - await self._fire_event("on_call_busy", call_id, addr) + # The call is established once the caller ACKs our 200 OK. + self.pending_acks[call_id] = SIPDialog( + call_id=call_id, + direction="incoming", + addr=addr, + from_header=message.get("From"), + to_header=message.get("To"), + contact=invite.remote_contact, + media=media, + ) + self._ack_waits[call_id] = asyncio.create_task( + self._resend_until_ack(call_id, resp, addr), + name=f"sip-ack-wait-{call_id}", + ) + log_sip_event("INCOMING_CALL_ACCEPTED", addr=f"{addr}", call_id=call_id) + + async def _resend_until_ack( + self, call_id: str, response: bytes, addr: tuple[str, int] + ) -> None: + """Resend our 200 OK until the ACK arrives, as UDP may lose either.""" + interval, waited = TIMER_T1, 0.0 + while waited < 64 * TIMER_T1: + await asyncio.sleep(interval) + waited += interval + if call_id not in self.pending_acks: + return + self.transport.sendto(response, addr) + log_sip_event("OK_RESENT", addr=f"{addr}", call_id=call_id) + interval = min(interval * 2, TIMER_T2) + + self._ack_waits.pop(call_id, None) + if self.pending_acks.pop(call_id, None) is not None: + log_sip_event("ACK_TIMEOUT", addr=f"{addr}", call_id=call_id) + self._close_rtp(call_id) + + def _stop_ack_wait(self, call_id: str) -> None: + if (task := self._ack_waits.pop(call_id, None)) is not None: + task.cancel() + + async def _open_rtp(self, call_id: str, session: RtpSession) -> int | None: + """Bind `session` to a free RTP port for the call and return the port.""" + port = self._port_pool.acquire() + if port is None: + log_sip_event("RTP_NO_FREE_PORT", call_id=call_id) + return None + try: + await self._rtp_endpoint(session, self.host, port) + except OSError as err: + self._port_pool.release(port) + log_sip_event("RTP_BIND_FAILED", call_id=call_id, port=port, error=err) + return None + self.rtp_sessions[call_id] = session + self._rtp_ports[call_id] = port + return port + + def _close_rtp(self, call_id: str) -> None: + """Close the call's RTP session and free its port.""" + if (session := self.rtp_sessions.pop(call_id, None)) is not None: + session.close() + if (port := self._rtp_ports.pop(call_id, None)) is not None: + self._port_pool.release(port) async def handle_ack(self, message: dict[str, str], addr: (str, int)) -> None: """Handle the ACK method, which establishes an accepted incoming call.""" @@ -741,7 +936,10 @@ async def handle_ack(self, message: dict[str, str], addr: (str, int)) -> None: if dialog is None: return + self._stop_ack_wait(call_id) self.active_calls[call_id] = dialog + if (session := self.rtp_sessions.get(call_id)) is not None: + session.start() log_sip_event("INCOMING_CALL_ESTABLISHED", addr=f"{addr}", call_id=call_id) await self._fire_event("on_call_established", dialog) @@ -751,11 +949,36 @@ async def handle_bye(self, message: dict[str, str], addr: (str, int)) -> None: self.transport.sendto(build_response(200, "OK", message), addr) if self.pending_acks.pop(call_id, None) is not None: + self._stop_ack_wait(call_id) + self._close_rtp(call_id) log_sip_event("CALL_ENDED_BEFORE_ACK", addr=f"{addr}", call_id=call_id) elif call_id in self.active_calls: dialog = self.active_calls.pop(call_id) + self._close_rtp(call_id) await self._fire_event("on_call_ended", dialog) + async def handle_cancel(self, message: dict[str, str], addr: (str, int)) -> None: + """Handle CANCEL, sent when the caller gives up or another phone answered.""" + call_id = message.get("Call-ID") + invite = self.ringing.pop(call_id, None) + if invite is None: + # Already answered (CANCEL has no effect then) or unknown. + known = call_id in self.pending_acks or call_id in self.active_calls + self.transport.sendto( + build_response(200, "OK", message) + if known + else build_response(481, "Call/Transaction Does Not Exist", message), + addr, + ) + return + + self.transport.sendto(build_response(200, "OK", message), addr) + self.transport.sendto( + build_response(487, "Request Terminated", invite.message), invite.addr + ) + log_sip_event("INCOMING_CALL_CANCELLED", addr=f"{addr}", call_id=call_id) + await self._fire_event("on_call_cancelled", call_id) + async def handle_options(self, message: dict[str, str], addr: (str, int)) -> None: """Handle the OPTIONS method.""" if not self.check_cseq(message, addr): @@ -768,14 +991,14 @@ async def handle_options(self, message: dict[str, str], addr: (str, int)) -> Non "OK", message, extra_headers=[ - "Allow: REGISTER, INVITE, ACK, BYE, OPTIONS", + "Allow: REGISTER, INVITE, ACK, BYE, CANCEL, OPTIONS", "Accept: application/sdp", ], ), addr, ) - async def send_invite( # noqa: PLR0915 + async def send_invite( self, target_addr: str, from_user: str, @@ -787,20 +1010,29 @@ async def send_invite( # noqa: PLR0915 _msg = "from_user and to_user are required" raise ValueError(_msg) - sdp = ( - "v=0\r\n" - f"o=- 123456789 123456789 IN IP4 {self.call_host}\r\n" - "s=MiniSIP Call\r\n" - f"c=IN IP4 {self.call_host}\r\n" - "t=0 0\r\n" - "m=audio 5005 RTP/AVP 8\r\n" - "a=rtpmap:8 PCMA/8000\r\n" - "a=fmtp:8 0-15\r\n" - "a=ptime:20\r\n" - "a=sendonly" - ) - call_id = self.new_call_id() + # The codec and remote address are filled in from the SDP answer. + port = await self._open_rtp(call_id, RtpSession()) + if port is None: + return None, None + try: + return await self._send_invite( + call_id, port, target_addr, from_user, to_user, timeout + ) + finally: + if call_id not in self.active_calls: + self._close_rtp(call_id) + + async def _send_invite( # noqa: PLR0913, PLR0915, PLR0917 + self, + call_id: str, + rtp_port: int, + target_addr: str, + from_user: str, + to_user: str, + timeout: int, # noqa: ASYNC109 + ) -> (str, dict[str, str]): + sdp = build_sdp(self.call_host, rtp_port, [PCMA, PCMU]) cseq_num = self.next_cseq_for(call_id) orig_from = self.sip_user_from(from_user) @@ -858,7 +1090,12 @@ async def send_invite( # noqa: PLR0915 call_state["status"] = "CONNECTED" call_state["final"] = True await self.send_ack(headers_resp, target_addr) - media = self.parse_sdp_media(headers_resp.get(":body")) + media = self.parse_sdp_media(headers_resp.get(":body")) or {} + session = self.rtp_sessions[call_id] + if media.get("payload_type") in CODEC_NAMES: + session.payload_type = media["payload_type"] + if media.get("ip") and media.get("port"): + session.remote = (media["ip"], media["port"]) dialog = SIPDialog( call_id=call_id, direction="outgoing", @@ -866,9 +1103,10 @@ async def send_invite( # noqa: PLR0915 from_header=orig_from, to_header=headers_resp.get("To"), contact=headers_resp.get("Contact"), - media=media or {}, + media=media, ) self.active_calls[call_id] = dialog + session.start() await self._fire_event("on_call_established", dialog) return call_id, headers_resp @@ -909,61 +1147,18 @@ async def send_ack(self, resp_headers: dict[str, str], target_addr: str) -> None self.transport.sendto(data, target_addr) log_sip_event("ACK_SENT", addr=f"{target_addr}", call_id=call_id) - async def send_rtp_audio(self, call_id: str, payload: bytes) -> None: - """Send a raw PCMA RTP payload to the negotiated media socket.""" - if call_id not in self.active_calls: + def rtp_session(self, call_id: str) -> RtpSession: + """Return the RTP session of an established call.""" + if call_id not in self.active_calls or call_id not in self.rtp_sessions: _msg = f"There is currently no active call with call id: {call_id}" raise ValueError(_msg) - - media = self.active_calls[call_id].media - if not media: - _msg = f"No negotiated RTP media for call id: {call_id}" - raise ValueError(_msg) - - if not payload: - return - - remote_ip = media.get("ip") - remote_port = media.get("port") - payload_type = media.get("payload_type", 8) - if not remote_ip or remote_port is None: - _msg = f"No RTP media endpoint available for call id: {call_id}" - raise ValueError(_msg) - - session = self.rtp_sessions.setdefault( - call_id, - { - "seq": 0, - "timestamp": 0, - }, - ) - seq = session["seq"] - timestamp = session["timestamp"] - ssrc = 0x12345678 - - packet = bytearray(12 + len(payload)) - packet[0] = 0x80 - packet[1] = payload_type & 0x7F - packet[2] = (seq >> 8) & 0xFF - packet[3] = seq & 0xFF - packet[4:8] = timestamp.to_bytes(4, byteorder="big", signed=False) - packet[8:12] = ssrc.to_bytes(4, byteorder="big", signed=False) - packet[12:] = payload - - self.transport.sendto(bytes(packet), (remote_ip, remote_port)) - session["seq"] = (seq + 1) & 0xFFFF - session["timestamp"] = (timestamp + len(payload)) & 0xFFFFFFFF + return self.rtp_sessions[call_id] async def play_audio(self, call_id: str, mime_type: str, audio_data: bytes) -> None: - """Send media audio to the negotiated RTP endpoint.""" - pcma = convert_audio_to_pcma(audio_data, mime_type) - if not pcma: - return - - chunk_size = 160 - for offset in range(0, len(pcma), chunk_size): - await asyncio.sleep(0.02) - await self.send_rtp_audio(call_id, pcma[offset : offset + chunk_size]) + """Send media audio into the call and wait until it has been sent.""" + session = self.rtp_session(call_id) + session.write_pcm(convert_audio_to_pcm(audio_data, mime_type)) + await session.drain() async def send_sip_info_dtmf( self, call_id: str, signal: str, duration: str @@ -1015,6 +1210,7 @@ async def send_bye(self, call_id: str) -> None: self.transport.sendto(data, addr) log_sip_event("BYE_SENT", addr=f"{addr}", call_id=call_id) self.active_calls.pop(call_id) + self._close_rtp(call_id) await self._fire_event("on_call_ended", dialog) async def handle_datagram(self, data: bytes, addr: (str, int)) -> None: @@ -1041,6 +1237,8 @@ async def handle_datagram(self, data: bytes, addr: (str, int)) -> None: await self.handle_options(message, addr) elif method == "ACK": await self.handle_ack(message, addr) + elif method == "CANCEL": + await self.handle_cancel(message, addr) else: log_sip_event("SIP_METHOD_NOT_IMPLEMENTED", addr=f"{addr}", method=method) resp = build_response(501, "Not Implemented", message) @@ -1073,6 +1271,12 @@ async def stop(self) -> None: await self._cleanup_task self._cleanup_task = None + for task in self._ack_waits.values(): + task.cancel() + self._ack_waits.clear() + for call_id in list(self.rtp_sessions): + self._close_rtp(call_id) + if self.transport: self.transport.close() self.transport = None diff --git a/custom_components/sg_150/sip/rtp.py b/custom_components/sg_150/sip/rtp.py new file mode 100644 index 0000000..aebc06e --- /dev/null +++ b/custom_components/sg_150/sip/rtp.py @@ -0,0 +1,442 @@ +""" +RTP media for MiniSIP calls. + +One RtpSession per call owns the UDP socket on the port advertised in the SDP. +It sends a continuous 20 ms stream of G.711 (silence when nothing is queued), +and plays received audio out through a small fixed jitter buffer. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import random +from collections import deque +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +logger = logging.getLogger("sip-server") + +PCMU = 0 +PCMA = 8 +CODEC_NAMES = {PCMU: "PCMU", PCMA: "PCMA"} +SAMPLE_RATE = 8000 +PTIME = 0.02 +SAMPLES_PER_FRAME = int(SAMPLE_RATE * PTIME) +RTP_HEADER_SIZE = 12 +RTP_VERSION = 2 + +# Live audio (a microphone) is dropped beyond this backlog, so a slow consumer +# can never build up more than this much delay. Queued clips are not limited. +MAX_LIVE_BACKLOG = 10 +_SILENT_FRAME = np.zeros(SAMPLES_PER_FRAME, dtype=np.int16) +# Ticks this far behind schedule are skipped instead of sent in a burst. +MAX_TICK_LAG = 5 + + +# ============================================================ +# G.711 +# ============================================================ + + +def _alaw_encode_table() -> np.ndarray: + """Build the A-law code for every int16 value (ITU-T G.711, Sun g711.c).""" + pcm = np.arange(-32768, 32768, dtype=np.int32) >> 3 + mask = np.where(pcm >= 0, 0xD5, 0x55) + pcm = np.where(pcm >= 0, pcm, -pcm - 1) + seg = np.searchsorted( + np.array([0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF]), pcm + ) + quant = np.where(seg < 2, pcm >> 1, pcm >> np.maximum(seg, 1)) & 0xF # noqa: PLR2004 + aval = np.where(seg >= 8, 0x7F, (seg << 4) | quant) # noqa: PLR2004 + return (aval ^ mask).astype(np.uint8) + + +def _alaw_decode_table() -> np.ndarray: + """Build the linear value of every A-law code.""" + code = np.arange(256, dtype=np.int32) ^ 0x55 + seg = (code & 0x70) >> 4 + t = (code & 0x0F) << 4 + t = np.where(seg == 0, t + 8, t + 0x108) + t = np.where(seg > 1, t << np.maximum(seg - 1, 0), t) + return np.where(code & 0x80, t, -t).astype(np.int16) + + +def _ulaw_encode_table() -> np.ndarray: + """Build the µ-law code for every int16 value (ITU-T G.711, Sun g711.c).""" + pcm = np.arange(-32768, 32768, dtype=np.int32) >> 2 + mask = np.where(pcm < 0, 0x7F, 0xFF) + pcm = np.minimum(np.abs(pcm), 8159) + (0x84 >> 2) + seg = np.searchsorted( + np.array([0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF]), pcm + ) + uval = np.where(seg >= 8, 0x7F, (seg << 4) | ((pcm >> (seg + 1)) & 0xF)) # noqa: PLR2004 + return (uval ^ mask).astype(np.uint8) + + +def _ulaw_decode_table() -> np.ndarray: + """Build the linear value of every µ-law code.""" + code = ~np.arange(256, dtype=np.int32) & 0xFF + t = (((code & 0x0F) << 3) + 0x84) << ((code & 0x70) >> 4) + return np.where(code & 0x80, 0x84 - t, t - 0x84).astype(np.int16) + + +_ENCODE = {PCMA: _alaw_encode_table(), PCMU: _ulaw_encode_table()} +_DECODE = {PCMA: _alaw_decode_table(), PCMU: _ulaw_decode_table()} + + +def g711_encode(pcm: np.ndarray, payload_type: int) -> bytes: + """Encode int16 PCM samples as PCMA or PCMU.""" + samples = np.asarray(pcm, dtype=np.int16).astype(np.int32) + 32768 + return _ENCODE[payload_type][samples].tobytes() + + +def g711_decode(payload: bytes, payload_type: int) -> np.ndarray: + """Decode a PCMA or PCMU payload to int16 PCM samples.""" + return _DECODE[payload_type][np.frombuffer(payload, dtype=np.uint8)] + + +# ============================================================ +# Packets and jitter buffer +# ============================================================ + + +def seq_diff(a: int, b: int) -> int: + """Return a - b for 16-bit sequence numbers, allowing for wrap-around.""" + return ((a - b + 0x8000) & 0xFFFF) - 0x8000 + + +@dataclass(frozen=True) +class RtpPacket: + """An RTP packet (RFC 3550) without CSRCs or header extensions.""" + + payload_type: int + sequence: int + timestamp: int + ssrc: int + payload: bytes + marker: bool = False + + def serialize(self) -> bytes: + """Return the packet as bytes.""" + header = bytes( + [ + RTP_VERSION << 6, + (0x80 if self.marker else 0) | (self.payload_type & 0x7F), + ] + ) + return ( + header + + self.sequence.to_bytes(2, "big") + + self.timestamp.to_bytes(4, "big") + + self.ssrc.to_bytes(4, "big") + + self.payload + ) + + @classmethod + def parse(cls, data: bytes) -> RtpPacket | None: + """Parse an RTP packet, or return None if it is not one.""" + if len(data) < RTP_HEADER_SIZE or data[0] >> 6 != RTP_VERSION: + return None + offset = RTP_HEADER_SIZE + 4 * (data[0] & 0x0F) + if data[0] & 0x10: # header extension + if len(data) < offset + 4: + return None + offset += 4 + 4 * int.from_bytes(data[offset + 2 : offset + 4], "big") + end = len(data) + if data[0] & 0x20: # padding + end -= data[-1] + if offset > end: + return None + return cls( + payload_type=data[1] & 0x7F, + sequence=int.from_bytes(data[2:4], "big"), + timestamp=int.from_bytes(data[4:8], "big"), + ssrc=int.from_bytes(data[8:12], "big"), + payload=data[offset:end], + marker=bool(data[1] & 0x80), + ) + + +class JitterBuffer: + """ + Reorders received packets and plays them out with a fixed delay. + + Playout starts once `depth` packets are buffered and then advances one + sequence number per pop. A missing packet pops as None, a late one is + dropped. After `depth` empty pops in a row the buffer primes again, so a + paused or restarted stream does not drift. + """ + + def __init__(self, depth: int = 3) -> None: + """Create a buffer that holds `depth` packets (20 ms each) of delay.""" + self.depth = depth + self._packets: dict[int, bytes] = {} + self._next: int | None = None + self._empty_pops = 0 + + def push(self, sequence: int, payload: bytes) -> None: + """Add a received packet.""" + if self._next is not None and seq_diff(sequence, self._next) < 0: + return # too late, its slot has already been played + self._packets[sequence] = payload + if self._next is not None and len(self._packets) > 2 * self.depth: + # Running behind the sender: skip ahead to keep the delay fixed. + newest = max(self._packets, key=lambda s: seq_diff(s, self._next)) + self._next = (newest - self.depth + 1) & 0xFFFF + self._packets = { + s: p for s, p in self._packets.items() if seq_diff(s, self._next) >= 0 + } + + def pop(self) -> bytes | None: + """Return the payload due now, or None if there is none.""" + if self._next is None: + if len(self._packets) < self.depth: + return None + self._next = min( + self._packets, key=lambda s: seq_diff(s, next(iter(self._packets))) + ) + payload = self._packets.pop(self._next, None) + self._next = (self._next + 1) & 0xFFFF + if payload is None and not self._packets: + self._empty_pops += 1 + if self._empty_pops >= self.depth: + self._next = None + self._empty_pops = 0 + else: + self._empty_pops = 0 + return payload + + +# ============================================================ +# Session +# ============================================================ + + +type FrameListener = Callable[[np.ndarray], None] + + +class RtpSession(asyncio.DatagramProtocol): + """ + The audio stream of one call. + + Every 20 ms tick sends one frame and plays one received frame out to the + listeners (received audio or silence). The frame sent is queued clip audio + mixed with queued live audio, or silence when neither has any. + """ + + def __init__( + self, + *, + payload_type: int = PCMA, + remote: tuple[str, int] | None = None, + ) -> None: + """ + Create a session sending `payload_type` audio. + + remote: where to send audio, from the other side's SDP. If it is None, + the source address of the first received packet is used. + """ + self.payload_type = payload_type + self.remote = remote + self.ssrc = random.getrandbits(32) + self._sequence = random.getrandbits(16) + self._timestamp = random.getrandbits(32) + self._first_packet = True + self.transport: asyncio.DatagramTransport | None = None + self.jitter = JitterBuffer() + # 20 ms frames of PCM: clips play in full, live audio is capped. + self._clip: deque[np.ndarray] = deque() + self._live: deque[np.ndarray] = deque() + self._drained = asyncio.Event() + self._drained.set() + self._listeners: list[FrameListener] = [] + self._task: asyncio.Task | None = None + + @property + def payload_type(self) -> int: + """Return the RTP payload type sent and accepted, PCMA or PCMU.""" + return self._payload_type + + @payload_type.setter + def payload_type(self, payload_type: int) -> None: + """Change the codec, before the call starts.""" + if payload_type not in CODEC_NAMES: + msg = f"Unsupported RTP payload type: {payload_type}" + raise ValueError(msg) + self._payload_type = payload_type + + @property + def running(self) -> bool: + """Return whether the 20 ms clock is running.""" + return self._task is not None + + @property + def codec(self) -> str: + """Return the name of the codec, PCMA or PCMU.""" + return CODEC_NAMES[self.payload_type] + + # asyncio.DatagramProtocol + + def connection_made(self, transport: asyncio.DatagramTransport) -> None: + """Remember the socket.""" + self.transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Buffer a received audio packet.""" + packet = RtpPacket.parse(data) + if packet is None or packet.payload_type != self.payload_type: + return # not RTP, or telephone-event and comfort noise + if self.remote is None: + self.remote = addr + self.jitter.push(packet.sequence, packet.payload) + + def error_received(self, exc: Exception) -> None: + """Log socket errors, such as ICMP port unreachable.""" + logger.debug("RTP transport error: %s", exc) + + # Sending + + def write_pcm(self, pcm: np.ndarray, *, live: bool = False) -> None: + """ + Queue 8 kHz int16 PCM to send. + + Clips (the default) play in full, after earlier clips. live: queue it + for real-time sources such as a microphone instead, mixed over any + clip, keeping only the newest MAX_LIVE_BACKLOG frames so a slow + consumer can't build up delay. + """ + queue = self._live if live else self._clip + pcm = np.asarray(pcm, dtype=np.int16) + for offset in range(0, len(pcm), SAMPLES_PER_FRAME): + frame = pcm[offset : offset + SAMPLES_PER_FRAME] + if len(frame) < SAMPLES_PER_FRAME: + frame = np.pad(frame, (0, SAMPLES_PER_FRAME - len(frame))) + queue.append(frame) + if live: + while len(queue) > MAX_LIVE_BACKLOG: + queue.popleft() + elif queue: + self._drained.clear() + + async def drain(self) -> None: + """Wait until all queued clip audio has been sent.""" + await self._drained.wait() + + def _next_frame(self) -> np.ndarray: + """Return the next frame to send: clip and live audio mixed.""" + clip = self._clip.popleft() if self._clip else None + if not self._clip: + self._drained.set() + live = self._live.popleft() if self._live else None + if clip is None: + return live if live is not None else _SILENT_FRAME + if live is None: + return clip + mixed = clip.astype(np.int32) + live.astype(np.int32) + return np.clip(mixed, -32768, 32767).astype(np.int16) + + # Receiving + + def subscribe(self, listener: FrameListener) -> Callable[[], None]: + """Call `listener` with every played-out 20 ms frame of received audio.""" + self._listeners.append(listener) + + def unsubscribe() -> None: + with contextlib.suppress(ValueError): + self._listeners.remove(listener) + + return unsubscribe + + # Clock + + def tick(self) -> None: + """Send one frame and play one received frame out, 20 ms of audio.""" + payload = g711_encode(self._next_frame(), self.payload_type) + if self.transport is not None and self.remote is not None: + packet = RtpPacket( + payload_type=self.payload_type, + sequence=self._sequence, + timestamp=self._timestamp, + ssrc=self.ssrc, + payload=payload, + marker=self._first_packet, + ) + self.transport.sendto(packet.serialize(), self.remote) + self._first_packet = False + self._sequence = (self._sequence + 1) & 0xFFFF + self._timestamp = (self._timestamp + SAMPLES_PER_FRAME) & 0xFFFFFFFF + + received = self.jitter.pop() + if self._listeners: + pcm = ( + g711_decode(received, self.payload_type) + if received is not None + else np.zeros(SAMPLES_PER_FRAME, dtype=np.int16) + ) + for listener in list(self._listeners): + try: + listener(pcm) + except Exception: + logger.exception("Error in RTP frame listener") + + async def _run(self) -> None: + loop = asyncio.get_running_loop() + next_tick = loop.time() + while True: + self.tick() + next_tick += PTIME + lag = loop.time() - next_tick + if lag > MAX_TICK_LAG * PTIME: + next_tick = loop.time() # the loop stalled; don't send a burst + await asyncio.sleep(max(0.0, next_tick - loop.time())) + + def start(self) -> None: + """Start the 20 ms clock.""" + if self._task is None: + self._task = asyncio.create_task(self._run(), name="sip-rtp-clock") + + def close(self) -> None: + """Stop the clock and close the socket.""" + if self._task is not None: + self._task.cancel() + self._task = None + if self.transport is not None: + self.transport.close() + self.transport = None + self._clip.clear() + self._live.clear() + self._drained.set() + self._listeners.clear() + + +class RtpPortPool: + """The local UDP ports available for RTP, one per call.""" + + def __init__(self, ports: range) -> None: + """Create a pool of `ports`.""" + self._free = list(ports) + + def acquire(self) -> int | None: + """Take a free port, or return None if all are in use.""" + return self._free.pop(0) if self._free else None + + def release(self, port: int) -> None: + """Give a port back.""" + if port not in self._free: + self._free.append(port) + + +type RtpEndpointFactory = Callable[[RtpSession, str, int], Awaitable[None]] + + +async def open_udp_endpoint(session: RtpSession, host: str, port: int) -> None: + """Bind `session` to a UDP socket on host:port.""" + loop = asyncio.get_running_loop() + await loop.create_datagram_endpoint(lambda: session, local_addr=(host, port)) diff --git a/custom_components/sg_150/talkback.py b/custom_components/sg_150/talkback.py new file mode 100644 index 0000000..80d5021 --- /dev/null +++ b/custom_components/sg_150/talkback.py @@ -0,0 +1,523 @@ +""" +Live talk-back for a door station camera, over WebRTC. + +Talkback answers each WebRTC session of the camera with an aiortc peer +connection carrying the camera's video and the door station's audio. A session +whose offer sends audio (the frontend's talk button) talks: it answers the +door station's ringing call, its microphone goes into the call, and when the +last talking session closes the call is hung up. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import fractions +import time +from collections import deque +from typing import TYPE_CHECKING + +import av +import numpy as np +from aiortc import ( + RTCConfiguration, + RTCPeerConnection, + RTCRtpSender, + RTCSessionDescription, +) +from aiortc.mediastreams import MediaStreamError, MediaStreamTrack +from aiortc.sdp import candidate_from_sdp +from homeassistant.components.camera import WebRTCAnswer, WebRTCError + +from .const import LOGGER +from .line import CallEnded, CallStarted, NotRingingError +from .sip.rtp import PTIME, SAMPLE_RATE, SAMPLES_PER_FRAME + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + from aiortc import RTCIceServer, RTCRtpCodecCapability + from homeassistant.components.camera import WebRTCSendMessage + + from .line import Call, GatewayLine, LineEvent + from .sip.rtp import RtpSession + + type JpegSource = Callable[[], AsyncIterator[bytes]] + +VIDEO_FPS = 15 +VIDEO_BITRATE = 800_000 +VIDEO_TIME_BASE = fractions.Fraction(1, 90000) +# A keyframe every two seconds, so a viewer recovers quickly from packet loss. +KEYFRAME_INTERVAL = 2 * VIDEO_FPS +VIDEO_QUEUE = 30 +RECONNECT_DELAY = 2.0 +# Door audio frames a viewer may lag behind before the oldest are dropped. +AUDIO_QUEUE = 5 +AUDIO_TIME_BASE = fractions.Fraction(1, SAMPLE_RATE) +# How long a candidate waits for its session's offer to be handled. +CANDIDATE_TIMEOUT = 10 + + +def offer_sends_audio(offer_sdp: str) -> bool: + """Return whether an SDP offer sends audio, i.e. the viewer wants to talk.""" + sends = False + in_audio = False + for raw in offer_sdp.splitlines(): + line = raw.strip() + if line.startswith("m="): + parts = line.split() + in_audio = parts[0] == "m=audio" and len(parts) > 1 and parts[1] != "0" + if in_audio: + sends = True # sendrecv is the default direction + elif in_audio and line in {"a=recvonly", "a=inactive"}: + sends = False + return sends + + +def _codecs(kind: str, mime_types: list[str]) -> list[RTCRtpCodecCapability]: + capabilities = RTCRtpSender.getCapabilities(kind).codecs + return [ + codec + for mime_type in mime_types + for codec in capabilities + if codec.mimeType.lower() == mime_type.lower() + ] + + +# ============================================================ +# Video +# ============================================================ + + +class EncodedVideoTrack(MediaStreamTrack): + """One viewer's copy of the shared VP8 stream, as pre-encoded packets.""" + + kind = "video" + + def __init__(self, source: VideoSource) -> None: + """Create a track fed by `source`.""" + super().__init__() + self._source = source + self._queue: asyncio.Queue[av.Packet] = asyncio.Queue(VIDEO_QUEUE) + self._needs_keyframe = True + + def put(self, packet: av.Packet) -> None: + """Queue an encoded packet, starting at a keyframe.""" + if self._needs_keyframe and not packet.is_keyframe: + return + if self._queue.full(): + # The viewer fell behind: drop what it has and resync on a keyframe. + while not self._queue.empty(): + self._queue.get_nowait() + self._needs_keyframe = True + self._source.request_keyframe() + return + self._needs_keyframe = False + self._queue.put_nowait(packet) + + async def recv(self) -> av.Packet: + """Return the next encoded packet.""" + if self.readyState != "live": + raise MediaStreamError + return await self._queue.get() + + def stop(self) -> None: + """Stop the track and leave the shared stream.""" + super().stop() + self._source.remove(self) + + +class VideoSource: + """ + The camera's MJPEG frames, decoded and encoded to VP8 once for all viewers. + + It runs only while at least one track exists. + """ + + def __init__(self, jpegs: JpegSource) -> None: + """Create a source reading JPEG frames from `jpegs`.""" + self._jpegs = jpegs + self._tracks: set[EncodedVideoTrack] = set() + self._task: asyncio.Task | None = None + self._decoder: av.CodecContext | None = None + self._encoder: av.VideoCodecContext | None = None + self._keyframe = True + self._started = 0.0 + + @property + def running(self) -> bool: + """Return whether frames are being read and encoded.""" + return self._task is not None + + def track(self) -> EncodedVideoTrack: + """Return a new track of the stream, starting the source if needed.""" + track = EncodedVideoTrack(self) + self._tracks.add(track) + self.request_keyframe() + if self._task is None: + self._task = asyncio.create_task(self._run(), name="sg150-video") + return track + + def remove(self, track: EncodedVideoTrack) -> None: + """Remove a track, stopping the source after the last one.""" + self._tracks.discard(track) + if not self._tracks and self._task is not None: + self._task.cancel() + self._task = None + + def request_keyframe(self) -> None: + """Make the next encoded frame a keyframe.""" + self._keyframe = True + + async def _run(self) -> None: + loop = asyncio.get_running_loop() + self._decoder = self._encoder = None + self._started = time.monotonic() + last_frame = -1.0 + while True: + try: + async for jpeg in self._jpegs(): + now = time.monotonic() + if now - last_frame < 1 / VIDEO_FPS: + continue + last_frame = now + pts = int((now - self._started) / VIDEO_TIME_BASE) + packets = await loop.run_in_executor(None, self._encode, jpeg, pts) + for packet in packets: + for track in list(self._tracks): + track.put(packet) + except Exception: # noqa: BLE001 + LOGGER.debug("Camera video stream failed", exc_info=True) + await asyncio.sleep(RECONNECT_DELAY) + + def _encode(self, jpeg: bytes, pts: int) -> list[av.Packet]: + """Decode a JPEG and encode it to VP8; runs in an executor thread.""" + if self._decoder is None: + self._decoder = av.CodecContext.create("mjpeg", "r") + try: + frames = self._decoder.decode(av.Packet(jpeg)) + except av.error.FFmpegError: + return [] + + packets: list[av.Packet] = [] + for decoded in frames: + frame = decoded.reformat(format="yuv420p") + if self._encoder is None or (frame.width, frame.height) != ( + self._encoder.width, + self._encoder.height, + ): + self._encoder = self._create_encoder(frame.width, frame.height) + self._keyframe = True + frame.pts = pts + frame.time_base = VIDEO_TIME_BASE + if self._keyframe: + frame.pict_type = av.video.frame.PictureType.I + self._keyframe = False + for packet in self._encoder.encode(frame): + packet.time_base = VIDEO_TIME_BASE + packets.append(packet) + return packets + + @staticmethod + def _create_encoder(width: int, height: int) -> av.VideoCodecContext: + """Create a real-time VP8 encoder, configured like aiortc's own.""" + encoder = av.CodecContext.create("libvpx", "w") + encoder.width = width + encoder.height = height + encoder.bit_rate = VIDEO_BITRATE + encoder.pix_fmt = "yuv420p" + encoder.time_base = VIDEO_TIME_BASE + encoder.framerate = fractions.Fraction(VIDEO_FPS) + encoder.gop_size = KEYFRAME_INTERVAL + encoder.qmin = 2 + encoder.qmax = 56 + encoder.options = { + "bufsize": str(VIDEO_BITRATE), + "cpu-used": "-6", + "deadline": "realtime", + "lag-in-frames": "0", + "minrate": str(VIDEO_BITRATE), + "maxrate": str(VIDEO_BITRATE), + "noise-sensitivity": "4", + "overshoot-pct": "15", + "partitions": "0", + "static-thresh": "1", + "undershoot-pct": "100", + } + return encoder + + +# ============================================================ +# Audio +# ============================================================ + + +class DoorAudioTrack(MediaStreamTrack): + """The door station's audio for one viewer, 20 ms frames, silence by default.""" + + kind = "audio" + + def __init__(self) -> None: + """Create a silent track.""" + super().__init__() + self._frames: deque[np.ndarray] = deque(maxlen=AUDIO_QUEUE) + self._start: float | None = None + self._timestamp = 0 + + def put(self, pcm: np.ndarray) -> None: + """Queue a 20 ms frame of 8 kHz int16 audio from the call.""" + self._frames.append(pcm) + + async def recv(self) -> av.AudioFrame: + """Return the next 20 ms frame, paced in real time.""" + if self.readyState != "live": + raise MediaStreamError + if self._start is None: + self._start = time.monotonic() + else: + self._timestamp += SAMPLES_PER_FRAME + wait = self._start + self._timestamp / SAMPLE_RATE - time.monotonic() + if wait < -5 * PTIME: + # Fell far behind, such as after the event loop stalled. + self._start = time.monotonic() - self._timestamp / SAMPLE_RATE + await asyncio.sleep(max(0.0, wait)) + + pcm = ( + self._frames.popleft() + if self._frames + else np.zeros(SAMPLES_PER_FRAME, dtype=np.int16) + ) + frame = av.AudioFrame.from_ndarray( + pcm.reshape(1, -1), format="s16", layout="mono" + ) + frame.sample_rate = SAMPLE_RATE + frame.pts = self._timestamp + frame.time_base = AUDIO_TIME_BASE + return frame + + +# ============================================================ +# Sessions +# ============================================================ + + +class _Session: + """One WebRTC session of the camera.""" + + def __init__( + self, session_id: str, *, talking: bool, video: EncodedVideoTrack + ) -> None: + self.id = session_id + self.talking = talking + self.pc: RTCPeerConnection | None = None + self.audio = DoorAudioTrack() + self.video = video + self.ready = asyncio.Event() + self.tasks: set[asyncio.Task] = set() + + async def close(self) -> None: + for task in self.tasks: + task.cancel() + self.audio.stop() + self.video.stop() + self.ready.set() + if self.pc is not None: + await self.pc.close() + + +class Talkback: + """WebRTC sessions of one door station camera, bridged to its SIP calls.""" + + def __init__(self, line: GatewayLine, device_id: str, jpegs: JpegSource) -> None: + """ + Create a bridge for the door station `device_id` on `line`. + + jpegs: opens the camera's MJPEG stream and yields its JPEG frames. + """ + self._line = line + self._device_id = device_id + self._video = VideoSource(jpegs) + self._sessions: dict[str, _Session] = {} + # Ids of talking sessions in the order they started talking; the last + # one's microphone goes into the call. + self._talkers: list[str] = [] + self._call: Call | None = None + self._call_audio: RtpSession | None = None + self._unsubscribe_call_audio: Callable[[], None] | None = None + # Whether a session talked during the current call, so closing the last + # talking session hangs it up. Calls nobody talked on are left alone. + self._talked = False + self._unsubscribe_line = line.subscribe(self._on_line_event) + for call in line.calls.values(): + if call.involves(device_id): + self._attach(call) + break + + @property + def video(self) -> VideoSource: + """Return the shared video source.""" + return self._video + + # Calls + + def _on_line_event(self, event: LineEvent) -> None: + if isinstance(event, CallStarted) and event.call.involves(self._device_id): + if self._call is None: + self._attach(event.call) + elif ( + isinstance(event, CallEnded) + and self._call is not None + and event.call.id == self._call.id + ): + self._detach() + + def _attach(self, call: Call) -> None: + try: + self._call_audio = call.rtp_session() + except ValueError: + return + self._call = call + self._talked = bool(self._talkers) + self._unsubscribe_call_audio = self._call_audio.subscribe(self._on_door_audio) + + def _detach(self) -> None: + if self._unsubscribe_call_audio is not None: + self._unsubscribe_call_audio() + self._unsubscribe_call_audio = None + self._call = self._call_audio = None + self._talked = False + + def _on_door_audio(self, pcm: np.ndarray) -> None: + for session in self._sessions.values(): + session.audio.put(pcm) + + async def _answer_ringing_call(self) -> None: + for call in list(self._line.ringing.values()): + if call.involves(self._device_id): + with contextlib.suppress(NotRingingError): + await self._line.answer(call.id) + return + + async def _read_microphone( + self, session: _Session, track: MediaStreamTrack + ) -> None: + resampler = av.AudioResampler(format="s16", layout="mono", rate=SAMPLE_RATE) + while True: + try: + frame = await track.recv() + except MediaStreamError: + return + if self._talkers[-1:] != [session.id] or self._call_audio is None: + continue + for resampled in resampler.resample(frame): + self._call_audio.write_pcm( + resampled.to_ndarray().reshape(-1), live=True + ) + + # WebRTC + + async def handle_offer( + self, + session_id: str, + offer_sdp: str, + send_message: WebRTCSendMessage, + ice_servers: list[RTCIceServer], + ) -> None: + """Answer a WebRTC offer; a talking offer also answers a ringing call.""" + session = _Session( + session_id, talking=offer_sends_audio(offer_sdp), video=self._video.track() + ) + self._sessions[session_id] = session + pc = session.pc = RTCPeerConnection(RTCConfiguration(iceServers=ice_servers)) + + audio = pc.addTransceiver( + session.audio, direction="sendrecv" if session.talking else "sendonly" + ) + audio.setCodecPreferences( + _codecs("audio", ["audio/PCMA", "audio/PCMU", "audio/opus"]) + ) + video = pc.addTransceiver(session.video, direction="sendonly") + video.setCodecPreferences(_codecs("video", ["video/VP8"])) + + @pc.on("track") + def on_track(track: MediaStreamTrack) -> None: + if track.kind == "audio" and session.talking: + task = asyncio.create_task(self._read_microphone(session, track)) + session.tasks.add(task) + task.add_done_callback(session.tasks.discard) + + @pc.on("connectionstatechange") + async def on_connection_state_change() -> None: + if pc.connectionState == "failed" and session_id in self._sessions: + send_message( + WebRTCError("webrtc_failed", "The WebRTC connection failed") + ) + await self.close_session(session_id) + + try: + await pc.setRemoteDescription( + RTCSessionDescription(sdp=offer_sdp, type="offer") + ) + await pc.setLocalDescription(await pc.createAnswer()) + except Exception: + await self.close_session(session_id) + raise + if self._sessions.get(session_id) is not session: + return # closed while the answer was being created + session.ready.set() + send_message(WebRTCAnswer(pc.localDescription.sdp)) + LOGGER.debug( + "WebRTC session %s started for %s (talking: %s)", + session_id, + self._device_id, + session.talking, + ) + + if session.talking: + self._talkers.append(session_id) + if self._call is not None: + self._talked = True + await self._answer_ringing_call() + + async def add_candidate( + self, + session_id: str, + candidate: str, + sdp_mid: str | None, + sdp_m_line_index: int | None, + ) -> None: + """Add a remote ICE candidate, once the session's offer is handled.""" + session = self._sessions.get(session_id) + if session is None: + msg = f"Unknown WebRTC session: {session_id}" + raise ValueError(msg) + await asyncio.wait_for(session.ready.wait(), CANDIDATE_TIMEOUT) + value = candidate.removeprefix("candidate:").strip() + if not value or self._sessions.get(session_id) is not session: + return # end of candidates, or the session closed + ice_candidate = candidate_from_sdp(value) + ice_candidate.sdpMid = sdp_mid + ice_candidate.sdpMLineIndex = sdp_m_line_index + await session.pc.addIceCandidate(ice_candidate) + + async def close_session(self, session_id: str) -> None: + """Close a session; hang up if it was the last one talking on a call.""" + session = self._sessions.pop(session_id, None) + if session is None: + return + await session.close() + if session_id not in self._talkers: + return + self._talkers.remove(session_id) + if not self._talkers and self._talked and (call := self._call) is not None: + LOGGER.debug("Last talking session closed, hanging up %s", call.id) + with contextlib.suppress(ValueError): # it ended meanwhile + await call.hangup() + + async def close(self) -> None: + """Close all sessions without hanging up, such as on unload.""" + self._unsubscribe_line() + self._detach() + self._talkers.clear() + for session_id in list(self._sessions): + await self.close_session(session_id) diff --git a/custom_components/sg_150/translations/de.json b/custom_components/sg_150/translations/de.json index fb0c0e6..8785b9d 100644 --- a/custom_components/sg_150/translations/de.json +++ b/custom_components/sg_150/translations/de.json @@ -24,7 +24,10 @@ "sip-password": "SIP Passwort", "sip-port": "SIP Port", "sip-number": "SIP Nummer", - "sip-auto-answer": "SIP Automatisch Antworten" + "sip-auto-answer": "SIP Automatisch Antworten", + "two-way-audio": "Gegensprechen", + "rtp-port-min": "Erster RTP Port", + "rtp-port-max": "Letzter RTP Port" }, "data_description": { "sip-caller-ip": "IP Addresse welche als SIP Caller genutzt wird.", @@ -32,7 +35,10 @@ "sip-password": "Password für die Anmeldung durch das Siedle Gateways", "sip-port": "Port für die Anmeldung durch das Siedle Gateways", "sip-number": "Telefonnummer für das im Siedle Gateway Konfigurierte TK-Telefon", - "sip-auto-answer": "Automatische Annahme von SIP-Anrufen" + "sip-auto-answer": "Eingehende SIP Anrufe automatisch annehmen. Ist Gegensprechen aktiv und dies aus, klingelt Home Assistant mit und nimmt das Gespärch an, sobald in der Live-Ansicht der Kamera gesprochen wird.", + "two-way-audio": "Über WebRTC aus der Live-Ansicht der Kamera mit der Türstation sprechen. Benötigt aktuell die Labs-Funktion Zwei-Wege-Audio und Home Assistant über HTTPS.", + "rtp-port-min": "Erster UDP Port für das Gesprächsaudio. Das Siedle Gateway muss die Ports vom ersten bis zum letzten erreichen können, einen pro gleichzeitigem Anruf.", + "rtp-port-max": "Letzter UDP Port für das Gesprächsaudio" } }, "reauth_confirm": { @@ -51,7 +57,8 @@ "error": { "auth": "Benutzername/Passwort falsch.", "connection": "Die Verbindung zum Server konnte nicht hergestellt werden.", - "unknown": "Unerwarteter Fehler" + "unknown": "Unerwarteter Fehler", + "rtp_ports": "Die RTP Ports müssen Zahlen zwischen 1024 und 65535 sein, und der erste darf nicht über dem letzten liegen." }, "abort": { "already_configured": "Konto ist bereits konfiguriert", @@ -68,16 +75,27 @@ "sip-user": "SIP Username", "sip-password": "SIP Passwort", "sip-port": "SIP Port", - "sip-number": "SIP Number" + "sip-number": "SIP Number", + "sip-auto-answer": "SIP Automatisch Antworten", + "two-way-audio": "Gegensprechen", + "rtp-port-min": "Erster RTP Port", + "rtp-port-max": "Letzter RTP Port" }, "data_description": { "sip-caller-ip": "IP Addresse welche als SIP Caller genutzt wird.", "sip-user": "Benutzername für die Anmeldung durch das Siedle Gateways", "sip-password": "Password für die Anmeldung durch das Siedle Gateways", "sip-port": "Port für die Anmeldung durch das Siedle Gateways", - "sip-number": "Telefonnummer für das im Siedle Gateway Konfigurierte TK-Telefon" + "sip-number": "Telefonnummer für das im Siedle Gateway Konfigurierte TK-Telefon", + "sip-auto-answer": "Eingehende SIP Anrufe automatisch annehmen. Ist Gegensprechen aktiv und dies aus, klingelt Home Assistant mit und nimmt an, sobald in der Live-Ansicht der Kamera gesprochen wird.", + "two-way-audio": "Über WebRTC aus der Live-Ansicht der Kamera mit der Türstation sprechen. Benötigt die Labs-Funktion Zwei-Wege-Audio und Home Assistant über HTTPS.", + "rtp-port-min": "Erster UDP Port für das Gesprächsaudio. Das Siedle Gateway muss die Ports vom ersten bis zum letzten erreichen können, einen pro gleichzeitigem Anruf.", + "rtp-port-max": "Letzter UDP Port für das Gesprächsaudio" } } + }, + "error": { + "rtp_ports": "Die RTP Ports müssen Zahlen zwischen 1024 und 65535 sein, und der erste darf nicht über dem letzten liegen." } }, "entity": { @@ -105,6 +123,9 @@ }, "light_switch": { "name": "Lichtschalter" + }, + "hang_up": { + "name": "Auflegen" } }, "sensor": { @@ -210,5 +231,10 @@ } } } + }, + "exceptions": { + "no_call_to_hang_up": { + "message": "Es gibt keinen Anruf zum Auflegen" + } } -} \ No newline at end of file +} diff --git a/custom_components/sg_150/translations/en.json b/custom_components/sg_150/translations/en.json index 6ab91f8..77d46ed 100644 --- a/custom_components/sg_150/translations/en.json +++ b/custom_components/sg_150/translations/en.json @@ -24,7 +24,10 @@ "sip-password": "SIP Passwort", "sip-port": "SIP Port", "sip-number": "SIP Number", - "sip-auto-answer": "SIP Auto Answer" + "sip-auto-answer": "SIP Auto Answer", + "two-way-audio": "Two-way audio", + "rtp-port-min": "First RTP Port", + "rtp-port-max": "Last RTP Port" }, "data_description": { "sip-caller-ip": "External ip address that will be used as the SIP Caller IP", @@ -32,7 +35,10 @@ "sip-password": "Password used by the Siedle Gateway to authenticate with the SIP-Server", "sip-port": "Port number Siedle Gateway will connect with the SIP-Server", "sip-number": "Phone number configured in Siedle Gateway for the Softphone", - "sip-auto-answer": "Automatically answer incoming SIP calls" + "sip-auto-answer": "Automatically answer incoming SIP calls. With two-way audio enabled and this off, Home Assistant rings along and answers when you press talk in the camera's live view.", + "two-way-audio": "Talk to the door station from the camera's live view over WebRTC. Needs the Two-way audio Labs feature and Home Assistant opened over HTTPS.", + "rtp-port-min": "First UDP port for call audio. The Siedle Gateway must be able to reach the ports from the first to the last one, one per call at the same time.", + "rtp-port-max": "Last UDP port for call audio" } }, "reauth_confirm": { @@ -51,7 +57,8 @@ "error": { "auth": "Username/Password is wrong.", "connection": "Unable to connect to the server.", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "rtp_ports": "The RTP ports must be numbers between 1024 and 65535, and the first must not be above the last." }, "abort": { "already_configured": "Account is already configured", @@ -68,16 +75,27 @@ "sip-user": "SIP Username", "sip-password": "SIP Passwort", "sip-port": "SIP Port", - "sip-number": "SIP Number" + "sip-number": "SIP Number", + "sip-auto-answer": "SIP Auto Answer", + "two-way-audio": "Two-way audio", + "rtp-port-min": "First RTP Port", + "rtp-port-max": "Last RTP Port" }, "data_description": { "sip-caller-ip": "External ip address that will be used as the SIP Caller IP", "sip-user": "Username the Siedle Gateway will use to connect the SIP-Client", "sip-password": "Password used by the Siedle Gateway to authenticate with the SIP-Server", "sip-port": "Port number Siedle Gateway will connect with the SIP-Server", - "sip-number": "Phone number configured in Siedle Gateway for the Softphone" + "sip-number": "Phone number configured in Siedle Gateway for the Softphone", + "sip-auto-answer": "Automatically answer incoming SIP calls. With two-way audio enabled and this off, Home Assistant rings along and answers when you press talk in the camera's live view.", + "two-way-audio": "Talk to the door station from the camera's live view over WebRTC. Needs the Two-way audio Labs feature and Home Assistant opened over HTTPS.", + "rtp-port-min": "First UDP port for call audio. The Siedle Gateway must be able to reach the ports from the first to the last one, one per call at the same time.", + "rtp-port-max": "Last UDP port for call audio" } } + }, + "error": { + "rtp_ports": "The RTP ports must be numbers between 1024 and 65535, and the first must not be above the last." } }, "entity": { @@ -105,6 +123,9 @@ }, "light_switch": { "name": "Lightswitch" + }, + "hang_up": { + "name": "Hang Up" } }, "sensor": { @@ -210,5 +231,10 @@ } } } + }, + "exceptions": { + "no_call_to_hang_up": { + "message": "There is no call to hang up" + } } -} \ No newline at end of file +} diff --git a/custom_components/sg_150/types.py b/custom_components/sg_150/types.py index 6bd1b29..0ce3dc1 100644 --- a/custom_components/sg_150/types.py +++ b/custom_components/sg_150/types.py @@ -253,6 +253,14 @@ def video_extraction( ) return capability.properties if capability else None + @property + def mjpeg_stream_url(self) -> str | None: + """Return the URL of the device's MJPEG stream, the camera's source.""" + video_extraction = self.video_extraction + if video_extraction is None or video_extraction.type != "MJPEG_STREAM": + return None + return video_extraction.url + @property def in_home(self) -> SG150DeviceCapabilityInHomeProperties | None: """Return the IN_HOME properties, if the device has one.""" diff --git a/hacs.json b/hacs.json index 0f70f8c..4e46aff 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "SG-150", - "homeassistant": "2026.7.0", + "homeassistant": "2026.11.0", "hacs": "2.0.5" } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5d27161..dcd0ffc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,9 @@ colorlog==6.12.0 homeassistant==2026.11.0b0 # Home Assistant constrains pydantic to this version but doesn't install it. pydantic==2.14.1 +# aiortc main relaxed its PyAV pin to av<20 (HA pins av==19.0.0); replace this +# with the release named in manifest.json once it is on PyPI. +aiortc @ https://github.com/aiortc/aiortc/archive/34b5fd3409220e1aeb8b62f2c56a9ae2085ca8c8.zip pip>=26.2.2 ruff==0.17.2 soundfile==0.14.0 diff --git a/tests/conftest.py b/tests/conftest.py index b63532f..89af41d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ DOMAIN, ) from custom_components.sg_150.sip.minisip import MiniSIPServer -from tests.utils import FIXTURES, FakeTransport +from tests.utils import FIXTURES, FakeTransport, fake_rtp_endpoint if TYPE_CHECKING: from collections.abc import Generator @@ -90,11 +90,12 @@ def gateway_api(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: @pytest.fixture def sip_servers() -> Generator[list[MiniSIPServer]]: - """Start SIP servers on a FakeTransport instead of a UDP socket.""" + """Start SIP servers on fake transports instead of UDP sockets.""" started: list[MiniSIPServer] = [] async def start(self: MiniSIPServer) -> None: self.transport = FakeTransport() + self._rtp_endpoint = fake_rtp_endpoint started.append(self) with patch.object(MiniSIPServer, "start", start): diff --git a/tests/sip_messages.py b/tests/sip_messages.py index 86ebd50..14eefc2 100644 --- a/tests/sip_messages.py +++ b/tests/sip_messages.py @@ -103,3 +103,17 @@ async def register(server: MiniSIPServer) -> None: await server.handle_datagram( sip(f"REGISTER sip:{HA_HOST} SIP/2.0", headers), GATEWAY ) + + +def cancel(call_id: str) -> bytes: + """Build the CANCEL the gateway sends when a handset answered first.""" + return sip( + f"CANCEL sip:{HA_NUMBER}@{HA_HOST} SIP/2.0", + { + "Via": f"SIP/2.0/UDP {GATEWAY[0]}:{GATEWAY[1]};branch=z1", + "From": f";tag=gw", + "To": f"", + "Call-ID": call_id, + "CSeq": "1 CANCEL", + }, + ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 72f49e8..a410417 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -11,12 +11,15 @@ from custom_components.sg_150.api import SG150ApiClient from custom_components.sg_150.const import ( CONF_HOST_ADDRESS, + CONF_RTP_PORT_MAX, + CONF_RTP_PORT_MIN, CONF_SIP_AUTO_ANSWER, CONF_SIP_CALLER_IP, CONF_SIP_NUMBER, CONF_SIP_PASSWORD, CONF_SIP_PORT, CONF_SIP_USER, + CONF_TWO_WAY_AUDIO, DOMAIN, ) from tests.conftest import API_URL, HOST, TOKEN @@ -71,7 +74,12 @@ async def test_user_flow_creates_entry( "username": "admin", "password": SG150ApiClient.hash_password("pw"), } - assert result["options"] == OPTIONS + assert result["options"] == { + **OPTIONS, + CONF_RTP_PORT_MIN: "5004", + CONF_RTP_PORT_MAX: "5013", + CONF_TWO_WAY_AUDIO: False, + } @pytest.mark.parametrize( @@ -132,6 +140,24 @@ async def test_options_flow_updates_options( assert len(sip_servers) == 2 +async def test_options_flow_rejects_a_bad_rtp_port_range( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_integration: MiniSIPServer, +) -> None: + """A reversed RTP port range is shown as an error and not stored.""" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {**config_entry.options, CONF_RTP_PORT_MIN: "6000", CONF_RTP_PORT_MAX: "5000"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "rtp_ports"} + assert CONF_RTP_PORT_MIN not in config_entry.options + + async def test_reauth_updates_credentials_and_reloads( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/test_entities.py b/tests/test_entities.py index ce69e09..651ba10 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -6,6 +6,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from custom_components.sg_150.const import DOMAIN @@ -17,6 +18,7 @@ from pytest_homeassistant.test_util.aiohttp import ( AiohttpClientMocker, ) + from pytest_homeassistant.typing import WebSocketGenerator from custom_components.sg_150.sip.minisip import MiniSIPServer @@ -31,6 +33,7 @@ ("sensor", "door-frontPhone Number", "Front Door Phone Number"), ("camera", "door-frontCamera", "Front Door Camera"), ("button", "door-backDoor Opener", "Back Door Door Opener"), + ("button", "door-front_hang_up", "Front Door Hang Up"), ("event", "pbx-externalBell", "Home Assistant Bell"), ("sensor", "pbx-externalPhone Number", "Home Assistant Phone Number"), ("sensor", "pbx-internal-phonePhone Number", "Kitchen Phone Phone Number"), @@ -88,6 +91,53 @@ async def test_door_opener_sends_dialable_call_number( ] +@pytest.mark.usefixtures("setup_integration") +async def test_hang_up_sits_next_to_each_camera(hass: HomeAssistant) -> None: + """Hang Up is on the device with the camera, not on every door station.""" + registry = er.async_get(hass) + camera = registry.async_get(entity_id(hass, "camera", "door-frontCamera")) + hang_up = registry.async_get(entity_id(hass, "button", "door-front_hang_up")) + + assert hang_up.device_id == camera.device_id + # The back door has a door opener, but only snapshots: no camera, no Hang Up. + assert registry.async_get_entity_id("button", DOMAIN, "door-back_hang_up") is None + + +async def test_hang_up_ends_only_its_devices_calls( + hass: HomeAssistant, setup_integration: MiniSIPServer +) -> None: + """Pressing Hang Up sends BYE for calls with its device, and leaves others.""" + await setup_integration.handle_datagram(invite("c5", "*111", HA_NUMBER), GATEWAY) + await setup_integration.handle_datagram(ack("c5"), GATEWAY) + await setup_integration.handle_datagram(invite("c6", "*999", "*9999"), GATEWAY) + await setup_integration.handle_datagram(ack("c6"), GATEWAY) + + await hass.services.async_call( + "button", + "press", + {"entity_id": entity_id(hass, "button", "door-front_hang_up")}, + blocking=True, + ) + + assert list(setup_integration.active_calls) == ["c6"] + assert setup_integration.transport.sent[-1][0].startswith(b"BYE ") + + +@pytest.mark.usefixtures("setup_integration") +async def test_hang_up_without_a_call_is_refused(hass: HomeAssistant) -> None: + """Pressing Hang Up with no call shows an error instead of doing nothing.""" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + "button", + "press", + {"entity_id": entity_id(hass, "button", "door-front_hang_up")}, + blocking=True, + ) + + assert err.value.translation_key == "no_call_to_hang_up" + assert str(err.value) == "There is no call to hang up" + + async def test_sip_connection_follows_registration( hass: HomeAssistant, setup_integration: MiniSIPServer ) -> None: @@ -140,3 +190,21 @@ async def test_camera_streams_during_a_call_with_its_door( await setup_integration.handle_datagram(bye("c3"), GATEWAY) await hass.async_block_till_done() assert hass.states.get(camera).state == "idle" + + +async def test_camera_without_two_way_audio_stays_mjpeg( + hass: HomeAssistant, + setup_integration: MiniSIPServer, + hass_ws_client: WebSocketGenerator, +) -> None: + """With the option off, the camera offers no stream types (MJPEG only).""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "camera/capabilities", + "entity_id": entity_id(hass, "camera", "door-frontCamera"), + } + ) + response = await client.receive_json() + + assert response["result"] == {"frontend_stream_types": [], "two_way_audio": False} diff --git a/tests/test_line.py b/tests/test_line.py index 09e655d..4f27c30 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -14,6 +14,7 @@ IncomingCall, LineEvent, NotRegisteredError, + NotRingingError, Registered, match_device, ) @@ -26,11 +27,12 @@ SIP_USER, ack, bye, + cancel, invite, register, sip, ) -from tests.utils import FakeTransport, load_devices +from tests.utils import FakeTransport, fake_rtp_endpoint, load_devices class GatewayLineTests(unittest.IsolatedAsyncioTestCase): @@ -40,20 +42,24 @@ def setUp(self) -> None: """Create a server, a line and an event recorder.""" self.devices = load_devices() self.server = MiniSIPServer( - {SIP_USER: SIP_PASSWORD}, host=HA_HOST, call_host=HA_HOST + {SIP_USER: SIP_PASSWORD}, + host=HA_HOST, + call_host=HA_HOST, + rtp_endpoint=fake_rtp_endpoint, ) self.transport = FakeTransport() self.server.transport = self.transport self.events: list[LineEvent] = [] self.line = self._line(auto_answer=True) - def _line(self, *, auto_answer: bool) -> GatewayLine: + def _line(self, *, auto_answer: bool, **kwargs: object) -> GatewayLine: line = GatewayLine( self.server, user=SIP_USER, number=HA_NUMBER, auto_answer=auto_answer, devices=self.devices.values, + **kwargs, ) line.subscribe(self.events.append) return line @@ -119,6 +125,85 @@ async def test_incoming_call_rejected_without_auto_answer(self) -> None: assert self._sent_status_lines() == ["SIP/2.0 486 Busy Here"] assert not self.line.calls + def _ring_along_line(self, ring_timeout: float = 60) -> GatewayLine: + self.server.on_incoming_call.clear() + self.events.clear() + return self._line(auto_answer=False, ring_along=True, ring_timeout=ring_timeout) + + async def test_ring_along_call_rings_until_answered(self) -> None: + """Without auto answer, ring-along keeps the call ringing for answer().""" + self.line = self._ring_along_line() + + await self._receive(invite("call-5", "*111", HA_NUMBER)) + + assert self._sent_status_lines() == ["SIP/2.0 180 Ringing"] + incoming = self.events[0] + assert isinstance(incoming, IncomingCall) + assert not incoming.answered + assert self.line.ringing["call-5"].involves("door-front") + + await self.line.answer("call-5") + await self._receive(ack("call-5")) + + assert self._sent_status_lines()[-1] == "SIP/2.0 200 OK" + assert not self.line.ringing + assert isinstance(self.events[-1], CallStarted) + assert self.line.calls["call-5"].rtp_session().codec == "PCMA" + + async def test_cancelled_ringing_call_cannot_be_answered(self) -> None: + """A handset answering first (CANCEL) ends the ringing call.""" + self.line = self._ring_along_line() + await self._receive(invite("call-6", "*111", HA_NUMBER)) + + await self._receive(cancel("call-6")) + + assert not self.line.ringing + with pytest.raises(NotRingingError): + await self.line.answer("call-6") + + async def test_unanswered_ringing_call_is_rejected_after_the_timeout(self) -> None: + """Nobody answering ends the call with 486 Busy Here.""" + self.line = self._ring_along_line(ring_timeout=0.01) + await self._receive(invite("call-7", "*111", HA_NUMBER)) + + await asyncio.sleep(0.05) + + assert self._sent_status_lines()[-1] == "SIP/2.0 486 Busy Here" + assert not self.line.ringing + + async def test_stop_cancels_ring_timers(self) -> None: + """Stopping the line forgets ringing calls without rejecting them.""" + self.line = self._ring_along_line(ring_timeout=0.01) + await self._receive(invite("call-8", "*111", HA_NUMBER)) + + await self.line.stop() + await asyncio.sleep(0.05) + + assert self._sent_status_lines() == ["SIP/2.0 180 Ringing"] + assert not self.line.ringing + + async def test_outgoing_offer_is_sendrecv_with_both_codecs(self) -> None: + """We offer PCMA and PCMU, sendrecv, on the call's RTP port.""" + task = await self._answer_outgoing("SIP/2.0 200 OK") + await task + + offer = next( + data.decode() + for data, _ in self.transport.sent + if data.startswith(b"INVITE ") + ) + assert "m=audio 5004 RTP/AVP 8 0\r\n" in offer + assert "a=sendrecv" in offer + assert "sendonly" not in offer + + async def test_failed_outgoing_call_frees_its_rtp_port(self) -> None: + """A busy outgoing call closes its RTP session.""" + task = await self._answer_outgoing("SIP/2.0 486 Busy Here") + with pytest.raises(CallFailedError): + await task + + assert not self.server.rtp_sessions + async def test_call_requires_registration(self) -> None: """Calling before the gateway registered raises NotRegisteredError.""" with pytest.raises(NotRegisteredError): diff --git a/tests/test_minisip_audio.py b/tests/test_minisip_audio.py index 6c2e026..d151c8a 100644 --- a/tests/test_minisip_audio.py +++ b/tests/test_minisip_audio.py @@ -1,5 +1,6 @@ """Tests for the MiniSIP audio handling.""" +import asyncio import io import unittest @@ -8,10 +9,35 @@ from custom_components.sg_150.sip.minisip import ( MiniSIPServer, - SIPDialog, convert_audio_to_pcma, ) -from tests.utils import FakeTransport +from custom_components.sg_150.sip.rtp import PCMA, RtpPacket, g711_encode +from tests.utils import FakeTransport, fake_rtp_endpoint + + +def _invite(call_id: str) -> dict[str, str]: + """Return a parsed INVITE offering PCMA on 192.168.1.20:4000.""" + return { + "Via": "SIP/2.0/UDP 192.168.1.20:5060;branch=z123", + "From": "", + "To": "", + "Call-ID": call_id, + "CSeq": "1 INVITE", + "Contact": "", + "Content-Type": "application/sdp", + ":body": ( + "v=0\r\n" + "o=- 1 1 IN IP4 192.168.1.20\r\n" + "s=call\r\n" + "c=IN IP4 192.168.1.20\r\n" + "t=0 0\r\n" + "m=audio 4000 RTP/AVP 8\r\n" + "a=rtpmap:8 PCMA/8000\r\n" + "a=ptime:20\r\n" + "a=sendrecv\r\n" + ), + ":start_line": "INVITE sip:456@127.0.0.1 SIP/2.0", + } class MiniSIPAudioTests(unittest.IsolatedAsyncioTestCase): @@ -41,39 +67,38 @@ async def test_parse_sdp_media_from_invite_response(self) -> None: assert media["ptime"] == 20 assert media["direction"] == "sendrecv" - async def test_send_rtp_audio_uses_negotiated_media_port(self) -> None: - """Test sending RTP audio uses the negotiated media port.""" + async def test_play_audio_is_sent_from_the_rtp_port_to_the_negotiated_one( + self, + ) -> None: + """Played audio leaves the call's RTP socket, not the SIP socket.""" server = MiniSIPServer( - {"user": "pass"}, host="127.0.0.1", call_host="127.0.0.1" - ) - server.active_calls["call-123"] = SIPDialog( - call_id="call-123", - direction="incoming", - addr=("192.168.1.20", 5060), - from_header="", - to_header="", - contact=None, - media={ - "ip": "192.168.1.20", - "port": 4000, - "payload_type": 8, - "codec": "PCMA", - "ptime": 20, - "direction": "sendrecv", - }, + {"user": "pass"}, + host="127.0.0.1", + call_host="127.0.0.1", + rtp_endpoint=fake_rtp_endpoint, ) server.transport = FakeTransport() + await server.handle_invite(_invite("call-123"), ("192.168.1.20", 5060)) + await server.handle_ack({"Call-ID": "call-123"}, ("192.168.1.20", 5060)) + session = server.rtp_session("call-123") + sip_sent = len(server.transport.sent) - pcm = b"\x00\x00" * 160 - - await server.send_rtp_audio("call-123", pcm) + wav = io.BytesIO() + sf.write(wav, np.full(320, 1000, dtype=np.int16), 8000, format="WAV") + await asyncio.wait_for( + server.play_audio("call-123", "audio/wav", wav.getvalue()), timeout=1 + ) - assert len(server.transport.sent) == 1 - data, addr = server.transport.sent[0] - assert addr == ("192.168.1.20", 4000) - assert data[0] == 128 # RTP version 2, no marker on first packet - assert data[1] == 8 # payload type 8 (PCMA) - assert len(data) > 12 + assert len(server.transport.sent) == sip_sent + assert {addr for _, addr in session.transport.sent} == {("192.168.1.20", 4000)} + assert session.transport.local_addr == ("127.0.0.1", 5004) + packets = [RtpPacket.parse(data) for data, _ in session.transport.sent] + assert {p.payload_type for p in packets} == {8} + assert packets[0].marker + clip = g711_encode(np.full(160, 1000), PCMA) + assert [p.payload for p in packets].count(clip) == 2 + sequences = [p.sequence for p in packets] + assert sequences == [(sequences[0] + i) & 0xFFFF for i in range(len(packets))] async def test_convert_audio_to_pcma_from_wav(self) -> None: """Test converting audio from WAV MIME data to PCMA.""" @@ -102,33 +127,14 @@ async def test_convert_audio_to_pcma_from_mp3(self) -> None: async def test_handle_invite_accepts_call_and_parses_sdp(self) -> None: """Test a received INVITE is accepted and its SDP media is stored.""" server = MiniSIPServer( - {"user": "pass"}, host="127.0.0.1", call_host="127.0.0.1" + {"user": "pass"}, + host="127.0.0.1", + call_host="127.0.0.1", + rtp_endpoint=fake_rtp_endpoint, ) server.transport = FakeTransport() - invite = { - "Via": "SIP/2.0/UDP 192.168.1.20:5060;branch=z123", - "From": "", - "To": "", - "Call-ID": "call-123", - "CSeq": "1 INVITE", - "Contact": "", - "Content-Type": "application/sdp", - ":body": ( - "v=0\r\n" - "o=- 1 1 IN IP4 192.168.1.20\r\n" - "s=call\r\n" - "c=IN IP4 192.168.1.20\r\n" - "t=0 0\r\n" - "m=audio 4000 RTP/AVP 8\r\n" - "a=rtpmap:8 PCMA/8000\r\n" - "a=ptime:20\r\n" - "a=sendrecv\r\n" - ), - ":start_line": "INVITE sip:456@127.0.0.1 SIP/2.0", - } - - await server.handle_invite(invite, ("192.168.1.20", 5060)) + await server.handle_invite(_invite("call-123"), ("192.168.1.20", 5060)) await server.handle_ack({"Call-ID": "call-123"}, ("192.168.1.20", 5060)) assert "call-123" in server.active_calls diff --git a/tests/test_minisip_behavior.py b/tests/test_minisip_behavior.py index acedb3e..5d78ae7 100644 --- a/tests/test_minisip_behavior.py +++ b/tests/test_minisip_behavior.py @@ -1,16 +1,22 @@ """Behavioral tests for the MiniSIP server.""" +import asyncio import re import unittest +from unittest.mock import patch +import pytest + +from custom_components.sg_150.sip import minisip from custom_components.sg_150.sip.minisip import ( CallContext, + IncomingCallDecision, MiniSIPServer, SIPDialog, md5_hex, ) -from tests.sip_messages import GATEWAY, HA_NUMBER, ack, bye, invite, sip -from tests.utils import FakeTransport +from tests.sip_messages import GATEWAY, HA_NUMBER, ack, bye, cancel, invite, sip +from tests.utils import FakeTransport, fake_rtp_endpoint class MiniSIPBehaviorTests(unittest.IsolatedAsyncioTestCase): @@ -23,6 +29,7 @@ async def test_register_with_valid_digest_registers_user(self) -> None: host="127.0.0.1", call_host="127.0.0.1", realm="example", + rtp_endpoint=fake_rtp_endpoint, ) server.transport = FakeTransport() @@ -72,6 +79,7 @@ async def test_handle_invite_accepts_call_and_tracks_active_session(self) -> Non {"alice": "secret"}, host="127.0.0.1", call_host="127.0.0.1", + rtp_endpoint=fake_rtp_endpoint, ) server.transport = FakeTransport() @@ -110,6 +118,7 @@ async def test_handle_bye_closes_call_and_returns_ok(self) -> None: {"alice": "secret"}, host="127.0.0.1", call_host="127.0.0.1", + rtp_endpoint=fake_rtp_endpoint, ) server.transport = FakeTransport() server.active_calls["call-456"] = SIPDialog( @@ -136,7 +145,11 @@ class MiniSIPDatagramTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: """Create a server with a fake transport and record call events.""" self.server = MiniSIPServer( - {"alice": "secret"}, host="127.0.0.1", call_host="127.0.0.1", port=5070 + {"alice": "secret"}, + host="127.0.0.1", + call_host="127.0.0.1", + port=5070, + rtp_endpoint=fake_rtp_endpoint, ) self.server.transport = FakeTransport() self.established: list[str] = [] @@ -190,7 +203,7 @@ async def test_options_is_answered(self) -> None: response = self._sent()[-1] assert response.startswith("SIP/2.0 200 OK") - assert "Allow: REGISTER, INVITE, ACK, BYE, OPTIONS" in response + assert "Allow: REGISTER, INVITE, ACK, BYE, CANCEL, OPTIONS" in response async def test_options_with_old_cseq_is_rejected(self) -> None: """An out-of-order OPTIONS gets a 400 that is actually sent.""" @@ -200,6 +213,186 @@ async def test_options_with_old_cseq_is_rejected(self) -> None: assert len(self._sent()) == 2 assert self._sent()[-1].startswith("SIP/2.0 400 Bad Request") + def _ring(self) -> None: + async def ring(_: CallContext) -> IncomingCallDecision: + return IncomingCallDecision.RING + + self.server.add_listener("on_incoming_call", ring) + + async def test_answer_sdp_offers_sendrecv_on_the_calls_rtp_port(self) -> None: + """Our 200 OK answers PCMA, sendrecv, on the port the RTP session has.""" + await self.server.handle_datagram(invite("c4", "*111", HA_NUMBER), GATEWAY) + + answer = self._sent()[-1] + assert "m=audio 5004 RTP/AVP 8\r\n" in answer + assert "a=sendrecv" in answer + assert self.server.rtp_sessions["c4"].remote == ("192.168.1.10", 4000) + + async def test_pcmu_offer_is_answered_with_pcmu(self) -> None: + """A gateway offering only PCMU gets a PCMU session.""" + data = invite("c5", "*111", HA_NUMBER).replace( + b"RTP/AVP 8\r\na=rtpmap:8 PCMA/8000", b"RTP/AVP 0" + ) + + await self.server.handle_datagram(data, GATEWAY) + + assert "m=audio 5004 RTP/AVP 0\r\n" in self._sent()[-1] + assert self.server.rtp_sessions["c5"].codec == "PCMU" + + async def test_rtp_session_runs_from_ack_until_bye(self) -> None: + """The RTP clock starts on ACK, and BYE closes it and frees the port.""" + await self.server.handle_datagram(invite("c6", "*111", HA_NUMBER), GATEWAY) + session = self.server.rtp_sessions["c6"] + assert not session.running + + await self.server.handle_datagram(ack("c6"), GATEWAY) + assert session.running + + await self.server.handle_datagram(bye("c6"), GATEWAY) + assert session.transport is None + assert "c6" not in self.server.rtp_sessions + + async def test_ended_call_gives_its_rtp_port_back(self) -> None: + """With a single RTP port, a second call can use it after the first.""" + self.server = MiniSIPServer( + {}, + call_host="127.0.0.1", + rtp_ports=range(5004, 5005), + rtp_endpoint=fake_rtp_endpoint, + ) + self.server.transport = FakeTransport() + await self.server.handle_datagram(invite("c6", "*111", HA_NUMBER), GATEWAY) + await self.server.handle_datagram(ack("c6"), GATEWAY) + await self.server.handle_datagram(bye("c6"), GATEWAY) + + await self.server.handle_datagram(invite("c6b", "*111", HA_NUMBER), GATEWAY) + + assert "m=audio 5004 " in self._sent()[-1] + + async def test_no_free_rtp_port_answers_service_unavailable(self) -> None: + """When every RTP port is in use, the call is refused with 503.""" + self.server = MiniSIPServer( + {}, + call_host="127.0.0.1", + rtp_ports=range(0), + rtp_endpoint=fake_rtp_endpoint, + ) + self.server.transport = FakeTransport() + + await self.server.handle_datagram(invite("c7", "*111", HA_NUMBER), GATEWAY) + + assert self._sent()[-1].startswith("SIP/2.0 503 Service Unavailable") + assert "c7" not in self.server.pending_acks + + async def test_ok_is_resent_until_ack(self) -> None: + """A lost 200 OK or ACK is covered by resending the 200 OK.""" + with ( + patch.object(minisip, "TIMER_T1", 0.01), + patch.object(minisip, "TIMER_T2", 0.02), + ): + await self.server.handle_datagram(invite("c8", "*111", HA_NUMBER), GATEWAY) + await asyncio.sleep(0.05) + await self.server.handle_datagram(ack("c8"), GATEWAY) + sent = len(self._sent()) + await asyncio.sleep(0.05) + + oks = [m for m in self._sent() if m.startswith("SIP/2.0 200 OK")] + assert len(oks) >= 3 + assert len(self._sent()) == sent + assert self.established == ["c8"] + + async def test_missing_ack_gives_up_and_frees_the_rtp_port(self) -> None: + """Without an ACK after 64 * T1 the call is dropped.""" + with ( + patch.object(minisip, "TIMER_T1", 0.001), + patch.object(minisip, "TIMER_T2", 0.004), + ): + await self.server.handle_datagram(invite("c9", "*111", HA_NUMBER), GATEWAY) + await asyncio.sleep(0.3) + + assert "c9" not in self.server.pending_acks + assert "c9" not in self.server.rtp_sessions + await self.server.handle_datagram(ack("c9"), GATEWAY) + assert self.established == [] + + async def test_retransmitted_invite_is_ignored(self) -> None: + """A second INVITE with the same Call-ID doesn't start a second call.""" + await self.server.handle_datagram(invite("c10", "*111", HA_NUMBER), GATEWAY) + sent = len(self._sent()) + + await self.server.handle_datagram(invite("c10", "*111", HA_NUMBER), GATEWAY) + + assert len(self._sent()) == sent + assert list(self.server.rtp_sessions) == ["c10"] + + async def test_ringing_call_is_answered_later(self) -> None: + """RING sends only 180 Ringing; answer_call sends the 200 OK.""" + self._ring() + + await self.server.handle_datagram(invite("r1", "*111", HA_NUMBER), GATEWAY) + + assert [m.split("\r\n")[0] for m in self._sent()] == ["SIP/2.0 180 Ringing"] + assert "r1" in self.server.ringing + assert "r1" not in self.server.rtp_sessions + + await self.server.answer_call("r1") + await self.server.handle_datagram(ack("r1"), GATEWAY) + + assert self._sent()[-1].startswith("SIP/2.0 200 OK") + assert ";tag=minisip" in self._sent()[-1] + assert self.established == ["r1"] + + async def test_ringing_call_can_be_rejected(self) -> None: + """reject_call answers 486 Busy Here.""" + self._ring() + await self.server.handle_datagram(invite("r2", "*111", HA_NUMBER), GATEWAY) + + await self.server.reject_call("r2") + + assert self._sent()[-1].startswith("SIP/2.0 486 Busy Here") + assert not self.server.ringing + with pytest.raises(ValueError, match="no ringing call"): + await self.server.answer_call("r2") + + async def test_cancel_ends_a_ringing_call(self) -> None: + """CANCEL is confirmed with 200, and the INVITE ends with 487.""" + self._ring() + cancelled: list[str] = [] + + async def on_cancelled(call_id: str) -> None: + cancelled.append(call_id) + + self.server.add_listener("on_call_cancelled", on_cancelled) + await self.server.handle_datagram(invite("r3", "*111", HA_NUMBER), GATEWAY) + + await self.server.handle_datagram(cancel("r3"), GATEWAY) + + statuses = [m.split("\r\n")[0] for m in self._sent()] + assert statuses[-2:] == ["SIP/2.0 200 OK", "SIP/2.0 487 Request Terminated"] + assert "CSeq: 1 CANCEL" in self._sent()[-2] + assert "CSeq: 1 INVITE" in self._sent()[-1] + assert cancelled == ["r3"] + assert not self.server.ringing + + async def test_cancel_after_answer_or_for_unknown_calls(self) -> None: + """CANCEL of an answered call is a no-op; an unknown one gets 481.""" + await self.server.handle_datagram(invite("r4", "*111", HA_NUMBER), GATEWAY) + + await self.server.handle_datagram(cancel("r4"), GATEWAY) + assert self._sent()[-1].startswith("SIP/2.0 200 OK") + assert "r4" in self.server.pending_acks + + await self.server.handle_datagram(cancel("nope"), GATEWAY) + assert self._sent()[-1].startswith("SIP/2.0 481 ") + + async def test_stop_closes_rtp_sessions(self) -> None: + """Stopping the server closes all call audio.""" + await self.server.handle_datagram(invite("c11", "*111", HA_NUMBER), GATEWAY) + + await self.server.stop() + + assert not self.server.rtp_sessions + @staticmethod def _options(call_id: str, cseq: int) -> bytes: return sip( diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..a2e5af6 --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,34 @@ +"""Tests for validating the integration options.""" + +import unittest + +from custom_components.sg_150.config_flow import validate_options +from custom_components.sg_150.const import CONF_RTP_PORT_MAX, CONF_RTP_PORT_MIN + + +class ValidateOptionsTests(unittest.TestCase): + """The RTP port range must be a sensible range.""" + + def test_default_and_valid_ranges_pass(self) -> None: + """Missing values fall back to 5004-5013; a single port is fine.""" + assert validate_options({}) == {} + assert ( + validate_options({CONF_RTP_PORT_MIN: "6000", CONF_RTP_PORT_MAX: "6000"}) + == {} + ) + + def test_bad_ranges_fail(self) -> None: + """Non-numbers, reversed ranges and privileged ports are refused.""" + for low, high in ( + ("abc", "5013"), + ("5013", "5004"), + ("80", "90"), + ("5004", "70000"), + ): + assert validate_options( + {CONF_RTP_PORT_MIN: low, CONF_RTP_PORT_MAX: high} + ) == {"base": "rtp_ports"} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rtp.py b/tests/test_rtp.py new file mode 100644 index 0000000..500ed5f --- /dev/null +++ b/tests/test_rtp.py @@ -0,0 +1,369 @@ +"""Tests for the RTP media layer.""" + +import asyncio +import io +import unittest + +import numpy as np +import pytest +import soundfile as sf + +from custom_components.sg_150.sip.rtp import ( + MAX_LIVE_BACKLOG, + PCMA, + PCMU, + SAMPLES_PER_FRAME, + JitterBuffer, + RtpPacket, + RtpPortPool, + RtpSession, + g711_decode, + g711_encode, +) +from tests.utils import FakeTransport + +GATEWAY_RTP = ("10.5.1.6", 10024) +ALL_SAMPLES = np.arange(-32768, 32768, dtype=np.int16) + + +def _libsndfile(samples: np.ndarray, subtype: str) -> bytes: + buffer = io.BytesIO() + sf.write(buffer, samples, 8000, format="RAW", subtype=subtype) + return buffer.getvalue() + + +def _packet(sequence: int, payload: bytes = b"\xd5" * 160, pt: int = PCMA) -> bytes: + return RtpPacket(pt, sequence, sequence * 160, 1234, payload).serialize() + + +class G711Tests(unittest.TestCase): + """The G.711 tables match libsndfile, which the clip conversion uses.""" + + def test_encoding_is_as_accurate_as_libsndfile(self) -> None: + """ + Every int16 value encodes like libsndfile, or to a neighbouring code. + + The two differ only in how negative values on a step boundary round, so + the worst-case error over the whole range is the same. + """ + for pt, subtype in ((PCMA, "ALAW"), (PCMU, "ULAW")): + ours = g711_encode(ALL_SAMPLES, pt) + theirs = _libsndfile(ALL_SAMPLES, subtype) + differ = np.frombuffer(ours, np.uint8) != np.frombuffer(theirs, np.uint8) + + assert (ALL_SAMPLES[differ] < 0).all() + assert differ.mean() < 0.01 + error = np.abs(g711_decode(ours, pt).astype(int) - ALL_SAMPLES) + reference = np.abs(g711_decode(theirs, pt).astype(int) - ALL_SAMPLES) + assert error.max() == reference.max() + + def test_decoding_matches_libsndfile(self) -> None: + """Every code decodes to the same linear value.""" + codes = bytes(range(256)) + for pt, subtype in ((PCMA, "ALAW"), (PCMU, "ULAW")): + expected, _ = sf.read( + io.BytesIO(codes), + dtype="int16", + format="RAW", + subtype=subtype, + samplerate=8000, + channels=1, + ) + np.testing.assert_array_equal(g711_decode(codes, pt), expected) + + def test_silence(self) -> None: + """Silence is the usual 0xD5 for A-law and 0xFF for µ-law.""" + assert g711_encode(np.zeros(2), PCMA) == b"\xd5\xd5" + assert g711_encode(np.zeros(2), PCMU) == b"\xff\xff" + + +class RtpPacketTests(unittest.TestCase): + """RTP packet parsing and serialization.""" + + def test_round_trip(self) -> None: + """A serialized packet parses back to the same packet.""" + packet = RtpPacket(8, 65535, 2**32 - 1, 0xDEADBEEF, b"abc", marker=True) + + assert RtpPacket.parse(packet.serialize()) == packet + + def test_parse_skips_csrcs_extension_and_padding(self) -> None: + """The payload starts after CSRCs and the extension, and ends before padding.""" + header = bytes([0x80 | 0x20 | 0x10 | 1, 8]) + bytes(10) + csrc = bytes(4) + extension = b"\xbe\xde\x00\x01" + bytes(4) + padding = b"\x00\x00\x03" + + packet = RtpPacket.parse(header + csrc + extension + b"voice" + padding) + + assert packet.payload == b"voice" + + def test_parse_rejects_other_datagrams(self) -> None: + """Short datagrams and other RTP versions are not packets.""" + assert RtpPacket.parse(b"\x80\x08") is None + assert RtpPacket.parse(bytes([0x40]) + bytes(20)) is None + + +class JitterBufferTests(unittest.TestCase): + """The jitter buffer reorders, drops late packets and keeps its delay.""" + + def test_waits_for_depth_then_plays_in_order(self) -> None: + """Playout starts after `depth` packets, in sequence order.""" + buffer = JitterBuffer(depth=3) + buffer.push(11, b"b") + buffer.push(10, b"a") + assert buffer.pop() is None + + buffer.push(12, b"c") + + assert [buffer.pop() for _ in range(3)] == [b"a", b"b", b"c"] + + def test_lost_packet_pops_none_and_late_packet_is_dropped(self) -> None: + """A gap plays as None; the missing packet arriving later is ignored.""" + buffer = JitterBuffer(depth=2) + for seq in (1, 3, 4): + buffer.push(seq, bytes([seq])) + + assert buffer.pop() == b"\x01" + assert buffer.pop() is None + buffer.push(2, b"\x02") + assert buffer.pop() == b"\x03" + assert buffer.pop() == b"\x04" + + def test_sequence_wrap_around(self) -> None: + """Sequence 0 follows 65535.""" + buffer = JitterBuffer(depth=2) + buffer.push(0, b"b") + buffer.push(65535, b"a") + + assert [buffer.pop(), buffer.pop()] == [b"a", b"b"] + + def test_primes_again_after_the_stream_pauses(self) -> None: + """After `depth` empty pops, a restarted stream is buffered afresh.""" + buffer = JitterBuffer(depth=2) + buffer.push(1, b"a") + buffer.push(2, b"b") + assert [buffer.pop(), buffer.pop()] == [b"a", b"b"] + assert [buffer.pop(), buffer.pop()] == [None, None] + + buffer.push(500, b"x") + assert buffer.pop() is None + buffer.push(501, b"y") + + assert [buffer.pop(), buffer.pop()] == [b"x", b"y"] + + def test_skips_ahead_when_the_sender_runs_fast(self) -> None: + """More than twice the depth buffered skips ahead to `depth` packets.""" + buffer = JitterBuffer(depth=2) + buffer.push(1, b"1") + buffer.push(2, b"2") + assert buffer.pop() == b"1" + for seq in range(3, 7): + buffer.push(seq, str(seq).encode()) + + assert [buffer.pop(), buffer.pop()] == [b"5", b"6"] + + +class RtpSessionTests(unittest.IsolatedAsyncioTestCase): + """RtpSession sends one frame and plays one frame out per tick.""" + + def _session(self, **kwargs: object) -> tuple[RtpSession, FakeTransport]: + session = RtpSession(**kwargs) + transport = FakeTransport() + session.connection_made(transport) + return session, transport + + def _sent(self, transport: FakeTransport) -> list[RtpPacket]: + return [RtpPacket.parse(data) for data, _ in transport.sent] + + def test_sends_silence_when_idle_with_advancing_headers(self) -> None: + """Idle ticks send marked-then-unmarked silence, 160 samples apart.""" + session, transport = self._session(remote=GATEWAY_RTP) + + session.tick() + session.tick() + + first, second = self._sent(transport) + assert first.payload == b"\xd5" * 160 + assert first.marker + assert not second.marker + assert second.sequence == (first.sequence + 1) & 0xFFFF + assert second.timestamp == (first.timestamp + 160) & 0xFFFFFFFF + assert first.ssrc == second.ssrc == session.ssrc + assert {addr for _, addr in transport.sent} == {GATEWAY_RTP} + + def test_ssrc_and_sequence_are_random_per_session(self) -> None: + """Two sessions do not share the old fixed SSRC.""" + sessions = [RtpSession() for _ in range(4)] + + assert len({s.ssrc for s in sessions}) > 1 + assert 0x12345678 not in {s.ssrc for s in sessions} + + def test_learns_the_remote_from_the_first_packet(self) -> None: + """Without an SDP address, audio goes back to where packets come from.""" + session, transport = self._session() + session.tick() + assert transport.sent == [] + + session.datagram_received(_packet(1), GATEWAY_RTP) + session.datagram_received(_packet(2), ("10.9.9.9", 1)) + session.tick() + + assert transport.sent[0][1] == GATEWAY_RTP + + def test_sdp_remote_wins_over_packet_source(self) -> None: + """The SDP address is kept even if packets come from elsewhere.""" + session, transport = self._session(remote=("10.5.1.6", 4000)) + + session.datagram_received(_packet(1), GATEWAY_RTP) + session.tick() + + assert transport.sent[0][1] == ("10.5.1.6", 4000) + + async def test_written_pcm_is_sent_in_frames_then_drained(self) -> None: + """PCM is split into 20 ms frames, padded, and drain() completes after.""" + session, transport = self._session(remote=GATEWAY_RTP) + session.write_pcm(np.full(SAMPLES_PER_FRAME + 10, 1000)) + + drained = asyncio.create_task(session.drain()) + await asyncio.sleep(0) + assert not drained.done() + for _ in range(3): + session.tick() + await asyncio.wait_for(drained, timeout=1) + + tone = g711_encode(np.full(SAMPLES_PER_FRAME, 1000), PCMA) + padded = g711_encode( + np.r_[np.full(10, 1000), np.zeros(SAMPLES_PER_FRAME - 10)], PCMA + ) + assert [p.payload for p in self._sent(transport)] == [ + tone, + padded, + b"\xd5" * 160, + ] + + def test_live_audio_backlog_is_capped(self) -> None: + """A live source never queues more than MAX_LIVE_BACKLOG frames.""" + session, transport = self._session(remote=GATEWAY_RTP) + frames = MAX_LIVE_BACKLOG + 5 + pcm = np.repeat(np.arange(frames, dtype=np.int16) * 100, SAMPLES_PER_FRAME) + + session.write_pcm(pcm, live=True) + for _ in range(frames): + session.tick() + + payloads = [p.payload for p in self._sent(transport)] + first_kept = g711_encode(np.full(SAMPLES_PER_FRAME, 500), PCMA) + assert payloads[0] == first_kept + assert payloads[MAX_LIVE_BACKLOG:] == [b"\xd5" * 160] * 5 + + async def test_live_audio_mixes_over_a_clip_without_cutting_it(self) -> None: + """Talking during a clip mixes in; the whole clip still plays.""" + session, transport = self._session(remote=GATEWAY_RTP) + clip_frames = MAX_LIVE_BACKLOG * 3 + session.write_pcm(np.full(clip_frames * SAMPLES_PER_FRAME, 1000)) + drained = asyncio.create_task(session.drain()) + + session.tick() + session.write_pcm(np.full(2 * SAMPLES_PER_FRAME, 30000), live=True) + for _ in range(clip_frames - 2): + session.tick() + await asyncio.sleep(0) + assert not drained.done() # the clip's last frame is still queued + + session.tick() + await asyncio.wait_for(drained, timeout=1) + payloads = [p.payload for p in self._sent(transport)] + clip = g711_encode(np.full(SAMPLES_PER_FRAME, 1000), PCMA) + loud = g711_encode(np.full(SAMPLES_PER_FRAME, 31000), PCMA) + mixed_to_limit = g711_encode(np.full(SAMPLES_PER_FRAME, 32767), PCMA) + assert len(payloads) == clip_frames + assert payloads[0] == clip + # 1000 + 30000 stays in range; it isn't the clipped maximum. + assert payloads[1:3] == [loud, loud] + assert loud != mixed_to_limit + assert payloads[3:] == [clip] * (clip_frames - 3) + + def test_mixing_clips_at_the_int16_range(self) -> None: + """Loud clip plus loud live audio saturates instead of wrapping.""" + session, transport = self._session(remote=GATEWAY_RTP) + session.write_pcm(np.full(SAMPLES_PER_FRAME, -30000)) + session.write_pcm(np.full(SAMPLES_PER_FRAME, -30000), live=True) + + session.tick() + + assert self._sent(transport)[0].payload == g711_encode( + np.full(SAMPLES_PER_FRAME, -32768), PCMA + ) + + async def test_live_audio_alone_does_not_hold_drain(self) -> None: + """drain() is for clips; queued live audio leaves it complete.""" + session, _ = self._session(remote=GATEWAY_RTP) + + session.write_pcm(np.full(SAMPLES_PER_FRAME, 1000), live=True) + + await asyncio.wait_for(session.drain(), timeout=0.1) + + def test_received_audio_is_decoded_for_listeners(self) -> None: + """Listeners get decoded audio once buffered, and silence before.""" + session, _ = self._session(payload_type=PCMU) + frames: list[np.ndarray] = [] + unsubscribe = session.subscribe(frames.append) + tone = g711_encode(np.full(SAMPLES_PER_FRAME, 1000), PCMU) + for seq in range(3): + session.datagram_received(_packet(seq, tone, PCMU), GATEWAY_RTP) + session.datagram_received(_packet(9, b"\x00", 101), GATEWAY_RTP) + + session.tick() + unsubscribe() + session.tick() + + assert len(frames) == 1 + np.testing.assert_array_equal(frames[0], g711_decode(tone, PCMU)) + + def test_changing_the_codec_changes_silence(self) -> None: + """Silence follows the negotiated payload type.""" + session, transport = self._session(remote=GATEWAY_RTP) + session.payload_type = PCMU + + session.tick() + + assert self._sent(transport)[0].payload == b"\xff" * 160 + + def test_unsupported_codec_is_refused(self) -> None: + """Only PCMA and PCMU can be sent.""" + with pytest.raises(ValueError, match="Unsupported RTP payload type"): + RtpSession(payload_type=9) + + async def test_clock_ticks_every_20ms_until_closed(self) -> None: + """start() sends about one packet per 20 ms; close() stops and closes.""" + session, transport = self._session(remote=GATEWAY_RTP) + closed = [] + transport.close = lambda: closed.append(True) + + session.start() + await asyncio.sleep(0.105) + session.close() + sent = len(transport.sent) + await asyncio.sleep(0.05) + + assert 4 <= sent <= 7 + assert len(transport.sent) == sent + assert closed == [True] + assert session.transport is None + + +class RtpPortPoolTests(unittest.TestCase): + """Ports are handed out once and come back on release.""" + + def test_acquire_and_release(self) -> None: + """The pool runs out, and released ports are reused.""" + pool = RtpPortPool(range(5004, 5006)) + + assert [pool.acquire(), pool.acquire(), pool.acquire()] == [5004, 5005, None] + pool.release(5004) + pool.release(5004) + assert [pool.acquire(), pool.acquire()] == [5004, None] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_talkback.py b/tests/test_talkback.py new file mode 100644 index 0000000..951644b --- /dev/null +++ b/tests/test_talkback.py @@ -0,0 +1,326 @@ +""" +Tests for Talkback, with an in-process aiortc peer as the browser. + +The browser offers like the Home Assistant frontend: a recvonly audio +transceiver to watch, a sendrecv one with a microphone to talk, and a recvonly +video transceiver. The SIP side is a MiniSIPServer on fake transports. +""" + +import asyncio +import fractions +import time +import unittest +from collections.abc import AsyncIterator +from unittest.mock import patch + +import av +import numpy as np +import pytest +from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription +from aiortc.mediastreams import MediaStreamError, MediaStreamTrack +from homeassistant.components.camera import WebRTCAnswer, WebRTCMessage + +from custom_components.sg_150.line import CallEnded, GatewayLine, LineEvent +from custom_components.sg_150.sip.minisip import MiniSIPServer +from custom_components.sg_150.sip.rtp import ( + PCMA, + SAMPLES_PER_FRAME, + RtpPacket, + g711_decode, + g711_encode, +) +from custom_components.sg_150.talkback import Talkback, offer_sends_audio +from tests.sip_messages import ( + GATEWAY, + HA_HOST, + HA_NUMBER, + SIP_PASSWORD, + SIP_USER, + ack, + invite, +) +from tests.utils import FakeTransport, fake_rtp_endpoint, load_devices + +DOOR = "door-front" +TIMEOUT = 10 +TONE = 4000 + + +def _jpeg(width: int = 320, height: int = 240) -> bytes: + encoder = av.CodecContext.create("mjpeg", "w") + encoder.width, encoder.height = width, height + encoder.pix_fmt = "yuvj420p" + encoder.time_base = fractions.Fraction(1, 15) + frame = av.VideoFrame.from_ndarray( + np.full((height, width, 3), 128, dtype=np.uint8), format="rgb24" + ).reformat(format="yuvj420p") + return b"".join(bytes(p) for p in [*encoder.encode(frame), *encoder.encode()]) + + +async def _camera() -> AsyncIterator[bytes]: + jpeg = _jpeg() + while True: + yield jpeg + await asyncio.sleep(1 / 30) + + +class MicrophoneTrack(MediaStreamTrack): + """A browser microphone playing a constant tone, 20 ms frames at 8 kHz.""" + + kind = "audio" + + def __init__(self) -> None: + """Start at timestamp zero.""" + super().__init__() + self._pts = 0 + self._start = time.monotonic() + + async def recv(self) -> av.AudioFrame: + """Return the next frame of the tone.""" + if self.readyState != "live": + raise MediaStreamError + self._pts += SAMPLES_PER_FRAME + await asyncio.sleep(max(0, self._start + self._pts / 8000 - time.monotonic())) + frame = av.AudioFrame.from_ndarray( + np.full((1, SAMPLES_PER_FRAME), TONE, dtype=np.int16), + format="s16", + layout="mono", + ) + frame.sample_rate = 8000 + frame.pts = self._pts + frame.time_base = fractions.Fraction(1, 8000) + return frame + + +class Browser: + """An aiortc peer that offers like the Home Assistant frontend.""" + + def __init__(self, *, talk: bool) -> None: + """Create the peer, with a microphone if it talks.""" + # No STUN servers: everything runs on this host. + self.pc = RTCPeerConnection(RTCConfiguration(iceServers=[])) + if talk: + self.pc.addTransceiver(MicrophoneTrack(), direction="sendrecv") + else: + self.pc.addTransceiver("audio", direction="recvonly") + self.pc.addTransceiver("video", direction="recvonly") + self.frames: dict[str, list[av.frame.Frame]] = {"audio": [], "video": []} + self._tasks: list[asyncio.Task] = [] + self.pc.on("track", self._on_track) + + def _on_track(self, track: MediaStreamTrack) -> None: + async def read() -> None: + while True: + try: + self.frames[track.kind].append(await track.recv()) + except MediaStreamError: + return + + self._tasks.append(asyncio.create_task(read())) + + async def connect(self, talkback: Talkback, session_id: str) -> str: + """Send our offer to `talkback` and apply its answer; return the offer.""" + await self.pc.setLocalDescription(await self.pc.createOffer()) + messages: list[WebRTCMessage] = [] + offer = self.pc.localDescription.sdp + await talkback.handle_offer(session_id, offer, messages.append, []) + assert isinstance(messages[0], WebRTCAnswer) + await self.pc.setRemoteDescription( + RTCSessionDescription(sdp=messages[0].answer, type="answer") + ) + return offer + + async def close(self) -> None: + """Close the peer.""" + for task in self._tasks: + task.cancel() + await self.pc.close() + + +async def _until(condition: object) -> None: + deadline = time.monotonic() + TIMEOUT + while not condition(): + if time.monotonic() > deadline: + msg = "condition not met in time" + raise AssertionError(msg) + await asyncio.sleep(0.02) + + +class OfferTests(unittest.TestCase): + """Telling watching offers from talking ones.""" + + def test_offer_sends_audio(self) -> None: + """Only an active audio section that sends counts as talking.""" + head = "v=0\r\n" + video = "m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=recvonly\r\n" + + assert offer_sends_audio(head + "m=audio 9 RTP 0\r\na=sendrecv\r\n" + video) + assert offer_sends_audio(head + "m=audio 9 RTP 0\r\na=sendonly\r\n") + assert offer_sends_audio(head + "m=audio 9 RTP 0\r\n") + assert not offer_sends_audio(head + "m=audio 9 RTP 0\r\na=recvonly\r\n") + assert not offer_sends_audio(head + "m=audio 0 RTP 0\r\na=sendrecv\r\n") + assert not offer_sends_audio(head + video.replace("recvonly", "sendrecv")) + + +# WebRTC needs real sockets for ICE; the Home Assistant test harness blocks them. +@pytest.mark.usefixtures("socket_enabled") +class TalkbackTests(unittest.IsolatedAsyncioTestCase): + """Talkback bridges browser sessions and the door station's calls.""" + + async def asyncSetUp(self) -> None: + """Create a ring-along line with a talkback for the front door.""" + # Keep ICE on loopback, whatever interfaces this host (or the harness) has. + loopback = patch("aioice.ice.get_host_addresses", return_value=["127.0.0.1"]) + loopback.start() + self.addCleanup(loopback.stop) + self.browsers: list[Browser] = [] + self._setup(auto_answer=False) + + async def asyncTearDown(self) -> None: + """Close the browsers, the talkback and the line.""" + for browser in self.browsers: + await browser.close() + await self.talkback.close() + await self.line.stop() + + def _setup(self, *, auto_answer: bool) -> None: + self.server = MiniSIPServer( + {SIP_USER: SIP_PASSWORD}, + host=HA_HOST, + call_host=HA_HOST, + rtp_endpoint=fake_rtp_endpoint, + ) + self.transport = FakeTransport() + self.server.transport = self.transport + self.events: list[LineEvent] = [] + self.line = GatewayLine( + self.server, + user=SIP_USER, + number=HA_NUMBER, + auto_answer=auto_answer, + ring_along=True, + devices=load_devices().values, + ) + self.line.subscribe(self.events.append) + self.talkback = Talkback(self.line, DOOR, _camera) + + async def _ring(self, call_id: str = "call-1") -> None: + await self.server.handle_datagram(invite(call_id, "*111", HA_NUMBER), GATEWAY) + + async def _ack(self, call_id: str = "call-1") -> None: + await self.server.handle_datagram(ack(call_id), GATEWAY) + + def _statuses(self) -> list[str]: + return [data.split(b"\r\n", 1)[0].decode() for data, _ in self.transport.sent] + + def _requests(self, method: bytes) -> int: + return sum(data.startswith(method) for data, _ in self.transport.sent) + + async def _browser(self, session_id: str, *, talk: bool) -> Browser: + browser = Browser(talk=talk) + self.browsers.append(browser) + await browser.connect(self.talkback, session_id) + return browser + + async def test_watching_gets_video_and_leaves_the_call_ringing(self) -> None: + """A recvonly session sees the camera but never answers.""" + await self._ring() + + browser = await self._browser("s1", talk=False) + await _until(lambda: browser.frames["video"]) + + assert "call-1" in self.line.ringing + assert self._statuses() == ["SIP/2.0 180 Ringing"] + assert self.talkback.video.running + + async def test_talking_answers_and_bridges_audio_both_ways(self) -> None: + """Talk answers the ringing call; audio flows; closing hangs up.""" + await self._ring() + + browser = await self._browser("s1", talk=True) + + assert self._statuses()[-1] == "SIP/2.0 200 OK" + assert not self.line.ringing + await self._ack() + call_audio = self.line.calls["call-1"].rtp_session() + + # Microphone -> call: the tone arrives in the RTP sent to the gateway. + def sent_tone() -> bool: + for data, _ in call_audio.transport.sent: + pcm = g711_decode(RtpPacket.parse(data).payload, PCMA) + if abs(int(np.median(pcm)) - TONE) < TONE * 0.1: + return True + return False + + await _until(sent_tone) + + # Door station -> browser: received RTP plays out to the browser. + door = g711_encode(np.full(SAMPLES_PER_FRAME, -TONE), PCMA) + for seq in range(100): + packet = RtpPacket(PCMA, seq, seq * SAMPLES_PER_FRAME, 99, door) + call_audio.datagram_received(packet.serialize(), ("192.168.1.10", 4000)) + + def heard_door() -> bool: + return any( + np.abs(frame.to_ndarray()).max() > TONE / 2 + for frame in browser.frames["audio"] + ) + + await _until(heard_door) + + await self.talkback.close_session("s1") + + assert self._requests(b"BYE ") == 1 + assert isinstance(self.events[-1], CallEnded) + assert not self.talkback.video.running + + async def test_last_talking_session_hangs_up(self) -> None: + """With two talkers, only closing the second one hangs up.""" + await self._ring() + await self._browser("s1", talk=True) + await self._ack() + await self._browser("s2", talk=True) + await self._browser("s3", talk=False) + + await self.talkback.close_session("s1") + await self.talkback.close_session("s3") + assert self._requests(b"BYE ") == 0 + + await self.talkback.close_session("s2") + assert self._requests(b"BYE ") == 1 + + async def test_watching_an_auto_answered_call_never_hangs_up(self) -> None: + """Calls nobody talked on keep running when viewers leave.""" + await self.talkback.close() + await self.line.stop() + self._setup(auto_answer=True) + await self._ring() + await self._ack() + + await self._browser("s1", talk=False) + await self.talkback.close_session("s1") + + assert self._requests(b"BYE ") == 0 + assert "call-1" in self.line.calls + + async def test_candidates_wait_for_the_offer_and_unknown_sessions_fail( + self, + ) -> None: + """End-of-candidates is accepted; unknown sessions raise.""" + await self._browser("s1", talk=False) + + await self.talkback.add_candidate("s1", "", "0", 0) + with pytest.raises(ValueError, match="Unknown WebRTC session"): + await self.talkback.add_candidate("nope", "", "0", 0) + + async def test_talking_without_a_call_answers_nothing(self) -> None: + """Talking with no call ringing just opens the session.""" + await self._browser("s1", talk=True) + + await self.talkback.close_session("s1") + + assert self.transport.sent == [] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_two_way_audio.py b/tests/test_two_way_audio.py new file mode 100644 index 0000000..97306d7 --- /dev/null +++ b/tests/test_two_way_audio.py @@ -0,0 +1,83 @@ +"""Two-way audio as Home Assistant sees it: options, camera and call model.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from homeassistant.components.labs import async_update_preview_feature +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from pytest_homeassistant.common import MockConfigEntry + +from custom_components.sg_150.const import ( + CONF_SIP_AUTO_ANSWER, + CONF_TWO_WAY_AUDIO, + DOMAIN, +) +from tests.sip_messages import GATEWAY, HA_NUMBER, cancel, invite + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from pytest_homeassistant.typing import WebSocketGenerator + + from custom_components.sg_150.sip.minisip import MiniSIPServer + + +@pytest.fixture +def config_entry(config_entry: MockConfigEntry) -> MockConfigEntry: + """Return the gateway entry with two-way audio on and auto answer off.""" + return MockConfigEntry( + domain=DOMAIN, + title=config_entry.title, + unique_id=config_entry.unique_id, + data=config_entry.data, + options={ + **config_entry.options, + CONF_SIP_AUTO_ANSWER: False, + CONF_TWO_WAY_AUDIO: True, + }, + ) + + +async def _capabilities(hass: HomeAssistant, ws: WebSocketGenerator) -> dict: + camera = er.async_get(hass).async_get_entity_id( + "camera", DOMAIN, "door-frontCamera" + ) + client = await ws() + await client.send_json_auto_id({"type": "camera/capabilities", "entity_id": camera}) + response = await client.receive_json() + assert response["success"], response + return response["result"] + + +@pytest.mark.usefixtures("setup_integration") +async def test_camera_offers_webrtc_and_talk_with_the_labs_feature( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The live view is WebRTC; the talk button needs the Labs feature too.""" + assert await _capabilities(hass, hass_ws_client) == { + "frontend_stream_types": ["web_rtc"], + "two_way_audio": False, + } + + assert await async_setup_component(hass, "labs", {}) + await async_update_preview_feature(hass, "camera", "two_way_audio", enabled=True) + + assert (await _capabilities(hass, hass_ws_client))["two_way_audio"] is True + + +async def test_incoming_call_rings_along_until_cancelled( + hass: HomeAssistant, setup_integration: MiniSIPServer +) -> None: + """Without auto answer, the call rings instead of being rejected busy.""" + await setup_integration.handle_datagram(invite("c1", "*111", HA_NUMBER), GATEWAY) + await hass.async_block_till_done() + + statuses = [d.split(b"\r\n", 1)[0] for d, _ in setup_integration.transport.sent] + assert statuses == [b"SIP/2.0 180 Ringing"] + assert "c1" in setup_integration.ringing + + await setup_integration.handle_datagram(cancel("c1"), GATEWAY) + + assert not setup_integration.ringing diff --git a/tests/utils.py b/tests/utils.py index ef8e3d8..90d7e7f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,6 +3,7 @@ import json from pathlib import Path +from custom_components.sg_150.sip.rtp import RtpSession from custom_components.sg_150.types import SG150Device, SG150Devices FIXTURES = Path(__file__).parent / "fixtures" @@ -27,3 +28,10 @@ def sendto(self, data: bytes, addr: tuple[str, int]) -> None: def close(self) -> None: """Do nothing, there is no socket to close.""" + + +async def fake_rtp_endpoint(session: RtpSession, host: str, port: int) -> None: + """Attach a FakeTransport to an RTP session instead of binding a socket.""" + transport = FakeTransport() + transport.local_addr = (host, port) + session.connection_made(transport) From ec3fe07db93fcf667107daf26bf741d355dd5afe Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 28 Oct 2026 23:08:57 +0100 Subject: [PATCH 4/8] Add a header and a HACS badge to the README, and list actions in a table - A header image in Home Assistant's style, with a light and a dark version picked by GitHub's theme. - The HACS default badge. - The actions get their own section, as a table with their fields and responses. Co-Authored-By: Claude Opus 5.5 --- README.md | 29 +++++++++++++++----- docs/header-dark.svg | 61 +++++++++++++++++++++++++++++++++++++++++++ docs/header-light.svg | 61 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 docs/header-dark.svg create mode 100644 docs/header-light.svg diff --git a/README.md b/README.md index 1552e11..39d40bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ + + + SG-150 for Home Assistant: a door station, an indoor station, the gateway and Home Assistant on one In-Home bus + + # Siedle Gateway 150 Integration +[![HACS Default](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) + This Home Assistant custom integration allowing integration with Siedle Gateway 150 (SG-150) via a local connection. It is developed to integrate a Siedle Door Camera with Home Assistant. This integration will run a minimal SIP Server that can be configured in the SG-150 as a telephone device. @@ -13,13 +20,7 @@ Currently the integration exposes: - A bell that rings when the door station calls Home Assistant - A Hang Up button next to each camera - A media source with the pictures the gateway stored during calls, per PBX phone (Media → Siedle Gateway) -- Actions to control the SG-150: - - start_call: Start a phone call within the In-Home system - - stop_call: Stop a phone call within the In-Home system - - send_sip_info_dtmf: Sends a DTMF signal to the Siedle Gateway during an In-Home phone call - - get_active_calls: Returns a list of active call IDs (`active_calls`) and, in `calls`, each call's direction and the caller/callee device (`id`, `name`) when known. - - play_audio: Play a media_source_id to an active call (Supporting wav/mp3, see [Tips & Tricks](#tips--tricks)) - - Every action takes an optional `config_entry_id` to pick the gateway; it can be omitted when only one gateway is set up. +- Actions to start and control calls (see [Actions](#actions)) # Disclaimer @@ -68,6 +69,20 @@ Without two-way audio, the camera shows the picture only. With two-way audio on, | Door Opener | Each door station | Opens the door. | | Hang Up | Next to each camera | Ends the call shown by that camera. If there is no call, Home Assistant shows an error. | +## Actions + +These actions let automations and scripts start and control calls. Every action also takes an optional `config_entry_id` to pick the gateway; leave it out when only one gateway is set up. + +| Action | What it does | Fields | Response | +|---|---|---|---| +| `sg_150.start_call` | Starts a call within the In-Home system, for example to a door station. | `to`: the number to call, including its prefix, without spaces | `call_id` of the new call | +| `sg_150.stop_call` | Ends a call. | `call_id` | `success` | +| `sg_150.send_sip_info_dtmf` | Sends a DTMF signal during a call, for example to trigger a function of the door station (see [Tips & Tricks](#tips--tricks)). | `call_id`, `signal` (0–9, `*` or `#`), `duration` in milliseconds (optional, default 100) | `success` | +| `sg_150.play_audio` | Plays a sound into a call, such as a text-to-speech message. Supports wav and mp3 (see [Tips & Tricks](#tips--tricks)). | `call_id`, `media_source_id` | `success` | +| `sg_150.get_active_calls` | Lists the calls in progress. | none | `active_calls`: the call IDs; `calls`: each call's direction and the devices on either end (`id`, `name`), when known | + +If an action fails, its response contains an `error` with the reason. + ## Two-way audio With **Two-way audio** turned on, you can hear the door station and talk back from the camera's live view, like on an indoor station. This, as of writing, needs Home Assistant 2026.11 or later, and the following configuration: diff --git a/docs/header-dark.svg b/docs/header-dark.svg new file mode 100644 index 0000000..a7aa6d8 --- /dev/null +++ b/docs/header-dark.svg @@ -0,0 +1,61 @@ + + SG-150 for Home Assistant: a door station, an indoor station, the gateway and Home Assistant on one In-Home bus + + + SG-150 + for Home Assistant + + + + + Door station + Ringing + + + + + + Indoor station + Idle + + + + + + Gateway + Connected + + + + + + Home Assistant + Talking + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/header-light.svg b/docs/header-light.svg new file mode 100644 index 0000000..145c509 --- /dev/null +++ b/docs/header-light.svg @@ -0,0 +1,61 @@ + + SG-150 for Home Assistant: a door station, an indoor station, the gateway and Home Assistant on one In-Home bus + + + SG-150 + for Home Assistant + + + + + Door station + Ringing + + + + + + Indoor station + Idle + + + + + + Gateway + Connected + + + + + + Home Assistant + Talking + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 154af482ffeca4c9876ec200052f0c67da8150e1 Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 4 Nov 2026 20:36:00 +0100 Subject: [PATCH 5/8] Fix deprecated via_device handling Setup the gateway device in init and link all the entities via get_gateway_identifier() --- custom_components/sg_150/__init__.py | 1 + custom_components/sg_150/coordinator.py | 22 ++++++++++++++++++++-- custom_components/sg_150/entity.py | 12 ++++-------- tests/test_init.py | 5 +++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/custom_components/sg_150/__init__.py b/custom_components/sg_150/__init__.py index 179b9e8..41f21f8 100644 --- a/custom_components/sg_150/__init__.py +++ b/custom_components/sg_150/__init__.py @@ -107,6 +107,7 @@ async def async_setup_entry( ) await coordinator.async_config_entry_first_refresh() + await coordinator.async_register_gateway_device() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await line.start() diff --git a/custom_components/sg_150/coordinator.py b/custom_components/sg_150/coordinator.py index 1f63fa3..5b4da24 100644 --- a/custom_components/sg_150/coordinator.py +++ b/custom_components/sg_150/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import SG150ApiClient, SG150ApiClientAuthenticationError, SG150ApiClientError -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, MANUFACTURER if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -39,6 +39,7 @@ class SG150Coordinator(DataUpdateCoordinator): config_entry: SG150ConfigEntry machine_info: SG150MaschineInfo | None = None + gateway_device_id: str | None = None data: SG150Data @@ -53,9 +54,26 @@ def __init__(self, hass: HomeAssistant) -> None: self.previous_devices: set[str] = set() def get_gateway_identifier(self) -> tuple[str, str]: - """Get the gateway identifier to be used within via_device.""" + """Get the identifier of the gateway device in the device registry.""" return (self.config_entry.domain, self.config_entry.entry_id) + async def async_register_gateway_device(self) -> None: + """Register the gateway so device entities can link to it via its id.""" + machine_info = self.machine_info + self.gateway_device_id = ( + dr.async_get(self.hass) + .async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={self.get_gateway_identifier()}, + name="Smart Gateway 150", + manufacturer=MANUFACTURER, + model=machine_info.machineName or "Unknown", + hw_version=machine_info.machineRevision or "?", + serial_number=machine_info.macAddress or "?", + ) + .id + ) + def get_api_client(self) -> SG150ApiClient: """Get the API client.""" return self.config_entry.runtime_data.client diff --git a/custom_components/sg_150/entity.py b/custom_components/sg_150/entity.py index 77eb116..a0e6260 100644 --- a/custom_components/sg_150/entity.py +++ b/custom_components/sg_150/entity.py @@ -44,15 +44,11 @@ class SG150GatewayEntity(CoordinatorEntity[SG150Coordinator]): def __init__(self, coordinator: SG150Coordinator, key: str) -> None: """Create a gateway entity identified by `key`.""" super().__init__(coordinator) - machine_info = coordinator.machine_info self._attr_translation_key = key - self._attr_unique_id = gateway_unique_id(machine_info.macAddress, key) + self._attr_unique_id = gateway_unique_id( + coordinator.machine_info.macAddress, key + ) self._attr_device_info = DeviceInfo( - name="Smart Gateway 150", - manufacturer=MANUFACTURER, - model=machine_info.machineName or "Unknown", - hw_version=machine_info.machineRevision or "?", - serial_number=machine_info.macAddress or "?", identifiers={coordinator.get_gateway_identifier()}, ) @@ -74,7 +70,7 @@ def __init__( identifiers={(coordinator.config_entry.domain, device.id)}, name=device.name, manufacturer=MANUFACTURER, - via_device=coordinator.get_gateway_identifier(), + via_device_id=coordinator.gateway_device_id, ) def get_device(self) -> SG150Device: diff --git a/tests/test_init.py b/tests/test_init.py index 62b994e..0873671 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -40,6 +40,11 @@ async def test_setup_creates_gateway_and_devices( "pbx-internal-phone", } + gateway = registry.async_get_device_by_identifier( + (DOMAIN, config_entry.entry_id), config_entry.entry_id + ) + assert all(d.via_device_id == gateway.id for d in devices if d.id != gateway.id) + async def test_unload_stops_sip_and_keeps_services( hass: HomeAssistant, From 26dddd61910600ac484549822dab886b2b104ec0 Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 4 Nov 2026 21:23:52 +0100 Subject: [PATCH 6/8] Fix blocking imports from pythondns See: https://github.com/rthalley/dnspython/issues/1083 https://github.com/home-assistant/core/pull/121702/changes --- custom_components/sg_150/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/custom_components/sg_150/__init__.py b/custom_components/sg_150/__init__.py index 41f21f8..bf8432d 100644 --- a/custom_components/sg_150/__init__.py +++ b/custom_components/sg_150/__init__.py @@ -9,6 +9,9 @@ from typing import TYPE_CHECKING +import dns.rdata +import dns.rdataclass +import dns.rdatatype from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -55,6 +58,17 @@ ] +# Taken from https://github.com/home-assistant/core/pull/121702/changes +def load_dnspython_rdata_classes() -> None: + """Load dnspython rdata classes used by aioice.""" + # aioice's mDNS records use class IN with the cache-flush bit set, which + # dnspython caches separately from IN and would otherwise import on demand. + for rdclass in (dns.rdataclass.IN, dns.rdataclass.IN | 0x8000): + for rdtype in dns.rdatatype.RdataType: + if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: + dns.rdata.get_rdata_class(rdclass, rdtype) # type: ignore[no-untyped-call] + + async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Register the services and the image view once per Home Assistant instance.""" async_setup_services(hass) @@ -67,6 +81,9 @@ async def async_setup_entry( entry: SG150ConfigEntry, ) -> bool: """Set up this integration using UI.""" + # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) + await hass.async_add_executor_job(load_dnspython_rdata_classes) + coordinator = SG150Coordinator(hass=hass) client = SG150ApiClient( From 637660eb5139f8de742da80300efede7d454db91 Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 4 Nov 2026 21:49:16 +0100 Subject: [PATCH 7/8] Fix google_crc32c warning on startup --- custom_components/sg_150/camera.py | 3 +-- custom_components/sg_150/talkback.py | 31 +++++++++++++++++-------- tests/test_talkback.py | 34 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/custom_components/sg_150/camera.py b/custom_components/sg_150/camera.py index 0423ae4..b18bd5f 100644 --- a/custom_components/sg_150/camera.py +++ b/custom_components/sg_150/camera.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING import httpx -from aiortc import RTCIceServer from homeassistant.components.camera import ( Camera, CameraEntityCapabilities, @@ -22,7 +21,7 @@ from .const import CONF_TWO_WAY_AUDIO, LOGGER from .entity import SG150DeviceEntity from .line import CallEnded, CallStarted -from .talkback import Talkback +from .talkback import RTCIceServer, Talkback if TYPE_CHECKING: from collections.abc import AsyncIterator diff --git a/custom_components/sg_150/talkback.py b/custom_components/sg_150/talkback.py index 80d5021..6123ad6 100644 --- a/custom_components/sg_150/talkback.py +++ b/custom_components/sg_150/talkback.py @@ -14,29 +14,42 @@ import contextlib import fractions import time +import warnings from collections import deque from typing import TYPE_CHECKING import av import numpy as np -from aiortc import ( - RTCConfiguration, - RTCPeerConnection, - RTCRtpSender, - RTCSessionDescription, -) -from aiortc.mediastreams import MediaStreamError, MediaStreamTrack -from aiortc.sdp import candidate_from_sdp from homeassistant.components.camera import WebRTCAnswer, WebRTCError from .const import LOGGER from .line import CallEnded, CallStarted, NotRingingError from .sip.rtp import PTIME, SAMPLE_RATE, SAMPLES_PER_FRAME +# aiortc only uses google-crc32c to checksum SCTP (data channel) packets, and the +# sessions here carry none but the frontend's unused channel. +# Suppress just that warning. +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r".*(?:pure[- ]Python implementation|using a python implementation).*", + category=RuntimeWarning, + module=r"^google_crc32c(?:\..+)?$", + ) + from aiortc import ( + RTCConfiguration, + RTCIceServer, + RTCPeerConnection, + RTCRtpSender, + RTCSessionDescription, + ) + from aiortc.mediastreams import MediaStreamError, MediaStreamTrack + from aiortc.sdp import candidate_from_sdp + if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable - from aiortc import RTCIceServer, RTCRtpCodecCapability + from aiortc import RTCRtpCodecCapability from homeassistant.components.camera import WebRTCSendMessage from .line import Call, GatewayLine, LineEvent diff --git a/tests/test_talkback.py b/tests/test_talkback.py index 951644b..16a5f2c 100644 --- a/tests/test_talkback.py +++ b/tests/test_talkback.py @@ -8,6 +8,7 @@ import asyncio import fractions +import socket import time import unittest from collections.abc import AsyncIterator @@ -16,6 +17,7 @@ import av import numpy as np import pytest +from aioice import Candidate, Connection from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription from aiortc.mediastreams import MediaStreamError, MediaStreamTrack from homeassistant.components.camera import WebRTCAnswer, WebRTCMessage @@ -162,6 +164,38 @@ def test_offer_sends_audio(self) -> None: assert not offer_sends_audio(head + video.replace("recvonly", "sendrecv")) +@pytest.mark.usefixtures("socket_enabled") +class IceCloseTests(unittest.IsolatedAsyncioTestCase): + """Closing an ICE connection while its checks still run.""" + + async def test_close_while_trickling_drops_stun_retransmissions(self) -> None: + """Checks outliving their closed sockets don't crash the event loop.""" + errors: list[dict] = [] + asyncio.get_running_loop().set_exception_handler( + lambda _, context: errors.append(context) + ) + silent = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.addCleanup(silent.close) + silent.bind(("127.0.0.1", 0)) + with patch("aioice.ice.get_host_addresses", return_value=["127.0.0.1"]): + connection = Connection(ice_controlling=True) + await connection.gather_candidates() + connection.remote_username, connection.remote_password = "abcd", "a" * 22 + port = silent.getsockname()[1] + await connection.add_remote_candidate( + Candidate("1", 1, "udp", 100, "127.0.0.1", port, "host") + ) + # No end-of-candidates: the browser is still trickling. + connect = asyncio.create_task(connection.connect()) + await asyncio.sleep(0.1) + + await connection.close() + await asyncio.sleep(0.6) # past the first STUN retransmission + connect.cancel() + + assert errors == [] + + # WebRTC needs real sockets for ICE; the Home Assistant test harness blocks them. @pytest.mark.usefixtures("socket_enabled") class TalkbackTests(unittest.IsolatedAsyncioTestCase): From 3f933dccad2bb6a608a0212a45df0b9f5be286b7 Mon Sep 17 00:00:00 2001 From: FWeinb Date: Wed, 4 Nov 2026 22:09:10 +0100 Subject: [PATCH 8/8] Update to latest working dependencies Still need to wait until a proper release of * pytest-homeassistant==2026.11.0 * aiortc==1.16.0 (including aioice>=0.12.1) was made. --- .github/dependabot.yml | 2 +- custom_components/sg_150/manifest.json | 3 ++- requirements.txt | 7 +++---- requirements_test.txt | 6 ++---- scripts/develop | 2 +- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3278cdb..a252518 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,4 +19,4 @@ updates: # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json - dependency-name: "homeassistant" # The test plugin pins one exact Home Assistant release, so it moves with it - - dependency-name: "pytest-home-assistant" \ No newline at end of file + - dependency-name: "pytest-homeassistant" \ No newline at end of file diff --git a/custom_components/sg_150/manifest.json b/custom_components/sg_150/manifest.json index 75483c6..52ca2bf 100644 --- a/custom_components/sg_150/manifest.json +++ b/custom_components/sg_150/manifest.json @@ -13,7 +13,8 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/fweinb/ha-sg-150/issues", "requirements": [ - "aiortc>=1.16.0", + "aiortc>=1.16.0,<2.0.0", + "aioice>=0.12.1,<1.0.0", "soundfile==0.13.1", "numpy>=2.3.2", "pydantic>=2.0" diff --git a/requirements.txt b/requirements.txt index dcd0ffc..842b23e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ colorlog==6.12.0 -homeassistant==2026.11.0b0 +homeassistant==2026.11.0 # Home Assistant constrains pydantic to this version but doesn't install it. pydantic==2.14.1 -# aiortc main relaxed its PyAV pin to av<20 (HA pins av==19.0.0); replace this -# with the release named in manifest.json once it is on PyPI. -aiortc @ https://github.com/aiortc/aiortc/archive/34b5fd3409220e1aeb8b62f2c56a9ae2085ca8c8.zip +aiortc>=1.16.0,<2.0.0 +aioice>=0.12.1,<1.0.0 pip>=26.2.2 ruff==0.17.2 soundfile==0.14.0 diff --git a/requirements_test.txt b/requirements_test.txt index c29cc3c..abc98f5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,4 @@ -r requirements.txt -# Home Assistant's own test package, released with Home Assistant itself. Until -# its 2026.11 release is on PyPI, install it from the core release tag. -pytest-home-assistant @ https://github.com/home-assistant/core/archive/refs/tags/2026.11.0b0.zip#subdirectory=packages/pytest-home-assistant -# Coverage for scripts/test; pytest-home-assistant doesn't bring it. +# Packages was not uploaded to 2026.11.0 yet. +pytest-homeassistant==2026.11.0 pytest-cov==7.1.0 diff --git a/scripts/develop b/scripts/develop index 7815c04..77b6638 100755 --- a/scripts/develop +++ b/scripts/develop @@ -17,4 +17,4 @@ fi export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" # Start Home Assistant -hass --config "${PWD}/config" --debug +hass --config "${PWD}/config" --debug --skip-pip-packages aiortc