aiogotoconnect 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiogotoconnect/__init__.py +23 -0
- aiogotoconnect/auth.py +83 -0
- aiogotoconnect/exceptions.py +22 -0
- aiogotoconnect/messaging.py +51 -0
- aiogotoconnect/py.typed +0 -0
- aiogotoconnect/sip/__init__.py +0 -0
- aiogotoconnect/sip/audio.py +50 -0
- aiogotoconnect/sip/call.py +361 -0
- aiogotoconnect/sip/messages.py +185 -0
- aiogotoconnect/sip/rtp.py +51 -0
- aiogotoconnect/sip/transport.py +135 -0
- aiogotoconnect-0.2.0.dist-info/METADATA +109 -0
- aiogotoconnect-0.2.0.dist-info/RECORD +16 -0
- aiogotoconnect-0.2.0.dist-info/WHEEL +5 -0
- aiogotoconnect-0.2.0.dist-info/licenses/LICENSE +18 -0
- aiogotoconnect-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from aiogotoconnect.auth import GoToOAuth, TokenProvider
|
|
2
|
+
from aiogotoconnect.exceptions import (
|
|
3
|
+
GoToApiError,
|
|
4
|
+
GoToAuthError,
|
|
5
|
+
GoToCallError,
|
|
6
|
+
GoToError,
|
|
7
|
+
GoToSipError,
|
|
8
|
+
)
|
|
9
|
+
from aiogotoconnect.messaging import GoToMessaging
|
|
10
|
+
from aiogotoconnect.sip.call import CallResult, GoToVoice
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CallResult",
|
|
14
|
+
"GoToApiError",
|
|
15
|
+
"GoToAuthError",
|
|
16
|
+
"GoToCallError",
|
|
17
|
+
"GoToError",
|
|
18
|
+
"GoToMessaging",
|
|
19
|
+
"GoToOAuth",
|
|
20
|
+
"GoToSipError",
|
|
21
|
+
"GoToVoice",
|
|
22
|
+
"TokenProvider",
|
|
23
|
+
]
|
aiogotoconnect/auth.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from aiogotoconnect.exceptions import GoToAuthError
|
|
9
|
+
|
|
10
|
+
# Any async callable returning a bearer token. Home Assistant passes its own.
|
|
11
|
+
TokenProvider = Callable[[], Awaitable[str]]
|
|
12
|
+
|
|
13
|
+
DEFAULT_TOKEN_URL = "https://authentication.logmeininc.com/oauth/token"
|
|
14
|
+
|
|
15
|
+
# Refresh this many seconds before expiry so a token never expires mid-request.
|
|
16
|
+
REFRESH_MARGIN_SECONDS = 60
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GoToOAuth:
|
|
20
|
+
"""Standalone OAuth provider: refresh-token grant with cached access token."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client_id: str,
|
|
25
|
+
client_secret: str,
|
|
26
|
+
refresh_token: str,
|
|
27
|
+
token_url: str = DEFAULT_TOKEN_URL,
|
|
28
|
+
session: aiohttp.ClientSession | None = None,
|
|
29
|
+
):
|
|
30
|
+
self._client_id = client_id
|
|
31
|
+
self._client_secret = client_secret
|
|
32
|
+
self.refresh_token = refresh_token # GoTo may rotate this on every refresh
|
|
33
|
+
self._token_url = token_url
|
|
34
|
+
self._session = session
|
|
35
|
+
self._access_token: str | None = None
|
|
36
|
+
self._expires_at = 0.0
|
|
37
|
+
self._refresh_lock = asyncio.Lock()
|
|
38
|
+
|
|
39
|
+
def _needs_refresh(self) -> bool:
|
|
40
|
+
expiring = time.monotonic() >= self._expires_at - REFRESH_MARGIN_SECONDS
|
|
41
|
+
return self._access_token is None or expiring
|
|
42
|
+
|
|
43
|
+
async def async_get_access_token(self) -> str:
|
|
44
|
+
if not self._needs_refresh():
|
|
45
|
+
return self._access_token
|
|
46
|
+
# Lock + re-check: concurrent callers wait, then reuse the token the first one fetched.
|
|
47
|
+
# Needed because a rotating refresh_token is single-use.
|
|
48
|
+
async with self._refresh_lock:
|
|
49
|
+
if self._needs_refresh():
|
|
50
|
+
await self._async_refresh()
|
|
51
|
+
return self._access_token
|
|
52
|
+
|
|
53
|
+
async def _async_refresh(self) -> None:
|
|
54
|
+
credentials = base64.b64encode(f"{self._client_id}:{self._client_secret}".encode()).decode()
|
|
55
|
+
headers = {"Authorization": f"Basic {credentials}"}
|
|
56
|
+
form = {"grant_type": "refresh_token", "refresh_token": self.refresh_token}
|
|
57
|
+
|
|
58
|
+
if self._session is None:
|
|
59
|
+
async with aiohttp.ClientSession() as session:
|
|
60
|
+
payload = await self._async_post_token(session, headers, form)
|
|
61
|
+
else:
|
|
62
|
+
payload = await self._async_post_token(self._session, headers, form)
|
|
63
|
+
|
|
64
|
+
# Error text is deliberately not included: it could echo a secret.
|
|
65
|
+
try:
|
|
66
|
+
access_token = payload["access_token"]
|
|
67
|
+
expires_in = float(payload["expires_in"])
|
|
68
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
69
|
+
raise GoToAuthError("Token response missing access_token or expires_in") from error
|
|
70
|
+
self._access_token = access_token
|
|
71
|
+
self.refresh_token = payload.get("refresh_token", self.refresh_token)
|
|
72
|
+
self._expires_at = time.monotonic() + expires_in
|
|
73
|
+
|
|
74
|
+
async def _async_post_token(self, session, headers, form) -> dict:
|
|
75
|
+
try:
|
|
76
|
+
async with session.post(self._token_url, headers=headers, data=form) as response:
|
|
77
|
+
if response.status != 200:
|
|
78
|
+
raise GoToAuthError(f"Token refresh failed with HTTP {response.status}")
|
|
79
|
+
return await response.json()
|
|
80
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
|
|
81
|
+
raise GoToAuthError("Token refresh request failed") from error
|
|
82
|
+
except ValueError as error: # JSONDecodeError (non-JSON body)
|
|
83
|
+
raise GoToAuthError("Token response was not valid JSON") from error
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class GoToError(Exception):
|
|
2
|
+
"""Base class for all aiogotoconnect errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GoToAuthError(GoToError):
|
|
6
|
+
"""Credentials were rejected: OAuth token refresh failed, SIP digest credentials refused, or SMS got a 401."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GoToApiError(GoToError):
|
|
10
|
+
"""A GoTo REST API call returned a non-success HTTP status."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, status: int):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status = status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GoToSipError(GoToError):
|
|
18
|
+
"""SIP registration or signalling failed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GoToCallError(GoToSipError):
|
|
22
|
+
"""An outbound call could not be completed."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
|
|
5
|
+
from aiogotoconnect.auth import TokenProvider
|
|
6
|
+
from aiogotoconnect.exceptions import GoToApiError, GoToAuthError, GoToError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GoToMessaging:
|
|
10
|
+
"""GoTo Messaging v2 client: send SMS from an owner number to contact numbers."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
token_provider: TokenProvider,
|
|
15
|
+
session: aiohttp.ClientSession | None = None,
|
|
16
|
+
base_url: str = "https://api.goto.com",
|
|
17
|
+
):
|
|
18
|
+
self._token_provider = token_provider
|
|
19
|
+
self._session = session
|
|
20
|
+
self._base_url = base_url
|
|
21
|
+
|
|
22
|
+
async def async_send_sms(self, owner_phone: str, contacts: list[str], body: str) -> dict:
|
|
23
|
+
# GoTo rejects owner == contact with a confusing error, so fail fast here.
|
|
24
|
+
if owner_phone in contacts:
|
|
25
|
+
raise ValueError("owner_phone must not also be a contact")
|
|
26
|
+
|
|
27
|
+
headers = {"Authorization": f"Bearer {await self._token_provider()}"}
|
|
28
|
+
payload = {"ownerPhoneNumber": owner_phone, "contactPhoneNumbers": contacts, "body": body}
|
|
29
|
+
url = f"{self._base_url}/messaging/v1/messages"
|
|
30
|
+
|
|
31
|
+
if self._session is None:
|
|
32
|
+
async with aiohttp.ClientSession() as session:
|
|
33
|
+
return await self._async_post(session, url, headers, payload)
|
|
34
|
+
return await self._async_post(self._session, url, headers, payload)
|
|
35
|
+
|
|
36
|
+
async def _async_post(self, session, url, headers, payload) -> dict:
|
|
37
|
+
try:
|
|
38
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
39
|
+
if response.status == 401:
|
|
40
|
+
raise GoToAuthError("SMS send rejected: access token not accepted")
|
|
41
|
+
# Error text is deliberately not included: it could echo phone numbers or tokens.
|
|
42
|
+
if response.status >= 400:
|
|
43
|
+
raise GoToApiError(f"SMS send failed with HTTP {response.status}", response.status)
|
|
44
|
+
# Success with no usable body is still success: GoTo returns JSON, but don't rely on it.
|
|
45
|
+
try:
|
|
46
|
+
parsed = await response.json(content_type=None)
|
|
47
|
+
except ValueError: # JSONDecodeError: body was not JSON
|
|
48
|
+
return {}
|
|
49
|
+
return {} if parsed is None else parsed # aiohttp yields None for an empty body
|
|
50
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
|
|
51
|
+
raise GoToError("SMS send request failed") from error
|
aiogotoconnect/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""WAV to raw G.711 conversion for the RTP sender.
|
|
2
|
+
|
|
3
|
+
Input is any PCM WAV (Home Assistant TTS output). Output is headerless a-law
|
|
4
|
+
(PCMA, payload type 8) or mu-law (PCMU) bytes at 8 kHz mono, one byte per sample.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import warnings
|
|
9
|
+
import wave
|
|
10
|
+
|
|
11
|
+
# ponytail: audioop is deprecated on 3.12 (PEP 594) and removed on 3.13, where
|
|
12
|
+
# the audioop-lts dependency provides the same module name. Silence only this
|
|
13
|
+
# import-time DeprecationWarning, so the rest of the suite still fails on warnings.
|
|
14
|
+
with warnings.catch_warnings():
|
|
15
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
16
|
+
import audioop
|
|
17
|
+
|
|
18
|
+
TARGET_RATE = 8000
|
|
19
|
+
CODECS = {"pcma": audioop.lin2alaw, "pcmu": audioop.lin2ulaw}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def wav_to_g711(wav_bytes: bytes, codec: str = "pcma") -> bytes:
|
|
23
|
+
"""Convert WAV bytes to 8 kHz mono G.711 (`pcma` or `pcmu`) payload bytes."""
|
|
24
|
+
encode = CODECS.get(codec)
|
|
25
|
+
if encode is None:
|
|
26
|
+
raise ValueError(f"unknown codec {codec!r}; expected 'pcma' or 'pcmu'")
|
|
27
|
+
try:
|
|
28
|
+
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
|
|
29
|
+
channels = wf.getnchannels()
|
|
30
|
+
width = wf.getsampwidth()
|
|
31
|
+
rate = wf.getframerate()
|
|
32
|
+
frames = wf.readframes(wf.getnframes())
|
|
33
|
+
except (wave.Error, EOFError) as exc:
|
|
34
|
+
raise ValueError(f"input is not a readable PCM WAV: {exc}") from exc
|
|
35
|
+
|
|
36
|
+
if not frames:
|
|
37
|
+
raise ValueError("WAV contains no audio frames") # would otherwise 'play' a silent call
|
|
38
|
+
if channels > 2:
|
|
39
|
+
# ponytail: stereo downmix only, multichannel needs a per-channel mixer.
|
|
40
|
+
raise ValueError(f"unsupported channel count: {channels}")
|
|
41
|
+
if width == 1:
|
|
42
|
+
# WAV 8-bit PCM is unsigned; audioop expects signed samples.
|
|
43
|
+
frames = audioop.bias(frames, 1, 128)
|
|
44
|
+
if width != 2:
|
|
45
|
+
frames = audioop.lin2lin(frames, width, 2)
|
|
46
|
+
if channels == 2:
|
|
47
|
+
frames = audioop.tomono(frames, 2, 0.5, 0.5)
|
|
48
|
+
if rate != TARGET_RATE:
|
|
49
|
+
frames, _ = audioop.ratecv(frames, 2, 1, rate, TARGET_RATE, None)
|
|
50
|
+
return encode(frames, 2)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Outbound SIP call: REGISTER -> INVITE -> play G.711 audio over RTP -> BYE.
|
|
2
|
+
|
|
3
|
+
# ponytail: per-call REGISTER; persistent registration if call setup latency ever matters
|
|
4
|
+
# ponytail: UDP retransmits are a simple 0.5s-doubling resend until the first response; no full
|
|
5
|
+
# RFC 3261 timer set (Timer E/G/H) and no re-INVITE/refresh handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import ssl
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from aiogotoconnect.exceptions import GoToAuthError, GoToCallError, GoToSipError
|
|
16
|
+
from aiogotoconnect.sip.audio import wav_to_g711
|
|
17
|
+
from aiogotoconnect.sip.messages import (
|
|
18
|
+
CODEC_PAYLOADS,
|
|
19
|
+
SipDialog,
|
|
20
|
+
SipResponse,
|
|
21
|
+
_check_number,
|
|
22
|
+
build_ack,
|
|
23
|
+
build_bye,
|
|
24
|
+
build_cancel,
|
|
25
|
+
build_invite,
|
|
26
|
+
build_register,
|
|
27
|
+
digest_response,
|
|
28
|
+
)
|
|
29
|
+
from aiogotoconnect.sip.rtp import RtpSender
|
|
30
|
+
from aiogotoconnect.sip.transport import SipTransport, make_transport
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__) # start-lines/status codes only: never headers (auth values)
|
|
33
|
+
|
|
34
|
+
_REPLY_TIMEOUT = 5.0 # non-INVITE transactions and post-CANCEL/BYE waits
|
|
35
|
+
_GAP_SECONDS = 0.5 # pause between repeats of the audio
|
|
36
|
+
_REFUSALS = {486: "busy", 603: "rejected"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CallResult:
|
|
41
|
+
answered: bool
|
|
42
|
+
duration: float
|
|
43
|
+
reason: str # "completed" | "remote-hangup" | "no-answer" | "busy" | "rejected"
|
|
44
|
+
# False only when we sent a BYE and the server did not answer it with a 200 (the call may
|
|
45
|
+
# still be up on the far side). True when no BYE was needed or the BYE was confirmed.
|
|
46
|
+
bye_confirmed: bool = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _branch_of(request: bytes) -> str:
|
|
50
|
+
return re.search(rb"branch=([^;,\s]+)", request).group(1).decode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _Leg:
|
|
55
|
+
"""An established dialog (after a 200 OK): what in-dialog requests need."""
|
|
56
|
+
|
|
57
|
+
dialog: SipDialog
|
|
58
|
+
to_tag: str
|
|
59
|
+
target: str | None # the 200's Contact URI = request-URI of ACK/BYE
|
|
60
|
+
routes: list[str] # Record-Route reversed (RFC 3261 12.1.2)
|
|
61
|
+
ack: bytes = b""
|
|
62
|
+
invite_cseq: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tag_of(response: SipResponse) -> str:
|
|
66
|
+
m = re.search(r";\s*tag=([^;>\s]+)", response.get("to", ""))
|
|
67
|
+
return m.group(1) if m else ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ok_for(request: bytes) -> bytes:
|
|
71
|
+
"""A 200 OK for an incoming request: echo Via/From/To/Call-ID/CSeq."""
|
|
72
|
+
echo = ("via", "from", "to", "call-id", "cseq")
|
|
73
|
+
lines = request.decode(errors="replace").split("\r\n\r\n")[0].split("\r\n")[1:]
|
|
74
|
+
kept = [ln for ln in lines if ln.partition(":")[0].strip().lower() in echo]
|
|
75
|
+
return "\r\n".join(["SIP/2.0 200 OK", *kept, "Content-Length: 0", "", ""]).encode()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _remote_media(sdp: str) -> tuple[str, int]:
|
|
79
|
+
c = re.search(r"^c=IN IP4 (\S+)", sdp, re.M)
|
|
80
|
+
m = re.search(r"^m=audio (\d+) ", sdp, re.M)
|
|
81
|
+
if not (c and m):
|
|
82
|
+
raise GoToCallError("200 OK carried no usable audio SDP")
|
|
83
|
+
return c.group(1), int(m.group(1))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Call:
|
|
87
|
+
"""One call's signalling; owns the transport and RTP socket for its lifetime."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, tp: SipTransport, mode: str, username, password, domain, number, rtp):
|
|
90
|
+
self.tp, self.via, self.username, self.password = tp, mode.upper(), username, password
|
|
91
|
+
self.domain, self.number, self.rtp = domain, number, rtp
|
|
92
|
+
self.ip, self.port = tp.local_addr
|
|
93
|
+
self.common = dict(username=username, domain=domain, local_ip=self.ip,
|
|
94
|
+
local_port=self.port, transport=self.via)
|
|
95
|
+
self.invite_branch = ""
|
|
96
|
+
self.pending: tuple[SipDialog, str] | None = None # (dialog, branch) of an INVITE with no final response yet
|
|
97
|
+
|
|
98
|
+
async def send(self, data: bytes) -> None:
|
|
99
|
+
log.debug("SIP -> %s", data.split(b"\r\n", 1)[0][:100].decode(errors="replace"))
|
|
100
|
+
await self.tp.send(data)
|
|
101
|
+
|
|
102
|
+
async def recv(self, timeout: float) -> bytes:
|
|
103
|
+
raw = await self.tp.receive(timeout)
|
|
104
|
+
log.debug("SIP <- %s", raw.split(b"\r\n", 1)[0][:100].decode(errors="replace"))
|
|
105
|
+
return raw
|
|
106
|
+
|
|
107
|
+
async def _final(self, method: str, cseq: int, timeout: float,
|
|
108
|
+
resend: bytes | None = None) -> SipResponse:
|
|
109
|
+
"""Next final (>=200) response to CSeq `cseq method`; provisionals, strays and requests are skipped.
|
|
110
|
+
|
|
111
|
+
`resend` (UDP only): retransmit it at 0.5s, 1s, 2s... until the first response to this
|
|
112
|
+
transaction arrives. Matching the whole CSeq ignores a stale retransmitted 407.
|
|
113
|
+
"""
|
|
114
|
+
loop = asyncio.get_running_loop()
|
|
115
|
+
deadline, delay = loop.time() + timeout, 0.5
|
|
116
|
+
if self.via != "UDP":
|
|
117
|
+
resend = None
|
|
118
|
+
next_resend = loop.time() + delay
|
|
119
|
+
while True:
|
|
120
|
+
remaining = max(0.0, deadline - loop.time())
|
|
121
|
+
wait = min(remaining, max(0.0, next_resend - loop.time())) if resend else remaining
|
|
122
|
+
try:
|
|
123
|
+
raw = await self.recv(wait)
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
if not resend or wait >= remaining:
|
|
126
|
+
raise
|
|
127
|
+
await self.send(resend)
|
|
128
|
+
delay *= 2
|
|
129
|
+
next_resend = loop.time() + delay
|
|
130
|
+
continue
|
|
131
|
+
if not raw.startswith(b"SIP/2.0"):
|
|
132
|
+
continue
|
|
133
|
+
resp = SipResponse.parse(raw)
|
|
134
|
+
if resp.get("cseq", "").split() != [str(cseq), method]:
|
|
135
|
+
continue
|
|
136
|
+
resend = None # the server has the request
|
|
137
|
+
if resp.status >= 200:
|
|
138
|
+
return resp
|
|
139
|
+
|
|
140
|
+
def _auth_line(self, resp: SipResponse, method: str, uri: str) -> str:
|
|
141
|
+
name, header = (("Authorization", "www-authenticate") if resp.status == 401
|
|
142
|
+
else ("Proxy-Authorization", "proxy-authenticate"))
|
|
143
|
+
challenge = resp.get(header)
|
|
144
|
+
if not challenge:
|
|
145
|
+
raise GoToSipError(f"{resp.status} response without a digest challenge")
|
|
146
|
+
return f"{name}: " + digest_response(challenge, method, uri, self.username, self.password)
|
|
147
|
+
|
|
148
|
+
async def transact(self, method, uri, dialog, build, timeout) -> SipResponse:
|
|
149
|
+
"""Send, answer one 401/407 challenge, return the final response.
|
|
150
|
+
|
|
151
|
+
`build(auth_line)` makes the request bytes. A second challenge means bad credentials:
|
|
152
|
+
raise rather than loop. Non-2xx finals to INVITE (the 407) are ACKed here.
|
|
153
|
+
"""
|
|
154
|
+
req = build(None)
|
|
155
|
+
for attempt in (0, 1):
|
|
156
|
+
await self.send(req)
|
|
157
|
+
resp = await self._final(method, dialog.cseq, timeout, resend=req)
|
|
158
|
+
if method == "INVITE":
|
|
159
|
+
self.pending = None # a final response ended it: nothing left to CANCEL
|
|
160
|
+
if resp.status not in (401, 407):
|
|
161
|
+
return resp
|
|
162
|
+
if method == "INVITE":
|
|
163
|
+
await self.ack_refusal(dialog, resp, _branch_of(req))
|
|
164
|
+
if attempt:
|
|
165
|
+
raise GoToAuthError("SIP credentials rejected by server")
|
|
166
|
+
dialog.cseq += 1
|
|
167
|
+
req = build(self._auth_line(resp, method, uri))
|
|
168
|
+
if method == "INVITE":
|
|
169
|
+
log.debug("INVITE challenged; retrying with credentials")
|
|
170
|
+
|
|
171
|
+
async def ack_refusal(self, dialog, resp, branch: str) -> None:
|
|
172
|
+
"""ACK a non-2xx final: same transaction (branch) as the INVITE."""
|
|
173
|
+
await self.send(build_ack(to_number=self.number, dialog=dialog, to_tag=_tag_of(resp),
|
|
174
|
+
branch=branch, **self.common))
|
|
175
|
+
|
|
176
|
+
def leg_from(self, dialog: SipDialog, resp: SipResponse) -> _Leg:
|
|
177
|
+
contact = re.search(r"<([^>]+)>|(sip:\S+)", resp.get("contact", ""))
|
|
178
|
+
target = (contact.group(1) or contact.group(2)) if contact else None
|
|
179
|
+
routes = re.findall(r"<[^>]*>", resp.get("record-route", ""))[::-1]
|
|
180
|
+
return _Leg(dialog, _tag_of(resp), target, routes)
|
|
181
|
+
|
|
182
|
+
async def ack_2xx(self, leg: _Leg) -> None:
|
|
183
|
+
"""2xx ACK: a new transaction (fresh branch) to the Contact, via the route set."""
|
|
184
|
+
leg.invite_cseq = leg.dialog.cseq
|
|
185
|
+
leg.ack = build_ack(to_number=self.number, dialog=leg.dialog, to_tag=leg.to_tag,
|
|
186
|
+
request_uri=leg.target, routes=leg.routes, **self.common)
|
|
187
|
+
await self.send(leg.ack)
|
|
188
|
+
|
|
189
|
+
def bye(self, leg: _Leg) -> bytes:
|
|
190
|
+
leg.dialog.cseq += 1
|
|
191
|
+
return build_bye(to_number=self.number, dialog=leg.dialog, to_tag=leg.to_tag,
|
|
192
|
+
request_uri=leg.target, routes=leg.routes, **self.common)
|
|
193
|
+
|
|
194
|
+
async def hangup(self, leg: _Leg) -> bool:
|
|
195
|
+
"""BYE and wait for its 200. False (and a warning) if it is refused or never answered."""
|
|
196
|
+
req = self.bye(leg)
|
|
197
|
+
await self.send(req)
|
|
198
|
+
try:
|
|
199
|
+
resp = await self._final("BYE", leg.dialog.cseq, _REPLY_TIMEOUT, resend=req)
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
log.warning("BYE got no reply; the far side may still think the call is up")
|
|
202
|
+
return False
|
|
203
|
+
if resp.status != 200:
|
|
204
|
+
log.warning("BYE refused: %s %s", resp.status, resp.reason)
|
|
205
|
+
return resp.status == 200
|
|
206
|
+
|
|
207
|
+
async def cancel_pending(self) -> bool:
|
|
208
|
+
"""CANCEL the INVITE that has no final response yet and settle whatever answers it.
|
|
209
|
+
|
|
210
|
+
Returns bye_confirmed. If the 200 raced the CANCEL, the call is up: ACK it, then hang up.
|
|
211
|
+
"""
|
|
212
|
+
dialog, branch = self.pending
|
|
213
|
+
self.pending = None
|
|
214
|
+
await self.send(build_cancel(to_number=self.number, dialog=dialog, branch=branch, **self.common))
|
|
215
|
+
try: # the 487 for the INVITE (the 200 for the CANCEL is skipped by CSeq)
|
|
216
|
+
resp = await self._final("INVITE", dialog.cseq, _REPLY_TIMEOUT)
|
|
217
|
+
except asyncio.TimeoutError:
|
|
218
|
+
return True
|
|
219
|
+
if resp.status == 200:
|
|
220
|
+
leg = self.leg_from(dialog, resp)
|
|
221
|
+
await self.ack_2xx(leg)
|
|
222
|
+
return await self.hangup(leg)
|
|
223
|
+
await self.ack_refusal(dialog, resp, branch)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
async def register(self) -> None:
|
|
227
|
+
dialog, uri = SipDialog(), f"sip:{self.domain}"
|
|
228
|
+
resp = await self.transact("REGISTER", uri, dialog, lambda auth: build_register(
|
|
229
|
+
username=self.username, domain=self.domain, local_ip=self.ip, local_port=self.port,
|
|
230
|
+
dialog=dialog, auth_header=auth, transport=self.via), _REPLY_TIMEOUT)
|
|
231
|
+
if resp.status != 200:
|
|
232
|
+
raise GoToSipError(f"REGISTER failed: {resp.status} {resp.reason}")
|
|
233
|
+
log.debug("registered")
|
|
234
|
+
|
|
235
|
+
async def invite(self, ring_timeout: float, codec: str) -> tuple[SipDialog, SipResponse | None, str, bool]:
|
|
236
|
+
"""Returns (dialog, final response or None on ring timeout, INVITE branch, bye_confirmed)."""
|
|
237
|
+
dialog = SipDialog()
|
|
238
|
+
|
|
239
|
+
def build(auth):
|
|
240
|
+
req = build_invite(to_number=self.number, media_port=self.rtp.local_port,
|
|
241
|
+
dialog=dialog, auth_header=auth, codec=codec, **self.common)
|
|
242
|
+
self.invite_branch = _branch_of(req)
|
|
243
|
+
self.pending = (dialog, self.invite_branch)
|
|
244
|
+
return req
|
|
245
|
+
|
|
246
|
+
uri = f"sip:{self.number}@{self.domain}"
|
|
247
|
+
try:
|
|
248
|
+
resp = await self.transact("INVITE", uri, dialog, build, ring_timeout)
|
|
249
|
+
except asyncio.TimeoutError:
|
|
250
|
+
log.debug("ring timeout; cancelling")
|
|
251
|
+
return dialog, None, self.invite_branch, await self.cancel_pending()
|
|
252
|
+
return dialog, resp, self.invite_branch, True
|
|
253
|
+
|
|
254
|
+
async def play(self, payload: bytes, dest, repeat: int, stop: asyncio.Event, pt: int) -> None:
|
|
255
|
+
for n in range(repeat):
|
|
256
|
+
if n:
|
|
257
|
+
try: # the inter-repeat gap, cut short by a remote hangup
|
|
258
|
+
await asyncio.wait_for(stop.wait(), _GAP_SECONDS)
|
|
259
|
+
except asyncio.TimeoutError:
|
|
260
|
+
pass
|
|
261
|
+
if stop.is_set():
|
|
262
|
+
return
|
|
263
|
+
await self.rtp.stream(payload, dest, payload_type=pt, stop=stop)
|
|
264
|
+
|
|
265
|
+
async def stream_until_done(self, payload: bytes, dest, repeat: int, pt: int, leg: _Leg) -> bool:
|
|
266
|
+
"""Stream audio while watching for the server's BYE. True if the remote hung up.
|
|
267
|
+
|
|
268
|
+
A retransmitted 200 to our INVITE means the server missed our ACK: send it again.
|
|
269
|
+
"""
|
|
270
|
+
stop = asyncio.Event()
|
|
271
|
+
play = asyncio.create_task(self.play(payload, dest, repeat, stop, pt))
|
|
272
|
+
hung_up, rx = False, None
|
|
273
|
+
try:
|
|
274
|
+
while not play.done():
|
|
275
|
+
rx = asyncio.create_task(self.recv(3600))
|
|
276
|
+
await asyncio.wait({rx, play}, return_when=asyncio.FIRST_COMPLETED)
|
|
277
|
+
if not rx.done():
|
|
278
|
+
break # audio finished; rx is reaped in finally
|
|
279
|
+
raw = rx.result()
|
|
280
|
+
if raw.startswith(b"BYE "):
|
|
281
|
+
await self.send(_ok_for(raw))
|
|
282
|
+
hung_up = True
|
|
283
|
+
stop.set()
|
|
284
|
+
break
|
|
285
|
+
if raw.startswith(b"SIP/2.0 200"):
|
|
286
|
+
resp = SipResponse.parse(raw)
|
|
287
|
+
if resp.get("cseq", "").split() == [str(leg.invite_cseq), "INVITE"]:
|
|
288
|
+
await self.send(leg.ack)
|
|
289
|
+
await play
|
|
290
|
+
finally:
|
|
291
|
+
stop.set()
|
|
292
|
+
for task in (rx, play): # also runs on outer cancellation: leave no orphan tasks
|
|
293
|
+
if task is not None and not task.done():
|
|
294
|
+
task.cancel()
|
|
295
|
+
await asyncio.gather(*(t for t in (rx, play) if t is not None), return_exceptions=True)
|
|
296
|
+
return hung_up
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class GoToVoice:
|
|
300
|
+
def __init__(self, username: str, password: str, server: str = "reg.jiveip.net",
|
|
301
|
+
proxy: str | None = None, transport: str = "udp", port: int | None = None,
|
|
302
|
+
ssl_context: ssl.SSLContext | None = None) -> None:
|
|
303
|
+
self.username, self.password, self.server = username, password, server
|
|
304
|
+
self.proxy, self.transport, self.port = proxy, transport, port
|
|
305
|
+
self.ssl_context = ssl_context # TLS only; None = verified system CAs
|
|
306
|
+
|
|
307
|
+
async def call_and_play(self, number: str, wav_bytes: bytes, *, ring_timeout: float = 25.0,
|
|
308
|
+
repeat: int = 1, codec: str = "pcma") -> CallResult:
|
|
309
|
+
_check_number(number) # fail before touching the network
|
|
310
|
+
payload = wav_to_g711(wav_bytes, codec) # also rejects unknown codecs (ValueError)
|
|
311
|
+
pt = CODEC_PAYLOADS[codec][0]
|
|
312
|
+
tp = make_transport(self.proxy or self.server, self.transport, self.port, self.ssl_context)
|
|
313
|
+
rtp = call = leg = None
|
|
314
|
+
acked = bye_sent = False
|
|
315
|
+
try:
|
|
316
|
+
await tp.connect()
|
|
317
|
+
rtp = RtpSender() # bound now so the INVITE's SDP carries the real port
|
|
318
|
+
call = _Call(tp, self.transport, self.username, self.password, self.server, number, rtp)
|
|
319
|
+
await call.register()
|
|
320
|
+
dialog, resp, branch, bye_ok = await call.invite(ring_timeout, codec)
|
|
321
|
+
if resp is None:
|
|
322
|
+
return CallResult(False, 0.0, "no-answer", bye_ok)
|
|
323
|
+
if resp.status in _REFUSALS:
|
|
324
|
+
await call.ack_refusal(dialog, resp, branch)
|
|
325
|
+
return CallResult(False, 0.0, _REFUSALS[resp.status])
|
|
326
|
+
if resp.status != 200:
|
|
327
|
+
await call.ack_refusal(dialog, resp, branch)
|
|
328
|
+
raise GoToCallError(f"call failed: {resp.status} {resp.reason}")
|
|
329
|
+
|
|
330
|
+
log.debug("answered")
|
|
331
|
+
leg = call.leg_from(dialog, resp)
|
|
332
|
+
started = time.monotonic()
|
|
333
|
+
await call.ack_2xx(leg)
|
|
334
|
+
acked = True # dialog is up: from here the finally must hang up on any failure
|
|
335
|
+
dest = _remote_media(resp.body)
|
|
336
|
+
log.debug("streaming audio to %s:%s", *dest)
|
|
337
|
+
hung_up = await call.stream_until_done(payload, dest, repeat, pt, leg)
|
|
338
|
+
duration = time.monotonic() - started
|
|
339
|
+
if hung_up:
|
|
340
|
+
acked = False # the server already ended the dialog
|
|
341
|
+
log.debug("remote hangup")
|
|
342
|
+
return CallResult(True, duration, "remote-hangup")
|
|
343
|
+
bye_sent = True
|
|
344
|
+
confirmed = await call.hangup(leg)
|
|
345
|
+
return CallResult(True, time.monotonic() - started, "completed", confirmed)
|
|
346
|
+
except OSError as exc: # TimeoutError, gaierror, ConnectionError, ssl.SSLError ...
|
|
347
|
+
raise GoToSipError(f"SIP I/O failure: {type(exc).__name__}: {exc}") from exc
|
|
348
|
+
finally:
|
|
349
|
+
if acked and not bye_sent:
|
|
350
|
+
try: # best-effort hangup so the callee isn't left off-hook; no wait for its 200
|
|
351
|
+
await call.send(call.bye(leg))
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
elif call is not None and call.pending is not None:
|
|
355
|
+
try: # still ringing (e.g. task cancelled): best-effort CANCEL so the phone stops
|
|
356
|
+
await call.cancel_pending()
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
if rtp:
|
|
360
|
+
await rtp.close()
|
|
361
|
+
await tp.close()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Pure SIP message layer: build requests, parse responses, compute digest auth.
|
|
2
|
+
|
|
3
|
+
No sockets, no state machine. Header structure mirrors the SIPp scenarios
|
|
4
|
+
proven against GoTo production. `auth_header` args are complete header lines
|
|
5
|
+
(e.g. "Proxy-Authorization: Digest ..."), so the caller picks the header name
|
|
6
|
+
from the challenge type (407 -> Proxy-Authorization, 401 -> Authorization).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from aiogotoconnect.exceptions import GoToSipError
|
|
17
|
+
|
|
18
|
+
CRLF = "\r\n"
|
|
19
|
+
# codec -> (RTP payload type, rtpmap name)
|
|
20
|
+
CODEC_PAYLOADS = {"pcma": (8, "PCMA"), "pcmu": (0, "PCMU")}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SipResponse:
|
|
25
|
+
status: int
|
|
26
|
+
reason: str
|
|
27
|
+
headers: dict[str, str] # keys lower-cased; repeated headers joined with ", "
|
|
28
|
+
body: str
|
|
29
|
+
|
|
30
|
+
def get(self, name: str, default: str | None = None) -> str | None:
|
|
31
|
+
return self.headers.get(name.lower(), default)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def parse(cls, raw: bytes) -> "SipResponse":
|
|
35
|
+
head, _, body = raw.decode("utf-8", errors="replace").partition(CRLF + CRLF)
|
|
36
|
+
lines = head.split(CRLF)
|
|
37
|
+
m = re.fullmatch(r"SIP/2\.0 (\d{3})(?: (.*))?", lines[0])
|
|
38
|
+
if not m:
|
|
39
|
+
raise GoToSipError(f"unparseable SIP response: {lines[0][:60]!r}")
|
|
40
|
+
headers: dict[str, str] = {}
|
|
41
|
+
for line in lines[1:]:
|
|
42
|
+
name, sep, value = line.partition(":")
|
|
43
|
+
if not sep:
|
|
44
|
+
continue # ponytail: header line folding not supported; GoTo doesn't fold
|
|
45
|
+
key = name.strip().lower()
|
|
46
|
+
value = value.strip()
|
|
47
|
+
headers[key] = f"{headers[key]}, {value}" if key in headers else value
|
|
48
|
+
return cls(int(m.group(1)), m.group(2) or "", headers, body)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SipDialog:
|
|
53
|
+
"""Identifiers for one call. Callers bump `cseq` between transactions."""
|
|
54
|
+
|
|
55
|
+
call_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
56
|
+
from_tag: str = field(default_factory=lambda: secrets.token_hex(4))
|
|
57
|
+
cseq: int = 1
|
|
58
|
+
|
|
59
|
+
def branch(self) -> str:
|
|
60
|
+
return "z9hG4bK" + secrets.token_hex(8)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _request(method: str, request_uri: str, dialog: SipDialog, username: str, domain: str,
|
|
64
|
+
local_ip: str, local_port: int, transport: str, to_uri: str, to_tag: str = "",
|
|
65
|
+
branch: str | None = None, cseq_method: str | None = None, extra: Sequence[str] = (),
|
|
66
|
+
routes: Sequence[str] = (), body: str = "") -> bytes:
|
|
67
|
+
# Over TLS the Contact must say so, or the server sends in-dialog requests (its BYE) over UDP.
|
|
68
|
+
contact_params = ";transport=tls" if transport.upper() == "TLS" else ""
|
|
69
|
+
lines = [
|
|
70
|
+
f"{method} {request_uri} SIP/2.0",
|
|
71
|
+
f"Via: SIP/2.0/{transport} {local_ip}:{local_port};branch={branch or dialog.branch()}",
|
|
72
|
+
f"From: <sip:{username}@{domain}>;tag={dialog.from_tag}",
|
|
73
|
+
f"To: <{to_uri}>" + (f";tag={to_tag}" if to_tag else ""),
|
|
74
|
+
f"Call-ID: {dialog.call_id}",
|
|
75
|
+
f"CSeq: {dialog.cseq} {cseq_method or method}",
|
|
76
|
+
*(f"Route: {r}" for r in routes),
|
|
77
|
+
f"Contact: <sip:{username}@{local_ip}:{local_port}{contact_params}>",
|
|
78
|
+
"Max-Forwards: 70",
|
|
79
|
+
*extra,
|
|
80
|
+
f"Content-Length: {len(body.encode())}",
|
|
81
|
+
]
|
|
82
|
+
# Header-injection guard: no CR/LF/control chars in any header line or URI.
|
|
83
|
+
if any(ord(c) < 0x20 or ord(c) == 0x7F for line in lines for c in line):
|
|
84
|
+
raise GoToSipError("control character in SIP header value")
|
|
85
|
+
return (CRLF.join(lines) + CRLF + CRLF + body).encode()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_number(to_number: str) -> str:
|
|
89
|
+
if not re.fullmatch(r"\+?[0-9*#]{1,32}", to_number):
|
|
90
|
+
raise ValueError(f"invalid destination number: {to_number!r}")
|
|
91
|
+
return to_number
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_register(username: str, domain: str, local_ip: str, local_port: int, dialog: SipDialog,
|
|
95
|
+
expires: int = 60, auth_header: str | None = None, transport: str = "UDP") -> bytes:
|
|
96
|
+
return _request("REGISTER", f"sip:{domain}", dialog, username, domain, local_ip, local_port,
|
|
97
|
+
transport, f"sip:{username}@{domain}",
|
|
98
|
+
extra=[f"Expires: {expires}", *([auth_header] if auth_header else [])])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sdp(local_ip: str, media_port: int, codec: str = "pcma") -> str:
|
|
102
|
+
pt, name = CODEC_PAYLOADS[codec]
|
|
103
|
+
return CRLF.join([
|
|
104
|
+
"v=0",
|
|
105
|
+
f"o=- 53655765 2353687637 IN IP4 {local_ip}",
|
|
106
|
+
"s=GoTo",
|
|
107
|
+
f"c=IN IP4 {local_ip}",
|
|
108
|
+
"t=0 0",
|
|
109
|
+
f"m=audio {media_port} RTP/AVP {pt} 101",
|
|
110
|
+
f"a=rtpmap:{pt} {name}/8000",
|
|
111
|
+
"a=ptime:20",
|
|
112
|
+
"a=sendrecv",
|
|
113
|
+
"a=rtpmap:101 telephone-event/8000",
|
|
114
|
+
"a=fmtp:101 0-15",
|
|
115
|
+
"",
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_invite(username: str, domain: str, to_number: str, local_ip: str, local_port: int,
|
|
120
|
+
media_port: int, dialog: SipDialog, auth_header: str | None = None,
|
|
121
|
+
transport: str = "UDP", codec: str = "pcma") -> bytes:
|
|
122
|
+
uri = f"sip:{_check_number(to_number)}@{domain}"
|
|
123
|
+
return _request("INVITE", uri, dialog, username, domain, local_ip, local_port, transport, uri,
|
|
124
|
+
extra=[*([auth_header] if auth_header else []), "Content-Type: application/sdp"],
|
|
125
|
+
body=_sdp(local_ip, media_port, codec))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_ack(username: str, domain: str, to_number: str, local_ip: str, local_port: int,
|
|
129
|
+
dialog: SipDialog, to_tag: str = "", routes: Sequence[str] = (),
|
|
130
|
+
branch: str | None = None, transport: str = "UDP",
|
|
131
|
+
request_uri: str | None = None) -> bytes:
|
|
132
|
+
"""Pass the INVITE's `branch` when ACKing a non-2xx; omit it for a 2xx ACK (new transaction).
|
|
133
|
+
`request_uri` = the 200 OK's Contact URI for a 2xx ACK (RFC 3261 12.2.1.1); To stays the callee."""
|
|
134
|
+
uri = f"sip:{_check_number(to_number)}@{domain}"
|
|
135
|
+
return _request("ACK", request_uri or uri, dialog, username, domain, local_ip, local_port,
|
|
136
|
+
transport, uri, to_tag=to_tag, branch=branch, routes=routes)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_bye(username: str, domain: str, to_number: str, local_ip: str, local_port: int,
|
|
140
|
+
dialog: SipDialog, to_tag: str = "", routes: Sequence[str] = (),
|
|
141
|
+
transport: str = "UDP", request_uri: str | None = None) -> bytes:
|
|
142
|
+
"""`request_uri` = the 200 OK's Contact URI (in-dialog requests go to the remote target)."""
|
|
143
|
+
uri = f"sip:{_check_number(to_number)}@{domain}"
|
|
144
|
+
return _request("BYE", request_uri or uri, dialog, username, domain, local_ip, local_port,
|
|
145
|
+
transport, uri, to_tag=to_tag, routes=routes)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_cancel(username: str, domain: str, to_number: str, local_ip: str, local_port: int,
|
|
149
|
+
dialog: SipDialog, branch: str, transport: str = "UDP") -> bytes:
|
|
150
|
+
"""`branch` and dialog.cseq must match the INVITE being cancelled (RFC 3261 9.1)."""
|
|
151
|
+
uri = f"sip:{_check_number(to_number)}@{domain}"
|
|
152
|
+
return _request("CANCEL", uri, dialog, username, domain, local_ip, local_port, transport, uri,
|
|
153
|
+
branch=branch)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _md5(s: str) -> str:
|
|
157
|
+
return hashlib.md5(s.encode()).hexdigest()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def digest_response(challenge: str, method: str, uri: str, username: str, password: str,
|
|
161
|
+
cnonce: str | None = None) -> str:
|
|
162
|
+
"""Answer a WWW-/Proxy-Authenticate challenge; returns "Digest username=..., ..." (value only)."""
|
|
163
|
+
params = {k.lower(): v1 or v2 for k, v1, v2 in
|
|
164
|
+
re.findall(r'(\w+)=(?:"([^"]*)"|([^\s,]*))', challenge.partition(" ")[2])}
|
|
165
|
+
algorithm = params.get("algorithm", "MD5")
|
|
166
|
+
if algorithm.upper() != "MD5":
|
|
167
|
+
raise GoToSipError(f"unsupported digest algorithm: {algorithm}")
|
|
168
|
+
if "realm" not in params or "nonce" not in params:
|
|
169
|
+
raise GoToSipError("malformed digest challenge: missing realm or nonce")
|
|
170
|
+
realm, nonce = params["realm"], params["nonce"]
|
|
171
|
+
ha1 = _md5(f"{username}:{realm}:{password}")
|
|
172
|
+
ha2 = _md5(f"{method}:{uri}")
|
|
173
|
+
fields = [f'username="{username}"', f'realm="{realm}"', f'nonce="{nonce}"', f'uri="{uri}"']
|
|
174
|
+
if "auth" in [q.strip() for q in params.get("qop", "").split(",")]:
|
|
175
|
+
cnonce = cnonce or secrets.token_hex(8)
|
|
176
|
+
nc = "00000001"
|
|
177
|
+
response = _md5(f"{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}")
|
|
178
|
+
tail = ["qop=auth", f"nc={nc}", f'cnonce="{cnonce}"']
|
|
179
|
+
else:
|
|
180
|
+
response = _md5(f"{ha1}:{nonce}:{ha2}")
|
|
181
|
+
tail = []
|
|
182
|
+
fields += [f'response="{response}"', "algorithm=MD5", *tail]
|
|
183
|
+
if "opaque" in params:
|
|
184
|
+
fields.append(f'opaque="{params["opaque"]}"')
|
|
185
|
+
return "Digest " + ", ".join(fields)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Paced RTP sender for G.711 payloads (160-byte frames, 20 ms apart)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
FRAME_BYTES = 160
|
|
9
|
+
FRAME_SECONDS = 0.02
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RtpSender:
|
|
13
|
+
def __init__(self, local_port: int = 0) -> None:
|
|
14
|
+
# Bound synchronously so local_port is known before the INVITE/SDP is built.
|
|
15
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
16
|
+
self._sock.setblocking(False)
|
|
17
|
+
self._sock.bind(("0.0.0.0", local_port))
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def local_port(self) -> int:
|
|
21
|
+
return self._sock.getsockname()[1]
|
|
22
|
+
|
|
23
|
+
async def stream(
|
|
24
|
+
self,
|
|
25
|
+
payload: bytes,
|
|
26
|
+
dest: tuple[str, int],
|
|
27
|
+
payload_type: int = 8,
|
|
28
|
+
stop: asyncio.Event | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
loop = asyncio.get_running_loop()
|
|
31
|
+
seq = random.randrange(0x10000)
|
|
32
|
+
timestamp = random.randrange(0x100000000)
|
|
33
|
+
ssrc = random.randrange(0x100000000)
|
|
34
|
+
start = loop.time()
|
|
35
|
+
# A trailing partial frame (< 160 bytes) is dropped, not padded: the
|
|
36
|
+
# right silence byte depends on the codec, and it is under 20 ms of audio.
|
|
37
|
+
for n in range(len(payload) // FRAME_BYTES):
|
|
38
|
+
if stop is not None and stop.is_set():
|
|
39
|
+
return
|
|
40
|
+
# Marker bit on the first packet only (talkspurt start).
|
|
41
|
+
pt_byte = payload_type | (0x80 if n == 0 else 0)
|
|
42
|
+
header = struct.pack("!BBHII", 0x80, pt_byte, seq, timestamp, ssrc)
|
|
43
|
+
frame = payload[n * FRAME_BYTES : (n + 1) * FRAME_BYTES]
|
|
44
|
+
await loop.sock_sendto(self._sock, header + frame, dest)
|
|
45
|
+
seq = (seq + 1) & 0xFFFF
|
|
46
|
+
timestamp = (timestamp + FRAME_BYTES) & 0xFFFFFFFF
|
|
47
|
+
# Drift-free pacing: sleep to an absolute target, not a fixed interval.
|
|
48
|
+
await asyncio.sleep(max(0, start + (n + 1) * FRAME_SECONDS - loop.time()))
|
|
49
|
+
|
|
50
|
+
async def close(self) -> None:
|
|
51
|
+
self._sock.close()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""SIP transports: bytes in, bytes out. One datagram (UDP) or one framed message (TLS) per receive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import ssl
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from aiogotoconnect.exceptions import GoToSipError
|
|
11
|
+
|
|
12
|
+
_CONTENT_LENGTH = re.compile(rb"^(?:content-length|l)[ \t]*:[ \t]*(\d+)", re.I | re.M)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SipTransport(Protocol):
|
|
16
|
+
async def connect(self) -> None: ...
|
|
17
|
+
async def send(self, data: bytes) -> None: ...
|
|
18
|
+
async def receive(self, timeout: float) -> bytes: ...
|
|
19
|
+
async def close(self) -> None: ...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def local_addr(self) -> tuple[str, int]: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _UdpProtocol(asyncio.DatagramProtocol):
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
28
|
+
|
|
29
|
+
def datagram_received(self, data: bytes, addr) -> None:
|
|
30
|
+
self.queue.put_nowait(data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UdpTransport:
|
|
34
|
+
def __init__(self, host: str, port: int = 5060):
|
|
35
|
+
self.host, self.port = host, port
|
|
36
|
+
self._transport: asyncio.DatagramTransport | None = None
|
|
37
|
+
self._protocol: _UdpProtocol | None = None
|
|
38
|
+
|
|
39
|
+
async def connect(self) -> None:
|
|
40
|
+
self._transport, self._protocol = await asyncio.get_running_loop().create_datagram_endpoint(
|
|
41
|
+
_UdpProtocol, remote_addr=(self.host, self.port)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def send(self, data: bytes) -> None:
|
|
45
|
+
if self._transport is None:
|
|
46
|
+
raise GoToSipError("UDP transport not connected")
|
|
47
|
+
self._transport.sendto(data)
|
|
48
|
+
|
|
49
|
+
async def receive(self, timeout: float) -> bytes:
|
|
50
|
+
if self._protocol is None:
|
|
51
|
+
raise GoToSipError("UDP transport not connected")
|
|
52
|
+
return await asyncio.wait_for(self._protocol.queue.get(), timeout)
|
|
53
|
+
|
|
54
|
+
async def close(self) -> None:
|
|
55
|
+
if self._transport is not None:
|
|
56
|
+
self._transport.close()
|
|
57
|
+
self._transport = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def local_addr(self) -> tuple[str, int]:
|
|
61
|
+
if self._transport is None:
|
|
62
|
+
raise GoToSipError("UDP transport not connected")
|
|
63
|
+
return self._transport.get_extra_info("sockname")[:2]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TlsTransport:
|
|
67
|
+
def __init__(self, host: str, port: int = 5061, ssl_context: ssl.SSLContext | None = None):
|
|
68
|
+
self.host, self.port = host, port
|
|
69
|
+
self._ssl = ssl_context or ssl.create_default_context()
|
|
70
|
+
self._reader: asyncio.StreamReader | None = None
|
|
71
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
72
|
+
# Bytes read but not yet returned; kept across calls so a receive timeout
|
|
73
|
+
# mid-message never loses data.
|
|
74
|
+
self._buf = b""
|
|
75
|
+
|
|
76
|
+
async def connect(self) -> None:
|
|
77
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
78
|
+
self.host, self.port, ssl=self._ssl, server_hostname=self.host
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def send(self, data: bytes) -> None:
|
|
82
|
+
if self._writer is None:
|
|
83
|
+
raise GoToSipError("TLS transport not connected")
|
|
84
|
+
self._writer.write(data)
|
|
85
|
+
await self._writer.drain()
|
|
86
|
+
|
|
87
|
+
def _pop_message(self) -> bytes | None:
|
|
88
|
+
"""Return one complete message from the buffer, or None if more bytes are needed."""
|
|
89
|
+
end = self._buf.find(b"\r\n\r\n")
|
|
90
|
+
if end < 0:
|
|
91
|
+
return None
|
|
92
|
+
head_end = end + 4
|
|
93
|
+
m = _CONTENT_LENGTH.search(self._buf[:head_end])
|
|
94
|
+
total = head_end + (int(m.group(1)) if m else 0)
|
|
95
|
+
if len(self._buf) < total:
|
|
96
|
+
return None
|
|
97
|
+
msg, self._buf = self._buf[:total], self._buf[total:]
|
|
98
|
+
return msg
|
|
99
|
+
|
|
100
|
+
async def _read_message(self) -> bytes:
|
|
101
|
+
while (msg := self._pop_message()) is None:
|
|
102
|
+
chunk = await self._reader.read(4096)
|
|
103
|
+
if not chunk:
|
|
104
|
+
raise GoToSipError("TLS connection closed by peer")
|
|
105
|
+
self._buf += chunk
|
|
106
|
+
return msg
|
|
107
|
+
|
|
108
|
+
async def receive(self, timeout: float) -> bytes:
|
|
109
|
+
if self._reader is None:
|
|
110
|
+
raise GoToSipError("TLS transport not connected")
|
|
111
|
+
return await asyncio.wait_for(self._read_message(), timeout)
|
|
112
|
+
|
|
113
|
+
async def close(self) -> None:
|
|
114
|
+
if self._writer is not None:
|
|
115
|
+
self._writer.close()
|
|
116
|
+
try:
|
|
117
|
+
await self._writer.wait_closed()
|
|
118
|
+
except (ssl.SSLError, OSError):
|
|
119
|
+
pass
|
|
120
|
+
self._writer = self._reader = None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def local_addr(self) -> tuple[str, int]:
|
|
124
|
+
if self._writer is None:
|
|
125
|
+
raise GoToSipError("TLS transport not connected")
|
|
126
|
+
return self._writer.get_extra_info("sockname")[:2]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_transport(host: str, mode: str, port: int | None = None,
|
|
130
|
+
ssl_context: ssl.SSLContext | None = None) -> SipTransport:
|
|
131
|
+
if mode == "udp":
|
|
132
|
+
return UdpTransport(host, port or 5060)
|
|
133
|
+
if mode == "tls":
|
|
134
|
+
return TlsTransport(host, port or 5061, ssl_context)
|
|
135
|
+
raise ValueError(f"unknown SIP transport mode: {mode!r}")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiogotoconnect
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Async Python client for GoTo Connect: SMS via Messaging v2, outbound TTS voice calls via SIP
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: aiohttp>=3.9
|
|
10
|
+
Requires-Dist: audioop-lts>=0.2; python_version >= "3.13"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-aiohttp; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# aiogotoconnect
|
|
18
|
+
|
|
19
|
+
Async Python client for GoTo Connect: send SMS via the Messaging v2 API and place outbound text-to-speech voice calls over SIP. Pure Python, asyncio only.
|
|
20
|
+
|
|
21
|
+
Used by [ha-goto](https://github.com/oneofthegeeks/ha-goto), the Home Assistant integration.
|
|
22
|
+
|
|
23
|
+
Formerly published as `gotoconnect` (v0.1.0); renamed so the unofficial client does not carry the bare product name. The old PyPI name stays claimed but dormant.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install aiogotoconnect
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python 3.12+.
|
|
32
|
+
|
|
33
|
+
## SMS
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from aiogotoconnect import GoToOAuth, GoToMessaging
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
auth = GoToOAuth(client_id="...", client_secret="...", refresh_token="...")
|
|
41
|
+
sms = GoToMessaging(auth.async_get_access_token)
|
|
42
|
+
await sms.async_send_sms("+15551230000", ["+15551234567"], "Hello from aiogotoconnect")
|
|
43
|
+
# GoTo may rotate the refresh token: persist auth.refresh_token after calls.
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`GoToMessaging` takes any async callable returning a bearer token, so a host app (e.g. Home Assistant) can supply its own.
|
|
49
|
+
|
|
50
|
+
## Voice calls
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from aiogotoconnect import GoToVoice
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
voice = GoToVoice("sip-username", "sip-password") # server="reg.jiveip.net"
|
|
58
|
+
with open("message.wav", "rb") as f: # 16-bit PCM WAV
|
|
59
|
+
result = await voice.call_and_play("+15551234567", f.read(), ring_timeout=25, repeat=1)
|
|
60
|
+
print(result) # CallResult(answered=True, duration=4.2, reason='completed')
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`reason` is one of `completed`, `remote-hangup`, `no-answer`, `busy`, `rejected`. `bye_confirmed` is `False` only when we hung up but the server never answered (or refused) our BYE, so the far side may still think the call is up; it is `True` when no BYE was needed or it was confirmed. Bad SIP credentials raise `GoToAuthError`.
|
|
66
|
+
|
|
67
|
+
UDP requests are retransmitted (0.5 s, 1 s, 2 s, ...) until the server responds, and the Record-Route set from the answer is honoured on the ACK and BYE. Cancelling the `call_and_play` task hangs up (or CANCELs a ringing call) before the cancellation propagates.
|
|
68
|
+
|
|
69
|
+
### Logging
|
|
70
|
+
|
|
71
|
+
Everything logs to `logging.getLogger("aiogotoconnect.sip.call")` at DEBUG: SIP start-lines and status codes plus state changes, never headers or credentials. `examples/make_call.py --verbose` turns it on.
|
|
72
|
+
|
|
73
|
+
## Exceptions
|
|
74
|
+
|
|
75
|
+
All library errors derive from `GoToError`:
|
|
76
|
+
|
|
77
|
+
- `GoToAuthError`: credentials rejected (OAuth refresh failed, SIP digest refused, SMS 401).
|
|
78
|
+
- `GoToApiError`: a REST call returned a non-success status (`.status`).
|
|
79
|
+
- `GoToSipError`: SIP registration/signalling failed, including network failures (timeouts, DNS, TLS errors are wrapped, never raised raw).
|
|
80
|
+
- `GoToCallError`: the call itself failed (e.g. 4xx/5xx to the INVITE, no usable SDP).
|
|
81
|
+
|
|
82
|
+
Bad arguments raise plain `ValueError` before any network traffic: an invalid destination number, a WAV that is unreadable, empty or has more than two channels, or an unknown `codec`.
|
|
83
|
+
|
|
84
|
+
### Create the SIP device in GoTo
|
|
85
|
+
|
|
86
|
+
1. In GoTo Connect Admin, go to **Devices** -> **Add Device** -> **SIP Device**.
|
|
87
|
+
2. Copy the **SIP Username**, **Password**, and the device's **SIP Server** and
|
|
88
|
+
**Outbound Proxy**. The registrar is typically `reg.jiveip.net` (the library's
|
|
89
|
+
default); the outbound proxy is account-specific (e.g.
|
|
90
|
+
`<account>.jive.rtcfront.net`) — pass it as `proxy=`. Calls proven live against
|
|
91
|
+
GoTo used both.
|
|
92
|
+
|
|
93
|
+
### Transport
|
|
94
|
+
|
|
95
|
+
UDP (port 5060) is the default. TLS (port 5061) is supported: `GoToVoice(..., transport="tls")`. TLS verifies the server certificate with the system CAs; pass `ssl_context=` to override. An outbound `proxy=` host, if set, is the connection target while `server` stays in the SIP URIs.
|
|
96
|
+
|
|
97
|
+
### Live smoke test
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
GOTO_SIP_USERNAME=... GOTO_SIP_PASSWORD=... python examples/make_call.py --to +15551234567 --wav message.wav --transport tls
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python3 -m venv .venv
|
|
107
|
+
.venv/bin/pip install -e '.[dev]'
|
|
108
|
+
.venv/bin/python -m pytest
|
|
109
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
aiogotoconnect/__init__.py,sha256=I7mw4br-wNp32_s5MnIHLoXHCyEbEehREc2y4kjlgtA,505
|
|
2
|
+
aiogotoconnect/auth.py,sha256=Ouo5Ipcm0ZrXRludMEt3FB4z6Ridx4x1tMc_2LrF6Ew,3494
|
|
3
|
+
aiogotoconnect/exceptions.py,sha256=muMP_hB_E3xnzd_TNK2no_447u0FAtmZGM69iZmjdGQ,614
|
|
4
|
+
aiogotoconnect/messaging.py,sha256=4gKHAPRZXkIYiXi820Ih6eAyhN-eFQ3JoYj29tXkCew,2401
|
|
5
|
+
aiogotoconnect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
aiogotoconnect/sip/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
aiogotoconnect/sip/audio.py,sha256=N-3G1-fZQ_I6cQu2N7FyOUSztHa0LLq8-EjRY55IGoM,2019
|
|
8
|
+
aiogotoconnect/sip/call.py,sha256=9GxxxbvGc3bu2CZypz7wkZVECkfwfbKIvqwQ3q1Gmec,16686
|
|
9
|
+
aiogotoconnect/sip/messages.py,sha256=Hf3OJx_gKDQ4fD9TOO597TtMYw735sY-VxgJhlpwKm8,8503
|
|
10
|
+
aiogotoconnect/sip/rtp.py,sha256=stcwaqxu8XvkK2bkPk4oLUIaVcmfb3hazOcMfCbdpRQ,1932
|
|
11
|
+
aiogotoconnect/sip/transport.py,sha256=UuquKdtw69IkoMxbBk4G1JgF-ufxGSSelXshzl2muE8,4846
|
|
12
|
+
aiogotoconnect-0.2.0.dist-info/licenses/LICENSE,sha256=_WgsbPtGW6Ljl9O-bBXsB1CXXvLTnHr5A0dJvvAh6bA,1070
|
|
13
|
+
aiogotoconnect-0.2.0.dist-info/METADATA,sha256=c_Ep975j3fn9qJIkmfJLyiCSiw1R-6Izv_9LJu26S1I,4484
|
|
14
|
+
aiogotoconnect-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
aiogotoconnect-0.2.0.dist-info/top_level.txt,sha256=XTlXped6I6vZu_E-uyNF1Ck2dApdrOUhgoXaZ6KWzec,15
|
|
16
|
+
aiogotoconnect-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oneofthegeeks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiogotoconnect
|