Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions custom_components/cuboai/api/async_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
57 changes: 37 additions & 20 deletions custom_components/cuboai/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -147,13 +147,20 @@ 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
self._device_id = device_id
self._baby_name = baby_name
self._state = None
self._attributes = {}
self._license_id = None

@property
def name(self):
Expand All @@ -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
Expand All @@ -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()}")
Expand Down
132 changes: 132 additions & 0 deletions tests/test_async_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading