1- # snoo.py
21import asyncio
32import json
43import logging
54import secrets
65import uuid
76from datetime import datetime as dt
7+ from typing import Callable
88
99import aiohttp
10+ import aiomqtt
1011from pubnub .enums import PNReconnectionPolicy
1112from pubnub .pnconfiguration import PNConfiguration
1213from pubnub .pubnub_asyncio import PubNubAsyncio
1314
1415from .containers import (
1516 AuthorizationInfo ,
17+ SnooData ,
1618 SnooDevice ,
1719 SnooStates ,
1820)
2325
2426
2527class 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