mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-07 20:13:57 -05:00
On the road to get gold quality scale, refactored services/actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
157
custom_components/garmin_connect/services.py
Normal file
157
custom_components/garmin_connect/services.py
Normal 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)
|
||||
@@ -379,8 +379,8 @@
|
||||
"menstrual_period_length": {
|
||||
"name": "Menstrual period length"
|
||||
},
|
||||
"last_synced": {
|
||||
"name": "Last synced"
|
||||
"device_last_synced": {
|
||||
"name": "Device last synced"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user