Skip to content

Commit 601afa9

Browse files
authored
Feature/sync measurement units (#144)
* Feature/sync measurement units Added feed unit conversion. Made Enum usage a bit more efficient. Removed api refresh on every number entity change, using async_write_ha_state instead. UnitTypes and CommonAPIKeys renamed to Unit and APIKey. Fixed changed settings not remaining on account settings page when an error occurs. Fixed account settings update validation not working properly. Unit sensors will update to chosen measurement unit when that unit is changed in account settings. Added checkbox to account settings to update ALL unit sensors, regardless of what's changed in account settings. Updated translations for member and unit stuff. Used AI for non-English languages, can't say how accurate they are. * Fixed water_low_threshold & fl.oz conversion factor * Cups and portions update Manual Feed Quantity now a dropdown for Cups unit. Added option for 'portions' for Manual Feed Quantity. * Quick fix for the last commit
1 parent 75f58eb commit 601afa9

30 files changed

Lines changed: 2606 additions & 482 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: 135 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@
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.translation import async_get_translations
1819

1920
from .api import PetLibroAPI
2021
from .const import (
22+
SENTINEL,
2123
DEFAULT_FEED,
2224
DEFAULT_WATER,
2325
DEFAULT_WEIGHT,
26+
MANUAL_FEED_PORTIONS,
2427
DOMAIN,
25-
CommonAPIKeys as API,
28+
APIKey as API,
2629
Gender,
27-
UnitTypes,
30+
Unit,
2831
)
2932
from .exceptions import PetLibroCannotConnect, PetLibroInvalidAuth
3033
from .hub import PetLibroHub
@@ -159,8 +162,6 @@ def async_get_options_flow(config_entry: ConfigEntry) -> PetlibroOptionsFlow:
159162
class PetlibroOptionsFlow(OptionsFlow):
160163
"""Handle an options flow for Petlibro."""
161164

162-
_SENTINEL = object()
163-
164165
def __init__(self):
165166
"""Initialise Petlibro Options Flow."""
166167
self._data: dict[str, Any] = {} # For storing temporary data.
@@ -179,6 +180,8 @@ async def async_step_init(
179180
"""Handle the initial options menu."""
180181

181182
_LOGGER.debug("Starting Petlibro options flow.")
183+
self.translations = await async_get_translations(
184+
self.hass, self.hass.config.language, "common")
182185
self.entry = self.config_entry
183186
self.hub = self.hass.data[DOMAIN][self.handler]
184187
self.api = self.hub.api
@@ -188,44 +191,116 @@ async def async_step_init(
188191
_LOGGER.debug(
189192
"Started Petlibro options flow for account %s", self.entry.data[CONF_EMAIL]
190193
)
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)
221+
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()
191232

192-
# Using a menu so more things can be added later.
193-
return self.async_show_menu(menu_options=["account_settings"])
233+
# --- Done
234+
return self.async_abort(
235+
reason="integration_settings_abort",
236+
description_placeholders={"integration_settings_abort": "\n".join(abort_messages)},
237+
)
194238

239+
_LOGGER.debug("Showing integration settings form.")
240+
return self._show_integration_settings_form(user_input)
241+
195242
async def async_step_account_settings(
196243
self, user_input: dict[str, Any] | None = None
197244
) -> ConfigFlowResult:
198245
"""Handle account settings."""
199246

200247
if not self.member:
201-
return self.async_abort(reason="account_update_nomember")
248+
return self.async_abort(reason="no_member_data")
202249

203250
user_input = user_input or {}
204251
if user_input:
205-
update_setting = self.collect_updates(
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)
257+
258+
unit_updates = self.collect_updates(
206259
fields=(API.FEED_UNIT, API.WATER_UNIT, API.WEIGHT_UNIT),
207-
user_input=user_input.pop("measurement_unit", {}),
208-
enum_cls=UnitTypes,
260+
user_input=measurement_units_raw,
261+
enum_cls=Unit,
209262
)
210263

211-
update_info = self.collect_updates(
264+
info_updates = self.collect_updates(
212265
fields=(API.NICKNAME, API.GENDER),
213-
user_input=user_input,
266+
user_input=account_info_raw,
214267
special={
215268
API.NICKNAME: lambda v: v or "",
216269
API.GENDER: lambda v: self.validate_enum(API.GENDER, v, Gender),
217270
},
218271
)
219272

220-
if not (update_info or update_setting):
221-
_LOGGER.debug("No account settings were changed.")
222-
return self.async_abort(reason="account_update_nochanges")
223-
224-
no_error = await self.api.member_update_info(update_info, update_setting)
225-
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)
226275

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
227301
return self.async_abort(
228-
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)},
229304
)
230305

231306
_LOGGER.debug("Showing account settings form.")
@@ -234,6 +309,23 @@ async def async_step_account_settings(
234309
# ------------------------------
235310
# Form Builders
236311
# ------------------------------
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+
)
237329

238330
def _show_account_settings_form(self, user_input: dict[str, Any]) -> ConfigFlowResult:
239331
"""Build and show the account settings form."""
@@ -252,12 +344,12 @@ def _show_account_settings_form(self, user_input: dict[str, Any]) -> ConfigFlowR
252344
vol.Required(
253345
str(API.GENDER),
254346
default=user_input.get(
255-
API.GENDER, getattr(self.member, API.GENDER, str(Gender.NONE))
347+
API.GENDER, getattr(self.member, API.GENDER, Gender.NONE).lower
256348
),
257349
): selector(
258350
{
259351
"select": {
260-
"options": [g.name.lower() for g in Gender],
352+
"options": [g.lower for g in Gender],
261353
"mode": "dropdown",
262354
"translation_key": "member_gender",
263355
}
@@ -280,33 +372,32 @@ def _get_measurement_schema(self, user_input: dict[str, Any]) -> vol.Schema:
280372
vol.Required(
281373
str(API.FEED_UNIT),
282374
default=user_input.get(
283-
API.FEED_UNIT, getattr(self.member, API.FEED_UNIT, DEFAULT_FEED.name)
375+
API.FEED_UNIT, getattr(self.member, API.FEED_UNIT, DEFAULT_FEED).lower
284376
),
285-
): self._unit_selector(
286-
(UnitTypes.CUPS, UnitTypes.OUNCES, UnitTypes.GRAMS, UnitTypes.MILLILITERS)
287-
),
377+
): self._unit_selector((Unit.CUPS, Unit.OUNCES, Unit.GRAMS, Unit.MILLILITERS)),
288378
vol.Required(
289379
str(API.WATER_UNIT),
290380
default=user_input.get(
291-
API.WATER_UNIT, getattr(self.member, API.WATER_UNIT, DEFAULT_WATER.name)
381+
API.WATER_UNIT, getattr(self.member, API.WATER_UNIT, DEFAULT_WATER).lower
292382
),
293-
): self._unit_selector((UnitTypes.OUNCES, UnitTypes.MILLILITERS)),
383+
): self._unit_selector((Unit.WATER_OUNCES, Unit.WATER_MILLILITERS)),
294384
vol.Required(
295385
str(API.WEIGHT_UNIT),
296386
default=user_input.get(
297387
API.WEIGHT_UNIT,
298-
getattr(self.member, API.WEIGHT_UNIT, DEFAULT_WEIGHT.name),
388+
getattr(self.member, API.WEIGHT_UNIT, DEFAULT_WEIGHT).lower,
299389
),
300-
): self._unit_selector((UnitTypes.POUNDS, UnitTypes.KILOGRAMS)),
390+
): self._unit_selector((Unit.POUNDS, Unit.KILOGRAMS)),
391+
vol.Optional("update_all_units", default=user_input.get("update_all_units", False)): bool,
301392
}
302393
)
303394

304-
def _unit_selector(self, options: tuple[Enum, ...]) -> Any:
395+
def _unit_selector(self, options: tuple[Unit, ...]) -> Any:
305396
"""Return a dropdown selector for measurement unit options."""
306397
return selector(
307398
{
308399
"select": {
309-
"options": [o.name.lower() for o in options],
400+
"options": [o.lower for o in options],
310401
"mode": "dropdown",
311402
"translation_key": "unit_type",
312403
}
@@ -324,7 +415,7 @@ def validate_enum(self, api_key: str, form_value: Any, enum_cls: type[Enum]) ->
324415

325416
form_value_str = str(form_value).upper()
326417
if form_value_str in enum_cls.__members__:
327-
return enum_cls[form_value_str].value
418+
return enum_cls[form_value_str]
328419

329420
_LOGGER.error("Invalid value: %s for API key: %s", form_value, api_key)
330421
return None
@@ -341,9 +432,9 @@ def collect_updates(
341432

342433
for api_key in fields:
343434
form_value = user_input.get(api_key)
344-
current_value = getattr(self.member, api_key, self._SENTINEL)
435+
current_value = getattr(self.member, api_key, SENTINEL)
345436

346-
if current_value is self._SENTINEL:
437+
if current_value is SENTINEL:
347438
_LOGGER.error("Unsupported API key: %s", api_key)
348439
continue
349440
if form_value == current_value:
@@ -358,6 +449,14 @@ def collect_updates(
358449
else:
359450
api_value = form_value
360451

361-
updates[api_key] = api_value
362-
452+
if api_value != current_value:
453+
updates[api_key] = api_value
454+
363455
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)

0 commit comments

Comments
 (0)