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.
@@ -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
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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