Skip to content

Commit dc700b3

Browse files
authored
Feature/Schedule-Control-Dry-Feeders (#209)
* Feature/Schedule-Control Feature/Schedule-Control * Fix Naming For Schedule Binary Sensor's Fix Naming For Schedule Binary Sensor's * Fix Trailing Comma Fix Trailing Comma * Last Plan vs Schedule naming updates Last Plan vs Schedule naming updates
1 parent 70f211c commit dc700b3

9 files changed

Lines changed: 1038 additions & 183 deletions

File tree

custom_components/petlibro/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .devices.litterboxes.luma_smart_litter_box import LumaSmartLitterBox
2323
from .const import DOMAIN, CONF_EMAIL, CONF_PASSWORD, PLATFORMS, UPDATE_INTERVAL_SECONDS # Assuming UPDATE_INTERVAL_SECONDS is defined in const
2424
from .hub import PetLibroHub
25+
from .services import async_setup_services, async_unload_services
2526

2627
_LOGGER = logging.getLogger(__name__)
2728

@@ -197,6 +198,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
197198
# Forward entry setups for each platform
198199
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
199200

201+
await async_setup_services(hass)
202+
200203
_LOGGER.info(f"Successfully set up PetLibro integration for {email}")
201204
return True
202205

@@ -220,6 +223,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
220223
if unload_ok:
221224
_LOGGER.info(f"Successfully unloaded PetLibro entry for {entry.data.get(CONF_EMAIL)}")
222225
await hub.async_unload() # If you have any cleanup to do in the hub
226+
await async_unload_services(hass)
223227
else:
224228
_LOGGER.error(f"Failed to unload PetLibro entry for {entry.data.get(CONF_EMAIL)}")
225229

custom_components/petlibro/api.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,65 @@ async def _post_update(endpoint: str, payload: dict[str, Any]) -> bool:
15201520

15211521
return success
15221522

1523+
async def feeding_plan_toggle(self, serial: str, plan: dict) -> None:
1524+
"""Enable or disable an existing feeding plan via the /enable endpoint."""
1525+
await self.session.post("/device/feedingPlan/enable", json={
1526+
"deviceSn": serial,
1527+
"planId": plan["id"],
1528+
"enable": plan["enable"],
1529+
})
1530+
1531+
async def feeding_plan_delete(self, serial: str, plan_id: int) -> None:
1532+
"""Permanently remove a feeding plan."""
1533+
await self.session.post("/device/feedingPlan/remove", json={
1534+
"deviceSn": serial,
1535+
"planId": plan_id,
1536+
})
1537+
1538+
async def feeding_plan_add(self, serial: str, plan: dict) -> None:
1539+
"""Create a new feeding plan."""
1540+
await self.session.post("/device/feedingPlan/add", json={
1541+
"id": 0,
1542+
"deviceSn": serial,
1543+
"executionTime": plan.get("executionTime"),
1544+
"repeatDay": plan.get("repeatDay", "[]"),
1545+
"label": plan.get("label", ""),
1546+
"enable": True,
1547+
"enableAudio": plan.get("enableAudio", False),
1548+
"audioTimes": 2,
1549+
"grainNum": plan.get("grainNum"),
1550+
"petIds": [],
1551+
})
1552+
1553+
async def feeding_plan_today_skip(self, serial: str, plan_id: int, skip: bool) -> None:
1554+
"""Skip or un-skip a single feeding plan event for today only."""
1555+
await self.session.post("/device/feedingPlan/enableTodaySingle", json={
1556+
"deviceSn": serial,
1557+
"planId": plan_id,
1558+
"enable": not skip,
1559+
})
1560+
1561+
async def feeding_plan_update(self, serial: str, plan: dict) -> None:
1562+
"""Enable, disable, or edit an existing feeding plan."""
1563+
await self.session.post("/device/feedingPlan/update", json={
1564+
"id": plan["id"],
1565+
"deviceSn": serial,
1566+
"executionTime": plan.get("executionTime"),
1567+
"repeatDay": plan.get("repeatDay", "[]"),
1568+
"label": plan.get("label", ""),
1569+
"enable": plan.get("enable", True),
1570+
"enableAudio": plan.get("enableAudio", False),
1571+
"audioTimes": plan.get("audioTimes", 2),
1572+
"grainNum": plan.get("grainNum"),
1573+
"petIds": [],
1574+
})
1575+
1576+
async def feeding_plan_today_all(self, serial: str, enable: bool) -> None:
1577+
"""Enable or disable ALL feeding plan events for today."""
1578+
await self.session.post("/device/feedingPlan/enableTodayAll", json={
1579+
"deviceSn": serial,
1580+
"enable": enable,
1581+
})
15231582

15241583
## Added this to fix dupe logs
15251584
class PetLibroDataCoordinator(DataUpdateCoordinator):

custom_components/petlibro/binary_sensor.py

Lines changed: 155 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,16 @@
88
from functools import cached_property
99
from typing import Optional
1010
import logging
11-
from .const import DOMAIN
11+
from .const import DOMAIN, Unit, APIKey as API, VALID_UNIT_TYPES
1212
from homeassistant.components.binary_sensor import (
1313
BinarySensorEntity,
1414
BinarySensorEntityDescription,
1515
BinarySensorDeviceClass,
1616
)
1717
from homeassistant.core import HomeAssistant
1818
from homeassistant.helpers.entity_platform import AddEntitiesCallback
19-
from homeassistant.config_entries import ConfigEntry # Added ConfigEntry import
20-
from .hub import PetLibroHub # Adjust the import path as necessary
21-
19+
from homeassistant.config_entries import ConfigEntry
20+
from .hub import PetLibroHub
2221

2322
_LOGGER = logging.getLogger(__name__)
2423

@@ -46,9 +45,12 @@ class PetLibroBinarySensorEntityDescription(BinarySensorEntityDescription, PetLi
4645
device_class_fn: Callable[[_DeviceT], BinarySensorDeviceClass | None] = lambda _: None
4746
should_report: Callable[[_DeviceT], bool] = lambda _: True
4847
device_class: Optional[BinarySensorDeviceClass] = None
48+
# Optional override for is_on — use when the entity key differs from the device property
49+
value_fn: Callable | None = None
50+
4951

5052
class PetLibroBinarySensorEntity(PetLibroEntity[_DeviceT], BinarySensorEntity):
51-
"""PETLIBRO sensor entity."""
53+
"""PETLIBRO binary sensor entity."""
5254

5355
entity_description: PetLibroBinarySensorEntityDescription[_DeviceT]
5456

@@ -60,34 +62,79 @@ def device_class(self) -> BinarySensorDeviceClass | None:
6062
@property
6163
def is_on(self) -> bool:
6264
"""Return True if the binary sensor is on."""
63-
# Check if the binary sensor should report its state
6465
if not self.entity_description.should_report(self.device):
6566
return False
6667

67-
# Retrieve the state using getattr, defaulting to None if the attribute is missing
68+
# Use value_fn override when the key doesn't match a device property directly
69+
if self.entity_description.value_fn is not None:
70+
return bool(self.entity_description.value_fn(self.device))
71+
6872
state = getattr(self.device, self.entity_description.key, None)
6973

70-
# Check if this is the first time the sensor is being refreshed by checking if _last_state exists
7174
last_state = getattr(self, '_last_state', None)
72-
initial_log_done = getattr(self, '_initial_log_done', False) # Track if we've logged the initial state
75+
initial_log_done = getattr(self, '_initial_log_done', False)
7376

74-
# If this is the initial boot, don't log anything but track the state
7577
if not initial_log_done:
76-
# Mark the initial log as done without logging
77-
self._initial_log_done = True
78+
self._initial_log_done = True
7879
elif last_state != state:
79-
# Log state changes: log online with INFO and offline with WARNING
8080
if state:
8181
_LOGGER.info(f"Device {self.device.name} is online.")
8282
else:
8383
_LOGGER.warning(f"Device {self.device.name} is offline.")
8484

85-
# Store the last state for future comparisons
8685
self._last_state = state
87-
88-
# Return the state, ensuring it's a boolean
8986
return bool(state)
9087

88+
@property
89+
def extra_state_attributes(self):
90+
"""Return entity specific state attributes."""
91+
match self.key:
92+
case "feeding_plan_state":
93+
# Today's feeding plan events with formatted amounts
94+
today_data = getattr(self.device, "feeding_plan_today_data", {})
95+
plans = today_data.get("plans", []) if isinstance(today_data, dict) else []
96+
if not plans:
97+
return {}
98+
plan_data = getattr(self.device, "feeding_plan_data", {})
99+
conv = getattr(self.device, "feed_conv_factor", 1)
100+
unit = self.member.feedUnitType
101+
weight = unit if unit in (Unit.GRAMS, Unit.OUNCES) else Unit.GRAMS
102+
volume = unit if unit in (Unit.MILLILITERS, Unit.CUPS) else Unit.MILLILITERS
103+
return {
104+
plan_data.get(str(plan["planId"]), {}).get("label") or f"plan_{plan.get('index', plan['planId'])}": {
105+
"time": plan.get("time"),
106+
"amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
107+
"amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
108+
"state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(plan.get("state"), "Unknown"),
109+
"repeat": plan.get("repeat"),
110+
"planID": plan.get("planId"),
111+
}
112+
for plan in plans
113+
} or {}
114+
case "feeding_schedule":
115+
# Full recurring schedule with formatted amounts
116+
plans = getattr(self.device, "feeding_plan_data", {})
117+
if not plans:
118+
return {}
119+
conv = getattr(self.device, "feed_conv_factor", 1)
120+
unit = self.member.feedUnitType
121+
weight = unit if unit in (Unit.GRAMS, Unit.OUNCES) else Unit.GRAMS
122+
volume = unit if unit in (Unit.MILLILITERS, Unit.CUPS) else Unit.MILLILITERS
123+
return {
124+
plan.get("label") or f"plan_{plan_id}": {
125+
"planID": int(plan_id),
126+
"time": plan.get("executionTime"),
127+
"amount (weight)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, weight, True)} {weight.symbol}",
128+
"amount (volume)": f"{Unit.convert_feed(plan.get('grainNum', 0) * conv, None, volume, True)} {volume.symbol}",
129+
"enabled": plan.get("enable", False),
130+
"repeat_days": plan.get("repeatDay", "[]"),
131+
"sound": plan.get("enableAudio", False),
132+
}
133+
for plan_id, plan in plans.items()
134+
} or {}
135+
return {}
136+
137+
91138
DEVICE_BINARY_SENSOR_MAP: dict[type[Device], list[PetLibroBinarySensorEntityDescription]] = {
92139
Feeder: [
93140
],
@@ -138,6 +185,21 @@ def is_on(self) -> bool:
138185
should_report=lambda device: device.light_switch is not None,
139186
name="Indicator"
140187
),
188+
PetLibroBinarySensorEntityDescription[AirSmartFeeder](
189+
key="feeding_plan_state",
190+
translation_key="feeding_plan_state",
191+
icon="mdi:calendar-check",
192+
should_report=lambda device: device.feeding_plan_state is not None,
193+
name="Today's Feeding Schedule"
194+
),
195+
PetLibroBinarySensorEntityDescription[AirSmartFeeder](
196+
key="feeding_schedule",
197+
translation_key="feeding_schedule",
198+
icon="mdi:calendar-clock",
199+
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
200+
value_fn=lambda device: device.feeding_plan_state,
201+
name="Feeding Schedule"
202+
),
141203
],
142204
GranarySmartFeeder: [
143205
PetLibroBinarySensorEntityDescription[GranarySmartFeeder](
@@ -186,6 +248,21 @@ def is_on(self) -> bool:
186248
should_report=lambda device: device.light_switch is not None,
187249
name="Indicator"
188250
),
251+
PetLibroBinarySensorEntityDescription[GranarySmartFeeder](
252+
key="feeding_plan_state",
253+
translation_key="feeding_plan_state",
254+
icon="mdi:calendar-check",
255+
should_report=lambda device: device.feeding_plan_state is not None,
256+
name="Today's Feeding Schedule"
257+
),
258+
PetLibroBinarySensorEntityDescription[GranarySmartFeeder](
259+
key="feeding_schedule",
260+
translation_key="feeding_schedule",
261+
icon="mdi:calendar-clock",
262+
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
263+
value_fn=lambda device: device.feeding_plan_state,
264+
name="Feeding Schedule"
265+
),
189266
],
190267
GranarySmartCameraFeeder: [
191268
PetLibroBinarySensorEntityDescription[GranarySmartCameraFeeder](
@@ -234,6 +311,21 @@ def is_on(self) -> bool:
234311
should_report=lambda device: device.light_switch is not None,
235312
name="Indicator"
236313
),
314+
PetLibroBinarySensorEntityDescription[GranarySmartCameraFeeder](
315+
key="feeding_plan_state",
316+
translation_key="feeding_plan_state",
317+
icon="mdi:calendar-check",
318+
should_report=lambda device: device.feeding_plan_state is not None,
319+
name="Today's Feeding Schedule"
320+
),
321+
PetLibroBinarySensorEntityDescription[GranarySmartCameraFeeder](
322+
key="feeding_schedule",
323+
translation_key="feeding_schedule",
324+
icon="mdi:calendar-clock",
325+
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
326+
value_fn=lambda device: device.feeding_plan_state,
327+
name="Feeding Schedule"
328+
),
237329
],
238330
OneRFIDSmartFeeder: [
239331
PetLibroBinarySensorEntityDescription[OneRFIDSmartFeeder](
@@ -313,6 +405,21 @@ def is_on(self) -> bool:
313405
should_report=lambda device: device.display_switch is not None,
314406
name="Display Status"
315407
),
408+
PetLibroBinarySensorEntityDescription[OneRFIDSmartFeeder](
409+
key="feeding_plan_state",
410+
translation_key="feeding_plan_state",
411+
icon="mdi:calendar-check",
412+
should_report=lambda device: device.feeding_plan_state is not None,
413+
name="Today's Feeding Schedule"
414+
),
415+
PetLibroBinarySensorEntityDescription[OneRFIDSmartFeeder](
416+
key="feeding_schedule",
417+
translation_key="feeding_schedule",
418+
icon="mdi:calendar-clock",
419+
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
420+
value_fn=lambda device: device.feeding_plan_state,
421+
name="Feeding Schedule"
422+
),
316423
],
317424
PolarWetFoodFeeder: [
318425
PetLibroBinarySensorEntityDescription[PolarWetFoodFeeder](
@@ -361,6 +468,13 @@ def is_on(self) -> bool:
361468
should_report=lambda device: device.light_switch is not None,
362469
name="Indicator"
363470
),
471+
PetLibroBinarySensorEntityDescription[PolarWetFoodFeeder](
472+
key="feeding_plan_state",
473+
translation_key="feeding_plan_state",
474+
icon="mdi:calendar-check",
475+
should_report=lambda device: device.feeding_plan_state is not None,
476+
name="Feeding Plan"
477+
),
364478
],
365479
SpaceSmartFeeder: [
366480
PetLibroBinarySensorEntityDescription[SpaceSmartFeeder](
@@ -432,6 +546,21 @@ def is_on(self) -> bool:
432546
should_report=lambda device: device.light_switch is not None,
433547
name="Indicator"
434548
),
549+
PetLibroBinarySensorEntityDescription[SpaceSmartFeeder](
550+
key="feeding_plan_state",
551+
translation_key="feeding_plan_state",
552+
icon="mdi:calendar-check",
553+
should_report=lambda device: device.feeding_plan_state is not None,
554+
name="Today's Feeding Schedule"
555+
),
556+
PetLibroBinarySensorEntityDescription[SpaceSmartFeeder](
557+
key="feeding_schedule",
558+
translation_key="feeding_schedule",
559+
icon="mdi:calendar-clock",
560+
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
561+
value_fn=lambda device: device.feeding_plan_state,
562+
name="Feeding Schedule"
563+
),
435564
],
436565
DockstreamSmartFountain: [
437566
PetLibroBinarySensorEntityDescription[DockstreamSmartFountain](
@@ -605,34 +734,30 @@ def is_on(self) -> bool:
605734
],
606735
}
607736

737+
608738
async def async_setup_entry(
609739
hass: HomeAssistant,
610-
entry: ConfigEntry, # Use ConfigEntry
740+
entry: ConfigEntry,
611741
async_add_entities: AddEntitiesCallback,
612742
) -> None:
613743
"""Set up PETLIBRO binary sensors using config entry."""
614-
# Retrieve the hub from hass.data that was set up in __init__.py
615744
hub: PetLibroHub = hass.data[DOMAIN].get(entry.entry_id)
616745

617746
if not hub:
618747
_LOGGER.error("Hub not found for entry: %s", entry.entry_id)
619748
return
620749

621-
# Ensure that the devices are loaded (if load_devices is not already called elsewhere)
622750
if not hub.devices:
623751
_LOGGER.warning("No devices found in hub during binary sensor setup.")
624752
return
625753

626-
# Log the contents of the hub data for debugging
627754
_LOGGER.debug("Hub data: %s", hub)
628-
629-
devices = hub.devices # Devices should already be loaded in the hub
755+
devices = hub.devices
630756
_LOGGER.debug("Devices in hub: %s", devices)
631757

632-
# Create binary sensor entities for each device based on the binary sensor map
633758
entities = [
634759
PetLibroBinarySensorEntity(device, hub, description)
635-
for device in devices.values() # Iterate through devices from the hub
760+
for device in devices.values()
636761
for device_type, entity_descriptions in DEVICE_BINARY_SENSOR_MAP.items()
637762
if isinstance(device, device_type)
638763
for description in entity_descriptions
@@ -641,10 +766,11 @@ async def async_setup_entry(
641766
if not entities:
642767
_LOGGER.warning("No binary sensors added, entities list is empty!")
643768
else:
644-
# Log the number of entities and their details
645769
_LOGGER.debug("Adding %d PetLibro binary sensors", len(entities))
646770
for entity in entities:
647-
_LOGGER.debug("Adding binary sensor entity: %s for device %s", entity.entity_description.name, entity.device.name)
648-
649-
# Add binary sensor entities to Home Assistant
650-
async_add_entities(entities)
771+
_LOGGER.debug(
772+
"Adding binary sensor entity: %s for device %s",
773+
entity.entity_description.name,
774+
entity.device.name,
775+
)
776+
async_add_entities(entities)

0 commit comments

Comments
 (0)