From 4fe04533f192afaadfcfff48f76673a28609a7bc Mon Sep 17 00:00:00 2001 From: Colin de Vries Date: Sun, 28 Sep 2025 14:36:01 +0200 Subject: [PATCH] Add files via upload --- petsseries/api.py | 68 +++++++++++----------- petsseries/config.py | 8 +-- petsseries/events.py | 130 +++++++++++++++++++++---------------------- petsseries/meals.py | 100 +++++++++++++++++++++++++++------ petsseries/models.py | 12 ++-- 5 files changed, 191 insertions(+), 127 deletions(-) diff --git a/petsseries/api.py b/petsseries/api.py index 47b56df..26668b6 100644 --- a/petsseries/api.py +++ b/petsseries/api.py @@ -8,29 +8,29 @@ import logging from typing import Any, Dict, Optional -import aiohttp +import aiohttp # type: ignore[import-not-found] from .auth import AuthManager +from .config import Config +from .events import EventsManager + +# Import MealsManager +from .meals import MealsManager from .models import ( - User, - Home, - Device, Consumer, + Device, + Home, ModeDevice, + User, ) -from .config import Config from .session import create_ssl_context -# Import MealsManager -from .meals import MealsManager -from .events import EventsManager - # Optional import for Tuya try: from .tuya import TuyaClient, TuyaError except ImportError: - TuyaClient = None - TuyaError = Exception + TuyaClient = None # type: ignore[assignment, misc] + TuyaError = Exception # type: ignore[assignment, misc] _LOGGER = logging.getLogger(__name__) @@ -53,8 +53,8 @@ def __init__( ): self.auth = AuthManager(token_file, access_token, refresh_token) self.session = None - self.headers = {} - self.headers_token = {} + self.headers: Dict[str, str] = {} + self.headers_token: Dict[str, str] = {} self.timeout = aiohttp.ClientTimeout(total=10.0) self.config = Config() self.tuya_client: Optional[TuyaClient] = None # type: ignore @@ -74,7 +74,7 @@ def __init__( client_id=tuya_credentials["client_id"], ip=tuya_credentials["ip"], local_key=tuya_credentials["local_key"], - version=tuya_credentials.get("version", 3.4), + version=float(tuya_credentials.get("version", 3.4)), ) _LOGGER.info("TuyaClient initialized successfully.") except TuyaError as e: @@ -102,7 +102,9 @@ async def initialize(self) -> None: Initialize the client by loading tokens and refreshing the access token if necessary. """ if self.auth.access_token and self.auth.refresh_token: - await self.auth.save_tokens(str(self.auth.access_token), str(self.auth.refresh_token)) + await self.auth.save_tokens( + str(self.auth.access_token), str(self.auth.refresh_token) + ) await self.auth.load_tokens() if await self.auth.is_token_expired(): _LOGGER.info("Access token expired, refreshing...") @@ -188,8 +190,12 @@ async def get_consumer(self) -> Consumer: ) as response: response.raise_for_status() data = await response.json() + # New consumer endpoint observed returns: id, identities, installations, url, identitiesUrl, installationsUrl, language + # countryCode may be absent; set to empty string if missing for backward compatibility return Consumer( - id=data["id"], country_code=data["countryCode"], url=data["url"] + id=str(data.get("id", "")), + country_code=str(data.get("countryCode", "")), + url=str(data.get("url", "")), ) except aiohttp.ClientResponseError as e: _LOGGER.error("Failed to get Consumer: %s %s", e.status, e.message) @@ -210,16 +216,19 @@ async def get_homes(self) -> list[Home]: ) as response: response.raise_for_status() homes_data = await response.json() + items = homes_data.get( + "item", homes_data if isinstance(homes_data, list) else [] + ) homes = [ Home( - id=home["id"], - name=home["name"], - shared=home["shared"], - number_of_devices=home["numberOfDevices"], - external_id=home["externalId"], - number_of_activities=home["numberOfActivities"], + id=home.get("id", ""), + name=home.get("name", ""), + shared=bool(home.get("shared", False)), + number_of_devices=int(home.get("numberOfDevices", 0)), + external_id=str(home.get("externalId", "")), + number_of_activities=int(home.get("numberOfActivities", 0)), ) - for home in homes_data + for home in items ] return homes except aiohttp.ClientResponseError as e: @@ -234,10 +243,7 @@ async def get_devices(self, home: Home) -> list[Device]: Get devices for the selected home. """ await self.ensure_token_valid() - url = ( - f"https://petsseries-backend.prod.eu-hs.iot.versuni.com/" - f"api/homes/{home.id}/devices" - ) + url = f"{self.config.base_url}/api/homes/{home.id}/devices" session = await self.get_client() try: async with session.get(url, headers=self.headers) as response: @@ -269,10 +275,7 @@ async def get_mode_devices(self, home: Home) -> list[ModeDevice]: Get mode devices for the selected home. """ await self.ensure_token_valid() - url = ( - f"https://petsseries-backend.prod.eu-hs.iot.versuni.com/" - f"api/homes/{home.id}/modes/home/devices" - ) + url = f"{self.config.base_url}/api/homes/{home.id}/modes/home/devices" session = await self.get_client() try: async with session.get(url, headers=self.headers) as response: @@ -298,8 +301,7 @@ async def update_device_settings( """ await self.ensure_token_valid() url = ( - f"https://petsseries-backend.prod.eu-hs.iot.versuni.com/" - f"api/homes/{home.id}/modes/home/devices/{device_id}" + f"{self.config.base_url}/api/homes/{home.id}/modes/home/devices/{device_id}" ) headers = { diff --git a/petsseries/config.py b/petsseries/config.py index dd7e7a3..c98e1f2 100644 --- a/petsseries/config.py +++ b/petsseries/config.py @@ -11,14 +11,12 @@ class Config: Represents the configuration for the PetsSeries system. """ - base_url: str = "https://petsseries-backend.prod.eu-hs.iot.versuni.com" + base_url: str = "https://petseries.prd.nbx.iot.versuni.com" user_info_url: str = ( "https://cdc.accounts.home.id/oidc/op/v1.0/4_JGZWlP8eQHpEqkvQElolbA/userinfo" ) - consumer_url: str = ( - "https://nbx-discovery.prod.eu-hs.iot.versuni.com/api/petsseries/consumer" - ) - homes_url: str = base_url + "/api/v1/home-management/available-homes" + consumer_url: str = base_url + "/api/consumer" + homes_url: str = base_url + "/api/homes" token_url: str = ( "https://cdc.accounts.home.id/oidc/op/v1.0/4_JGZWlP8eQHpEqkvQElolbA/token" ) diff --git a/petsseries/events.py b/petsseries/events.py index 6ab27cd..1265663 100644 --- a/petsseries/events.py +++ b/petsseries/events.py @@ -5,25 +5,25 @@ """ import logging -from typing import List import urllib.parse +from typing import List -import aiohttp +import aiohttp # type: ignore[import-not-found] +from .config import Config from .models import ( - Home, + DeviceOfflineEvent, + DeviceOnlineEvent, Event, - MotionEvent, - MealDispensedEvent, - MealUpcomingEvent, - FoodLevelLowEvent, - MealEnabledEvent, FilterReplacementDueEvent, + FoodLevelLowEvent, FoodOutletStuckEvent, - DeviceOfflineEvent, - DeviceOnlineEvent, + Home, + MealDispensedEvent, + MealEnabledEvent, + MealUpcomingEvent, + MotionEvent, ) -from .config import Config _LOGGER = logging.getLogger(__name__) @@ -88,8 +88,7 @@ async def get_events( to_date_encoded = urllib.parse.quote(to_date.isoformat()) url = ( - f"https://petsseries-backend.prod.eu-hs.iot.versuni.com/" - f"api/homes/{home.id}/events" + f"{self.config.base_url}/api/homes/{home.id}/events" f"?from={from_date_encoded}&to={to_date_encoded}&clustered={clustered}" f"{types_param}" ) @@ -126,10 +125,7 @@ async def get_event(self, home: Home, event_id: str) -> Event: Exception: For any unexpected errors. """ await self.client.ensure_token_valid() - url = ( - f"https://petsseries-backend.prod.eu-hs.iot.versuni.com/" - f"api/homes/{home.id}/events/{event_id}" - ) + url = f"{self.config.base_url}/api/homes/{home.id}/events/{event_id}" session = await self.client.get_client() try: async with session.get(url, headers=self.client.headers) as response: @@ -159,11 +155,11 @@ def parse_event(self, event: dict) -> Event: match event_type: case "motion_detected": return MotionEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), thumbnail_key=event.get("thumbnailKey"), @@ -175,11 +171,11 @@ def parse_event(self, event: dict) -> Event: ) case "meal_dispensed": return MealDispensedEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), meal_name=event.get("mealName"), @@ -192,11 +188,11 @@ def parse_event(self, event: dict) -> Event: ) case "meal_upcoming": return MealUpcomingEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), meal_name=event.get("mealName"), @@ -209,11 +205,11 @@ def parse_event(self, event: dict) -> Event: ) case "food_level_low": return FoodLevelLowEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), device_id=event.get("deviceId"), @@ -223,11 +219,11 @@ def parse_event(self, event: dict) -> Event: ) case "meal_enabled": return MealEnabledEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), meal_amount=event.get("mealAmount"), @@ -241,11 +237,11 @@ def parse_event(self, event: dict) -> Event: ) case "filter_replacement_due": return FilterReplacementDueEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), device_id=event.get("deviceId"), @@ -255,11 +251,11 @@ def parse_event(self, event: dict) -> Event: ) case "food_outlet_stuck": return FoodOutletStuckEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), device_id=event.get("deviceId"), @@ -269,11 +265,11 @@ def parse_event(self, event: dict) -> Event: ) case "device_offline": return DeviceOfflineEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), device_id=event.get("deviceId"), @@ -283,11 +279,11 @@ def parse_event(self, event: dict) -> Event: ) case "device_online": return DeviceOnlineEvent( - id=event.get("id"), - type=event_type, - source=event.get("source"), - time=event.get("time"), - url=event.get("url"), + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), cluster_id=event.get("clusterId"), metadata=event.get("metadata"), device_id=event.get("deviceId"), @@ -299,9 +295,9 @@ def parse_event(self, event: dict) -> Event: _LOGGER.warning("Unknown event type: %s", event_type) # Generic event return Event( - id=event["id"], - type=event_type, - source=event["source"], - time=event["time"], - url=event["url"], + id=str(event.get("id", "")), + type=str(event_type), + source=str(event.get("source", "")), + time=str(event.get("time", "")), + url=str(event.get("url", "")), ) diff --git a/petsseries/meals.py b/petsseries/meals.py index c198b19..82d329d 100644 --- a/petsseries/meals.py +++ b/petsseries/meals.py @@ -5,13 +5,13 @@ """ import logging -from typing import List import urllib.parse +from typing import Any, List -import aiohttp +import aiohttp # type: ignore[import-not-found] -from .models import Meal, Home from .config import Config +from .models import Home, Meal _LOGGER = logging.getLogger(__name__) @@ -87,33 +87,73 @@ async def update_meal(self, home: Home, meal: Meal) -> Meal: url = f"{self.config.base_url}/api/homes/{home.id}/meals/{meal.id}" # Prepare the payload with updated fields + def _to_iso8601(value): + try: + # datetime/date/time objects + return value.isoformat() # type: ignore[attr-defined] + except Exception: + return str(value) + + # Normalize repeat days to a list of ints + repeat_days: List[int] = ( + meal.repeat_days if meal.repeat_days else [1, 2, 3, 4, 5, 6, 7] + ) + payload = { "name": meal.name, "portionAmount": meal.portion_amount, - "feedTime": meal.feed_time.isoformat(), - "repeatDays": meal.repeat_days or [1, 2, 3, 4, 5, 6, 7], + "feedTime": _to_iso8601(meal.feed_time), + "repeatDays": repeat_days, } session = await self.client.get_client() try: - async with session.patch( - url, headers=self.client.headers, json=payload - ) as response: + headers = { + **self.client.headers, + "Content-Type": "application/json; charset=UTF-8", + } + async with session.patch(url, headers=headers, json=payload) as response: if response.status == 200: updated_data = await response.json() _LOGGER.info("Meal %s updated successfully.", meal.id) + + def _ensure_int_list(value: Any, fallback: List[int]) -> List[int]: + if isinstance(value, list): + result: List[int] = [] + for v in value: + try: + result.append(int(v)) + except Exception: + continue + return result or fallback + return fallback + + parsed_repeat_days = _ensure_int_list( + updated_data.get("repeatDays"), repeat_days + ) return Meal( id=updated_data["id"], name=updated_data["name"], portion_amount=updated_data["portionAmount"], feed_time=updated_data["feedTime"], - repeat_days=updated_data.get( - "repeatDays", [1, 2, 3, 4, 5, 6, 7] - ), + repeat_days=parsed_repeat_days, device_id=updated_data["deviceId"], enabled=updated_data.get("enabled", True), url=updated_data["url"], ) + if response.status == 204: + _LOGGER.info("Meal %s updated successfully (no content).", meal.id) + # Return the provided meal as the updated representation + return Meal( + id=meal.id, + name=meal.name, + portion_amount=meal.portion_amount, + feed_time=_to_iso8601(meal.feed_time), + repeat_days=repeat_days, + device_id=meal.device_id, + enabled=getattr(meal, "enabled", True), + url=getattr(meal, "url", url), + ) text = await response.text() _LOGGER.error( "Failed to update meal %s: %s %s", meal.id, response.status, text @@ -127,6 +167,7 @@ async def update_meal(self, home: Home, meal: Meal) -> Meal: except Exception as e: _LOGGER.error("Unexpected error in update_meal: %s", e) raise + raise RuntimeError("Failed to update meal") async def create_meal(self, home: Home, meal: Meal) -> Meal: """ @@ -145,13 +186,19 @@ async def create_meal(self, home: Home, meal: Meal) -> Meal: """ await self.client.ensure_token_valid() if meal.repeat_days is None: - repeat_days = [1, 2, 3, 4, 5, 6, 7] + repeat_days: List[int] = [1, 2, 3, 4, 5, 6, 7] else: repeat_days = meal.repeat_days + def _to_iso8601(value): + try: + return value.isoformat() # type: ignore[attr-defined] + except Exception: + return str(value) + payload = { "deviceId": meal.device_id, - "feedTime": meal.feed_time.isoformat(), + "feedTime": _to_iso8601(meal.feed_time), "name": meal.name, "portionAmount": meal.portion_amount, "repeatDays": repeat_days, @@ -159,12 +206,15 @@ async def create_meal(self, home: Home, meal: Meal) -> Meal: session = await self.client.get_client() try: + headers = { + **self.client.headers, + "Content-Type": "application/json; charset=UTF-8", + } async with session.post( f"{self.config.base_url}/api/homes/{home.id}/meals", - headers=self.client.headers, + headers=headers, json=payload, ) as response: - if response.status == 201: location = response.headers.get("Location") if not location: @@ -186,12 +236,27 @@ async def create_meal(self, home: Home, meal: Meal) -> Meal: id=meal_id, name=meal.name, portion_amount=meal.portion_amount, - feed_time=meal.feed_time.isoformat(), + feed_time=_to_iso8601(meal.feed_time), repeat_days=repeat_days, device_id=meal.device_id, enabled=True, url=location, ) + if response.status == 200: + created = await response.json() + _LOGGER.info( + "Meal created successfully with ID: %s", created.get("id") + ) + return Meal( + id=created["id"], + name=created["name"], + portion_amount=created["portionAmount"], + feed_time=created["feedTime"], + repeat_days=created.get("repeatDays", repeat_days), + device_id=created["deviceId"], + enabled=created.get("enabled", True), + url=created.get("url", ""), + ) text = await response.text() _LOGGER.error("Failed to create meal: %s %s", response.status, text) response.raise_for_status() @@ -201,6 +266,7 @@ async def create_meal(self, home: Home, meal: Meal) -> Meal: except Exception as e: _LOGGER.error("Unexpected error in create_meal: %s", e) raise + raise RuntimeError("Failed to create meal") async def set_meal_enabled(self, home: Home, meal_id: str, enabled: bool) -> bool: """ @@ -256,6 +322,7 @@ async def set_meal_enabled(self, home: Home, meal_id: str, enabled: bool) -> boo except Exception as e: _LOGGER.error("Unexpected error in set_meal_enabled: %s", e) raise + return False async def enable_meal(self, home: Home, meal_id: str) -> bool: """ @@ -305,3 +372,4 @@ async def delete_meal(self, home: Home, meal_id: str) -> bool: except Exception as e: _LOGGER.error("Unexpected error in delete_meal: %s", e) raise + return False diff --git a/petsseries/models.py b/petsseries/models.py index 8acc19f..b590cdd 100644 --- a/petsseries/models.py +++ b/petsseries/models.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import Enum -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional @dataclass @@ -73,7 +73,7 @@ class Meal: name (str): Name of the meal. portion_amount (float): Amount of the portion. feed_time (str): Scheduled feeding time. - repeat_days (List[str]): Days when the meal repeats. + repeat_days (List[int]): Days when the meal repeats. device_id (str): Identifier of the device associated with the meal. enabled (bool): Indicates if the meal is enabled. url (str): URL endpoint for the meal. @@ -83,7 +83,7 @@ class Meal: name: str portion_amount: float feed_time: str - repeat_days: List[str] + repeat_days: List[int] device_id: str enabled: bool url: str @@ -148,12 +148,12 @@ class ModeDevice: Attributes: id (str): Unique identifier for the mode device. name (str): Name of the mode device. - settings (Dict[str, Dict[str, any]]): Settings associated with the mode device. + settings (Dict[str, Dict[str, Any]]): Settings associated with the mode device. """ id: str name: str - settings: Dict[str, Dict[str, any]] + settings: Dict[str, Dict[str, Any]] class EventType(Enum): @@ -184,7 +184,7 @@ class Event: """ id: str - type: EventType + type: str source: str time: str url: str