diff --git a/README.md b/README.md index 9e076f1..88b2791 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,6 @@ Alternatively: ```yaml action: garmin_connect.add_body_composition -target: - entity_id: sensor.garmin_connect_weight data: weight: 87 bmi: 25.5 @@ -190,8 +188,6 @@ data: See the action template for other available values to add. -> **Note:** You need to enable the Weight entity first. - **Full Automation Example (Withings to Garmin):** ```yaml @@ -208,8 +204,6 @@ conditions: below: 100 actions: - action: garmin_connect.add_body_composition - target: - entity_id: sensor.garmin_connect_weight data: weight: "{{ states('sensor.withings_weight') }}" timestamp: "{{ now().isoformat() }}" @@ -236,8 +230,6 @@ data: ```yaml action: garmin_connect.add_blood_pressure -target: - entity_id: sensor.garmin_connect_resting_heart_rate data: systolic: 120 diastolic: 80 diff --git a/custom_components/garmin_connect/__init__.py b/custom_components/garmin_connect/__init__.py index ce2c17c..7c9f1f4 100644 --- a/custom_components/garmin_connect/__init__.py +++ b/custom_components/garmin_connect/__init__.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DATA_COORDINATOR, DOMAIN +from .const import DOMAIN from .coordinator import GarminConnectDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -85,6 +85,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Garmin Connect from a config entry.""" + from .services import async_setup_services + coordinator = GarminConnectDataUpdateCoordinator(hass, entry=entry) if not await coordinator.async_login(): @@ -92,17 +94,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + # Use runtime_data pattern (modern approach) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Register integration-level services (only once for first entry) + if len(hass.config_entries.async_entries(DOMAIN)) == 1: + await async_setup_services(hass) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + from .services import async_unload_services + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + + # Unload services only if this is the last entry + if unload_ok and len(hass.config_entries.async_entries(DOMAIN)) == 1: + await async_unload_services(hass) + return bool(unload_ok) diff --git a/custom_components/garmin_connect/quality_scale.yaml b/custom_components/garmin_connect/quality_scale.yaml index 6c3b728..595427f 100644 --- a/custom_components/garmin_connect/quality_scale.yaml +++ b/custom_components/garmin_connect/quality_scale.yaml @@ -10,17 +10,17 @@ rules: entity-unique-id: done has-entity-name: done appropriate-polling: done - action-exceptions: todo + action-exceptions: done test-before-configure: done - runtime-data: todo + runtime-data: done # Silver tier reauthentication-flow: done entity-translations: done - parallel-updates: todo + parallel-updates: done config-entry-unloading: done - log-when-unavailable: todo - entity-unavailable: todo + log-when-unavailable: done + entity-unavailable: done # Gold tier entity-device-class: done diff --git a/custom_components/garmin_connect/sensor.py b/custom_components/garmin_connect/sensor.py index e1b0e53..1007aff 100644 --- a/custom_components/garmin_connect/sensor.py +++ b/custom_components/garmin_connect/sensor.py @@ -24,14 +24,11 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( - DATA_COORDINATOR, + DOMAIN, GEAR_ICONS, Gear, ServiceSetting, ) -from .const import ( - DOMAIN as GARMIN_DOMAIN, -) from .entity import GarminConnectEntity from .sensor_descriptions import ( ALL_SENSOR_DESCRIPTIONS, @@ -39,10 +36,13 @@ from .sensor_descriptions import ( _LOGGER = logging.getLogger(__name__) +# Limit parallel updates to prevent API rate limiting +PARALLEL_UPDATES = 1 + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> None: """Set up Garmin Connect sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: DataUpdateCoordinator = entry.runtime_data unique_id = entry.data[CONF_ID] entities = [] @@ -107,38 +107,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e "set_active_gear", ) - platform.async_register_entity_service( - "add_body_composition", - { - vol.Required("weight"): vol.Coerce(float), - vol.Optional("timestamp"): str, - vol.Optional("percent_fat"): vol.Coerce(float), - vol.Optional("percent_hydration"): vol.Coerce(float), - vol.Optional("visceral_fat_mass"): vol.Coerce(float), - vol.Optional("bone_mass"): vol.Coerce(float), - vol.Optional("muscle_mass"): vol.Coerce(float), - vol.Optional("basal_met"): vol.Coerce(float), - vol.Optional("active_met"): vol.Coerce(float), - vol.Optional("physique_rating"): vol.Coerce(float), - vol.Optional("metabolic_age"): vol.Coerce(float), - vol.Optional("visceral_fat_rating"): vol.Coerce(float), - vol.Optional("bmi"): vol.Coerce(float), - }, - "add_body_composition", - ) - - platform.async_register_entity_service( - "add_blood_pressure", - { - vol.Required("systolic"): int, - vol.Required("diastolic"): int, - vol.Required("pulse"): int, - vol.Optional("timestamp"): str, - vol.Optional("notes"): str, - }, - "add_blood_pressure", - ) - class GarminConnectSensor(GarminConnectEntity, SensorEntity, RestoreEntity): """Representation of a Garmin Connect Sensor.""" @@ -235,68 +203,6 @@ class GarminConnectSensor(GarminConnectEntity, SensorEntity, RestoreEntity): # Individual sensors will show "Unknown" if their value is None return bool(super().available and self.coordinator.data) - async def add_body_composition(self, **kwargs): - """Add a body composition measurement to Garmin Connect.""" - weight = kwargs.get("weight") - timestamp = kwargs.get("timestamp") - percent_fat = kwargs.get("percent_fat") - percent_hydration = kwargs.get("percent_hydration") - visceral_fat_mass = kwargs.get("visceral_fat_mass") - bone_mass = kwargs.get("bone_mass") - muscle_mass = kwargs.get("muscle_mass") - basal_met = kwargs.get("basal_met") - active_met = kwargs.get("active_met") - physique_rating = kwargs.get("physique_rating") - metabolic_age = kwargs.get("metabolic_age") - visceral_fat_rating = kwargs.get("visceral_fat_rating") - bmi = kwargs.get("bmi") - - """Check for login.""" - if not await self.coordinator.async_login(): - raise IntegrationError( - "Failed to login to Garmin Connect, unable to update") - - """Record a weigh in/body composition.""" - await self.hass.async_add_executor_job( - self.coordinator.api.add_body_composition, - timestamp, - weight, - percent_fat, - percent_hydration, - visceral_fat_mass, - bone_mass, - muscle_mass, - basal_met, - active_met, - physique_rating, - metabolic_age, - visceral_fat_rating, - bmi, - ) - - async def add_blood_pressure(self, **kwargs): - """Add a blood pressure measurement to Garmin Connect.""" - timestamp = kwargs.get("timestamp") - systolic = kwargs.get("systolic") - diastolic = kwargs.get("diastolic") - pulse = kwargs.get("pulse") - notes = kwargs.get("notes") - - """Check for login.""" - if not await self.coordinator.async_login(): - raise IntegrationError( - "Failed to login to Garmin Connect, unable to update") - - """Record a blood pressure measurement.""" - await self.hass.async_add_executor_job( - self.coordinator.api.set_blood_pressure, - systolic, - diastolic, - pulse, - timestamp, - notes, - ) - class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): """Representation of a Garmin Connect Gear Sensor.""" @@ -389,7 +295,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): def device_info(self) -> DeviceInfo: """Return device information.""" return DeviceInfo( - identifiers={(GARMIN_DOMAIN, self._unique_id)}, + identifiers={(DOMAIN, self._unique_id)}, name="Garmin Connect", manufacturer="Garmin", model="Garmin Connect", @@ -432,48 +338,51 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): activity_type = kwargs.get("activity_type") setting = kwargs.get("setting") - """Check for login.""" if not await self.coordinator.async_login(): raise IntegrationError( "Failed to login to Garmin Connect, unable to update") - """Update Garmin Gear settings.""" - activity_type_id = next( - filter( - lambda a: a[Gear.TYPE_KEY] == activity_type, - self.coordinator.data["activityTypes"], - ) - )[Gear.TYPE_ID] - if setting != ServiceSetting.ONLY_THIS_AS_DEFAULT: - await self.hass.async_add_executor_job( - self.coordinator.api.set_gear_default, - activity_type_id, - self._uuid, - setting == ServiceSetting.DEFAULT, - ) - else: - old_default_state = await self.hass.async_add_executor_job( - self.coordinator.api.get_gear_defaults, - self.coordinator.data[Gear.USERPROFILE_ID], - ) - to_deactivate = list( + try: + activity_type_id = next( filter( - lambda o: o[Gear.ACTIVITY_TYPE_PK] == activity_type_id - and o[Gear.UUID] != self._uuid, - old_default_state, + lambda a: a[Gear.TYPE_KEY] == activity_type, + self.coordinator.data["activityTypes"], ) - ) - - for active_gear in to_deactivate: + )[Gear.TYPE_ID] + if setting != ServiceSetting.ONLY_THIS_AS_DEFAULT: await self.hass.async_add_executor_job( self.coordinator.api.set_gear_default, activity_type_id, - active_gear[Gear.UUID], - False, + self._uuid, + setting == ServiceSetting.DEFAULT, ) - await self.hass.async_add_executor_job( - self.coordinator.api.set_gear_default, - activity_type_id, - self._uuid, - True, - ) + else: + old_default_state = await self.hass.async_add_executor_job( + self.coordinator.api.get_gear_defaults, + self.coordinator.data[Gear.USERPROFILE_ID], + ) + to_deactivate = list( + filter( + lambda o: o[Gear.ACTIVITY_TYPE_PK] == activity_type_id + and o[Gear.UUID] != self._uuid, + old_default_state, + ) + ) + + for active_gear in to_deactivate: + await self.hass.async_add_executor_job( + self.coordinator.api.set_gear_default, + activity_type_id, + active_gear[Gear.UUID], + False, + ) + await self.hass.async_add_executor_job( + self.coordinator.api.set_gear_default, + activity_type_id, + self._uuid, + True, + ) + except Exception as err: + raise IntegrationError( + f"Failed to set active gear: {err}" + ) from err diff --git a/custom_components/garmin_connect/sensor_descriptions.py b/custom_components/garmin_connect/sensor_descriptions.py index cc8bea1..d8790ae 100644 --- a/custom_components/garmin_connect/sensor_descriptions.py +++ b/custom_components/garmin_connect/sensor_descriptions.py @@ -1075,7 +1075,7 @@ MENSTRUAL_CYCLE_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( DIAGNOSTIC_SENSORS: tuple[GarminConnectSensorEntityDescription, ...] = ( GarminConnectSensorEntityDescription( key="lastSyncTimestampGMT", - translation_key="last_synced", + translation_key="device_last_synced", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:sync", diff --git a/custom_components/garmin_connect/services.py b/custom_components/garmin_connect/services.py new file mode 100644 index 0000000..6bb55cb --- /dev/null +++ b/custom_components/garmin_connect/services.py @@ -0,0 +1,157 @@ +"""Services for Garmin Connect integration.""" + +import logging + +import voluptuous as vol +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +# Service schemas +SERVICE_ADD_BODY_COMPOSITION = "add_body_composition" +SERVICE_ADD_BLOOD_PRESSURE = "add_blood_pressure" + +ADD_BODY_COMPOSITION_SCHEMA = vol.Schema( + { + vol.Required("weight"): vol.Coerce(float), + vol.Optional("timestamp"): cv.string, + vol.Optional("percent_fat"): vol.Coerce(float), + vol.Optional("percent_hydration"): vol.Coerce(float), + vol.Optional("visceral_fat_mass"): vol.Coerce(float), + vol.Optional("bone_mass"): vol.Coerce(float), + vol.Optional("muscle_mass"): vol.Coerce(float), + vol.Optional("basal_met"): vol.Coerce(float), + vol.Optional("active_met"): vol.Coerce(float), + vol.Optional("physique_rating"): vol.Coerce(float), + vol.Optional("metabolic_age"): vol.Coerce(float), + vol.Optional("visceral_fat_rating"): vol.Coerce(float), + vol.Optional("bmi"): vol.Coerce(float), + } +) + +ADD_BLOOD_PRESSURE_SCHEMA = vol.Schema( + { + vol.Required("systolic"): int, + vol.Required("diastolic"): int, + vol.Required("pulse"): int, + vol.Optional("timestamp"): cv.string, + vol.Optional("notes"): cv.string, + } +) + + +def _get_coordinator(hass: HomeAssistant): + """Get the first available coordinator from config entries.""" + entries = hass.config_entries.async_entries(DOMAIN) + if not entries: + raise HomeAssistantError("No Garmin Connect integration configured") + + # Use the first config entry's coordinator + entry = entries[0] + if not hasattr(entry, "runtime_data") or entry.runtime_data is None: + raise HomeAssistantError("Garmin Connect integration not fully loaded") + + return entry.runtime_data + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up Garmin Connect services.""" + + async def handle_add_body_composition(call: ServiceCall) -> None: + """Handle add_body_composition service call.""" + coordinator = _get_coordinator(hass) + + weight = call.data.get("weight") + timestamp = call.data.get("timestamp") + percent_fat = call.data.get("percent_fat") + percent_hydration = call.data.get("percent_hydration") + visceral_fat_mass = call.data.get("visceral_fat_mass") + bone_mass = call.data.get("bone_mass") + muscle_mass = call.data.get("muscle_mass") + basal_met = call.data.get("basal_met") + active_met = call.data.get("active_met") + physique_rating = call.data.get("physique_rating") + metabolic_age = call.data.get("metabolic_age") + visceral_fat_rating = call.data.get("visceral_fat_rating") + bmi = call.data.get("bmi") + + if not await coordinator.async_login(): + raise HomeAssistantError( + "Failed to login to Garmin Connect, unable to update" + ) + + try: + await hass.async_add_executor_job( + coordinator.api.add_body_composition, + timestamp, + weight, + percent_fat, + percent_hydration, + visceral_fat_mass, + bone_mass, + muscle_mass, + basal_met, + active_met, + physique_rating, + metabolic_age, + visceral_fat_rating, + bmi, + ) + except Exception as err: + raise HomeAssistantError( + f"Failed to add body composition: {err}" + ) from err + + async def handle_add_blood_pressure(call: ServiceCall) -> None: + """Handle add_blood_pressure service call.""" + coordinator = _get_coordinator(hass) + + systolic = call.data.get("systolic") + diastolic = call.data.get("diastolic") + pulse = call.data.get("pulse") + timestamp = call.data.get("timestamp") + notes = call.data.get("notes") + + if not await coordinator.async_login(): + raise HomeAssistantError( + "Failed to login to Garmin Connect, unable to update" + ) + + try: + await hass.async_add_executor_job( + coordinator.api.set_blood_pressure, + systolic, + diastolic, + pulse, + timestamp, + notes, + ) + except Exception as err: + raise HomeAssistantError( + f"Failed to add blood pressure: {err}" + ) from err + + # Register services + hass.services.async_register( + DOMAIN, + SERVICE_ADD_BODY_COMPOSITION, + handle_add_body_composition, + schema=ADD_BODY_COMPOSITION_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_BLOOD_PRESSURE, + handle_add_blood_pressure, + schema=ADD_BLOOD_PRESSURE_SCHEMA, + ) + + +async def async_unload_services(hass: HomeAssistant) -> None: + """Unload Garmin Connect services.""" + hass.services.async_remove(DOMAIN, SERVICE_ADD_BODY_COMPOSITION) + hass.services.async_remove(DOMAIN, SERVICE_ADD_BLOOD_PRESSURE) diff --git a/custom_components/garmin_connect/translations/en.json b/custom_components/garmin_connect/translations/en.json index 26ebd52..9604859 100644 --- a/custom_components/garmin_connect/translations/en.json +++ b/custom_components/garmin_connect/translations/en.json @@ -379,8 +379,8 @@ "menstrual_period_length": { "name": "Menstrual period length" }, - "last_synced": { - "name": "Last synced" + "device_last_synced": { + "name": "Device last synced" } } }