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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
venv/
.venv/
__pycache__/
*.pyc
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ If you enjoy this integration and want to support its development, please consid
> [!NOTE]
>* Tracking RFID per pet intance eat/drink - (PLWF305) - API Information gathered, working on implementation.
>* Live camera feed for Granary Smart Camera Feeder (PLAF203) - Currently missing the API to setup live stream. Seems to connect via Kalay TUTK, if you have any experience integrating with this platform, please reach out to help us implement this.
>* The Granary Smart Camera Feeder exposes the TUTK/Kalay camera credentials (`camera_id`, `camera_auth_info`, `tutk_user_token`, `tutk_app_url`) as sensor attributes. These can be used with external TUTK clients (e.g. go2rtc) to stream the feed locally.

# NOTICE
#### Alpha/Beta state notice for this plugin:
Expand Down
10 changes: 10 additions & 0 deletions custom_components/petlibro/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,16 @@ async def device_attribute_settings(self, serial: str) -> Dict[str, Any]:
async def device_events(self, serial: str) -> Dict[str, Any]:
return await self.session.post_serial("/data/event/deviceEventsV2", serial)

async def tutk_info(self) -> Dict[str, Any]:
"""Fetch the TUTK/Kalay session info (userToken + appTutkUrl) for the account.

The endpoint is account-scoped (member/third) and returns the same
credentials to primary and shared accounts; see issue #267. Uses an
empty payload.
"""
_LOGGER.debug("Requesting TUTK session info")
return await self.session.post("/member/third/tutk/info", json={})

async def device_upgrade(self, serial: str) -> Dict[str, Any]:
return await self.session.post_serial("/device/ota/getUpgrade", serial)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ async def refresh(self):
feeding_plan_list = (await self.api.device_feeding_plan_list(self.serial)
if self._data.get("enableFeedingPlan") else [])
get_device_events = await self.api.device_events(self.serial)
try:
get_tutk_info = await self.api.tutk_info()
except PetLibroAPIError as err:
_LOGGER.warning(f"Error fetching TUTK info for GranarySmartCameraFeeder: {err}")
get_tutk_info = {}
# Update internal data with fetched API data
self.update_data({
"grainStatus": grain_status or {},
Expand All @@ -42,6 +47,7 @@ async def refresh(self):
"feedingPlan": feeding_plan_list or [],
"workRecord": get_work_record or [],
"getDeviceEvents": get_device_events or {},
"tutkInfo": get_tutk_info or {},
})
except PetLibroAPIError as err:
_LOGGER.error(f"Error refreshing data for GranarySmartCameraFeeder: {err}")
Expand Down Expand Up @@ -228,6 +234,47 @@ def motion_detected(self) -> bool:
def sound_detected(self) -> bool:
events = self._data.get("getDeviceEvents", {}).get("data", {}).get("eventInfos", [])
return any(event.get("eventKey") == "SOUND_DETECTED" for event in events)

@property
def camera_id(self) -> str:
"""Return the camera TUTK/Kalay UID (20-char) from the device record."""
return cast(str, self._data.get("cameraId", "") or "")

@property
def camera_auth_info(self) -> str:
"""Return the camera TUTK auth info from realInfo."""
value = self._data.get("realInfo", {}).get("cameraAuthInfo")
return value if isinstance(value, str) else ""

@property
def tutk_user_token(self) -> str:
"""Return the TUTK user token from /member/third/tutk/info."""
return cast(str, self._data.get("tutkInfo", {}).get("userToken", "") or "")

@property
def tutk_app_url(self) -> str:
"""Return the TUTK app/vsaas URL from /member/third/tutk/info."""
return cast(str, self._data.get("tutkInfo", {}).get("appTutkUrl", "") or "")

@property
def enable_camera(self) -> bool:
"""Return whether the camera is enabled."""
return bool(self._data.get("realInfo", {}).get("enableCamera", False))

@property
def camera_switch(self) -> bool:
"""Return the camera switch state."""
return bool(self._data.get("realInfo", {}).get("cameraSwitch", False))

@property
def motion_detection_switch(self) -> bool:
"""Return the motion detection switch state."""
return bool(self._data.get("realInfo", {}).get("motionDetectionSwitch", False))

@property
def sound_detection_switch(self) -> bool:
"""Return the sound detection switch state."""
return bool(self._data.get("realInfo", {}).get("soundDetectionSwitch", False))

@property
def remaining_desiccant(self) -> float | None:
Expand Down
12 changes: 12 additions & 0 deletions custom_components/petlibro/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,18 @@ def extra_state_attributes(self):
unit.symbol: VolumeConverter.convert(getattr(self.device, key, 0), UnitOfVolume.MILLILITERS, unit.symbol)
for unit in VALID_UNIT_TYPES[API.WATER_UNIT] if unit
}
if isinstance(self.device, GranarySmartCameraFeeder) and self.key == "wifi_ssid":
camera_attributes = {
"camera_id": self.device.camera_id,
"camera_auth_info": self.device.camera_auth_info,
"tutk_user_token": self.device.tutk_user_token,
"tutk_app_url": self.device.tutk_app_url,
"enable_camera": self.device.enable_camera,
"camera_switch": self.device.camera_switch,
"motion_detection_switch": self.device.motion_detection_switch,
"sound_detection_switch": self.device.sound_detection_switch,
}
return {**(super().extra_state_attributes or {}), **camera_attributes}
return super().extra_state_attributes

DEVICE_SENSOR_MAP: dict[type[Device], list[PetLibroSensorEntityDescription]] = {
Expand Down