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.
- aiogotoconnect-0.2.0/LICENSE +18 -0
- aiogotoconnect-0.2.0/PKG-INFO +109 -0
- aiogotoconnect-0.2.0/README.md +93 -0
- aiogotoconnect-0.2.0/pyproject.toml +32 -0
- aiogotoconnect-0.2.0/setup.cfg +4 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/__init__.py +23 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/auth.py +83 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/exceptions.py +22 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/messaging.py +51 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/py.typed +0 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/sip/__init__.py +0 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/sip/audio.py +50 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/sip/call.py +361 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/sip/messages.py +185 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/sip/rtp.py +51 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect/sip/transport.py +135 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect.egg-info/PKG-INFO +109 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect.egg-info/SOURCES.txt +21 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect.egg-info/dependency_links.txt +1 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect.egg-info/requires.txt +9 -0
- aiogotoconnect-0.2.0/src/aiogotoconnect.egg-info/top_level.txt +1 -0
- aiogotoconnect-0.2.0/tests/test_auth.py +132 -0
- aiogotoconnect-0.2.0/tests/test_messaging.py +156 -0
|
@@ -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,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
|
|
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)
|