Skip to content

Commit 35e72a3

Browse files
committed
feat: convert to MQTT
1 parent 35866b1 commit 35e72a3

2 files changed

Lines changed: 108 additions & 28 deletions

File tree

python_snoo/containers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ class AuthorizationInfo:
5555
aws_refresh: str
5656

5757

58+
@dataclasses.dataclass
59+
class AwsIOT:
60+
awsRegion: str
61+
clientEndpoint: str
62+
clientReady: bool
63+
thingName: str
64+
65+
5866
@dataclasses.dataclass
5967
class SnooDevice(DataClassJSONMixin):
6068
serialNumber: str
@@ -64,7 +72,7 @@ class SnooDevice(DataClassJSONMixin):
6472
deviceType: int | None = None
6573
presence: dict | None = None
6674
presenceIoT: dict | None = None
67-
awsIoT: dict | None = None
75+
awsIoT: AwsIOT | None = None
6876
lastSSID: dict | None = None
6977
provisionedAt: str | None = None
7078

python_snoo/snoo.py

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
# snoo.py
21
import asyncio
32
import json
43
import logging
54
import secrets
65
import uuid
76
from datetime import datetime as dt
7+
from typing import Callable
88

99
import aiohttp
10+
import aiomqtt
1011
from pubnub.enums import PNReconnectionPolicy
1112
from pubnub.pnconfiguration import PNConfiguration
1213
from pubnub.pubnub_asyncio import PubNubAsyncio
1314

1415
from .containers import (
1516
AuthorizationInfo,
17+
SnooData,
1618
SnooDevice,
1719
SnooStates,
1820
)
@@ -23,7 +25,7 @@
2325

2426

2527
class Snoo:
26-
def __init__(self, email, password, clientsession: aiohttp.ClientSession):
28+
def __init__(self, email: str, password: str, clientsession: aiohttp.ClientSession):
2729
self.email = email
2830
self.password = password
2931
self.session = clientsession
@@ -87,6 +89,9 @@ def __init__(self, email, password, clientsession: aiohttp.ClientSession):
8789
self.data_map = {}
8890
self.pubnub_instances: dict[str, SnooPubNub] = {}
8991
self.reauth_task: asyncio.Task | None = None
92+
self._client_map: dict[str, aiomqtt.Client] = {}
93+
self._mqtt_tasks: dict[str, asyncio.Task] = {}
94+
self._client_cond = asyncio.Condition()
9095

9196
async def refresh_tokens(self):
9297
# TODO: Figure out hwo to get this to work and not do a serializaiton exception
@@ -106,12 +111,12 @@ def check_tokens(self):
106111
if self.tokens is None:
107112
raise Exception("You need to authenticate before you continue")
108113

109-
def generate_snoo_auth_headers(self, amz_token):
114+
def generate_snoo_auth_headers(self, amz_token: str) -> dict[str, str]:
110115
hdrs = self.snoo_auth_hdr.copy()
111116
hdrs["authorization"] = f"Bearer {amz_token}"
112117
return hdrs
113118

114-
def generate_snoo_data_url(self, device_id, snoo_token):
119+
def generate_snoo_data_url(self, device_id: str | float, snoo_token: str) -> str:
115120
if isinstance(device_id, float):
116121
device_id = str(int(device_id))
117122
req_uuid = uuid.uuid1()
@@ -122,13 +127,13 @@ def generate_snoo_data_url(self, device_id, snoo_token):
122127
url = f"https://happiestbaby.pubnubapi.com/v2/history/sub-key/sub-c-97bade2a-483d-11e6-8b3b-02ee2ddab7fe/channel/ActivityState.{device_id}?pnsdk=PubNub-Kotlin%2F7.4.0&l_pub=0.064&auth={snoo_token}&requestid={req_uuid}&include_token=true&count=1&include_meta=false&reverse=false&uuid=android_{app_dev_id}_{dev_uuid}"
123128
return url
124129

125-
def generate_id(self):
130+
def generate_id(self) -> str:
126131
app_dev_id_len = 24
127132
n = app_dev_id_len * 3 // 4
128133
app_dev_id = secrets.token_urlsafe(n)
129134
return app_dev_id
130135

131-
async def subscribe(self, device: SnooDevice, function):
136+
async def subscribe(self, device: SnooDevice, function: Callable):
132137
pnconfig = PNConfiguration()
133138
pnconfig.subscribe_key = "sub-c-97bade2a-483d-11e6-8b3b-02ee2ddab7fe"
134139
pnconfig.publish_key = "pub-c-699074b0-7664-4be2-abf8-dcbb9b6cd2bf"
@@ -154,7 +159,15 @@ async def disconnect(self):
154159
except asyncio.CancelledError:
155160
pass
156161
self.pubnub_instances = {}
157-
self.reauth_task.cancel()
162+
163+
for task in self._mqtt_tasks.values():
164+
task.cancel()
165+
await asyncio.gather(*self._mqtt_tasks.values(), return_exceptions=True)
166+
self._mqtt_tasks = {}
167+
168+
if self.reauth_task:
169+
self.reauth_task.cancel()
170+
self.reauth_task = None
158171

159172
def publish_callback(self, result, status):
160173
if status.is_error():
@@ -163,12 +176,24 @@ def publish_callback(self, result, status):
163176
async def send_command(self, command: str, device: SnooDevice, **kwargs):
164177
ts = int(dt.now().timestamp() * 10_000_000)
165178
try:
166-
await (
167-
self.pubnub.publish()
168-
.channel(f"ControlCommand.{device.serialNumber}")
169-
.message({"ts": ts, "command": command, **kwargs})
170-
.future()
171-
)
179+
# Acquire the condition lock
180+
async with self._client_cond:
181+
try:
182+
# Wait up to 30 seconds for the client to connect.
183+
# The wait_for() method will wait until the lambda returns True.
184+
# It releases the lock while waiting and re-acquires it before returning.
185+
await asyncio.wait_for(
186+
self._client_cond.wait_for(lambda: device.serialNumber in self._client_map), timeout=30.0
187+
)
188+
except asyncio.TimeoutError:
189+
_LOGGER.error(f"Timed out waiting for client for device {device.serialNumber} to connect.")
190+
raise SnooCommandException(f"Client for device {device.serialNumber} is not connected.") from None
191+
192+
# Once wait_for returns, we know the client exists and we hold the lock.
193+
await self._client_map[device.serialNumber].publish(
194+
topic=f"{device.awsIoT.thingName}/state_machine/control",
195+
payload=json.dumps({"ts": ts, "command": command, **kwargs}),
196+
)
172197
except Exception as e:
173198
raise SnooCommandException from e
174199

@@ -196,15 +221,15 @@ async def set_sticky_white_noise(self, device: SnooDevice, on: bool):
196221
async def get_status(self, device: SnooDevice):
197222
await self.send_command("send_status", device)
198223

199-
async def auth_amazon(self):
224+
async def auth_amazon(self) -> dict:
200225
r = await self.session.post(self.aws_auth_url, data=self.aws_auth_data, headers=self.aws_auth_hdr)
201226
resp = await r.json(content_type=None)
202227
if "__type" in resp and resp["__type"] == "NotAuthorizedException":
203228
raise InvalidSnooAuth()
204229
result = resp["AuthenticationResult"]
205230
return result
206231

207-
async def auth_snoo(self, id_token):
232+
async def auth_snoo(self, id_token: str) -> dict:
208233
hdrs = self.generate_snoo_auth_headers(id_token)
209234
r = await self.session.post(self.snoo_auth_url, data=self.snoo_auth_data, headers=hdrs)
210235
return await r.json()
@@ -226,7 +251,7 @@ async def authorize(self) -> AuthorizationInfo:
226251
raise SnooAuthException from ex
227252
return self.tokens
228253

229-
async def schedule_reauthorization(self, snoo_expiry: int):
254+
async def schedule_reauthorization(self, snoo_expiry: float):
230255
_LOGGER.info("Snoo token has expired - reauthorizing...")
231256
try:
232257
await asyncio.sleep(snoo_expiry)
@@ -248,14 +273,61 @@ async def get_devices(self) -> list[SnooDevice]:
248273
devs = [SnooDevice.from_dict(dev) for dev in resp["snoo"]]
249274
return devs
250275

251-
# lowest = 'lvl-2'
252-
# low='lvl-1'
253-
# normal='lvl10'
254-
# high='lvl+1'
255-
# highest='lvl+2'
256-
#
257-
#
258-
# soothing
259-
# normal='lvl10'
260-
# high='lvl+1'
261-
# highest='lvl+2'
276+
def start_subscribe(self, device: SnooDevice, function: Callable):
277+
if device.serialNumber in self._mqtt_tasks and not self._mqtt_tasks[device.serialNumber].done():
278+
_LOGGER.warning(f"Subscription task for device {device.serialNumber} is already running.")
279+
return
280+
281+
self._mqtt_tasks[device.serialNumber] = asyncio.create_task(self.subscribe_mqtt(device, function))
282+
283+
async def subscribe_mqtt(self, device: SnooDevice, function: Callable):
284+
host = device.awsIoT.clientEndpoint
285+
port = 443
286+
websocket_path = "/mqtt"
287+
token = self.tokens.aws_id
288+
headers = {"token": token}
289+
password = None
290+
client_id = f"HA_{uuid.uuid4()}"
291+
user_name = "?SDK=iOS&Version=2.40.1"
292+
293+
logging.debug(f"Attempting to connect to wss://{host}:{port}{websocket_path}")
294+
295+
try:
296+
async with aiomqtt.Client(
297+
hostname=host,
298+
port=port,
299+
username=user_name,
300+
password=password,
301+
identifier=client_id,
302+
transport="websockets",
303+
websocket_path=websocket_path,
304+
websocket_headers=headers,
305+
tls_params=aiomqtt.TLSParameters(),
306+
protocol=aiomqtt.ProtocolVersion.V31,
307+
timeout=10,
308+
) as client:
309+
logging.info(f"✅ Successfully connected to MQTT broker for {device.serialNumber}!")
310+
311+
# Acquire the lock, add the client to the map, and notify waiting tasks.
312+
async with self._client_cond:
313+
self._client_map[device.serialNumber] = client
314+
self._client_cond.notify_all()
315+
316+
topic = f"{device.awsIoT.thingName}/state_machine/activity_state"
317+
await client.subscribe(topic)
318+
logging.info(f"Subscribed to topic: {topic}")
319+
320+
async for message in client.messages:
321+
logging.debug(f"Received message on topic '{message.topic}': {message.payload.decode()}")
322+
function(SnooData.from_json(message.payload.decode()))
323+
324+
except aiomqtt.MqttError as e:
325+
logging.error(f"MQTT connection for {device.serialNumber} failed: {e}")
326+
except Exception as e:
327+
logging.error(f"MQTT connection for {device.serialNumber} failed with an unexpected error: {e}")
328+
finally:
329+
# When the connection is lost, remove the client from the map.
330+
logging.info(f"MQTT connection closed for {device.serialNumber}.")
331+
async with self._client_cond:
332+
if device.serialNumber in self._client_map:
333+
del self._client_map[device.serialNumber]

0 commit comments

Comments
 (0)