cradlewise-client 0.1.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.
- cradlewise_client/__init__.py +57 -0
- cradlewise_client/certificates.py +214 -0
- cradlewise_client/cloud.py +421 -0
- cradlewise_client/commands.py +279 -0
- cradlewise_client/local.py +365 -0
- cradlewise_client/remote.py +23 -0
- cradlewise_client/state.py +566 -0
- cradlewise_client-0.1.0.dist-info/METADATA +37 -0
- cradlewise_client-0.1.0.dist-info/RECORD +11 -0
- cradlewise_client-0.1.0.dist-info/WHEEL +4 -0
- cradlewise_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Lightweight clients for Cradlewise local and cloud transports."""
|
|
2
|
+
|
|
3
|
+
from .certificates import (
|
|
4
|
+
BrokerCertificateError,
|
|
5
|
+
materialize_credentials,
|
|
6
|
+
pin_server_ca,
|
|
7
|
+
validate_server_chain,
|
|
8
|
+
)
|
|
9
|
+
from .cloud import (
|
|
10
|
+
CloudAccountClient,
|
|
11
|
+
CloudApiError,
|
|
12
|
+
CloudAuthenticationError,
|
|
13
|
+
CloudProvisioningError,
|
|
14
|
+
CradleAccount,
|
|
15
|
+
ProvisionedCredentials,
|
|
16
|
+
)
|
|
17
|
+
from .commands import (
|
|
18
|
+
CommandError,
|
|
19
|
+
CommandUnavailable,
|
|
20
|
+
CradlewiseCommandHandler,
|
|
21
|
+
build_desired,
|
|
22
|
+
shadow_payload,
|
|
23
|
+
)
|
|
24
|
+
from .local import (
|
|
25
|
+
LocalConnectionError,
|
|
26
|
+
LocalCradleClient,
|
|
27
|
+
LocalCradleUpdate,
|
|
28
|
+
LocalCredentials,
|
|
29
|
+
)
|
|
30
|
+
from .remote import REMOTE_MQTT_ENDPOINT, RemoteCradleClient
|
|
31
|
+
from .state import CradlewiseStateStore, normalize_device_state
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"CommandError",
|
|
35
|
+
"CommandUnavailable",
|
|
36
|
+
"CloudAccountClient",
|
|
37
|
+
"CloudApiError",
|
|
38
|
+
"CloudAuthenticationError",
|
|
39
|
+
"CloudProvisioningError",
|
|
40
|
+
"BrokerCertificateError",
|
|
41
|
+
"CradleAccount",
|
|
42
|
+
"CradlewiseCommandHandler",
|
|
43
|
+
"LocalConnectionError",
|
|
44
|
+
"LocalCradleClient",
|
|
45
|
+
"LocalCradleUpdate",
|
|
46
|
+
"LocalCredentials",
|
|
47
|
+
"REMOTE_MQTT_ENDPOINT",
|
|
48
|
+
"RemoteCradleClient",
|
|
49
|
+
"ProvisionedCredentials",
|
|
50
|
+
"CradlewiseStateStore",
|
|
51
|
+
"normalize_device_state",
|
|
52
|
+
"materialize_credentials",
|
|
53
|
+
"pin_server_ca",
|
|
54
|
+
"build_desired",
|
|
55
|
+
"shadow_payload",
|
|
56
|
+
"validate_server_chain",
|
|
57
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Safe broker certificate discovery and credential materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.exceptions import InvalidSignature
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.x509.oid import NameOID
|
|
15
|
+
|
|
16
|
+
from .cloud import ProvisionedCredentials
|
|
17
|
+
from .local import MQTT_PORT, LocalCredentials
|
|
18
|
+
|
|
19
|
+
MQTT_BROKER_COMMON_NAME = "aws.greengrass.clientdevices.mqtt.Moquette"
|
|
20
|
+
MQTT_CA_COMMON_NAME = "Greengrass Core CA"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BrokerCertificateError(RuntimeError):
|
|
24
|
+
"""Raised when the local broker chain cannot be safely pinned."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fetch_server_chain(
|
|
28
|
+
host: str,
|
|
29
|
+
client_certificate_path: Path,
|
|
30
|
+
client_private_key_path: Path,
|
|
31
|
+
*,
|
|
32
|
+
port: int = MQTT_PORT,
|
|
33
|
+
) -> list[bytes]:
|
|
34
|
+
"""Fetch the untrusted broker chain using the provisioned client identity."""
|
|
35
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
36
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
37
|
+
context.check_hostname = False
|
|
38
|
+
context.verify_mode = ssl.CERT_NONE
|
|
39
|
+
context.load_cert_chain(client_certificate_path, client_private_key_path)
|
|
40
|
+
try:
|
|
41
|
+
with socket.create_connection((host, port), timeout=10) as raw_socket:
|
|
42
|
+
with context.wrap_socket(raw_socket, server_hostname=host) as tls_socket:
|
|
43
|
+
chain = _unverified_chain(tls_socket)
|
|
44
|
+
except BrokerCertificateError:
|
|
45
|
+
raise
|
|
46
|
+
except (OSError, ssl.SSLError) as exc:
|
|
47
|
+
raise BrokerCertificateError(
|
|
48
|
+
f"could not inspect local MQTT broker TLS: {exc}"
|
|
49
|
+
) from exc
|
|
50
|
+
if len(chain) < 2:
|
|
51
|
+
raise BrokerCertificateError(
|
|
52
|
+
"local MQTT broker did not return a certificate chain"
|
|
53
|
+
)
|
|
54
|
+
return chain
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_server_chain(
|
|
58
|
+
certificate_data: list[bytes],
|
|
59
|
+
host: str,
|
|
60
|
+
*,
|
|
61
|
+
now: datetime | None = None,
|
|
62
|
+
) -> x509.Certificate:
|
|
63
|
+
"""Validate the expected Greengrass broker shape before trusting its CA."""
|
|
64
|
+
certificates = [_load_certificate(value) for value in certificate_data]
|
|
65
|
+
leaf = certificates[0]
|
|
66
|
+
issuers = [
|
|
67
|
+
certificate
|
|
68
|
+
for certificate in certificates[1:]
|
|
69
|
+
if certificate.subject == leaf.issuer
|
|
70
|
+
]
|
|
71
|
+
if len(issuers) != 1:
|
|
72
|
+
raise BrokerCertificateError(
|
|
73
|
+
"local MQTT broker chain has no unique issuer for its leaf"
|
|
74
|
+
)
|
|
75
|
+
ca = issuers[0]
|
|
76
|
+
if _common_name(leaf) != MQTT_BROKER_COMMON_NAME:
|
|
77
|
+
raise BrokerCertificateError(
|
|
78
|
+
"local MQTT broker certificate has an unexpected common name"
|
|
79
|
+
)
|
|
80
|
+
if _common_name(ca) != MQTT_CA_COMMON_NAME or ca.subject != ca.issuer:
|
|
81
|
+
raise BrokerCertificateError(
|
|
82
|
+
"local MQTT broker issuer is not a self-issued Greengrass Core CA"
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
constraints = ca.extensions.get_extension_for_class(x509.BasicConstraints)
|
|
86
|
+
except x509.ExtensionNotFound as exc:
|
|
87
|
+
raise BrokerCertificateError(
|
|
88
|
+
"Greengrass Core CA is missing basic constraints"
|
|
89
|
+
) from exc
|
|
90
|
+
if not constraints.value.ca:
|
|
91
|
+
raise BrokerCertificateError(
|
|
92
|
+
"Greengrass Core CA certificate is not marked as a CA"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
current = now or datetime.now(timezone.utc)
|
|
96
|
+
for certificate in (leaf, ca):
|
|
97
|
+
if not (
|
|
98
|
+
certificate.not_valid_before_utc
|
|
99
|
+
<= current
|
|
100
|
+
<= certificate.not_valid_after_utc
|
|
101
|
+
):
|
|
102
|
+
raise BrokerCertificateError(
|
|
103
|
+
"local MQTT broker chain contains an expired or future certificate"
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
leaf.verify_directly_issued_by(ca)
|
|
107
|
+
ca.verify_directly_issued_by(ca)
|
|
108
|
+
except (InvalidSignature, ValueError, TypeError) as exc:
|
|
109
|
+
raise BrokerCertificateError(
|
|
110
|
+
"local MQTT broker chain signature validation failed"
|
|
111
|
+
) from exc
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
alternative_names = leaf.extensions.get_extension_for_class(
|
|
115
|
+
x509.SubjectAlternativeName
|
|
116
|
+
).value
|
|
117
|
+
except x509.ExtensionNotFound as exc:
|
|
118
|
+
raise BrokerCertificateError(
|
|
119
|
+
"local MQTT broker certificate has no subject alternative name"
|
|
120
|
+
) from exc
|
|
121
|
+
try:
|
|
122
|
+
expected_ip = ipaddress.ip_address(host)
|
|
123
|
+
except ValueError:
|
|
124
|
+
valid_host = host in alternative_names.get_values_for_type(x509.DNSName)
|
|
125
|
+
else:
|
|
126
|
+
valid_host = expected_ip in alternative_names.get_values_for_type(
|
|
127
|
+
x509.IPAddress
|
|
128
|
+
)
|
|
129
|
+
if not valid_host:
|
|
130
|
+
raise BrokerCertificateError(
|
|
131
|
+
f"local MQTT broker certificate is not valid for {host}"
|
|
132
|
+
)
|
|
133
|
+
return ca
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def pin_server_ca(
|
|
137
|
+
host: str,
|
|
138
|
+
client_certificate_path: Path,
|
|
139
|
+
client_private_key_path: Path,
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Fetch, validate, and return the crib's current broker CA as PEM."""
|
|
142
|
+
chain = fetch_server_chain(
|
|
143
|
+
host,
|
|
144
|
+
client_certificate_path,
|
|
145
|
+
client_private_key_path,
|
|
146
|
+
)
|
|
147
|
+
ca = validate_server_chain(chain, host)
|
|
148
|
+
return ca.public_bytes(serialization.Encoding.PEM).decode("ascii")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def materialize_credentials(
|
|
152
|
+
directory: Path,
|
|
153
|
+
credentials: ProvisionedCredentials,
|
|
154
|
+
*,
|
|
155
|
+
server_ca_certificate: str | None = None,
|
|
156
|
+
) -> LocalCredentials:
|
|
157
|
+
"""Write one runtime-only credential bundle for Paho MQTT."""
|
|
158
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
directory.chmod(0o700)
|
|
160
|
+
values = {
|
|
161
|
+
"ca.pem": credentials.group_ca_certificate,
|
|
162
|
+
"client_cert.pem": credentials.client_certificate,
|
|
163
|
+
"client_key.pem": credentials.client_private_key,
|
|
164
|
+
"device_id": credentials.device_id,
|
|
165
|
+
}
|
|
166
|
+
if server_ca_certificate is not None:
|
|
167
|
+
values["server_ca.pem"] = server_ca_certificate
|
|
168
|
+
for name, value in values.items():
|
|
169
|
+
path = directory / name
|
|
170
|
+
path.write_text(value, encoding="utf-8")
|
|
171
|
+
path.chmod(0o600)
|
|
172
|
+
return LocalCredentials.from_directory(directory)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _load_certificate(value: bytes) -> x509.Certificate:
|
|
176
|
+
if value.startswith(b"-----BEGIN"):
|
|
177
|
+
return x509.load_pem_x509_certificate(value)
|
|
178
|
+
return x509.load_der_x509_certificate(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _unverified_chain(tls_socket: ssl.SSLSocket) -> list[bytes]:
|
|
182
|
+
"""Return the peer chain across supported Python SSL API versions."""
|
|
183
|
+
get_chain = getattr(tls_socket, "get_unverified_chain", None)
|
|
184
|
+
if get_chain is None:
|
|
185
|
+
ssl_object = getattr(tls_socket, "_sslobj", None)
|
|
186
|
+
get_chain = getattr(ssl_object, "get_unverified_chain", None)
|
|
187
|
+
if get_chain is None:
|
|
188
|
+
raise BrokerCertificateError(
|
|
189
|
+
"Python does not support retrieving the broker chain"
|
|
190
|
+
)
|
|
191
|
+
return [_certificate_bytes(certificate) for certificate in get_chain()]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _certificate_bytes(certificate: object) -> bytes:
|
|
195
|
+
if isinstance(certificate, bytes):
|
|
196
|
+
return certificate
|
|
197
|
+
public_bytes = getattr(certificate, "public_bytes", None)
|
|
198
|
+
if public_bytes is None:
|
|
199
|
+
raise BrokerCertificateError(
|
|
200
|
+
"Python returned an unsupported broker certificate object"
|
|
201
|
+
)
|
|
202
|
+
value = public_bytes()
|
|
203
|
+
if isinstance(value, str):
|
|
204
|
+
return value.encode("ascii")
|
|
205
|
+
if isinstance(value, bytes):
|
|
206
|
+
return value
|
|
207
|
+
raise BrokerCertificateError(
|
|
208
|
+
"Python returned an unsupported broker certificate encoding"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _common_name(certificate: x509.Certificate) -> str | None:
|
|
213
|
+
values = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
|
214
|
+
return values[0].value if values else None
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Cradlewise account discovery and certificate provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
import requests
|
|
13
|
+
from botocore.auth import SigV4Auth
|
|
14
|
+
from botocore.awsrequest import AWSRequest
|
|
15
|
+
from botocore.credentials import Credentials
|
|
16
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
17
|
+
from pycognito import Cognito
|
|
18
|
+
|
|
19
|
+
USER_POOL_ID = "us-east-1_hRGLsOxun"
|
|
20
|
+
CLIENT_ID = "4jnn2bbtroa3e6ra73dc8m8luh"
|
|
21
|
+
# Public native-app configuration embedded in the distributed Android APK.
|
|
22
|
+
# This is not an account credential and cannot be kept confidential by a
|
|
23
|
+
# mobile client.
|
|
24
|
+
CLIENT_SECRET = "qfmc9tv70upcacajmhpl4a9n3orehteo93icbng0fljkt6916em"
|
|
25
|
+
IDENTITY_POOL_ID = "us-east-1:53b70db5-7440-4ecf-8dac-d6202eb6c1d2"
|
|
26
|
+
REGION = "us-east-1"
|
|
27
|
+
API_ENDPOINT = "https://backend.cradlewise.com/prod-latest"
|
|
28
|
+
S3_BUCKET = "cradlewise-device-certs"
|
|
29
|
+
REQUEST_TIMEOUT_SECONDS = 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CloudAuthenticationError(RuntimeError):
|
|
33
|
+
"""Raised when account credentials cannot be authenticated."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CloudApiError(RuntimeError):
|
|
37
|
+
"""Raised when a Cradlewise cloud request fails."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CloudProvisioningError(CloudApiError):
|
|
41
|
+
"""Raised when device certificates cannot be provisioned."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CradleAccount:
|
|
46
|
+
"""One baby profile and its associated cradle."""
|
|
47
|
+
|
|
48
|
+
baby_id: int
|
|
49
|
+
cradle_id: str
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ProvisionedCredentials:
|
|
55
|
+
"""PEM material returned for local and AWS IoT MQTT connections."""
|
|
56
|
+
|
|
57
|
+
device_id: str
|
|
58
|
+
client_certificate: str
|
|
59
|
+
client_private_key: str
|
|
60
|
+
group_ca_certificate: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sign_request(
|
|
64
|
+
method: str,
|
|
65
|
+
url: str,
|
|
66
|
+
credentials: Credentials,
|
|
67
|
+
*,
|
|
68
|
+
body: str | None = None,
|
|
69
|
+
headers: dict[str, str] | None = None,
|
|
70
|
+
) -> dict[str, str]:
|
|
71
|
+
"""Sign one Cradlewise API request with temporary AWS credentials."""
|
|
72
|
+
request = AWSRequest(
|
|
73
|
+
method=method,
|
|
74
|
+
url=url,
|
|
75
|
+
data=body,
|
|
76
|
+
headers=headers or {},
|
|
77
|
+
)
|
|
78
|
+
SigV4Auth(credentials, "execute-api", REGION).add_auth(request)
|
|
79
|
+
return dict(request.headers)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CloudAccountClient:
|
|
83
|
+
"""Blocking account client intended for an executor in async consumers."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
email: str,
|
|
89
|
+
password: str,
|
|
90
|
+
session: requests.Session | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
if not email.strip() or not password:
|
|
93
|
+
raise CloudAuthenticationError("email and password are required")
|
|
94
|
+
self.email = email.strip()
|
|
95
|
+
self._password = password
|
|
96
|
+
self._session = session or requests.Session()
|
|
97
|
+
self._credentials: Credentials | None = None
|
|
98
|
+
self._raw_credentials: dict[str, Any] | None = None
|
|
99
|
+
|
|
100
|
+
def authenticate(self) -> None:
|
|
101
|
+
"""Authenticate with Cognito and cache temporary AWS credentials."""
|
|
102
|
+
try:
|
|
103
|
+
cognito = Cognito(
|
|
104
|
+
USER_POOL_ID,
|
|
105
|
+
CLIENT_ID,
|
|
106
|
+
username=self.email,
|
|
107
|
+
client_secret=CLIENT_SECRET,
|
|
108
|
+
)
|
|
109
|
+
cognito.authenticate(password=self._password)
|
|
110
|
+
if not cognito.id_token:
|
|
111
|
+
raise CloudAuthenticationError(
|
|
112
|
+
"Cradlewise authentication returned no ID token"
|
|
113
|
+
)
|
|
114
|
+
identity = boto3.client("cognito-identity", region_name=REGION)
|
|
115
|
+
logins = {
|
|
116
|
+
f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}": (cognito.id_token)
|
|
117
|
+
}
|
|
118
|
+
identity_response = identity.get_id(
|
|
119
|
+
IdentityPoolId=IDENTITY_POOL_ID,
|
|
120
|
+
Logins=logins,
|
|
121
|
+
)
|
|
122
|
+
credentials_response = identity.get_credentials_for_identity(
|
|
123
|
+
IdentityId=identity_response["IdentityId"],
|
|
124
|
+
Logins=logins,
|
|
125
|
+
)
|
|
126
|
+
raw = credentials_response["Credentials"]
|
|
127
|
+
self._credentials = Credentials(
|
|
128
|
+
access_key=raw["AccessKeyId"],
|
|
129
|
+
secret_key=raw["SecretKey"],
|
|
130
|
+
token=raw["SessionToken"],
|
|
131
|
+
)
|
|
132
|
+
self._raw_credentials = raw
|
|
133
|
+
except CloudAuthenticationError:
|
|
134
|
+
raise
|
|
135
|
+
except ClientError as exc:
|
|
136
|
+
error = exc.response.get("Error", {})
|
|
137
|
+
if error.get("Code") in {
|
|
138
|
+
"NotAuthorizedException",
|
|
139
|
+
"PasswordResetRequiredException",
|
|
140
|
+
"UserNotConfirmedException",
|
|
141
|
+
"UserNotFoundException",
|
|
142
|
+
}:
|
|
143
|
+
raise CloudAuthenticationError(
|
|
144
|
+
"Cradlewise account authentication failed"
|
|
145
|
+
) from exc
|
|
146
|
+
raise CloudApiError(
|
|
147
|
+
"Cradlewise authentication service is unavailable"
|
|
148
|
+
) from exc
|
|
149
|
+
except (BotoCoreError, requests.RequestException, OSError) as exc:
|
|
150
|
+
raise CloudApiError(
|
|
151
|
+
"Cradlewise authentication service is unavailable"
|
|
152
|
+
) from exc
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
raise CloudAuthenticationError(
|
|
155
|
+
"Cradlewise account authentication failed"
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
def list_accounts(self) -> list[CradleAccount]:
|
|
159
|
+
"""List paired cradle profiles for the authenticated account."""
|
|
160
|
+
payload = self._request_json(
|
|
161
|
+
"GET",
|
|
162
|
+
f"{API_ENDPOINT}/accounts?emailId={quote(self.email)}",
|
|
163
|
+
)
|
|
164
|
+
accounts = payload.get("accounts")
|
|
165
|
+
if not isinstance(accounts, list):
|
|
166
|
+
raise CloudApiError("Cradlewise accounts response has no account list")
|
|
167
|
+
result = []
|
|
168
|
+
for account in accounts:
|
|
169
|
+
if not isinstance(account, dict) or not account.get("cradle_id"):
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
baby_id = int(account["baby_id"])
|
|
173
|
+
except (KeyError, TypeError, ValueError):
|
|
174
|
+
continue
|
|
175
|
+
result.append(
|
|
176
|
+
CradleAccount(
|
|
177
|
+
baby_id=baby_id,
|
|
178
|
+
cradle_id=str(account["cradle_id"]).strip(),
|
|
179
|
+
name=str(account.get("name") or "Cradlewise"),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
def get_cradle_state(self, cradle_id: str) -> dict[str, Any]:
|
|
185
|
+
"""Fetch the cloud shadow state through the Cradlewise REST API."""
|
|
186
|
+
return self._request_json(
|
|
187
|
+
"GET",
|
|
188
|
+
f"{API_ENDPOINT}/cradles/{cradle_id}/state",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def get_cradle_ip(self, cradle_id: str) -> str | None:
|
|
192
|
+
"""Resolve the last reported local address, preferring onlineStatus v2."""
|
|
193
|
+
v2_error: CloudApiError | None = None
|
|
194
|
+
try:
|
|
195
|
+
payload = self._request_json(
|
|
196
|
+
"GET",
|
|
197
|
+
f"{API_ENDPOINT}/cradles/{cradle_id}/onlineStatus/v2",
|
|
198
|
+
)
|
|
199
|
+
state_message = payload.get("state_message")
|
|
200
|
+
if isinstance(state_message, str):
|
|
201
|
+
parsed = json.loads(state_message)
|
|
202
|
+
address = _local_ip(parsed)
|
|
203
|
+
if address:
|
|
204
|
+
return address
|
|
205
|
+
except (CloudApiError, json.JSONDecodeError, TypeError) as exc:
|
|
206
|
+
v2_error = (
|
|
207
|
+
exc
|
|
208
|
+
if isinstance(exc, CloudApiError)
|
|
209
|
+
else CloudApiError("invalid onlineStatus v2 state message")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
payload = self._request_json(
|
|
214
|
+
"GET",
|
|
215
|
+
f"{API_ENDPOINT}/cradles/{cradle_id}/onlineStatus",
|
|
216
|
+
)
|
|
217
|
+
address = payload.get("local_ip")
|
|
218
|
+
return (
|
|
219
|
+
address.strip()
|
|
220
|
+
if isinstance(address, str) and address.strip()
|
|
221
|
+
else None
|
|
222
|
+
)
|
|
223
|
+
except CloudApiError as exc:
|
|
224
|
+
if v2_error is not None:
|
|
225
|
+
raise CloudApiError(
|
|
226
|
+
f"Cradlewise online status failed: v2={v2_error}; v1={exc}"
|
|
227
|
+
) from exc
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
def provision_credentials(
|
|
231
|
+
self,
|
|
232
|
+
account: CradleAccount,
|
|
233
|
+
*,
|
|
234
|
+
app_version: str = "2.55.5",
|
|
235
|
+
timezone: str = "UTC",
|
|
236
|
+
country: str = "US",
|
|
237
|
+
) -> ProvisionedCredentials:
|
|
238
|
+
"""Register a Home Assistant client and download its MQTT certificate."""
|
|
239
|
+
body = json.dumps(
|
|
240
|
+
{
|
|
241
|
+
"email_id": self.email,
|
|
242
|
+
"baby_id": account.baby_id,
|
|
243
|
+
"fcm_token": "home_assistant",
|
|
244
|
+
"device": {
|
|
245
|
+
"registration_date": datetime.date.today().isoformat(),
|
|
246
|
+
"app_version": app_version,
|
|
247
|
+
"country": country,
|
|
248
|
+
"os": "android",
|
|
249
|
+
"device_name": "Home Assistant",
|
|
250
|
+
"os_version": "14",
|
|
251
|
+
"timezone": timezone,
|
|
252
|
+
"type": "phone",
|
|
253
|
+
"resolution": "{1440,3120}",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
separators=(",", ":"),
|
|
257
|
+
)
|
|
258
|
+
payload = self._request_json(
|
|
259
|
+
"POST",
|
|
260
|
+
f"{API_ENDPOINT}/cradles/pairedUsers/v3",
|
|
261
|
+
body=body,
|
|
262
|
+
headers={"Content-Type": "application/json"},
|
|
263
|
+
)
|
|
264
|
+
if payload.get("errorType") == "API_FAILED":
|
|
265
|
+
raise CloudProvisioningError(
|
|
266
|
+
str(payload.get("message") or "certificate provisioning failed")
|
|
267
|
+
)
|
|
268
|
+
device_config = payload.get("device_config")
|
|
269
|
+
if not isinstance(device_config, dict):
|
|
270
|
+
raise CloudProvisioningError(
|
|
271
|
+
"Cradlewise certificate response has no device configuration"
|
|
272
|
+
)
|
|
273
|
+
group_ca = device_config.get("group_ca_cert")
|
|
274
|
+
object_keys = device_config.get("s3_object_keys")
|
|
275
|
+
if not isinstance(group_ca, str) or not group_ca.strip():
|
|
276
|
+
raise CloudProvisioningError(
|
|
277
|
+
"Cradlewise certificate response has no group CA"
|
|
278
|
+
)
|
|
279
|
+
if not isinstance(object_keys, list) or not object_keys:
|
|
280
|
+
raise CloudProvisioningError(
|
|
281
|
+
"Cradlewise certificate response has no certificate objects"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
raw = self._require_raw_credentials()
|
|
285
|
+
s3 = boto3.client(
|
|
286
|
+
"s3",
|
|
287
|
+
region_name=REGION,
|
|
288
|
+
aws_access_key_id=raw["AccessKeyId"],
|
|
289
|
+
aws_secret_access_key=raw["SecretKey"],
|
|
290
|
+
aws_session_token=raw["SessionToken"],
|
|
291
|
+
)
|
|
292
|
+
pem_objects = [self._download_s3_text(s3, str(key)) for key in object_keys]
|
|
293
|
+
client_certificate = next(
|
|
294
|
+
(value for value in pem_objects if "BEGIN CERTIFICATE" in value),
|
|
295
|
+
None,
|
|
296
|
+
)
|
|
297
|
+
private_key = next(
|
|
298
|
+
(value for value in pem_objects if "PRIVATE KEY" in value),
|
|
299
|
+
None,
|
|
300
|
+
)
|
|
301
|
+
if client_certificate is None or private_key is None:
|
|
302
|
+
raise CloudProvisioningError(
|
|
303
|
+
"Cradlewise certificate objects did not contain a certificate and key"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
device_id = device_config.get("device_id")
|
|
307
|
+
if not isinstance(device_id, str) or not device_id.strip():
|
|
308
|
+
first_key = str(object_keys[0])
|
|
309
|
+
device_id = first_key.rsplit("/", 1)[-1].removesuffix(".pem")
|
|
310
|
+
if not device_id:
|
|
311
|
+
raise CloudProvisioningError(
|
|
312
|
+
"Cradlewise certificate response has no device identity"
|
|
313
|
+
)
|
|
314
|
+
return ProvisionedCredentials(
|
|
315
|
+
device_id=device_id.strip(),
|
|
316
|
+
client_certificate=client_certificate,
|
|
317
|
+
client_private_key=private_key,
|
|
318
|
+
group_ca_certificate=group_ca,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _request_json(
|
|
322
|
+
self,
|
|
323
|
+
method: str,
|
|
324
|
+
url: str,
|
|
325
|
+
*,
|
|
326
|
+
body: str | None = None,
|
|
327
|
+
headers: dict[str, str] | None = None,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
credentials = self._require_credentials()
|
|
330
|
+
response = self._send(method, url, credentials, body=body, headers=headers)
|
|
331
|
+
if response.status_code in {401, 403}:
|
|
332
|
+
self.authenticate()
|
|
333
|
+
credentials = self._require_credentials()
|
|
334
|
+
response = self._send(
|
|
335
|
+
method,
|
|
336
|
+
url,
|
|
337
|
+
credentials,
|
|
338
|
+
body=body,
|
|
339
|
+
headers=headers,
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
response.raise_for_status()
|
|
343
|
+
except requests.HTTPError as exc:
|
|
344
|
+
raise CloudApiError(
|
|
345
|
+
f"Cradlewise cloud request failed: HTTP {response.status_code}"
|
|
346
|
+
) from exc
|
|
347
|
+
try:
|
|
348
|
+
payload = response.json()
|
|
349
|
+
except requests.JSONDecodeError as exc:
|
|
350
|
+
raise CloudApiError("Cradlewise cloud response was not JSON") from exc
|
|
351
|
+
if not isinstance(payload, dict):
|
|
352
|
+
raise CloudApiError("Cradlewise cloud response was not an object")
|
|
353
|
+
return payload
|
|
354
|
+
|
|
355
|
+
def _send(
|
|
356
|
+
self,
|
|
357
|
+
method: str,
|
|
358
|
+
url: str,
|
|
359
|
+
credentials: Credentials,
|
|
360
|
+
*,
|
|
361
|
+
body: str | None,
|
|
362
|
+
headers: dict[str, str] | None,
|
|
363
|
+
) -> requests.Response:
|
|
364
|
+
signed_headers = sign_request(
|
|
365
|
+
method,
|
|
366
|
+
url,
|
|
367
|
+
credentials,
|
|
368
|
+
body=body,
|
|
369
|
+
headers=headers,
|
|
370
|
+
)
|
|
371
|
+
try:
|
|
372
|
+
return self._session.request(
|
|
373
|
+
method,
|
|
374
|
+
url,
|
|
375
|
+
headers=signed_headers,
|
|
376
|
+
data=body,
|
|
377
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
378
|
+
)
|
|
379
|
+
except requests.RequestException as exc:
|
|
380
|
+
raise CloudApiError("Cradlewise cloud request failed") from exc
|
|
381
|
+
|
|
382
|
+
def _require_credentials(self) -> Credentials:
|
|
383
|
+
if self._credentials is None:
|
|
384
|
+
self.authenticate()
|
|
385
|
+
if self._credentials is None:
|
|
386
|
+
raise CloudAuthenticationError(
|
|
387
|
+
"Cradlewise authentication returned no AWS credentials"
|
|
388
|
+
)
|
|
389
|
+
return self._credentials
|
|
390
|
+
|
|
391
|
+
def _require_raw_credentials(self) -> dict[str, Any]:
|
|
392
|
+
self._require_credentials()
|
|
393
|
+
if self._raw_credentials is None:
|
|
394
|
+
raise CloudAuthenticationError(
|
|
395
|
+
"Cradlewise authentication returned no raw AWS credentials"
|
|
396
|
+
)
|
|
397
|
+
return self._raw_credentials
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _download_s3_text(s3: Any, key: str) -> str:
|
|
401
|
+
errors = []
|
|
402
|
+
for candidate in (key, f"public/{key}"):
|
|
403
|
+
try:
|
|
404
|
+
response = s3.get_object(Bucket=S3_BUCKET, Key=candidate)
|
|
405
|
+
value = response["Body"].read().decode("utf-8")
|
|
406
|
+
if value.strip():
|
|
407
|
+
return value
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
errors.append(exc)
|
|
410
|
+
raise CloudProvisioningError(
|
|
411
|
+
f"could not download provisioned certificate object {key}"
|
|
412
|
+
) from errors[-1]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _local_ip(payload: dict[str, Any]) -> str | None:
|
|
416
|
+
address = (
|
|
417
|
+
payload.get("info", {}).get("connectivity", {}).get("localIP")
|
|
418
|
+
if isinstance(payload.get("info"), dict)
|
|
419
|
+
else None
|
|
420
|
+
)
|
|
421
|
+
return address.strip() if isinstance(address, str) and address.strip() else None
|