On the road to get gold quality scale, refactored services/actions

This commit is contained in:
Ron Klinkien
2026-01-03 11:20:23 +01:00
parent ecc40ce127
commit 039aae3e9f
7 changed files with 225 additions and 156 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -379,8 +379,8 @@
"menstrual_period_length": {
"name": "Menstrual period length"
},
"last_synced": {
"name": "Last synced"
"device_last_synced": {
"name": "Device last synced"
}
}
}