aiogotoconnect 0.2.0__tar.gz

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,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,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,93 @@
1
+ # aiogotoconnect
2
+
3
+ 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.
4
+
5
+ Used by [ha-goto](https://github.com/oneofthegeeks/ha-goto), the Home Assistant integration.
6
+
7
+ 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.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install aiogotoconnect
13
+ ```
14
+
15
+ Python 3.12+.
16
+
17
+ ## SMS
18
+
19
+ ```python
20
+ import asyncio
21
+ from aiogotoconnect import GoToOAuth, GoToMessaging
22
+
23
+ async def main():
24
+ auth = GoToOAuth(client_id="...", client_secret="...", refresh_token="...")
25
+ sms = GoToMessaging(auth.async_get_access_token)
26
+ await sms.async_send_sms("+15551230000", ["+15551234567"], "Hello from aiogotoconnect")
27
+ # GoTo may rotate the refresh token: persist auth.refresh_token after calls.
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ `GoToMessaging` takes any async callable returning a bearer token, so a host app (e.g. Home Assistant) can supply its own.
33
+
34
+ ## Voice calls
35
+
36
+ ```python
37
+ import asyncio
38
+ from aiogotoconnect import GoToVoice
39
+
40
+ async def main():
41
+ voice = GoToVoice("sip-username", "sip-password") # server="reg.jiveip.net"
42
+ with open("message.wav", "rb") as f: # 16-bit PCM WAV
43
+ result = await voice.call_and_play("+15551234567", f.read(), ring_timeout=25, repeat=1)
44
+ print(result) # CallResult(answered=True, duration=4.2, reason='completed')
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ `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`.
50
+
51
+ 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.
52
+
53
+ ### Logging
54
+
55
+ 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.
56
+
57
+ ## Exceptions
58
+
59
+ All library errors derive from `GoToError`:
60
+
61
+ - `GoToAuthError`: credentials rejected (OAuth refresh failed, SIP digest refused, SMS 401).
62
+ - `GoToApiError`: a REST call returned a non-success status (`.status`).
63
+ - `GoToSipError`: SIP registration/signalling failed, including network failures (timeouts, DNS, TLS errors are wrapped, never raised raw).
64
+ - `GoToCallError`: the call itself failed (e.g. 4xx/5xx to the INVITE, no usable SDP).
65
+
66
+ 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`.
67
+
68
+ ### Create the SIP device in GoTo
69
+
70
+ 1. In GoTo Connect Admin, go to **Devices** -> **Add Device** -> **SIP Device**.
71
+ 2. Copy the **SIP Username**, **Password**, and the device's **SIP Server** and
72
+ **Outbound Proxy**. The registrar is typically `reg.jiveip.net` (the library's
73
+ default); the outbound proxy is account-specific (e.g.
74
+ `<account>.jive.rtcfront.net`) — pass it as `proxy=`. Calls proven live against
75
+ GoTo used both.
76
+
77
+ ### Transport
78
+
79
+ 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.
80
+
81
+ ### Live smoke test
82
+
83
+ ```bash
84
+ GOTO_SIP_USERNAME=... GOTO_SIP_PASSWORD=... python examples/make_call.py --to +15551234567 --wav message.wav --transport tls
85
+ ```
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ python3 -m venv .venv
91
+ .venv/bin/pip install -e '.[dev]'
92
+ .venv/bin/python -m pytest
93
+ ```
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aiogotoconnect"
7
+ version = "0.2.0"
8
+ description = "Async Python client for GoTo Connect: SMS via Messaging v2, outbound TTS voice calls via SIP"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "aiohttp>=3.9",
14
+ "audioop-lts>=0.2; python_version >= '3.13'",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest",
20
+ "pytest-asyncio",
21
+ "pytest-aiohttp",
22
+ ]
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools.package-data]
28
+ aiogotoconnect = ["py.typed"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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
@@ -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)