diff --git a/custom_components/cuboai/api/async_api.py b/custom_components/cuboai/api/async_api.py index a3944ef..ae36ce7 100644 --- a/custom_components/cuboai/api/async_api.py +++ b/custom_components/cuboai/api/async_api.py @@ -403,3 +403,101 @@ async def get_camera_state( finally: if close_session: await session.close() + + +async def get_camera_details( + device_id: str, access_token: str, user_agent: str, session: aiohttp.ClientSession | None = None +) -> dict | None: + """Get detailed camera information from /user/cameras endpoint. + + Extracts and combines data from the camera registration, profile, + and report settings for a specific device. + + Args: + device_id: Camera device ID + access_token: CuboAI access token + user_agent: User agent string + session: Optional aiohttp session + + Returns: + Dict with camera details or None if not found. Keys include: + - device_id, license_id, created, role + - baby_name, birth_date, gender, avatar_url + - timezone, sleep_time, wakeup_time + - alexa_enabled + """ + import json + + url = f"{API_BASE}/user/cameras" + headers = _get_common_headers(access_token, user_agent) + + close_session = session is None + session = session or aiohttp.ClientSession() + try: + async with session.get(url, headers=headers) as resp: + resp.raise_for_status() + response_data = await resp.json() + + # Find the camera data + camera_data = None + for cam in response_data.get("data", []): + if cam.get("device_id") == device_id: + camera_data = cam + break + + if not camera_data: + return None + + # Find the profile data + profile_data = {} + for profile in response_data.get("profiles", []): + if profile.get("device_id") == device_id: + profile_json = profile.get("profile", "{}") + try: + profile_data = json.loads(profile_json) if isinstance(profile_json, str) else profile_json + except Exception: + profile_data = {} + break + + # Find report settings + report_settings = {} + for settings in response_data.get("report_settings", []): + if settings.get("device_id") == device_id: + report_settings = settings + break + + # Parse settings JSON from camera data + settings = {} + settings_json = camera_data.get("settings", "{}") + try: + settings = json.loads(settings_json) if isinstance(settings_json, str) else settings_json + except Exception: + settings = {} + + # Map gender: 0=male, 1=female + gender_raw = profile_data.get("gender") + gender = "male" if gender_raw == 0 else "female" if gender_raw == 1 else None + + return { + # Camera registration info + "device_id": device_id, + "license_id": camera_data.get("license_id"), + "created": camera_data.get("created"), + "role": camera_data.get("role"), + # Profile info + "baby_name": profile_data.get("baby"), + "birth_date": profile_data.get("birth"), + "gender": gender, + "avatar_url": profile_data.get("avatar"), + # Settings + "alexa_enabled": settings.get("alexa_enable", False), + # Report settings + "timezone": report_settings.get("time_zone"), + "sleep_time": report_settings.get("sleep_time"), + "wakeup_time": report_settings.get("wakeup_time"), + "report_time": report_settings.get("report_time"), + "gmt_offset": report_settings.get("gmt_offset"), + } + finally: + if close_session: + await session.close() diff --git a/custom_components/cuboai/sensor.py b/custom_components/cuboai/sensor.py index c3519b1..bf5a773 100644 --- a/custom_components/cuboai/sensor.py +++ b/custom_components/cuboai/sensor.py @@ -8,7 +8,7 @@ from .api.async_api import ( download_image, - get_camera_profiles_raw, + get_camera_details, get_camera_state, get_n_alerts_paged, get_subscription_info, @@ -147,6 +147,12 @@ async def _external_refresh_token(self): class CuboBabyInfoSensor(CuboBaseSensor): + """Sensor exposing baby profile and camera details. + + Provides baby info, device registration, and report settings. + Also supplies serial_number to the device registry. + """ + def __init__(self, hass, entry, name, device_id, baby_name, access_token, refresh_token, user_agent): super().__init__(hass, entry, access_token, refresh_token, user_agent) self._name = name @@ -154,6 +160,7 @@ def __init__(self, hass, entry, name, device_id, baby_name, access_token, refres self._baby_name = baby_name self._state = None self._attributes = {} + self._license_id = None @property def name(self): @@ -173,12 +180,15 @@ def extra_state_attributes(self): @property def device_info(self): - return { + info = { "identifiers": {(DOMAIN, self._device_id)}, "name": f"CuboAI {self._baby_name}", "manufacturer": "CuboAI", "model": "Baby Monitor", } + if self._license_id: + info["serial_number"] = self._license_id + return info async def async_update(self): import traceback @@ -187,30 +197,37 @@ async def async_update(self): await self._load_latest_tokens() session = await self._get_session() try: - profiles = await get_camera_profiles_raw(self._access_token, self._user_agent, session) + data = await get_camera_details(self._device_id, self._access_token, self._user_agent, session) except aiohttp.ClientResponseError as e: if e.status == 401: log_to_file(f"Access token expired in BabyInfoSensor: {e}") await self._external_refresh_token() - profiles = await get_camera_profiles_raw(self._access_token, self._user_agent, session) + data = await get_camera_details(self._device_id, self._access_token, self._user_agent, session) else: raise - found = False - for item in profiles: - if item["device_id"] == self._device_id: - profile = json.loads(item.get("profile", "{}")) - birth_date = profile.get("birth") - gender = profile.get("gender") - gender_text = "male" if gender == 0 else "female" if gender == 1 else "unknown" - self._attributes = { - "baby": profile.get("baby"), - "birth": birth_date, - "gender": gender_text, - "device_id": self._device_id, - } - found = True - break - if not found: + + if data: + self._license_id = data.get("license_id") + self._attributes = { + # Baby profile + "baby": data.get("baby_name"), + "birth": data.get("birth_date"), + "gender": data.get("gender"), + "avatar_url": data.get("avatar_url"), + # Device info + "device_id": self._device_id, + "license_id": data.get("license_id"), + "created": data.get("created"), + "role": data.get("role"), + # Settings + "alexa_enabled": data.get("alexa_enabled"), + "timezone": data.get("timezone"), + "sleep_time": data.get("sleep_time"), + "wakeup_time": data.get("wakeup_time"), + "report_time": data.get("report_time"), + "gmt_offset": data.get("gmt_offset"), + } + else: self._attributes = {"baby": None, "birth": None, "gender": None, "device_id": self._device_id} except Exception as e: log_to_file(f"Failed to update Cubo baby profile info: {e}\n{traceback.format_exc()}") diff --git a/tests/test_async_api.py b/tests/test_async_api.py index 622d3ae..6355158 100644 --- a/tests/test_async_api.py +++ b/tests/test_async_api.py @@ -351,3 +351,135 @@ def test_handles_invalid_json_params(self): result = async_api._normalize_alert(alert) assert result["params"] == "not-valid-json" + + +class TestGetCameraDetailsAsync: + """Test async get_camera_details.""" + + @pytest.mark.asyncio + async def test_returns_camera_details(self): + """Test that camera details are extracted correctly.""" + with aioresponses() as m: + m.get( + "https://api.getcubo.com/prod/user/cameras", + payload={ + "data": [ + { + "device_id": "device-123", + "license_id": "LICENSE123", + "created": "2023-01-01T00:00:00.000Z", + "role": "admin", + "settings": '{"alexa_enable": true}', + } + ], + "profiles": [ + { + "device_id": "device-123", + "profile": '{"baby": "TestBaby", "birth": "2022-06-15", "gender": 1, "avatar": "https://example.com/avatar.jpg"}', + } + ], + "report_settings": [ + { + "device_id": "device-123", + "time_zone": "Europe/London", + "sleep_time": "19:00", + "wakeup_time": "07:00", + "report_time": 9, + "gmt_offset": 0, + } + ], + }, + ) + + result = await async_api.get_camera_details( + device_id="device-123", + access_token="test-token", + user_agent="TestAgent/1.0", + ) + + assert result["device_id"] == "device-123" + assert result["license_id"] == "LICENSE123" + assert result["baby_name"] == "TestBaby" + assert result["birth_date"] == "2022-06-15" + assert result["gender"] == "female" + assert result["avatar_url"] == "https://example.com/avatar.jpg" + assert result["alexa_enabled"] is True + assert result["timezone"] == "Europe/London" + assert result["sleep_time"] == "19:00" + assert result["wakeup_time"] == "07:00" + + @pytest.mark.asyncio + async def test_returns_none_for_unknown_device(self): + """Test that None is returned for unknown device.""" + with aioresponses() as m: + m.get( + "https://api.getcubo.com/prod/user/cameras", + payload={ + "data": [{"device_id": "other-device"}], + "profiles": [], + "report_settings": [], + }, + ) + + result = await async_api.get_camera_details( + device_id="unknown-device", + access_token="test-token", + user_agent="TestAgent/1.0", + ) + + assert result is None + + @pytest.mark.asyncio + async def test_handles_missing_profile(self): + """Test handling when profile data is missing.""" + with aioresponses() as m: + m.get( + "https://api.getcubo.com/prod/user/cameras", + payload={ + "data": [ + { + "device_id": "device-123", + "license_id": "LICENSE123", + } + ], + "profiles": [], + "report_settings": [], + }, + ) + + result = await async_api.get_camera_details( + device_id="device-123", + access_token="test-token", + user_agent="TestAgent/1.0", + ) + + assert result["device_id"] == "device-123" + assert result["license_id"] == "LICENSE123" + assert result["baby_name"] is None + assert result["gender"] is None + + @pytest.mark.asyncio + async def test_maps_gender_correctly(self): + """Test gender mapping (0=male, 1=female).""" + with aioresponses() as m: + m.get( + "https://api.getcubo.com/prod/user/cameras", + payload={ + "data": [{"device_id": "device-male"}], + "profiles": [ + { + "device_id": "device-male", + "profile": '{"gender": 0}', + } + ], + "report_settings": [], + }, + ) + + result = await async_api.get_camera_details( + device_id="device-male", + access_token="test-token", + user_agent="TestAgent/1.0", + ) + + assert result["gender"] == "male"