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.
- thermoworks_cloud/__init__.py +4 -0
- thermoworks_cloud/auth.py +291 -0
- thermoworks_cloud/core.py +85 -0
- thermoworks_cloud/models/__init__.py +0 -0
- thermoworks_cloud/models/device.py +101 -0
- thermoworks_cloud/models/device_channel.py +107 -0
- thermoworks_cloud/models/user.py +132 -0
- thermoworks_cloud/models/user_credentials.py +79 -0
- thermoworks_cloud/utils.py +14 -0
- thermoworks_cloud-0.1.1.dist-info/LICENSE.txt +674 -0
- thermoworks_cloud-0.1.1.dist-info/METADATA +704 -0
- thermoworks_cloud-0.1.1.dist-info/RECORD +14 -0
- thermoworks_cloud-0.1.1.dist-info/WHEEL +5 -0
- thermoworks_cloud-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|