Skip to content

Commit d39c4de

Browse files
feature/sync-measurement-units (#138)
* Entity measurement units update Removed useless __json__ from IntEnums (oops) 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 * Changed 'grain' to 'portion' * Unit sensors sync when changed + fixes 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. Fixed water units not working properly. Removed "portion" number entities. Updated Dockstream 2 entities to work with unit system. Updated translations for member and unit stuff. Used AI for non-english languages, can't say how accurate they are. * Round water_low_threshold unit conversion * Fixed update_all_units not updating all feed units * Cleaning up sensor entity update * Fixed missing method argument * Increased max manual feed amount to 48 (4 cups) --------- Co-authored-by: Jamie Jones <29973406+jjjonesjr33@users.noreply.github.com>
1 parent e707fbd commit d39c4de

26 files changed

Lines changed: 1734 additions & 394 deletions

custom_components/petlibro/config_flow.py

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@
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
13+
from homeassistant.const import CONF_API_TOKEN, CONF_EMAIL, CONF_PASSWORD, CONF_REGION, Platform
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
1819

1920
from .api import PetLibroAPI
2021
from .const import (
2122
DEFAULT_FEED,
2223
DEFAULT_WATER,
2324
DEFAULT_WEIGHT,
25+
ROUNDING_RULES,
2426
DOMAIN,
25-
CommonAPIKeys as API,
27+
APIKey as API,
2628
Gender,
27-
UnitTypes,
29+
Unit,
2830
)
2931
from .exceptions import PetLibroCannotConnect, PetLibroInvalidAuth
3032
from .hub import PetLibroHub
@@ -201,25 +203,54 @@ async def async_step_account_settings(
201203
return self.async_abort(reason="account_update_nomember")
202204

203205
user_input = user_input or {}
204-
if user_input:
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)
211+
205212
update_setting = self.collect_updates(
206213
fields=(API.FEED_UNIT, API.WATER_UNIT, API.WEIGHT_UNIT),
207-
user_input=user_input.pop("measurement_unit", {}),
208-
enum_cls=UnitTypes,
214+
user_input=update_setting_temp,
215+
enum_cls=Unit,
209216
)
210217

211218
update_info = self.collect_updates(
212219
fields=(API.NICKNAME, API.GENDER),
213-
user_input=user_input,
220+
user_input=update_info_temp,
214221
special={
215222
API.NICKNAME: lambda v: v or "",
216223
API.GENDER: lambda v: self.validate_enum(API.GENDER, v, Gender),
217224
},
218225
)
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)
219249

220250
if not (update_info or update_setting):
221251
_LOGGER.debug("No account settings were changed.")
222-
return self.async_abort(reason="account_update_nochanges")
252+
reason = "account_update_nochanges" + ("_update_sensors" if update_all_units else "")
253+
return self.async_abort(reason=reason)
223254

224255
no_error = await self.api.member_update_info(update_info, update_setting)
225256
await self.hub.async_refresh(force_member=True)
@@ -252,12 +283,12 @@ def _show_account_settings_form(self, user_input: dict[str, Any]) -> ConfigFlowR
252283
vol.Required(
253284
str(API.GENDER),
254285
default=user_input.get(
255-
API.GENDER, getattr(self.member, API.GENDER, str(Gender.NONE))
286+
API.GENDER, getattr(self.member, API.GENDER, Gender.NONE).lower
256287
),
257288
): selector(
258289
{
259290
"select": {
260-
"options": [g.name.lower() for g in Gender],
291+
"options": [g.lower for g in Gender],
261292
"mode": "dropdown",
262293
"translation_key": "member_gender",
263294
}
@@ -280,33 +311,32 @@ def _get_measurement_schema(self, user_input: dict[str, Any]) -> vol.Schema:
280311
vol.Required(
281312
str(API.FEED_UNIT),
282313
default=user_input.get(
283-
API.FEED_UNIT, getattr(self.member, API.FEED_UNIT, DEFAULT_FEED.name)
314+
API.FEED_UNIT, getattr(self.member, API.FEED_UNIT, DEFAULT_FEED).lower
284315
),
285-
): self._unit_selector(
286-
(UnitTypes.CUPS, UnitTypes.OUNCES, UnitTypes.GRAMS, UnitTypes.MILLILITERS)
287-
),
316+
): self._unit_selector((Unit.CUPS, Unit.OUNCES, Unit.GRAMS, Unit.MILLILITERS)),
288317
vol.Required(
289318
str(API.WATER_UNIT),
290319
default=user_input.get(
291-
API.WATER_UNIT, getattr(self.member, API.WATER_UNIT, DEFAULT_WATER.name)
320+
API.WATER_UNIT, getattr(self.member, API.WATER_UNIT, DEFAULT_WATER).lower
292321
),
293-
): self._unit_selector((UnitTypes.OUNCES, UnitTypes.MILLILITERS)),
322+
): self._unit_selector((Unit.WATER_OUNCES, Unit.WATER_MILLILITERS)),
294323
vol.Required(
295324
str(API.WEIGHT_UNIT),
296325
default=user_input.get(
297326
API.WEIGHT_UNIT,
298-
getattr(self.member, API.WEIGHT_UNIT, DEFAULT_WEIGHT.name),
327+
getattr(self.member, API.WEIGHT_UNIT, DEFAULT_WEIGHT).lower,
299328
),
300-
): self._unit_selector((UnitTypes.POUNDS, UnitTypes.KILOGRAMS)),
329+
): self._unit_selector((Unit.POUNDS, Unit.KILOGRAMS)),
330+
vol.Optional("update_all_units", default=user_input.get("update_all_units", False)): bool,
301331
}
302332
)
303333

304-
def _unit_selector(self, options: tuple[Enum, ...]) -> Any:
334+
def _unit_selector(self, options: tuple[Unit, ...]) -> Any:
305335
"""Return a dropdown selector for measurement unit options."""
306336
return selector(
307337
{
308338
"select": {
309-
"options": [o.name.lower() for o in options],
339+
"options": [o.lower for o in options],
310340
"mode": "dropdown",
311341
"translation_key": "unit_type",
312342
}
@@ -324,7 +354,7 @@ def validate_enum(self, api_key: str, form_value: Any, enum_cls: type[Enum]) ->
324354

325355
form_value_str = str(form_value).upper()
326356
if form_value_str in enum_cls.__members__:
327-
return enum_cls[form_value_str].value
357+
return enum_cls[form_value_str]
328358

329359
_LOGGER.error("Invalid value: %s for API key: %s", form_value, api_key)
330360
return None
@@ -358,6 +388,7 @@ def collect_updates(
358388
else:
359389
api_value = form_value
360390

361-
updates[api_key] = api_value
362-
391+
if api_value != current_value:
392+
updates[api_key] = api_value
393+
363394
return updates

custom_components/petlibro/const.py

Lines changed: 115 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, UnitOfMass, UnitOfVolume
66

7+
type _Unit = Unit
8+
79
DOMAIN = "petlibro"
810

911
# Configuration keys
@@ -19,42 +21,6 @@
1921
UPDATE_INTERVAL_SECONDS = 60 # You can adjust this value based on your needs
2022

2123

22-
class UnitTypes(IntEnum):
23-
"""Weight, feed, and water units with symbols."""
24-
25-
CUPS = 1, "cup"
26-
OUNCES = 2, UnitOfMass.OUNCES
27-
GRAMS = 3, UnitOfMass.GRAMS
28-
MILLILITERS = 4, UnitOfVolume.MILLILITERS
29-
KILOGRAMS = 5, UnitOfMass.KILOGRAMS
30-
POUNDS = 6, UnitOfMass.POUNDS
31-
32-
def __new__(cls, value: int, symbol: str):
33-
"Ensures IntEnum functionality while allowing symbols."
34-
obj = int.__new__(cls, value)
35-
obj._value_ = value
36-
obj._symbol = symbol # noqa: SLF001
37-
return obj
38-
39-
def __str__(self) -> str:
40-
"""Returns the name as a string."""
41-
return self.name
42-
43-
def __json__(self) -> int:
44-
"""Returns the int value when sending to the API."""
45-
return int(self)
46-
47-
@property
48-
def symbol(self) -> str:
49-
"""Returns unit symbol."""
50-
return self._symbol
51-
52-
53-
DEFAULT_WEIGHT = UnitTypes.POUNDS
54-
DEFAULT_FEED = UnitTypes.CUPS
55-
DEFAULT_WATER = UnitTypes.OUNCES
56-
57-
5824
class Gender(IntEnum):
5925
"""Gender/sex options."""
6026

@@ -72,13 +38,10 @@ def __new__(cls, value: int, icon: str, symbol: str, emoji: str):
7238
obj._emoji = emoji # noqa: SLF001
7339
return obj
7440

75-
def __str__(self) -> str:
76-
"""Returns the name as a string."""
77-
return self.name
78-
79-
def __json__(self) -> int:
80-
"""Returns the int value when sending to API."""
81-
return int(self)
41+
@property
42+
def lower(self) -> str:
43+
"""Returns unit name in lower case."""
44+
return self.name.lower()
8245

8346
@property
8447
def icon(self) -> str:
@@ -96,13 +59,120 @@ def emoji(self) -> str:
9659
return self._emoji
9760

9861

99-
class CommonAPIKeys(StrEnum):
62+
class APIKey(StrEnum):
10063
"""Common API JSON keys."""
10164

102-
ACCOUNT_ID = "id"
65+
# Common
66+
ID = "id"
67+
NAME = "name"
68+
WEIGHT = "weight"
69+
70+
# Member
10371
EMAIL = "email"
10472
NICKNAME = "nickname"
10573
GENDER = "gender"
10674
FEED_UNIT = "feedUnitType"
10775
WATER_UNIT = "waterUnitType"
10876
WEIGHT_UNIT = "weightUnitType"
77+
78+
# Pet
79+
BIRTHDAY = "birthday"
80+
TYPE = "type"
81+
SEX = "gender"
82+
BREED_NAME = "breedName"
83+
BREED_ID = "breedId"
84+
PET_ID = "petId"
85+
86+
87+
class Unit(IntEnum):
88+
"""Weight, feed, and water units with symbols and conversion."""
89+
90+
CUPS = 1, 1/12, "cup", ""
91+
OUNCES = 2, 0.35, UnitOfMass.OUNCES, "weight"
92+
GRAMS = 3, 10, UnitOfMass.GRAMS, "weight"
93+
MILLILITERS = 4, 20, UnitOfVolume.MILLILITERS, "volume"
94+
95+
KILOGRAMS = 5, 1, UnitOfMass.KILOGRAMS, "weight"
96+
POUNDS = 6, 2.20459, UnitOfMass.POUNDS, "weight"
97+
98+
WATER_OUNCES = 2 +6, 0.035195, UnitOfVolume.FLUID_OUNCES, "volume"
99+
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
105+
106+
def __new__(cls, value: int, factor: float, symbol: str, device_class: str):
107+
"Ensures IntEnum functionality while allowing extra attributes."
108+
109+
obj = int.__new__(cls, value if value <= 6 else value - 6)
110+
obj._value_ = value
111+
obj._factor = factor # noqa: SLF001
112+
obj._symbol = symbol # noqa: SLF001
113+
obj._device_class = device_class # noqa: SLF001
114+
return obj
115+
116+
@property
117+
def lower(self) -> str:
118+
"""Returns unit name in lower case."""
119+
return self.name.lower()
120+
121+
@property
122+
def factor(self) -> float:
123+
"""Returns unit conversion factor."""
124+
return self._factor
125+
126+
@property
127+
def symbol(self) -> str:
128+
"""Returns unit symbol."""
129+
return self._symbol
130+
131+
@property
132+
def device_class(self) -> str:
133+
"""Returns unit device class."""
134+
return self._device_class
135+
136+
@classmethod
137+
def round(self, value: float, unit: _Unit):
138+
return round(value, ROUNDING_RULES.get(unit, 0))
139+
140+
@classmethod
141+
def convert_feed(
142+
self, value: float, from_unit: _Unit | None, to_unit: _Unit | None, rounded: bool = False
143+
):
144+
"""Convert PetLibro feed units. Use **None** for portion unit (1/12th of a cup)."""
145+
if value and from_unit != to_unit:
146+
if not {from_unit, to_unit}.issubset(VALID_UNIT_TYPES[APIKey.FEED_UNIT]):
147+
raise ValueError(f"Incompatible conversion: {from_unit} -> {to_unit}")
148+
149+
from_factor = from_unit.factor if from_unit else 1
150+
to_factor = to_unit.factor if to_unit else 1
151+
152+
api_value = value / from_factor
153+
new_value = api_value * to_factor
154+
else:
155+
new_value = value
156+
157+
if not to_unit:
158+
return round(new_value)
159+
if rounded:
160+
return Unit.round(new_value, to_unit)
161+
return new_value
162+
163+
164+
DEFAULT_WEIGHT = Unit.POUNDS
165+
DEFAULT_FEED = Unit.CUPS
166+
DEFAULT_WATER = Unit.WATER_OUNCES
167+
MAX_FEED_PORTIONS = 48
168+
VALID_UNIT_TYPES: dict[str, set[Unit]] = {
169+
APIKey.WEIGHT_UNIT: {Unit.POUNDS, Unit.KILOGRAMS, None},
170+
APIKey.FEED_UNIT: {Unit.CUPS, Unit.OUNCES, Unit.GRAMS, Unit.MILLILITERS, None},
171+
APIKey.WATER_UNIT: {Unit.WATER_OUNCES, Unit.WATER_MILLILITERS, None},
172+
}
173+
ROUNDING_RULES = {
174+
Unit.CUPS: 3, Unit.OUNCES: 2, Unit.POUNDS: 2, Unit.WATER_OUNCES: 2, Unit.KILOGRAMS: 2
175+
}
176+
WATER_MAPPING = {
177+
Unit.MILLILITERS: Unit.WATER_MILLILITERS, Unit.OUNCES: Unit.WATER_OUNCES
178+
}

custom_components/petlibro/devices/device.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@
55

66
from ..api import PetLibroAPI
77
from .event import Event, EVENT_UPDATE
8+
from ..member import Member
89

910

1011
_LOGGER = getLogger(__name__)
1112

1213

1314
class Device(Event):
14-
def __init__(self, data: dict, api: PetLibroAPI):
15+
def __init__(self, data: dict, member: Member, api: PetLibroAPI):
1516
super().__init__()
1617
self._data: dict = {}
1718
self.api = api
19+
self.member = member
1820

1921
self.update_data(data)
2022

0 commit comments

Comments
 (0)