Skip to content

Commit a23a273

Browse files
Fix/1.2.30 fixes (#158)
* Fixes for the 1.2.30 pre-release version. Fixes for the pre-release: - Removed unnecesary 'async_schedule_reload' calls in 'async_step_integration_settings' and 'async_step_account_settings'. - "ml" & "fl oz" changed to "mL" & "fl. oz." to match HA convention in translation file. - Fixed an if statement within 'Unit_Entities.update_sensor_entity_units' that was causing entities to be dis/enabled incorrectly. New features: - Added 'device_feeding_plan_list' API call. - Feeding plans now show amount (weight) and amount (volume) within 'feeding_plan_state' sensor entity. - Feeding plans show their 'label' as their name if they have one, otherwise fall back to the original "plan_{index}". - Added 3 new entities for dry feeders: sensor.next_feed_time sensor.next_feed_quantity_weight sensor.next_feed_quantity_volume Included in the pre-release but I forgot to mention: - Added 'last_feed_quantity' property method to dry feeder device classes, it was only included for the Space Smart Feeder before. * Added 'cups' warning to account settings * Air Smart Feeder feed amounts corrected Fixes for the pre-release: - Air Smart Feeder feed amounts corrected - Cleaned up repitition in number.py, select.py and sensor.py --------- Co-authored-by: Jamie Jones <29973406+jjjonesjr33@users.noreply.github.com>
1 parent 8989341 commit a23a273

28 files changed

Lines changed: 1114 additions & 650 deletions

custom_components/petlibro/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# https://api.us.petlibro.com/device/ota/getUpgrade
1111
# https://api.us.petlibro.com/device/data/grainStatus
1212
# https://api.us.petlibro.com/device/feedingPlan/todayNew
13+
# https://api.us.petlibro.com/device/feedingPlan/list
1314
# https://api.us.petlibro.com/device/wetFeedingPlan/wetListV3
1415

1516
from logging import getLogger
@@ -535,6 +536,9 @@ async def device_grain_status(self, serial: str) -> Dict[str, Any]:
535536
async def device_feeding_plan_today_new(self, serial: str) -> Dict[str, Any]:
536537
return await self.session.post_serial("/device/feedingPlan/todayNew", serial)
537538

539+
async def device_feeding_plan_list(self, serial: str) -> List[Dict[str, Any]]:
540+
return await self.session.post_serial("/device/feedingPlan/list", serial)
541+
538542
async def device_wet_feeding_plan(self, serial: str) -> Dict[str, Any]:
539543
return await self.session.post_serial("/device/wetFeedingPlan/wetListV3", serial)
540544

custom_components/petlibro/config_flow.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,20 +215,18 @@ async def async_step_integration_settings(
215215

216216
abort_messages = [self.get_common_translation("settings_updated", "Settings updated")]
217217

218-
# --- Reload integration if feed unit is cups
218+
# --- Update entities and warn user if feed unit is cups
219219
if self.member.feedUnitType == Unit.CUPS:
220-
reload_integration = await self.hub.unit_entities.sync_manual_feed_entity_visibility(Unit.CUPS)
220+
reload_needed = await self.hub.unit_entities.sync_manual_feed_entity_visibility(Unit.CUPS)
221221

222-
if reload_integration:
222+
if reload_needed:
223223
abort_messages.append(
224-
self.get_common_translation("reloading_integration", "The integration is being reloaded")
224+
self.get_common_translation("reloading_integration", "The integration will reload shortly")
225225
)
226226
_LOGGER.debug("'manual_feed_portions' value changed while feed unit is 'cups', reloading integration.")
227-
self.hass.config_entries.async_schedule_reload(self.handler)
228227
else:
229228
_LOGGER.debug("No Manual Feed entities found — nothing to reload.")
230-
else:
231-
await self.hub.async_refresh()
229+
await self.hub.async_refresh()
232230

233231
# --- Done
234232
return self.async_abort(
@@ -271,7 +269,9 @@ async def async_step_account_settings(
271269
)
272270

273271
# --- Update entity options if units changed or update_all_units
274-
reload_integration = await self.hub.unit_entities.update_sensor_entity_units(unit_updates, update_all_units)
272+
reload_needed = False
273+
if unit_updates or update_all_units:
274+
reload_needed = await self.hub.unit_entities.update_sensor_entity_units(unit_updates, update_all_units)
275275

276276
# --- Apply account-level changes through API
277277
abort_messages = []
@@ -286,15 +286,16 @@ async def async_step_account_settings(
286286
_LOGGER.error("Error updating account info via API.")
287287
return self.async_abort(reason="error_check_logs")
288288

289+
# --- Build the abort message
289290
if update_all_units:
290291
abort_messages.append(self.get_common_translation("sensors_updated", "Sensor entities were updated"))
291292

292-
# --- Reload or refresh
293-
if reload_integration:
294-
_LOGGER.debug("Reloading integration due to feed unit change to/from cups, or update_all_units chosen.")
295-
abort_messages.append(self.get_common_translation("reloading_integration", "The integration is being reloaded"))
296-
self.hass.config_entries.async_schedule_reload(self.handler)
297-
elif info_updates or unit_updates:
293+
if reload_needed:
294+
_LOGGER.debug("Reloading integration due to feed unit change to/from cups.")
295+
abort_messages.append(self.get_common_translation("reloading_integration", "The integration will reload shortly"))
296+
297+
# --- Refresh hub data
298+
if info_updates or unit_updates or reload_needed:
298299
await self.hub.async_refresh(force_member=True)
299300

300301
# --- Done

custom_components/petlibro/const.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class Unit(IntEnum):
9595
WATER int values must be different to avoid aliasing. Take care when using .value
9696
"""
9797

98-
CUPS = 1, 1/12, "cups", ""
98+
CUPS = 1, round(1/12, 16), "cups", ""
9999
OUNCES = 2, 0.35, UnitOfMass.OUNCES, "weight"
100100
GRAMS = 3, 10, UnitOfMass.GRAMS, "weight"
101101
MILLILITERS = 4, 20, UnitOfVolume.MILLILITERS, "volume"
@@ -137,7 +137,7 @@ def device_class(self) -> str:
137137
return self._device_class
138138

139139
@classmethod
140-
def round(self, value: float, unit: _Unit):
140+
def round(self, value: float, unit: _Unit) -> float:
141141
return round(value, ROUNDING_RULES.get(unit, 0))
142142

143143
@classmethod
@@ -167,7 +167,8 @@ def convert_feed(
167167
DEFAULT_WEIGHT = Unit.POUNDS
168168
DEFAULT_FEED = Unit.CUPS
169169
DEFAULT_WATER = Unit.WATER_OUNCES
170-
MAX_FEED_PORTIONS = 48
170+
DEFAULT_PORTIONS_IN_CUP = 12
171+
DEFAULT_MAX_FEED_PORTIONS = 48
171172
MANUAL_FEED_PORTIONS = "manual_feed_portions"
172173
VALID_UNIT_TYPES: dict[str, set[Unit]] = {
173174
APIKey.WEIGHT_UNIT: {Unit.POUNDS, Unit.KILOGRAMS, None},

custom_components/petlibro/devices/device.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ..api import PetLibroAPI
77
from .event import Event, EVENT_UPDATE
88
from ..member import Member
9+
from ..const import DEFAULT_MAX_FEED_PORTIONS
910

1011

1112
_LOGGER = getLogger(__name__)
@@ -17,6 +18,9 @@ def __init__(self, data: dict, member: Member, api: PetLibroAPI):
1718
self._data: dict = {}
1819
self.api = api
1920
self.member = member
21+
22+
self.feed_conv_factor = 1
23+
self.max_feed_portions = DEFAULT_MAX_FEED_PORTIONS
2024

2125
self.update_data(data)
2226

custom_components/petlibro/devices/feeders/air_smart_feeder.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1+
import ast
2+
from zoneinfo import ZoneInfo
13
import aiohttp
24

35
from typing import cast
46
from logging import getLogger
57
from ...exceptions import PetLibroAPIError
68
from ..device import Device
7-
from datetime import datetime
9+
from datetime import datetime, timedelta, time
810
from homeassistant.util import dt as dt_util
9-
from ...const import MAX_FEED_PORTIONS
1011

1112
_LOGGER = getLogger(__name__)
1213

1314
class AirSmartFeeder(Device): # Inherit directly from Device
1415
def __init__(self, *args, **kwargs):
1516
"""Initialize the feeder with default values."""
1617
super().__init__(*args, **kwargs)
17-
self.conversion_mode = "1/24" # Static definition for AirSmartFeeder
1818
self._manual_feed_quantity = None # Default to None initially
19+
20+
self.feed_conv_factor = 0.5 # Air Smart Feeder uses #
21+
self.max_feed_portions = 16 # unique values here #
1922

2023
async def refresh(self):
2124
"""Refresh the device data from the API."""
@@ -28,6 +31,7 @@ async def refresh(self):
2831
get_upgrade = await self.api.get_device_upgrade(self.serial)
2932
attribute_settings = await self.api.device_attribute_settings(self.serial)
3033
get_feeding_plan_today = await self.api.device_feeding_plan_today_new(self.serial)
34+
feeding_plan_list = await self.api.device_feeding_plan_list(self.serial)
3135
get_work_record = await self.api.get_device_work_record(self.serial)
3236

3337
# Update internal data with fetched API data
@@ -37,6 +41,7 @@ async def refresh(self):
3741
"getUpgrade": get_upgrade or {},
3842
"getAttributeSetting": attribute_settings or {},
3943
"getfeedingplantoday": get_feeding_plan_today or {},
44+
"feedingPlan": feeding_plan_list or [],
4045
"workRecord": get_work_record or [],
4146
})
4247
except PetLibroAPIError as err:
@@ -223,9 +228,89 @@ def last_feed_quantity(self) -> int | None:
223228
return 0
224229

225230
@property
226-
def feeding_plan_today_data(self) -> str:
231+
def feeding_plan_today_data(self) -> dict:
227232
return self._data.get("getfeedingplantoday", {})
228233

234+
@property
235+
def feeding_plan_data(self) -> dict:
236+
"""Return the feeding plan data dictionary."""
237+
return {
238+
str(plan["id"]): plan
239+
for plan in self._data.get("feedingPlan", [])
240+
if isinstance(plan, dict) and "id" in plan
241+
} or {}
242+
243+
@property
244+
def get_next_feed(self) -> dict:
245+
"""Get the next scheduled feeding plan.
246+
247+
:Returns:
248+
{
249+
"id": int,
250+
"utc_time": datetime,
251+
}
252+
"""
253+
now_utc = dt_util.now(dt_util.UTC)
254+
next_feed = {}
255+
256+
for feed in self.feeding_plan_data.values():
257+
feed: dict
258+
259+
if not (feed.get("id") or feed.get("enable") or ":" in feed.get("executionTime", "")):
260+
continue
261+
262+
timezone = ZoneInfo(feed.get("timezone", "UTC"))
263+
repeat_days = ast.literal_eval(feed.get("repeatDay", ""))
264+
now_local = now_utc.astimezone(timezone)
265+
hour, minute = map(int, feed["executionTime"].split(":"))
266+
267+
if not repeat_days:
268+
plan_dt_local = datetime.combine(now_local.date(), time(hour, minute), timezone)
269+
if plan_dt_local > now_local:
270+
candidate_dt_local = plan_dt_local # today
271+
else:
272+
candidate_dt_local = plan_dt_local + timedelta(days=1) # tomorrow
273+
else:
274+
for i in range(8): # 0-7 days ahead
275+
day_dt_local = now_local + timedelta(days=i)
276+
if day_dt_local.isoweekday() not in repeat_days:
277+
continue
278+
279+
plan_dt_local = datetime.combine(day_dt_local.date(), time(hour, minute), timezone)
280+
if plan_dt_local > now_local:
281+
candidate_dt_local = plan_dt_local
282+
break
283+
284+
if candidate_dt_local:
285+
candidate_dt_utc = candidate_dt_local.astimezone(dt_util.UTC)
286+
if not next_feed or candidate_dt_utc < next_feed["utc_time"]:
287+
next_feed = {
288+
"id": feed["id"],
289+
"utc_time": candidate_dt_utc,
290+
}
291+
return next_feed
292+
293+
@property
294+
def next_feed_time(self) -> datetime | None:
295+
"""Return the next scheduled feed time as a datetime object (UTC)."""
296+
_LOGGER.debug("next_feed_time called for device: %s", self.serial)
297+
298+
next_feed = self.get_next_feed.copy()
299+
if next_feed and (utc_time := next_feed.get("utc_time")):
300+
_LOGGER.debug("Returning datetime object: %s", utc_time.isoformat())
301+
return utc_time
302+
return None
303+
304+
@property
305+
def next_feed_quantity(self) -> int | None:
306+
"""Return the next scheduled feed amount."""
307+
next_feed = self.get_next_feed.copy()
308+
if next_feed and (plan_id := next_feed.get("id")):
309+
feeding_plan = self.feeding_plan_data.get(str(plan_id), {})
310+
if feeding_plan:
311+
return feeding_plan.get("grainNum", 0)
312+
return 0
313+
229314
@property
230315
def manual_feed_quantity(self):
231316
if self._manual_feed_quantity is None:
@@ -302,7 +387,7 @@ def manual_feed_quantity(self, value: float):
302387
async def set_manual_feed_quantity(self, value: float):
303388
"""Set the manual feed quantity with a default value handling"""
304389
_LOGGER.debug(f"Setting manual feed quantity: serial={self.serial}, value={value}")
305-
self.manual_feed_quantity = max(1, min(value, MAX_FEED_PORTIONS)) # Ensure value is within valid range
390+
self.manual_feed_quantity = max(1, min(value, self.max_feed_portions)) # Ensure value is within valid range
306391

307392
# Method for manual feeding
308393
async def set_manual_feed(self) -> None:

custom_components/petlibro/devices/feeders/granary_smart_camera_feeder.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import ast
2+
from zoneinfo import ZoneInfo
13
import aiohttp
24

35
from typing import cast
46
from logging import getLogger
57
from ...exceptions import PetLibroAPIError
6-
from ...const import MAX_FEED_PORTIONS
78
from ..device import Device
8-
from datetime import datetime
9+
from datetime import datetime, timedelta, time
910
from homeassistant.util import dt as dt_util
1011

1112
_LOGGER = getLogger(__name__)
@@ -27,6 +28,7 @@ async def refresh(self):
2728
attribute_settings = await self.api.device_attribute_settings(self.serial)
2829
get_upgrade = await self.api.get_device_upgrade(self.serial)
2930
get_feeding_plan_today = await self.api.device_feeding_plan_today_new(self.serial)
31+
feeding_plan_list = await self.api.device_feeding_plan_list(self.serial)
3032
get_work_record = await self.api.get_device_work_record(self.serial)
3133

3234
# Update internal data with fetched API data
@@ -36,6 +38,7 @@ async def refresh(self):
3638
"getAttributeSetting": attribute_settings or {},
3739
"getUpgrade": get_upgrade or {},
3840
"getfeedingplantoday": get_feeding_plan_today or {},
41+
"feedingPlan": feeding_plan_list or [],
3942
"workRecord": get_work_record or [],
4043
})
4144
except PetLibroAPIError as err:
@@ -247,9 +250,89 @@ def last_feed_quantity(self) -> int | None:
247250
return 0
248251

249252
@property
250-
def feeding_plan_today_data(self) -> str:
253+
def feeding_plan_today_data(self) -> dict:
251254
return self._data.get("getfeedingplantoday", {})
252255

256+
@property
257+
def feeding_plan_data(self) -> dict:
258+
"""Return the feeding plan data dictionary."""
259+
return {
260+
str(plan["id"]): plan
261+
for plan in self._data.get("feedingPlan", [])
262+
if isinstance(plan, dict) and "id" in plan
263+
} or {}
264+
265+
@property
266+
def get_next_feed(self) -> dict:
267+
"""Get the next scheduled feeding plan.
268+
269+
:Returns:
270+
{
271+
"id": int,
272+
"utc_time": datetime,
273+
}
274+
"""
275+
now_utc = dt_util.now(dt_util.UTC)
276+
next_feed = {}
277+
278+
for feed in self.feeding_plan_data.values():
279+
feed: dict
280+
281+
if not (feed.get("id") or feed.get("enable") or ":" in feed.get("executionTime", "")):
282+
continue
283+
284+
timezone = ZoneInfo(feed.get("timezone", "UTC"))
285+
repeat_days = ast.literal_eval(feed.get("repeatDay", ""))
286+
now_local = now_utc.astimezone(timezone)
287+
hour, minute = map(int, feed["executionTime"].split(":"))
288+
289+
if not repeat_days:
290+
plan_dt_local = datetime.combine(now_local.date(), time(hour, minute), timezone)
291+
if plan_dt_local > now_local:
292+
candidate_dt_local = plan_dt_local # today
293+
else:
294+
candidate_dt_local = plan_dt_local + timedelta(days=1) # tomorrow
295+
else:
296+
for i in range(8): # 0-7 days ahead
297+
day_dt_local = now_local + timedelta(days=i)
298+
if day_dt_local.isoweekday() not in repeat_days:
299+
continue
300+
301+
plan_dt_local = datetime.combine(day_dt_local.date(), time(hour, minute), timezone)
302+
if plan_dt_local > now_local:
303+
candidate_dt_local = plan_dt_local
304+
break
305+
306+
if candidate_dt_local:
307+
candidate_dt_utc = candidate_dt_local.astimezone(dt_util.UTC)
308+
if not next_feed or candidate_dt_utc < next_feed["utc_time"]:
309+
next_feed = {
310+
"id": feed["id"],
311+
"utc_time": candidate_dt_utc,
312+
}
313+
return next_feed
314+
315+
@property
316+
def next_feed_time(self) -> datetime | None:
317+
"""Return the next scheduled feed time as a datetime object (UTC)."""
318+
_LOGGER.debug("next_feed_time called for device: %s", self.serial)
319+
320+
next_feed = self.get_next_feed.copy()
321+
if next_feed and (utc_time := next_feed.get("utc_time")):
322+
_LOGGER.debug("Returning datetime object: %s", utc_time.isoformat())
323+
return utc_time
324+
return None
325+
326+
@property
327+
def next_feed_quantity(self) -> int | None:
328+
"""Return the next scheduled feed amount."""
329+
next_feed = self.get_next_feed.copy()
330+
if next_feed and (plan_id := next_feed.get("id")):
331+
feeding_plan = self.feeding_plan_data.get(str(plan_id), {})
332+
if feeding_plan:
333+
return feeding_plan.get("grainNum", 0)
334+
return 0
335+
253336
@property
254337
def manual_feed_quantity(self):
255338
if self._manual_feed_quantity is None:
@@ -326,7 +409,7 @@ def manual_feed_quantity(self, value: float):
326409
async def set_manual_feed_quantity(self, value: float):
327410
"""Set the manual feed quantity with a default value handling"""
328411
_LOGGER.debug(f"Setting manual feed quantity: serial={self.serial}, value={value}")
329-
self.manual_feed_quantity = max(1, min(value, MAX_FEED_PORTIONS)) # Ensure value is within valid range
412+
self.manual_feed_quantity = max(1, min(value, self.max_feed_portions)) # Ensure value is within valid range
330413

331414
# Method for manual feeding
332415
async def set_manual_feed(self) -> None:

0 commit comments

Comments
 (0)