thermoworks-cloud 0.1.1__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,4 @@
1
+ from .auth import AuthFactory
2
+ from .core import ThermoworksCloud
3
+
4
+ __all__ = ["ThermoworksCloud", "AuthFactory"]
@@ -0,0 +1,291 @@
1
+ """Manage authentication for the Thermoworks Cloud API."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from datetime import datetime, timedelta
5
+ from enum import Enum
6
+ from typing import Protocol, TypedDict, cast
7
+
8
+ from aiohttp import ClientResponse, ClientResponseError, ClientSession
9
+
10
+ from .models.user_credentials import UserCredentials, UserLoginResponse
11
+
12
+ class Auth(Protocol):
13
+ """
14
+ Interface for making authenticated requests.
15
+ """
16
+ @property
17
+ def user_id(self) -> str:
18
+ ...
19
+
20
+ async def async_get_access_token(self) -> str:
21
+ ...
22
+
23
+ async def request(self, method, url, **kwargs) -> ClientResponse:
24
+ ...
25
+
26
+ class _AuthBase(ABC, Auth):
27
+ """Abstract class to make authenticated requests."""
28
+
29
+ def __init__(self, websession: ClientSession, host: str, api_key: str) -> None:
30
+ """Initialize the auth."""
31
+ self.websession = websession
32
+ self.host = host
33
+ self.api_key = api_key
34
+
35
+ @abstractmethod
36
+ async def async_get_access_token(self) -> str:
37
+ """Return a valid access token."""
38
+
39
+ async def request(self, method, url) -> ClientResponse:
40
+ """Make a request."""
41
+
42
+ access_token = await self.async_get_access_token()
43
+ headers = {
44
+ "authorization": f"Bearer {access_token}"
45
+ }
46
+ url = f"{self.host}/{url}?key={self.api_key}"
47
+
48
+ return await self.websession.request(
49
+ method,
50
+ url,
51
+ headers=headers,
52
+ )
53
+
54
+
55
+ def is_expired(expiration_time, buffer_seconds=60):
56
+ """Determine if the key needs renewal based on a buffer time.
57
+
58
+ Args:
59
+ expiration_time (datetime): The calculated expiration time of the key.
60
+ buffer_seconds (int): Time before expiration to renew the key (default is 60 seconds).
61
+
62
+ Returns:
63
+ bool: True if the key needs renewal, False otherwise.
64
+
65
+ """
66
+ # Get the current time
67
+ current_time = datetime.now()
68
+ experation_threshold = expiration_time - timedelta(seconds=buffer_seconds)
69
+
70
+ # Check if the key is within the renewal buffer time
71
+ return current_time >= experation_threshold
72
+
73
+
74
+ class WebConfigResponse(TypedDict):
75
+ """Similar to <https://firebase.google.com/docs/reference/firebase-management/rest/v1beta1/projects>."""
76
+
77
+ projectId: str
78
+ appId: str
79
+ databaseURL: str
80
+ storageBucket: str
81
+ locationId: str
82
+ authDomain: str
83
+ messagingSenderId: str
84
+ measurementId: str
85
+
86
+
87
+ class FirestoreError(TypedDict):
88
+ """See <https://firebase.google.com/docs/reference/rest/auth#section-error-format>."""
89
+
90
+ message: str
91
+ domain: str
92
+ reason: str
93
+
94
+
95
+ class ErrorResponse(TypedDict):
96
+ """See <https://firebase.google.com/docs/reference/rest/auth#section-error-format>."""
97
+
98
+ code: int
99
+ message: str
100
+ errors: list[FirestoreError]
101
+
102
+
103
+ class AuthenticationErrorReason(Enum):
104
+ """From <https://firebase.google.com/docs/reference/rest/auth?authuser=0#section-sign-in-email-password>."""
105
+
106
+ INVALID_EMAIL = "INVALID_EMAIL"
107
+ EMAIL_NOT_FOUND = "EMAIL_NOT_FOUND"
108
+ INVALID_PASSWORD = "INVALID_PASSWORD"
109
+ USER_DISABLED = "USER_DISABLED"
110
+
111
+
112
+ class AuthenticationError(Exception):
113
+ """Custom exception for authentication errors."""
114
+
115
+ def __init__(
116
+ self,
117
+ message: str,
118
+ reason: AuthenticationErrorReason,
119
+ details: list[FirestoreError],
120
+ ) -> None:
121
+ """Initialize an authentication error."""
122
+
123
+ super().__init__(f"Error authenticating with Firestore: {reason.value}")
124
+ self.message = message
125
+ self.reason = reason
126
+ self.details = details
127
+
128
+
129
+ class _TokenManager:
130
+ """Manage access tokens for Auth."""
131
+
132
+ _IDENTITY_HOST = "https://identitytoolkit.googleapis.com"
133
+ _TOKEN_HOST = "https://securetoken.googleapis.com"
134
+
135
+ _user_credentials: UserCredentials
136
+
137
+
138
+ def __init__(self, websession: ClientSession, api_key: str) -> None:
139
+ """Initialize token manager."""
140
+ self._websession = websession
141
+ self._api_key = api_key
142
+
143
+ async def login(self, email: str, password: str) -> None:
144
+ """Exchange login credentials for token credentials."""
145
+
146
+ url = f"{self._IDENTITY_HOST}/v1/accounts:signInWithPassword"
147
+ query = {"key": self._api_key}
148
+ headers = {"Content-Type": "application/json"}
149
+ json = {
150
+ "email": email,
151
+ "password": password,
152
+ "returnSecureToken": True,
153
+ }
154
+
155
+ response = await self._websession.request(
156
+ "post", url, headers=headers, json=json, params=query
157
+ )
158
+
159
+ if response.ok:
160
+ login_response = await response.json()
161
+ cast(UserLoginResponse, login_response)
162
+ self._user_credentials = UserCredentials.from_user_login_response(
163
+ login_response
164
+ )
165
+ elif response.status == 400:
166
+ login_response = await response.json()
167
+ if login_response["error"]:
168
+ error_response = cast(ErrorResponse, login_response["error"])
169
+ error_message = error_response["message"]
170
+ error_details = error_response["errors"]
171
+
172
+ if error_message in AuthenticationErrorReason._value2member_map_:
173
+ raise AuthenticationError(
174
+ message=f"Authentication failed: {error_message}",
175
+ reason=AuthenticationErrorReason(error_message),
176
+ details=error_details,
177
+ )
178
+ else:
179
+ try:
180
+ response.raise_for_status()
181
+ except ClientResponseError as e:
182
+ raise RuntimeError("Unable to authenticate") from e
183
+
184
+ @property
185
+ def user_id(self) -> str:
186
+ """Return id of the user that the manager is authenticate with."""
187
+ return self._user_credentials.user_id
188
+
189
+ @property
190
+ def access_token(self) -> str:
191
+ """Return an access token for use with Auth."""
192
+ return self._user_credentials.access_token
193
+
194
+ def is_token_valid(self) -> bool:
195
+ """Return a bool indicating whether the token is expired."""
196
+
197
+ return not is_expired(self._user_credentials.expiration_time)
198
+
199
+ async def refresh_access_token(self) -> None:
200
+ """Refresh the access token."""
201
+
202
+ url = f"{self._TOKEN_HOST}/v1/token?key={self._api_key}"
203
+ headers = {"Content-Type": "application/json"}
204
+ json = {
205
+ "grant_type": "refresh_token",
206
+ "refresh_token": self._user_credentials.refresh_token,
207
+ }
208
+
209
+ response = await self._websession.request(
210
+ "post", url, headers=headers, json=json
211
+ )
212
+ response.raise_for_status()
213
+ refresh_token_response = await response.json()
214
+ self._user_credentials = UserCredentials.from_refresh_token_response(
215
+ refresh_token_response
216
+ )
217
+
218
+
219
+ class _Auth(_AuthBase):
220
+ """Execute authenticated requests."""
221
+
222
+ def __init__(
223
+ self,
224
+ websession: ClientSession,
225
+ api_url_root: str,
226
+ api_key: str,
227
+ token_manager: _TokenManager,
228
+ ) -> None:
229
+ """Initialize the auth."""
230
+ super().__init__(websession, api_url_root, api_key)
231
+ self.token_manager = token_manager
232
+
233
+ @property
234
+ def user_id(self) -> str:
235
+ """The id of the user that is authenticated."""
236
+ return self.token_manager.user_id
237
+
238
+ async def async_get_access_token(self) -> str:
239
+ """Return a valid access token."""
240
+ if self.token_manager.is_token_valid():
241
+ return self.token_manager.access_token
242
+
243
+ await self.token_manager.refresh_access_token()
244
+ return self.token_manager.access_token
245
+
246
+
247
+ class AuthFactory:
248
+ """Create Auth objects."""
249
+
250
+ _API_KEY = "AIzaSyCf079iccUFc1k7VHdGXng22zXDy8Y3KEY"
251
+ _APP_ID = "1:78998049458:web:b41e9d405d8c7de95eefab"
252
+ _FIREBASE_HOST = "https://firebase.googleapis.com"
253
+ _FIRESTORE_HOST = "https://firestore.googleapis.com"
254
+
255
+ _config: WebConfigResponse
256
+
257
+ def __init__(self, websession: ClientSession) -> None:
258
+ """Initialize the factory."""
259
+
260
+ assert websession is not None, "parameter cannot be None"
261
+ self._websession = websession
262
+
263
+ async def get_config(self) -> None:
264
+ """Get the Firestore project information for this application."""
265
+
266
+ url = f"{self._FIREBASE_HOST}/v1alpha/projects/-/apps/{self._APP_ID}/webConfig"
267
+ headers = {
268
+ "accept": "application/json",
269
+ "x-goog-api-key": self._API_KEY,
270
+ }
271
+
272
+ try:
273
+ response = await self._websession.request("get", url, headers=headers)
274
+ response.raise_for_status()
275
+ self._config: WebConfigResponse = await response.json()
276
+ except Exception as e:
277
+ raise RuntimeError("Unable to fetch application configuration") from e
278
+
279
+ async def build_auth(self, email: str, password: str) -> Auth:
280
+ """Build an Auth instance."""
281
+
282
+ await self.get_config()
283
+
284
+ token_manager = _TokenManager(self._websession, self._API_KEY)
285
+ await token_manager.login(email, password)
286
+ return _Auth(
287
+ self._websession,
288
+ api_url_root=f"{self._FIRESTORE_HOST}/v1/projects/{self._config['projectId']}/databases/(default)/documents",
289
+ api_key=self._API_KEY,
290
+ token_manager=token_manager,
291
+ )
@@ -0,0 +1,85 @@
1
+ """Accessor for the Thermoworks Cloud API."""
2
+
3
+ import logging
4
+
5
+ from thermoworks_cloud.utils import format_client_response
6
+ from .auth import Auth
7
+ from .models.device import Device, document_to_device
8
+ from .models.device_channel import DeviceChannel, document_to_device_channel
9
+ from .models.user import User, document_to_user
10
+
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ class ThermoworksCloud:
15
+ """Accessor for the Thermoworks Cloud API."""
16
+
17
+ def __init__(self, auth: Auth) -> None:
18
+ """Initialize the API."""
19
+ self._auth = auth
20
+
21
+ async def get_user(self) -> User:
22
+ """Fetch information for the authenticated user."""
23
+
24
+ try:
25
+ response = await self._auth.request("get", f"users/{self._auth.user_id}")
26
+ if response.ok:
27
+ user_document = await response.json()
28
+ return document_to_user(user_document)
29
+
30
+ try:
31
+ error_response = await format_client_response(response)
32
+ except:
33
+ error_response = "Could not read response body."
34
+ _LOGGER.error("Received error response while getting user: %s", error_response)
35
+
36
+ response.raise_for_status()
37
+
38
+ except Exception as e:
39
+ raise RuntimeError("Failed to get user") from e
40
+
41
+
42
+ async def get_device(self, device_serial: str) -> Device:
43
+ """Fetch a device by serial number."""
44
+
45
+ try:
46
+ response = await self._auth.request("get", f"devices/{device_serial}")
47
+ if response.ok:
48
+ device_document = await response.json()
49
+ return document_to_device(device_document)
50
+
51
+ try:
52
+ error_response = await format_client_response(response)
53
+ except:
54
+ error_response = "Could not read response body."
55
+ _LOGGER.error("Received error response while getting device: %s", error_response)
56
+
57
+ response.raise_for_status()
58
+
59
+ except Exception as e:
60
+ raise RuntimeError("Failed to get device") from e
61
+
62
+
63
+ async def get_device_channel(
64
+ self, device_serial: str, channel: str
65
+ ) -> DeviceChannel:
66
+ """Fetch channel information for a device."""
67
+
68
+ try:
69
+ response = await self._auth.request(
70
+ "get", f"devices/{device_serial}/channels/{channel}"
71
+ )
72
+ if response.ok:
73
+ device_channel_document = await response.json()
74
+ return document_to_device_channel(device_channel_document)
75
+
76
+ try:
77
+ error_response = await format_client_response(response)
78
+ except:
79
+ error_response = "Could not read response body."
80
+ _LOGGER.error("Received error response while getting device channel: %s", error_response)
81
+
82
+ response.raise_for_status()
83
+
84
+ except Exception as e:
85
+ raise RuntimeError("Failed to get device channel") from e
File without changes
@@ -0,0 +1,101 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ from thermoworks_cloud.utils import parse_datetime
5
+
6
+
7
+ @dataclass
8
+ class BigQueryInfo:
9
+ table_id: str
10
+ dataset_id: str
11
+
12
+
13
+ @dataclass
14
+ class Device:
15
+ device_id: str
16
+ serial: str
17
+ label: str
18
+ type: str
19
+ firmware: str
20
+ color: str
21
+ thumbnail: str
22
+ device_display_units: str
23
+ iot_device_id: str
24
+ device_name: str
25
+ account_id: str
26
+ status: str
27
+ battery_state: str
28
+ big_query_info: BigQueryInfo
29
+ battery: int
30
+ wifi_strength: int
31
+ recording_interval_in_seconds: int
32
+ transmit_interval_in_seconds: int
33
+ pending_load: bool
34
+ battery_alert_sent: bool
35
+ export_version: float
36
+ last_seen: datetime
37
+ last_purged: datetime
38
+ last_archive: datetime
39
+ last_telemetry_saved: datetime
40
+ last_wifi_connection: datetime
41
+ last_bluetooth_connection: datetime
42
+ session_start: datetime
43
+ create_time: datetime
44
+ update_time: datetime
45
+
46
+
47
+ def parse_big_query_info(data: dict) -> BigQueryInfo:
48
+ """Parse bigQuery into a BigQueryInfo dataclass."""
49
+ fields = data["fields"]
50
+ return BigQueryInfo(
51
+ table_id=fields["tableId"]["stringValue"],
52
+ dataset_id=fields["datasetId"]["stringValue"],
53
+ )
54
+
55
+
56
+ def document_to_device(document: dict) -> Device:
57
+ """Convert a Firestore Document object into a Device object."""
58
+ fields = document["fields"]
59
+
60
+ return Device(
61
+ device_id=fields["deviceId"]["stringValue"],
62
+ serial=fields["serial"]["stringValue"],
63
+ label=fields["label"]["stringValue"],
64
+ type=fields["type"]["stringValue"],
65
+ firmware=fields["firmware"]["stringValue"],
66
+ color=fields["color"]["stringValue"],
67
+ thumbnail=fields["thumbnail"]["stringValue"],
68
+ device_display_units=fields["deviceDisplayUnits"]["stringValue"],
69
+ iot_device_id=fields["iotDeviceId"]["stringValue"],
70
+ device_name=fields["device"]["stringValue"],
71
+ account_id=fields["accountId"]["stringValue"],
72
+ status=fields["status"]["stringValue"],
73
+ battery_state=fields["batteryState"]["stringValue"],
74
+ big_query_info=parse_big_query_info(fields["bigQuery"]["mapValue"]),
75
+ battery=int(fields["battery"]["integerValue"]),
76
+ wifi_strength=int(fields["wifi_stength"]["integerValue"]),
77
+ recording_interval_in_seconds=int(
78
+ fields["recordingIntervalInSeconds"]["integerValue"]
79
+ ),
80
+ transmit_interval_in_seconds=int(
81
+ fields["transmitIntervalInSeconds"]["integerValue"]
82
+ ),
83
+ pending_load=fields["pendingLoad"]["booleanValue"],
84
+ battery_alert_sent=fields["batteryAlertSent"]["booleanValue"],
85
+ export_version=fields["exportVersion"]["doubleValue"],
86
+ last_seen=parse_datetime(fields["lastSeen"]["timestampValue"]),
87
+ last_purged=parse_datetime(fields["lastPurged"]["timestampValue"]),
88
+ last_archive=parse_datetime(fields["lastArchive"]["timestampValue"]),
89
+ last_telemetry_saved=parse_datetime(
90
+ fields["lastTelemetrySaved"]["timestampValue"]
91
+ ),
92
+ last_wifi_connection=parse_datetime(
93
+ fields["lastWifiConnection"]["timestampValue"]
94
+ ),
95
+ last_bluetooth_connection=parse_datetime(
96
+ fields["lastBluetoothConnection"]["timestampValue"]
97
+ ),
98
+ session_start=parse_datetime(fields["sessionStart"]["timestampValue"]),
99
+ create_time=parse_datetime(document["createTime"]),
100
+ update_time=parse_datetime(document["updateTime"]),
101
+ )
@@ -0,0 +1,107 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ from thermoworks_cloud.utils import parse_datetime
5
+
6
+
7
+ @dataclass
8
+ class Reading:
9
+ """A temperature reading from a device channel."""
10
+
11
+ value: float
12
+ """"The temperature units as a string like "F" """
13
+ units: str
14
+
15
+
16
+ @dataclass
17
+ class Alarm:
18
+ """An alarm on a device channel."""
19
+
20
+ enabled: bool
21
+ alarming: bool
22
+ value: int
23
+ """"The temperature units as a string like "F" """
24
+ units: str
25
+
26
+
27
+ @dataclass
28
+ class MinMaxReading:
29
+ reading: Reading
30
+ date_reading: datetime
31
+
32
+
33
+ @dataclass
34
+ class DeviceChannel:
35
+ last_telemetry_saved: datetime
36
+ value: float
37
+ """"The temperature units as a string like "F" """
38
+ units: str
39
+ """"The only observed value for this field is "NORMAL"."""
40
+ status: str
41
+ type: str
42
+ """Customer provided 'name' for this device channel."""
43
+ label: str
44
+ last_seen: datetime
45
+ alarm_high: Alarm | None
46
+ alarm_low: Alarm | None
47
+ """The device channel number"""
48
+ number: str
49
+ minimum: MinMaxReading | None
50
+ maximum: MinMaxReading | None
51
+ show_avg_temp: bool
52
+
53
+
54
+ def parse_alarm(alarm_data: dict) -> Alarm:
55
+ """Parse alarm data into an Alarm object."""
56
+ return Alarm(
57
+ enabled=alarm_data["fields"]["enabled"]["booleanValue"],
58
+ alarming=alarm_data["fields"]["alarming"]["booleanValue"],
59
+ value=int(alarm_data["fields"]["value"]["integerValue"]),
60
+ units=alarm_data["fields"]["units"]["stringValue"],
61
+ )
62
+
63
+
64
+ def parse_min_max_reading(data: dict) -> MinMaxReading:
65
+ """Parse minimum or maximum reading data."""
66
+ return MinMaxReading(
67
+ reading=Reading(
68
+ value=data["fields"]["reading"]["mapValue"]["fields"]["value"][
69
+ "doubleValue"
70
+ ],
71
+ units=data["fields"]["reading"]["mapValue"]["fields"]["units"][
72
+ "stringValue"
73
+ ],
74
+ ),
75
+ date_reading=parse_datetime(data["fields"]["dateReading"]["timestampValue"]),
76
+ )
77
+
78
+
79
+ def document_to_device_channel(document: dict) -> DeviceChannel:
80
+ """Convert a Firestore Document object into a Device object."""
81
+ fields = document["fields"]
82
+
83
+ return DeviceChannel(
84
+ last_telemetry_saved=parse_datetime(
85
+ fields["lastTelemetrySaved"]["timestampValue"]
86
+ ),
87
+ value=fields["value"]["doubleValue"],
88
+ units=fields["units"]["stringValue"],
89
+ status=fields["status"]["stringValue"],
90
+ type=fields["type"]["stringValue"],
91
+ label=fields["label"]["stringValue"],
92
+ last_seen=parse_datetime(fields["lastSeen"]["timestampValue"]),
93
+ alarm_high=parse_alarm(fields["alarmHigh"]["mapValue"])
94
+ if "alarmHigh" in fields
95
+ else None,
96
+ alarm_low=parse_alarm(fields["alarmLow"]["mapValue"])
97
+ if "alarmLow" in fields
98
+ else None,
99
+ number=fields["number"]["stringValue"],
100
+ minimum=parse_min_max_reading(fields["minimum"]["mapValue"])
101
+ if "minimum" in fields
102
+ else None,
103
+ maximum=parse_min_max_reading(fields["maximum"]["mapValue"])
104
+ if "maximum" in fields
105
+ else None,
106
+ show_avg_temp=fields["showAvgTemp"]["booleanValue"],
107
+ )