Skip to content

Commit 7194c80

Browse files
committed
Cups and portions update
Manual Feed Quantity now a dropdown for Cups unit. Added option for 'portions' for Manual Feed Quantity.
1 parent 5d54288 commit 7194c80

22 files changed

Lines changed: 1011 additions & 268 deletions

custom_components/petlibro/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
162162

163163
# Initialize PetLibroHub
164164
try:
165-
hub = PetLibroHub(hass, entry.data)
165+
hub = PetLibroHub(hass, entry)
166166

167167
# Store the hub in hass.data
168168
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub
@@ -172,6 +172,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
172172

173173
# Load devices only once here
174174
await hub.load_devices()
175+
176+
# Initialize Helpers
177+
await hub._initialize_helpers()
175178

176179
# Start the coordinator for periodic updates
177180
await hub.coordinator.async_config_entry_first_refresh()

custom_components/petlibro/config_flow.py

Lines changed: 118 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@
1010
import voluptuous as vol
1111

1212
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
13-
from homeassistant.const import CONF_API_TOKEN, CONF_EMAIL, CONF_PASSWORD, CONF_REGION, Platform
13+
from homeassistant.const import CONF_API_TOKEN, CONF_EMAIL, CONF_PASSWORD, CONF_REGION
1414
from homeassistant.core import callback
1515
from homeassistant.data_entry_flow import section
1616
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1717
from homeassistant.helpers.selector import selector
18-
from homeassistant.helpers.entity_registry import async_get as get_entity_registry
18+
from homeassistant.helpers.translation import async_get_translations
1919

2020
from .api import PetLibroAPI
2121
from .const import (
22+
SENTINEL,
2223
DEFAULT_FEED,
2324
DEFAULT_WATER,
2425
DEFAULT_WEIGHT,
25-
ROUNDING_RULES,
26+
MANUAL_FEED_PORTIONS,
2627
DOMAIN,
2728
APIKey as API,
2829
Gender,
@@ -161,8 +162,6 @@ def async_get_options_flow(config_entry: ConfigEntry) -> PetlibroOptionsFlow:
161162
class PetlibroOptionsFlow(OptionsFlow):
162163
"""Handle an options flow for Petlibro."""
163164

164-
_SENTINEL = object()
165-
166165
def __init__(self):
167166
"""Initialise Petlibro Options Flow."""
168167
self._data: dict[str, Any] = {} # For storing temporary data.
@@ -181,6 +180,8 @@ async def async_step_init(
181180
"""Handle the initial options menu."""
182181

183182
_LOGGER.debug("Starting Petlibro options flow.")
183+
self.translations = await async_get_translations(
184+
self.hass, self.hass.config.language, "common")
184185
self.entry = self.config_entry
185186
self.hub = self.hass.data[DOMAIN][self.handler]
186187
self.api = self.hub.api
@@ -190,73 +191,116 @@ async def async_step_init(
190191
_LOGGER.debug(
191192
"Started Petlibro options flow for account %s", self.entry.data[CONF_EMAIL]
192193
)
194+
return self.async_show_menu(menu_options=["integration_settings", "account_settings"])
195+
196+
async def async_step_integration_settings(
197+
self, user_input: dict[str, Any] | None = None
198+
) -> ConfigFlowResult:
199+
"""Handle integration-level settings for the Petlibro integration."""
200+
201+
user_input = user_input or {}
202+
203+
if user_input:
204+
manual_feed_portions = user_input.get(MANUAL_FEED_PORTIONS)
205+
current_value = self.entry.options.get(MANUAL_FEED_PORTIONS, False)
206+
207+
# --- Nothing changed
208+
if manual_feed_portions == current_value:
209+
_LOGGER.debug("No integration settings changed.")
210+
return self.async_abort(reason=self.get_common_translation("no_settings_changed", "No settings were changed"))
211+
212+
# --- Update option
213+
self.hub.update_options({MANUAL_FEED_PORTIONS: manual_feed_portions})
214+
_LOGGER.debug("Updated %s to %s", MANUAL_FEED_PORTIONS, manual_feed_portions)
215+
216+
abort_messages = [self.get_common_translation("settings_updated", "Settings updated")]
217+
218+
# --- Reload integration if feed unit is cups
219+
if self.member.feedUnitType == Unit.CUPS:
220+
reload_integration = await self.hub.unit_entities.sync_manual_feed_entity_visibility(Unit.CUPS)
193221

194-
# Using a menu so more things can be added later.
195-
return self.async_show_menu(menu_options=["account_settings"])
222+
if reload_integration:
223+
abort_messages.append(
224+
self.get_common_translation("reloading_integration", "The integration is being reloaded")
225+
)
226+
_LOGGER.debug("'manual_feed_portions' value changed while feed unit is 'cups', reloading integration.")
227+
self.hass.config_entries.async_schedule_reload(self.handler)
228+
else:
229+
_LOGGER.debug("No Manual Feed entities found — nothing to reload.")
230+
else:
231+
await self.hub.async_refresh()
232+
233+
# --- Done
234+
return self.async_abort(
235+
reason="integration_settings_abort",
236+
description_placeholders={"integration_settings_abort": "\n".join(abort_messages)},
237+
)
196238

239+
_LOGGER.debug("Showing integration settings form.")
240+
return self._show_integration_settings_form(user_input)
241+
197242
async def async_step_account_settings(
198243
self, user_input: dict[str, Any] | None = None
199244
) -> ConfigFlowResult:
200245
"""Handle account settings."""
201246

202247
if not self.member:
203-
return self.async_abort(reason="account_update_nomember")
248+
return self.async_abort(reason="no_member_data")
204249

205250
user_input = user_input or {}
206-
if user_input:
207-
update_setting_temp = user_input.pop("measurement_unit", {})
208-
update_info_temp = user_input.copy()
209-
user_input.update(**update_setting_temp)
210-
update_all_units = update_setting_temp.pop("update_all_units", False)
251+
if user_input:
252+
# --- Extract updates
253+
measurement_units_raw: dict = user_input.pop("measurement_unit", {})
254+
account_info_raw = user_input.copy()
255+
user_input.update(**measurement_units_raw)
256+
update_all_units = measurement_units_raw.pop("update_all_units", False)
211257

212-
update_setting = self.collect_updates(
258+
unit_updates = self.collect_updates(
213259
fields=(API.FEED_UNIT, API.WATER_UNIT, API.WEIGHT_UNIT),
214-
user_input=update_setting_temp,
260+
user_input=measurement_units_raw,
215261
enum_cls=Unit,
216262
)
217263

218-
update_info = self.collect_updates(
264+
info_updates = self.collect_updates(
219265
fields=(API.NICKNAME, API.GENDER),
220-
user_input=update_info_temp,
266+
user_input=account_info_raw,
221267
special={
222268
API.NICKNAME: lambda v: v or "",
223269
API.GENDER: lambda v: self.validate_enum(API.GENDER, v, Gender),
224270
},
225271
)
226-
227-
if update_setting or update_all_units:
228-
registry = get_entity_registry(self.hass)
229-
for unit_type in self.hub.unit_sensor_unique_ids:
230-
unit = (input if isinstance(input := update_setting.get(unit_type), Unit)
231-
else Unit(input) if input else getattr(self.member, unit_type, None))
232-
if (unit_type not in update_setting or not unit or not unit.device_class) and not update_all_units:
233-
continue
234-
_LOGGER.debug("Updating %s sensor entities", unit_type)
235-
if update_all_units and unit_type == API.FEED_UNIT:
236-
target_units = {"weight": unit if unit.device_class == "weight" else Unit.GRAMS,
237-
"volume": unit if unit.device_class == "volume" else Unit.MILLILITERS}
238-
else:
239-
target_units = {unit.device_class: unit}
240-
for device_class, target_unit in target_units.items():
241-
display_precision = ROUNDING_RULES.get(target_unit, 0)
242-
options = { "unit_of_measurement": target_unit.symbol,
243-
"display_precision": display_precision,
244-
"suggested_display_precision": display_precision }
245-
for unique_id in self.hub.unit_sensor_unique_ids.get(unit_type, {}).get(device_class, []):
246-
entity_id = registry.async_get_entity_id(Platform.SENSOR, DOMAIN, unique_id)
247-
_LOGGER.debug("Setting %s to %s with display precision %s", entity_id, unit.symbol, display_precision)
248-
registry.async_update_entity_options(entity_id, Platform.SENSOR, options)
249-
250-
if not (update_info or update_setting):
251-
_LOGGER.debug("No account settings were changed.")
252-
reason = "account_update_nochanges" + ("_update_sensors" if update_all_units else "")
253-
return self.async_abort(reason=reason)
254272

255-
no_error = await self.api.member_update_info(update_info, update_setting)
256-
await self.hub.async_refresh(force_member=True)
273+
# --- 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)
257275

276+
# --- Apply account-level changes through API
277+
abort_messages = []
278+
if not (info_updates or unit_updates):
279+
_LOGGER.debug("No account settings were changed.")
280+
abort_messages.append(self.get_common_translation("no_settings_changed", "No settings were changed"))
281+
else:
282+
success = await self.api.member_update_info(update_info = info_updates, update_setting = unit_updates)
283+
if success:
284+
abort_messages.append(self.get_common_translation("account_updated", "Account update successful"))
285+
else:
286+
_LOGGER.error("Error updating account info via API.")
287+
return self.async_abort(reason="error_check_logs")
288+
289+
if update_all_units:
290+
abort_messages.append(self.get_common_translation("sensors_updated", "Sensor entities were updated"))
291+
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:
298+
await self.hub.async_refresh(force_member=True)
299+
300+
# --- Done
258301
return self.async_abort(
259-
reason="account_update_success" if no_error else "error_check_logs"
302+
reason="account_settings_abort",
303+
description_placeholders={"account_settings_abort": "\n".join(abort_messages)},
260304
)
261305

262306
_LOGGER.debug("Showing account settings form.")
@@ -265,6 +309,23 @@ async def async_step_account_settings(
265309
# ------------------------------
266310
# Form Builders
267311
# ------------------------------
312+
313+
def _show_integration_settings_form(self, user_input: dict[str, Any]) -> ConfigFlowResult:
314+
"""Build and show the integration settings form."""
315+
316+
return self.async_show_form(
317+
data_schema=vol.Schema(
318+
{
319+
vol.Optional(
320+
MANUAL_FEED_PORTIONS,
321+
default=self.entry.options.get(MANUAL_FEED_PORTIONS, False),
322+
): selector({"boolean": {}})
323+
}
324+
),
325+
description_placeholders={
326+
"uom": getattr(self.member, API.FEED_UNIT, DEFAULT_FEED).symbol
327+
},
328+
)
268329

269330
def _show_account_settings_form(self, user_input: dict[str, Any]) -> ConfigFlowResult:
270331
"""Build and show the account settings form."""
@@ -371,9 +432,9 @@ def collect_updates(
371432

372433
for api_key in fields:
373434
form_value = user_input.get(api_key)
374-
current_value = getattr(self.member, api_key, self._SENTINEL)
435+
current_value = getattr(self.member, api_key, SENTINEL)
375436

376-
if current_value is self._SENTINEL:
437+
if current_value is SENTINEL:
377438
_LOGGER.error("Unsupported API key: %s", api_key)
378439
continue
379440
if form_value == current_value:
@@ -392,3 +453,10 @@ def collect_updates(
392453
updates[api_key] = api_value
393454

394455
return updates
456+
457+
def get_common_translation(self, translation_key: str, fallback: str = "") -> str:
458+
"""Get a translated string under the 'common' key from the user's chosen language."""
459+
translation_path = f"component.{DOMAIN}.common.{translation_key}"
460+
if translation_path not in self.translations:
461+
_LOGGER.warning("Translation key %s not found in translation file.", translation_key)
462+
return self.translations.get(translation_path, fallback)

custom_components/petlibro/const.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, UnitOfMass, UnitOfVolume
66

77
type _Unit = Unit
8+
SENTINEL = object()
89

910
DOMAIN = "petlibro"
11+
GITHUB = "https://github.com/jjjonesjr33/petlibro"
1012

1113
# Configuration keys
1214
CONF_EMAIL = "email"
@@ -85,9 +87,15 @@ class APIKey(StrEnum):
8587

8688

8789
class Unit(IntEnum):
88-
"""Weight, feed, and water units with symbols and conversion."""
90+
"""Weight, feed, and water units with symbols and conversion.
91+
92+
KILOGRAMS, POUNDS, and WATER values can be converted using HA's built-in unit
93+
converter, so their 'factor's and 'device_class's likely won't be used much.
94+
95+
WATER int values must be different to avoid aliasing. Take care when using .value
96+
"""
8997

90-
CUPS = 1, 1/12, "cup", ""
98+
CUPS = 1, 1/12, "cups", ""
9199
OUNCES = 2, 0.35, UnitOfMass.OUNCES, "weight"
92100
GRAMS = 3, 10, UnitOfMass.GRAMS, "weight"
93101
MILLILITERS = 4, 20, UnitOfVolume.MILLILITERS, "volume"
@@ -97,11 +105,6 @@ class Unit(IntEnum):
97105

98106
WATER_OUNCES = 2 +6, 0.033814, UnitOfVolume.FLUID_OUNCES, "volume"
99107
WATER_MILLILITERS = 4 +6, 1, UnitOfVolume.MILLILITERS, "volume"
100-
101-
# KILOGRAMS, POUNDS, and WATER_ values can be converted using HA's built-in unit
102-
# converter, so their "factor"s and "device_class"s likely won't be used much or at all.
103-
104-
# WATER_ int values must be different to avoid aliasing. Take care when using .value
105108

106109
def __new__(cls, value: int, factor: float, symbol: str, device_class: str):
107110
"Ensures IntEnum functionality while allowing extra attributes."
@@ -165,6 +168,7 @@ def convert_feed(
165168
DEFAULT_FEED = Unit.CUPS
166169
DEFAULT_WATER = Unit.WATER_OUNCES
167170
MAX_FEED_PORTIONS = 48
171+
MANUAL_FEED_PORTIONS = "manual_feed_portions"
168172
VALID_UNIT_TYPES: dict[str, set[Unit]] = {
169173
APIKey.WEIGHT_UNIT: {Unit.POUNDS, Unit.KILOGRAMS, None},
170174
APIKey.FEED_UNIT: {Unit.CUPS, Unit.OUNCES, Unit.GRAMS, Unit.MILLILITERS, None},
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Helper methods for components within the Petlibro integration."""

0 commit comments

Comments
 (0)