Skip to content

Commit 30fbdaa

Browse files
authored
Feature/Schedule-Card-Support (#225)
* Feature/Schedule-Card-Support * Label = Blank if API = Blank, ID First in Event
1 parent 4759efa commit 30fbdaa

20 files changed

Lines changed: 769 additions & 511 deletions

custom_components/petlibro/binary_sensor.py

Lines changed: 84 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Support for PETLIBRO binary sensors."""
22
from __future__ import annotations
3+
import json
34
from .api import make_api_call
45
import aiohttp
56
from aiohttp import ClientSession, ClientError
@@ -9,6 +10,7 @@
910
from typing import Optional
1011
import logging
1112
from .const import DOMAIN, Unit, APIKey as API, VALID_UNIT_TYPES
13+
from homeassistant.util.dt import now as ha_now
1214
from homeassistant.components.binary_sensor import (
1315
BinarySensorEntity,
1416
BinarySensorEntityDescription,
@@ -89,61 +91,76 @@ def is_on(self) -> bool:
8991
def extra_state_attributes(self):
9092
"""Return entity specific state attributes."""
9193
match self.key:
92-
case "feeding_plan_state":
93-
# Today's feeding schedule events with amounts in all units
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-
return {
101-
plan_data.get(str(plan["planId"]), {}).get("label") or f"plan_{plan.get('index', plan['planId'])}": {
102-
"planID": plan.get("planId"),
103-
"time": plan.get("time"),
104-
**{
105-
f"amount_{unit.symbol.lower()}": Unit.convert_feed(plan.get("grainNum", 0) * conv, None, unit, True)
106-
for unit in VALID_UNIT_TYPES[API.FEED_UNIT] if unit
107-
},
108-
"amount_raw": plan.get("grainNum", 0),
109-
"feed_conv_factor": conv,
110-
"enabled": plan_data.get(str(plan["planId"]), {}).get("enable", False),
111-
"repeat_days": plan_data.get(str(plan["planId"]), {}).get("repeatDay", "[]"),
112-
"sound": plan_data.get(str(plan["planId"]), {}).get("enableAudio", False),
113-
"feed_state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(plan.get("state"), "Unknown"),
114-
"repeat": plan.get("repeat"),
115-
}
116-
for plan in plans
117-
} or {}
11894
case "feeding_schedule":
119-
# Full recurring schedule with amounts in all units + today's state
95+
# Full recurring schedule as a list with all unit amounts + today's state
12096
plans = getattr(self.device, "feeding_plan_data", {})
121-
if not plans:
122-
return {}
12397
conv = getattr(self.device, "feed_conv_factor", 1)
124-
# Build lookup of today's feed states by planId
98+
today_weekday = ha_now().isoweekday() # 1=Mon … 7=Sun, matches repeatDay format
99+
# Build lookup of today's feed states and data by planId
125100
today_data = getattr(self.device, "feeding_plan_today_data", {})
126101
today_plans = today_data.get("plans", []) if isinstance(today_data, dict) else []
127102
today_state_map = {p["planId"]: p.get("state") for p in today_plans}
128-
return {
129-
plan.get("label") or f"plan_{plan_id}": {
130-
"planID": int(plan_id),
103+
state_map = {1: "pending", 2: "to_be_skipped", 3: "dispensed", 4: "skipped", 5: "state_5", 6: "unknown"}
104+
schedule = []
105+
seen_plan_ids = set()
106+
# First pass: plans from the full schedule list
107+
for plan_id, plan in plans.items():
108+
raw_repeat = plan.get("repeatDay", "[]")
109+
try:
110+
repeat_list = json.loads(raw_repeat) if isinstance(raw_repeat, str) else raw_repeat
111+
except (json.JSONDecodeError, TypeError):
112+
repeat_list = []
113+
pid = int(plan_id)
114+
seen_plan_ids.add(pid)
115+
in_today = pid in today_state_map
116+
today = in_today or (today_weekday in repeat_list)
117+
if in_today:
118+
raw_state = today_state_map.get(pid)
119+
state = state_map.get(raw_state, "unknown")
120+
else:
121+
state = "pending"
122+
schedule.append({
123+
"id": pid,
124+
"label": plan.get("label", ""),
131125
"time": plan.get("executionTime"),
132126
**{
133127
f"amount_{unit.symbol.lower()}": Unit.convert_feed(plan.get("grainNum", 0) * conv, None, unit, True)
134128
for unit in VALID_UNIT_TYPES[API.FEED_UNIT] if unit
135129
},
136130
"amount_raw": plan.get("grainNum", 0),
137-
"feed_conv_factor": conv,
138131
"enabled": plan.get("enable", False),
139-
"repeat_days": plan.get("repeatDay", "[]"),
132+
"recurring": bool(repeat_list),
133+
"repeat_days": repeat_list,
140134
"sound": plan.get("enableAudio", False),
141-
"feed_state": {1: "Pending", 2: "Skipped", 3: "Completed", 4: "Skipped, Time Passed"}.get(
142-
today_state_map.get(int(plan_id)), "Not Scheduled Today"
143-
),
144-
}
145-
for plan_id, plan in plans.items()
146-
} or {}
135+
"today": today,
136+
"state": state,
137+
})
138+
# Second pass: today-only plans not in the full schedule (e.g. non-recurring one-time feeds)
139+
for tp in today_plans:
140+
pid = tp["planId"]
141+
if pid in seen_plan_ids:
142+
continue
143+
raw_state = tp.get("state")
144+
state = state_map.get(raw_state, "unknown")
145+
schedule.append({
146+
"id": pid,
147+
"label": "",
148+
"time": tp.get("time"),
149+
**{
150+
f"amount_{unit.symbol.lower()}": Unit.convert_feed(tp.get("grainNum", 0) * conv, None, unit, True)
151+
for unit in VALID_UNIT_TYPES[API.FEED_UNIT] if unit
152+
},
153+
"amount_raw": tp.get("grainNum", 0),
154+
"enabled": True,
155+
"recurring": False,
156+
"repeat_days": [],
157+
"sound": False,
158+
"today": True,
159+
"state": state,
160+
})
161+
if not schedule:
162+
return {"feed_conv_factor": conv, "schedule": []}
163+
return {"feed_conv_factor": conv, "schedule": schedule}
147164
return {}
148165

149166

@@ -198,19 +215,18 @@ def extra_state_attributes(self):
198215
name="Indicator"
199216
),
200217
PetLibroBinarySensorEntityDescription[AirSmartFeeder](
201-
key="feeding_plan_state",
202-
translation_key="feeding_plan_state",
218+
key="today_feeding_schedule",
219+
translation_key="today_feeding_schedule",
203220
icon="mdi:calendar-check",
204-
device_class=BinarySensorDeviceClass.RUNNING,
205-
should_report=lambda device: device.feeding_plan_state is not None,
221+
should_report=lambda device: device.feeding_plan_today_data is not None,
222+
value_fn=lambda device: not device.today_feeding_plan_state,
206223
name="Today's Feeding Schedule"
207224
),
208225
PetLibroBinarySensorEntityDescription[AirSmartFeeder](
209226
key="feeding_schedule",
210227
translation_key="feeding_schedule",
211228
icon="mdi:calendar-clock",
212-
device_class=BinarySensorDeviceClass.RUNNING,
213-
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
229+
should_report=lambda device: device.feeding_plan_state is not None,
214230
value_fn=lambda device: device.feeding_plan_state,
215231
name="Feeding Schedule"
216232
),
@@ -263,19 +279,18 @@ def extra_state_attributes(self):
263279
name="Indicator"
264280
),
265281
PetLibroBinarySensorEntityDescription[GranarySmartFeeder](
266-
key="feeding_plan_state",
267-
translation_key="feeding_plan_state",
282+
key="today_feeding_schedule",
283+
translation_key="today_feeding_schedule",
268284
icon="mdi:calendar-check",
269-
device_class=BinarySensorDeviceClass.RUNNING,
270-
should_report=lambda device: device.feeding_plan_state is not None,
285+
should_report=lambda device: device.feeding_plan_today_data is not None,
286+
value_fn=lambda device: not device.today_feeding_plan_state,
271287
name="Today's Feeding Schedule"
272288
),
273289
PetLibroBinarySensorEntityDescription[GranarySmartFeeder](
274290
key="feeding_schedule",
275291
translation_key="feeding_schedule",
276292
icon="mdi:calendar-clock",
277-
device_class=BinarySensorDeviceClass.RUNNING,
278-
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
293+
should_report=lambda device: device.feeding_plan_state is not None,
279294
value_fn=lambda device: device.feeding_plan_state,
280295
name="Feeding Schedule"
281296
),
@@ -328,19 +343,18 @@ def extra_state_attributes(self):
328343
name="Indicator"
329344
),
330345
PetLibroBinarySensorEntityDescription[GranarySmartCameraFeeder](
331-
key="feeding_plan_state",
332-
translation_key="feeding_plan_state",
346+
key="today_feeding_schedule",
347+
translation_key="today_feeding_schedule",
333348
icon="mdi:calendar-check",
334-
device_class=BinarySensorDeviceClass.RUNNING,
335-
should_report=lambda device: device.feeding_plan_state is not None,
349+
should_report=lambda device: device.feeding_plan_today_data is not None,
350+
value_fn=lambda device: not device.today_feeding_plan_state,
336351
name="Today's Feeding Schedule"
337352
),
338353
PetLibroBinarySensorEntityDescription[GranarySmartCameraFeeder](
339354
key="feeding_schedule",
340355
translation_key="feeding_schedule",
341356
icon="mdi:calendar-clock",
342-
device_class=BinarySensorDeviceClass.RUNNING,
343-
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
357+
should_report=lambda device: device.feeding_plan_state is not None,
344358
value_fn=lambda device: device.feeding_plan_state,
345359
name="Feeding Schedule"
346360
),
@@ -424,19 +438,18 @@ def extra_state_attributes(self):
424438
name="Display Status"
425439
),
426440
PetLibroBinarySensorEntityDescription[OneRFIDSmartFeeder](
427-
key="feeding_plan_state",
428-
translation_key="feeding_plan_state",
441+
key="today_feeding_schedule",
442+
translation_key="today_feeding_schedule",
429443
icon="mdi:calendar-check",
430-
device_class=BinarySensorDeviceClass.RUNNING,
431-
should_report=lambda device: device.feeding_plan_state is not None,
444+
should_report=lambda device: device.feeding_plan_today_data is not None,
445+
value_fn=lambda device: not device.today_feeding_plan_state,
432446
name="Today's Feeding Schedule"
433447
),
434448
PetLibroBinarySensorEntityDescription[OneRFIDSmartFeeder](
435449
key="feeding_schedule",
436450
translation_key="feeding_schedule",
437451
icon="mdi:calendar-clock",
438-
device_class=BinarySensorDeviceClass.RUNNING,
439-
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
452+
should_report=lambda device: device.feeding_plan_state is not None,
440453
value_fn=lambda device: device.feeding_plan_state,
441454
name="Feeding Schedule"
442455
),
@@ -492,8 +505,8 @@ def extra_state_attributes(self):
492505
key="feeding_plan_state",
493506
translation_key="feeding_plan_state",
494507
icon="mdi:calendar-check",
495-
device_class=BinarySensorDeviceClass.RUNNING,
496508
should_report=lambda device: device.feeding_plan_state is not None,
509+
value_fn=lambda device: not device.feeding_plan_today_data.get("allSkipped", False),
497510
name="Today's Feeding Schedule"
498511
),
499512
],
@@ -568,19 +581,18 @@ def extra_state_attributes(self):
568581
name="Indicator"
569582
),
570583
PetLibroBinarySensorEntityDescription[SpaceSmartFeeder](
571-
key="feeding_plan_state",
572-
translation_key="feeding_plan_state",
584+
key="today_feeding_schedule",
585+
translation_key="today_feeding_schedule",
573586
icon="mdi:calendar-check",
574-
device_class=BinarySensorDeviceClass.RUNNING,
575-
should_report=lambda device: device.feeding_plan_state is not None,
587+
should_report=lambda device: device.feeding_plan_today_data is not None,
588+
value_fn=lambda device: not device.today_feeding_plan_state,
576589
name="Today's Feeding Schedule"
577590
),
578591
PetLibroBinarySensorEntityDescription[SpaceSmartFeeder](
579592
key="feeding_schedule",
580593
translation_key="feeding_schedule",
581594
icon="mdi:calendar-clock",
582-
device_class=BinarySensorDeviceClass.RUNNING,
583-
should_report=lambda device: bool(getattr(device, "feeding_plan_data", {})),
595+
should_report=lambda device: device.feeding_plan_state is not None,
584596
value_fn=lambda device: device.feeding_plan_state,
585597
name="Feeding Schedule"
586598
),

custom_components/petlibro/devices/feeders/air_smart_feeder.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,14 @@ def today_feeding_times(self) -> int:
6969

7070
@property
7171
def feeding_plan_state(self) -> bool:
72-
"""Return the state of the feeding plan, based on API data."""
72+
"""Return the state of the feeding schedule, based on API data."""
7373
return bool(self._data.get("enableFeedingPlan", False))
7474

75+
@property
76+
def today_feeding_plan_state(self) -> bool:
77+
"""Return True if all of today's plans are skipped."""
78+
return bool(self.feeding_plan_today_data.get("allSkipped", False))
79+
7580
@property
7681
def battery_state(self) -> str:
7782
return cast(str, self._data.get("realInfo", {}).get("batteryState", "unknown"))

custom_components/petlibro/devices/feeders/granary_smart_camera_feeder.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ async def refresh(self):
3131
get_work_record = await self.api.get_device_work_record(self.serial)
3232
feeding_plan_list = (await self.api.device_feeding_plan_list(self.serial)
3333
if self._data.get("enableFeedingPlan") else [])
34-
3534
# Update internal data with fetched API data
3635
self.update_data({
3736
"grainStatus": grain_status or {},
@@ -40,7 +39,7 @@ async def refresh(self):
4039
"getUpgrade": get_upgrade or {},
4140
"getfeedingplantoday": get_feeding_plan_today or {},
4241
"feedingPlan": feeding_plan_list or [],
43-
"workRecord": get_work_record or [],
42+
"workRecord": get_work_record or [],
4443
})
4544
except PetLibroAPIError as err:
4645
_LOGGER.error(f"Error refreshing data for GranarySmartCameraFeeder: {err}")
@@ -69,6 +68,11 @@ def feeding_plan_state(self) -> bool:
6968
"""Return the state of the feeding plan, based on API data."""
7069
return bool(self._data.get("enableFeedingPlan", False))
7170

71+
@property
72+
def today_feeding_plan_state(self) -> bool:
73+
"""Return True if all of today's plans are skipped."""
74+
return bool(self.feeding_plan_today_data.get("allSkipped", False))
75+
7276
@property
7377
def battery_state(self) -> str:
7478
return cast(str, self._data.get("realInfo", {}).get("batteryState", "unknown"))

custom_components/petlibro/devices/feeders/granary_smart_feeder.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def refresh(self):
4242
"getDefaultMatrix": get_default_matrix or {},
4343
"getfeedingplantoday": get_feeding_plan_today or {},
4444
"feedingPlan": feeding_plan_list or [],
45-
"workRecord": get_work_record if get_work_record is not None else []
45+
"workRecord": get_work_record if get_work_record is not None else [],
4646
})
4747
except PetLibroAPIError as err:
4848
_LOGGER.error(f"Error refreshing data for GranarySmartFeeder: {err}")
@@ -71,6 +71,11 @@ def feeding_plan_state(self) -> bool:
7171
"""Return the state of the feeding plan, based on API data."""
7272
return bool(self._data.get("enableFeedingPlan", False))
7373

74+
@property
75+
def today_feeding_plan_state(self) -> bool:
76+
"""Return True if all of today's plans are skipped."""
77+
return bool(self.feeding_plan_today_data.get("allSkipped", False))
78+
7479
@property
7580
def battery_state(self) -> str:
7681
return cast(str, self._data.get("realInfo", {}).get("batteryState", "unknown"))

custom_components/petlibro/devices/feeders/one_rfid_smart_feeder.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ async def refresh(self):
3434
get_feeding_plan_today = await self.api.device_feeding_plan_today_new(self.serial)
3535
feeding_plan_list = (await self.api.device_feeding_plan_list(self.serial)
3636
if self._data.get("enableFeedingPlan") else [])
37-
3837
# Update internal data with fetched API data
3938
self.update_data({
4039
"grainStatus": grain_status or {},
@@ -44,7 +43,7 @@ async def refresh(self):
4443
"getDefaultMatrix": get_default_matrix or {},
4544
"getfeedingplantoday": get_feeding_plan_today or {},
4645
"feedingPlan": feeding_plan_list or [],
47-
"workRecord": get_work_record if get_work_record is not None else []
46+
"workRecord": get_work_record if get_work_record is not None else [],
4847
})
4948
except PetLibroAPIError as err:
5049
_LOGGER.error(f"Error refreshing data for OneRFIDSmartFeeder: {err}")
@@ -81,6 +80,11 @@ def feeding_plan_state(self) -> bool:
8180
"""Return the state of the feeding plan, based on API data."""
8281
return bool(self._data.get("enableFeedingPlan", False))
8382

83+
@property
84+
def today_feeding_plan_state(self) -> bool:
85+
"""Return True if all of today's plans are skipped."""
86+
return bool(self.feeding_plan_today_data.get("allSkipped", False))
87+
8488
@property
8589
def battery_state(self) -> str:
8690
return cast(str, self._data.get("realInfo", {}).get("batteryState", "unknown"))

custom_components/petlibro/devices/feeders/space_smart_feeder.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async def refresh(self):
3232
get_device_events = await self.api.device_events(self.serial)
3333
feeding_plan_list = (await self.api.device_feeding_plan_list(self.serial)
3434
if self._data.get("enableFeedingPlan") else [])
35-
35+
3636
# Update internal data with fetched API data
3737
self.update_data({
3838
"grainStatus": grain_status or {},
@@ -42,7 +42,7 @@ async def refresh(self):
4242
"feedingPlan": feeding_plan_list or [],
4343
"getDeviceEvents": get_device_events or {},
4444
"getUpgrade": get_upgrade or {},
45-
"workRecord": get_work_record if get_work_record is not None else []
45+
"workRecord": get_work_record if get_work_record is not None else [],
4646
})
4747
except PetLibroAPIError as err:
4848
_LOGGER.error(f"Error refreshing data for SpaceSmartFeeder: {err}")
@@ -71,6 +71,11 @@ def feeding_plan_state(self) -> bool:
7171
"""Return the state of the feeding plan, based on API data."""
7272
return bool(self._data.get("enableFeedingPlan", False))
7373

74+
@property
75+
def today_feeding_plan_state(self) -> bool:
76+
"""Return True if all of today's plans are skipped."""
77+
return bool(self.feeding_plan_today_data.get("allSkipped", False))
78+
7479
@property
7580
def battery_state(self) -> str:
7681
return cast(str, self._data.get("realInfo", {}).get("batteryState", "unknown"))

0 commit comments

Comments
 (0)