mirror of
https://github.com/cyberjunky/home-assistant-garmin_connect.git
synced 2026-01-07 20:13:57 -05:00
New options
This commit is contained in:
112
SENSOR_GROUPS.md
Normal file
112
SENSOR_GROUPS.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Sensor Groups Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Garmin Connect integration now supports organizing sensors into logical groups that can be enabled or disabled together. This helps reduce clutter in your Home Assistant instance by only showing the sensors you actually use.
|
||||
|
||||
## Available Sensor Groups
|
||||
|
||||
### Enabled by Default
|
||||
|
||||
- **Activity & Steps**: Step counts, distance, floors climbed, and daily goals
|
||||
- **Calories & Nutrition**: Calorie tracking including active, burned, consumed, and remaining
|
||||
- **Heart Rate**: Heart rate monitoring including resting, min/max, and HRV status
|
||||
- **Stress**: Stress levels and duration tracking
|
||||
- **Sleep**: Sleep duration, quality scores, and sleep stages
|
||||
- **Body Battery**: Garmin Body Battery energy monitoring
|
||||
- **Hydration**: Water intake tracking and hydration goals
|
||||
- **Intensity & Activity Time**: Active time, sedentary time, and intensity minutes
|
||||
- **Health Monitoring**: SpO2 (blood oxygen), respiration rate, and altitude
|
||||
- **Fitness & Performance**: Fitness age, endurance score, and metabolic age
|
||||
|
||||
### Disabled by Default
|
||||
|
||||
- **Body Composition**: Weight, BMI, body fat, muscle mass, and bone mass (requires compatible scale)
|
||||
- **Activity Tracking**: Recent activities, badges, points, and gamification
|
||||
- **Advanced Sensors**: Additional detailed metrics and alternative measurements
|
||||
|
||||
## How to Configure
|
||||
|
||||
### Initial Setup
|
||||
|
||||
When you first install the integration, all default sensor groups are enabled automatically. This ensures backward compatibility with previous versions.
|
||||
|
||||
### Changing Sensor Groups
|
||||
|
||||
1. Go to **Settings** → **Devices & Services**
|
||||
2. Find the **Garmin Connect** integration
|
||||
3. Click **Configure** (or the three dots menu → **Configure**)
|
||||
4. Select which sensor groups you want to enable
|
||||
5. Click **Submit**
|
||||
6. **Reload the integration** for changes to take effect
|
||||
|
||||
### Per-Sensor Control
|
||||
|
||||
Even within enabled sensor groups, you can still disable individual sensors:
|
||||
|
||||
1. Go to **Settings** → **Devices & Services**
|
||||
2. Find the **Garmin Connect** integration
|
||||
3. Click on the device
|
||||
4. Find the sensor you want to disable
|
||||
5. Click on it and toggle **Enable entity** off
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- **Existing installations**: If you upgrade from a previous version, all default sensor groups will be automatically enabled, maintaining your current setup
|
||||
- **Configuration-less**: If you never configure sensor groups, all default groups remain enabled
|
||||
- **Individual control**: The `entity_registry_enabled_default` setting on individual sensors still works within enabled groups
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Reduced Clutter**: Only see sensors relevant to your use case
|
||||
2. **Performance**: Fewer entities to process in Home Assistant
|
||||
3. **Organization**: Sensors are logically grouped for easier management
|
||||
4. **Flexibility**: Enable/disable entire categories or individual sensors
|
||||
5. **Scalability**: Easy to add new sensor categories in the future
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal Setup (Basic Activity Tracking)
|
||||
Enable only:
|
||||
- Activity & Steps
|
||||
- Heart Rate
|
||||
- Sleep
|
||||
|
||||
### Comprehensive Health Monitoring
|
||||
Enable:
|
||||
- All default groups
|
||||
- Body Composition (if you have a compatible scale)
|
||||
- Advanced Sensors (for detailed analysis)
|
||||
|
||||
### Athlete/Training Focus
|
||||
Enable:
|
||||
- Activity & Steps
|
||||
- Heart Rate
|
||||
- Stress
|
||||
- Body Battery
|
||||
- Intensity & Activity Time
|
||||
- Fitness & Performance
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Storage
|
||||
|
||||
Sensor group preferences are stored in the integration's options, separate from the main configuration. This allows:
|
||||
- Easy reconfiguration without re-authentication
|
||||
- No impact on existing authentication tokens
|
||||
- Clean separation of concerns
|
||||
|
||||
### Implementation
|
||||
|
||||
- Sensor descriptions are organized into tuples by category
|
||||
- A mapping dictionary links group IDs to sensor tuples
|
||||
- The `get_sensors_for_groups()` function dynamically builds the sensor list
|
||||
- Backward compatibility is ensured by checking for `None` options
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future additions:
|
||||
- Export/import sensor group configurations
|
||||
- Preset configurations for different use cases
|
||||
- Dynamic group enabling based on detected Garmin device capabilities
|
||||
- Statistics about which groups are most commonly enabled
|
||||
@@ -5,20 +5,20 @@ from collections.abc import Awaitable
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
import requests
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import requests
|
||||
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DAY_TO_NUMBER,
|
||||
@@ -35,14 +35,16 @@ PLATFORMS = ["sensor"]
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry from username/password to token-based authentication."""
|
||||
_LOGGER.debug(
|
||||
"Migrating Garmin Connect config entry from version %s", entry.version)
|
||||
_LOGGER.debug("Migrating Garmin Connect config entry from version %s", entry.version)
|
||||
|
||||
if entry.version == 1:
|
||||
# Check if we need to migrate (old entries have username/password, new ones have token)
|
||||
if CONF_TOKEN not in entry.data and CONF_USERNAME in entry.data and CONF_PASSWORD in entry.data:
|
||||
_LOGGER.info(
|
||||
"Migrating Garmin Connect config entry to token-based authentication")
|
||||
if (
|
||||
CONF_TOKEN not in entry.data
|
||||
and CONF_USERNAME in entry.data
|
||||
and CONF_PASSWORD in entry.data
|
||||
):
|
||||
_LOGGER.info("Migrating Garmin Connect config entry to token-based authentication")
|
||||
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
@@ -69,14 +71,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Update the config entry
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Successfully migrated Garmin Connect config entry")
|
||||
_LOGGER.info("Successfully migrated Garmin Connect config entry")
|
||||
return True
|
||||
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.error(
|
||||
"Failed to migrate Garmin Connect config entry. "
|
||||
"Please re-add the integration. Error: %s", err
|
||||
"Please re-add the integration. Error: %s",
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -133,8 +135,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
self.api = Garmin(is_cn=self._in_china)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN,
|
||||
update_interval=DEFAULT_UPDATE_INTERVAL)
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL)
|
||||
|
||||
async def async_login(self) -> bool:
|
||||
"""
|
||||
@@ -156,38 +157,31 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"Token not found in config entry. This may be an old config entry that needs migration. "
|
||||
"Please remove and re-add the Garmin Connect integration."
|
||||
)
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Token not found, please re-add the integration")
|
||||
raise ConfigEntryAuthFailed("Token not found, please re-add the integration")
|
||||
|
||||
await self.hass.async_add_executor_job(self.api.login, self.entry.data[CONF_TOKEN])
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
_LOGGER.error("Authentication error occurred during login: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred during login: %s", err)
|
||||
_LOGGER.error("Too many request error occurred during login: %s", err)
|
||||
return False
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred during Garmin Connect login request: %s", err
|
||||
)
|
||||
_LOGGER.error("Connection error occurred during Garmin Connect login request: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during login: %s", err.response.text)
|
||||
_LOGGER.error("Authentication error occurred during login: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error occurred during login: %s", err.response.text)
|
||||
"Too many requests error occurred during login: %s", err.response.text
|
||||
)
|
||||
return False
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred during login: %s", err)
|
||||
_LOGGER.error("Unknown HTTP error occurred during login: %s", err)
|
||||
return False
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during login: %s", err)
|
||||
_LOGGER.exception("Unknown error occurred during login: %s", err)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -246,8 +240,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
(today + timedelta(days=1)).isoformat(),
|
||||
)
|
||||
if last_activities:
|
||||
_LOGGER.debug("Last activities data fetched: %s",
|
||||
last_activities)
|
||||
_LOGGER.debug("Last activities data fetched: %s", last_activities)
|
||||
else:
|
||||
_LOGGER.debug("No last activities data found")
|
||||
|
||||
@@ -268,8 +261,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
# Calculate user points and user level
|
||||
user_points = 0
|
||||
for badge in badges:
|
||||
user_points += badge["badgePoints"] * \
|
||||
badge["badgeEarnedNumber"]
|
||||
user_points += badge["badgePoints"] * badge["badgeEarnedNumber"]
|
||||
|
||||
# Add user points to summary
|
||||
summary["userPoints"] = user_points
|
||||
@@ -295,8 +287,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
# Activity types
|
||||
activity_types = await self.hass.async_add_executor_job(self.api.get_activity_types)
|
||||
if activity_types:
|
||||
_LOGGER.debug("Activity types data fetched: %s",
|
||||
activity_types)
|
||||
_LOGGER.debug("Activity types data fetched: %s", activity_types)
|
||||
else:
|
||||
_LOGGER.debug("No activity types data found")
|
||||
|
||||
@@ -356,32 +347,27 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("No hydration data found")
|
||||
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during update: %s", err.response.text)
|
||||
_LOGGER.error("Authentication error occurred during update: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred during update: %s", err)
|
||||
_LOGGER.error("Too many request error occurred during update: %s", err)
|
||||
return {}
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred during update: %s", err)
|
||||
_LOGGER.error("Connection error occurred during update: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred during update: %s", err.response.text)
|
||||
_LOGGER.error("Authentication error occurred during update: %s", err.response.text)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error occurred during update: %s", err.response.text)
|
||||
"Too many requests error occurred during update: %s", err.response.text
|
||||
)
|
||||
return {}
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred during update: %s", err)
|
||||
_LOGGER.error("Unknown HTTP error occurred during update: %s", err)
|
||||
return False
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception(
|
||||
"Unknown error occurred during update: %s", err)
|
||||
_LOGGER.exception("Unknown error occurred during update: %s", err)
|
||||
return {}
|
||||
|
||||
try:
|
||||
@@ -396,9 +382,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
# Gear stats data like distance, time, etc.
|
||||
tasks: list[Awaitable] = [
|
||||
self.hass.async_add_executor_job(
|
||||
self.api.get_gear_stats, gear_item[Gear.UUID]
|
||||
)
|
||||
self.hass.async_add_executor_job(self.api.get_gear_stats, gear_item[Gear.UUID])
|
||||
for gear_item in gear
|
||||
]
|
||||
gear_stats = await asyncio.gather(*tasks)
|
||||
@@ -417,29 +401,28 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("No gear defaults data found")
|
||||
except GarminConnectAuthenticationError as err:
|
||||
_LOGGER.error(
|
||||
"Authentication error occurred while fetching Gear data: %s", err.response.text)
|
||||
"Authentication error occurred while fetching Gear data: %s", err.response.text
|
||||
)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except GarminConnectTooManyRequestsError as err:
|
||||
_LOGGER.error(
|
||||
"Too many request error occurred while fetching Gear data: %s", err)
|
||||
_LOGGER.error("Too many request error occurred while fetching Gear data: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except GarminConnectConnectionError as err:
|
||||
_LOGGER.error(
|
||||
"Connection error occurred while fetching Gear data: %s", err)
|
||||
_LOGGER.error("Connection error occurred while fetching Gear data: %s", err)
|
||||
raise ConfigEntryNotReady from err
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if err.response.status_code == 401:
|
||||
_LOGGER.error(
|
||||
"Authentication error while fetching Gear data: %s", err.response.text)
|
||||
"Authentication error while fetching Gear data: %s", err.response.text
|
||||
)
|
||||
elif err.response.status_code == 404:
|
||||
_LOGGER.error(
|
||||
"URL not found error while fetching Gear data: %s", err.response.text)
|
||||
_LOGGER.error("URL not found error while fetching Gear data: %s", err.response.text)
|
||||
elif err.response.status_code == 429:
|
||||
_LOGGER.error(
|
||||
"Too many requests error while fetching Gear data: %s", err.response.text)
|
||||
"Too many requests error while fetching Gear data: %s", err.response.text
|
||||
)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Unknown HTTP error occurred while fetching Gear data: %s", err)
|
||||
_LOGGER.error("Unknown HTTP error occurred while fetching Gear data: %s", err)
|
||||
except (KeyError, TypeError, ValueError, ConnectionError) as err:
|
||||
_LOGGER.debug("Error occurred while fetching Gear data: %s", err)
|
||||
|
||||
@@ -454,8 +437,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
sleep_time_seconds = sleep_data["dailySleepDTO"]["sleepTimeSeconds"]
|
||||
if sleep_time_seconds:
|
||||
_LOGGER.debug("Sleep time seconds data: %s",
|
||||
sleep_time_seconds)
|
||||
_LOGGER.debug("Sleep time seconds data: %s", sleep_time_seconds)
|
||||
else:
|
||||
_LOGGER.debug("No sleep time seconds data found")
|
||||
except KeyError:
|
||||
@@ -467,8 +449,7 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
hrv_status = hrv_data["hrvSummary"]
|
||||
_LOGGER.debug("HRV summary status: %s", hrv_status)
|
||||
except KeyError:
|
||||
_LOGGER.debug(
|
||||
"Error occurred while processing HRV summary status data")
|
||||
_LOGGER.debug("Error occurred while processing HRV summary status data")
|
||||
|
||||
# Endurance status
|
||||
try:
|
||||
@@ -542,10 +523,8 @@ def calculate_next_active_alarms(alarms, time_zone):
|
||||
)
|
||||
|
||||
days_to_add = DAY_TO_NUMBER[day] % 7
|
||||
alarm = start_of_week + \
|
||||
timedelta(minutes=alarm_time, days=days_to_add)
|
||||
_LOGGER.debug("Start of week: %s, Alarm: %s",
|
||||
start_of_week, alarm)
|
||||
alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add)
|
||||
_LOGGER.debug("Start of week: %s, Alarm: %s", start_of_week, alarm)
|
||||
|
||||
# If the alarm time is in the past, move it to the next week
|
||||
if alarm < now:
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
"""Config flow for Garmin Connect integration."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
import requests
|
||||
|
||||
from garminconnect import (
|
||||
Garmin,
|
||||
GarminConnectAuthenticationError,
|
||||
GarminConnectConnectionError,
|
||||
GarminConnectTooManyRequestsError,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ID, CONF_TOKEN, CONF_PASSWORD, CONF_USERNAME
|
||||
import voluptuous as vol
|
||||
import garth
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import CONF_MFA, DOMAIN
|
||||
from .const import CONF_MFA, CONF_SENSOR_GROUPS, DOMAIN
|
||||
from .sensor_descriptions import SENSOR_GROUPS, get_default_enabled_groups
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +34,12 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return GarminConnectOptionsFlowHandler(config_entry)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize schemas and internal state for the Garmin Connect configuration flow handler.
|
||||
@@ -66,11 +81,14 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if country == "CN":
|
||||
self._in_china = True
|
||||
|
||||
self._api = Garmin(email=self._username,
|
||||
password=self._password, return_on_mfa=True, is_cn=self._in_china)
|
||||
self._api = Garmin(
|
||||
email=self._username, password=self._password, return_on_mfa=True, is_cn=self._in_china
|
||||
)
|
||||
|
||||
try:
|
||||
self._login_result1, self._login_result2 = await self.hass.async_add_executor_job(self._api.login)
|
||||
self._login_result1, self._login_result2 = await self.hass.async_add_executor_job(
|
||||
self._api.login
|
||||
)
|
||||
|
||||
if self._login_result1 == "needs_mfa": # MFA is required
|
||||
return await self.async_step_mfa()
|
||||
@@ -106,7 +124,9 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
If the MFA code is invalid or an error occurs, prompts the user to re-enter the code. On successful authentication, creates or updates the configuration entry.
|
||||
"""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._api.resume_login, self._login_result2, self._mfa_code)
|
||||
await self.hass.async_add_executor_job(
|
||||
self._api.resume_login, self._login_result2, self._mfa_code
|
||||
)
|
||||
|
||||
except garth.exc.GarthException as err:
|
||||
_LOGGER.error("Error during MFA login: %s", err)
|
||||
@@ -131,62 +151,48 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
existing_entry = await self.async_set_unique_id(self._username)
|
||||
|
||||
if existing_entry:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data=config_data)
|
||||
self.hass.config_entries.async_update_entry(existing_entry, data=config_data)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=cast(str, self._username), data=config_data
|
||||
)
|
||||
return self.async_create_entry(title=cast(str, self._username), data=config_data)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""
|
||||
Handle the initial user step of the configuration flow.
|
||||
Handle the initial user step of the configuration flow.
|
||||
|
||||
If no input is provided, displays a form to collect username and password. If credentials are submitted, stores them and attempts authentication with Garmin Connect.
|
||||
"""
|
||||
If no input is provided, displays a form to collect username and password. If credentials are submitted, stores them and attempts authentication with Garmin Connect.
|
||||
"""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(self.data_schema)
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=vol.Schema(self.data_schema))
|
||||
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
|
||||
return await self._async_garmin_connect_login(step_id="user")
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
async def async_step_mfa(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""
|
||||
Handle the multi-factor authentication (MFA) step in the configuration flow.
|
||||
|
||||
If user input is not provided, displays a form to collect the MFA code. If input is provided, stores the MFA code and proceeds with MFA authentication.
|
||||
"""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema)
|
||||
)
|
||||
return self.async_show_form(step_id="mfa", data_schema=vol.Schema(self.mfa_data_schema))
|
||||
|
||||
self._mfa_code = user_input[CONF_MFA]
|
||||
_LOGGER.debug("MFA code received")
|
||||
|
||||
return await self._async_garmin_connect_mfa_login()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""
|
||||
Start the reauthorization process using existing configuration entry data.
|
||||
|
||||
Extracts the username from the entry data (using CONF_ID if CONF_USERNAME is not available for migrated entries) and advances to the reauthorization confirmation step.
|
||||
"""
|
||||
# For backward compatibility: try CONF_USERNAME first, fall back to CONF_ID
|
||||
self._username = entry_data.get(
|
||||
CONF_USERNAME) or entry_data.get(CONF_ID)
|
||||
self._username = entry_data.get(CONF_USERNAME) or entry_data.get(CONF_ID)
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -213,3 +219,59 @@ class GarminConnectConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
|
||||
return await self._async_garmin_connect_login(step_id="reauth_confirm")
|
||||
|
||||
|
||||
class GarminConnectOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle options flow for Garmin Connect integration."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the sensor group options."""
|
||||
if user_input is not None:
|
||||
# Convert list to set for storage
|
||||
enabled_groups = set(user_input.get(CONF_SENSOR_GROUPS, []))
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={CONF_SENSOR_GROUPS: list(enabled_groups)},
|
||||
)
|
||||
|
||||
# Get currently enabled groups from options, or use defaults for backward compatibility
|
||||
current_options = self.config_entry.options.get(CONF_SENSOR_GROUPS)
|
||||
if current_options is None:
|
||||
# First time setup or upgraded from version without options
|
||||
enabled_groups = get_default_enabled_groups()
|
||||
else:
|
||||
enabled_groups = set(current_options)
|
||||
|
||||
# Build the multi-select schema with descriptions
|
||||
options_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_SENSOR_GROUPS,
|
||||
default=list(enabled_groups),
|
||||
): cv.multi_select(
|
||||
{
|
||||
group_id: f"{group.name} - {group.description}"
|
||||
for group_id, group in SENSOR_GROUPS.items()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=options_schema,
|
||||
description_placeholders={
|
||||
"info": (
|
||||
"Select which sensor groups to enable. "
|
||||
"Individual sensors within enabled groups can still be "
|
||||
"disabled in the entity settings. "
|
||||
"Changes will be applied after reloading the integration."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,12 +4,19 @@ from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime, UnitOfVolume
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfLength,
|
||||
UnitOfMass,
|
||||
UnitOfTime,
|
||||
UnitOfVolume,
|
||||
)
|
||||
|
||||
DOMAIN = "garmin_connect"
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
CONF_MFA = "mfa_code"
|
||||
CONF_SENSOR_GROUPS = "sensor_groups"
|
||||
DAY_TO_NUMBER = {
|
||||
"Mo": 1,
|
||||
"M": 1,
|
||||
|
||||
23
custom_components/garmin_connect/entity.py
Normal file
23
custom_components/garmin_connect/entity.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Base entity for Garmin Connect integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class GarminConnectEntity(CoordinatorEntity):
|
||||
"""Base entity for Garmin Connect."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator, unique_id: str) -> None:
|
||||
"""Initialize Garmin Connect entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
@@ -7,8 +7,6 @@ import logging
|
||||
from numbers import Number
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -24,56 +22,67 @@ from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
CONF_SENSOR_GROUPS,
|
||||
DATA_COORDINATOR,
|
||||
DOMAIN as GARMIN_DOMAIN,
|
||||
GARMIN_ENTITY_LIST,
|
||||
GEAR_ICONS,
|
||||
Gear,
|
||||
ServiceSetting,
|
||||
)
|
||||
from .entity import GarminConnectEntity
|
||||
from .sensor_descriptions import (
|
||||
ALL_SENSOR_DESCRIPTIONS,
|
||||
get_default_enabled_groups,
|
||||
get_sensors_for_groups,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
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 = hass.data[GARMIN_DOMAIN][entry.entry_id][DATA_COORDINATOR]
|
||||
unique_id = entry.data[CONF_ID]
|
||||
|
||||
entities = []
|
||||
for (
|
||||
sensor_type,
|
||||
(name, unit, icon, device_class, state_class, enabled_by_default),
|
||||
) in GARMIN_ENTITY_LIST.items():
|
||||
|
||||
# Get enabled sensor groups from options, or use defaults for backward compatibility
|
||||
enabled_groups_list = entry.options.get(CONF_SENSOR_GROUPS)
|
||||
if enabled_groups_list is None:
|
||||
# Backward compatibility: if no options set, enable all default groups
|
||||
enabled_groups = get_default_enabled_groups()
|
||||
else:
|
||||
# Convert list back to set
|
||||
enabled_groups = set(enabled_groups_list)
|
||||
|
||||
# Get sensor descriptions based on enabled groups
|
||||
sensor_descriptions = get_sensors_for_groups(enabled_groups)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Setting up sensors with enabled groups: %s (%d sensors)",
|
||||
enabled_groups,
|
||||
len(sensor_descriptions),
|
||||
)
|
||||
|
||||
# Add main sensors using entity descriptions
|
||||
for description in sensor_descriptions:
|
||||
_LOGGER.debug(
|
||||
"Registering entity: %s, %s, %s, %s, %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
"Registering entity: %s (%s)",
|
||||
description.key,
|
||||
description.translation_key,
|
||||
)
|
||||
entities.append(
|
||||
GarminConnectSensor(
|
||||
coordinator,
|
||||
unique_id,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
description,
|
||||
)
|
||||
)
|
||||
|
||||
# Add gear sensors
|
||||
if "gear" in coordinator.data:
|
||||
for gear_item in coordinator.data["gear"]:
|
||||
name = gear_item["displayName"]
|
||||
@@ -86,15 +95,10 @@ async def async_setup_entry(
|
||||
enabled_by_default = True
|
||||
|
||||
_LOGGER.debug(
|
||||
"Registering entity: %s, %s, %s, %s, %s, %s, %s, %s",
|
||||
"Registering gear entity: %s, %s, %s",
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
uuid,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_by_default,
|
||||
)
|
||||
entities.append(
|
||||
GarminConnectGearSensor(
|
||||
@@ -159,73 +163,54 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
class GarminConnectSensor(GarminConnectEntity, SensorEntity):
|
||||
"""Representation of a Garmin Connect Sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator,
|
||||
unique_id,
|
||||
sensor_type,
|
||||
name,
|
||||
unit,
|
||||
icon,
|
||||
device_class,
|
||||
state_class,
|
||||
enabled_default: bool = True,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
unique_id: str,
|
||||
description,
|
||||
):
|
||||
"""Initialize a Garmin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._unique_id = unique_id
|
||||
self._type = sensor_type
|
||||
self._device_class = device_class
|
||||
self._state_class = state_class
|
||||
self._enabled_default = enabled_default
|
||||
|
||||
self._attr_name = name
|
||||
self._attr_device_class = self._device_class
|
||||
self._attr_icon = icon
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_unique_id = f"{self._unique_id}_{self._type}"
|
||||
self._attr_state_class = state_class
|
||||
super().__init__(coordinator, unique_id)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""
|
||||
Return the current value of the sensor, applying type-specific formatting and conversions.
|
||||
Return the current value of the sensor.
|
||||
|
||||
For activity and badge sensors, returns the count. For last activity, returns the activity name. HRV status and stress qualifier values are capitalized. Duration and seconds values are converted from seconds to minutes, and mass values from grams to kilograms. For alarms, returns the next active alarm if available. Timestamp values are converted to timezone-aware datetime objects. Returns None if data is unavailable.
|
||||
Uses the entity description's value_fn if provided, otherwise applies
|
||||
type-specific formatting and conversions for backward compatibility.
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
value = self.coordinator.data.get(self._type)
|
||||
# Use custom value function if provided in description
|
||||
if self.entity_description.value_fn:
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
# Fallback to legacy value extraction
|
||||
value = self.coordinator.data.get(self.entity_description.key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if self._type == "lastActivities" or self._type == "badges":
|
||||
value = len(self.coordinator.data[self._type])
|
||||
# Legacy type-specific handling
|
||||
sensor_type = self.entity_description.key
|
||||
|
||||
if self._type == "lastActivity":
|
||||
value = self.coordinator.data[self._type]["activityName"]
|
||||
if sensor_type == "lastActivities" or sensor_type == "badges":
|
||||
value = len(self.coordinator.data[sensor_type])
|
||||
|
||||
elif self._type == "hrvStatus":
|
||||
value = self.coordinator.data[self._type]["status"].capitalize()
|
||||
elif sensor_type == "lastActivity":
|
||||
value = self.coordinator.data[sensor_type]["activityName"]
|
||||
|
||||
elif self._type == "enduranceScore":
|
||||
value = self.coordinator.data[self._type]["overallScore"]
|
||||
elif sensor_type == "enduranceScore":
|
||||
value = self.coordinator.data[sensor_type]["overallScore"]
|
||||
|
||||
elif "Duration" in self._type or "Seconds" in self._type:
|
||||
value = round(value // 60, 2)
|
||||
|
||||
elif "Mass" in self._type or self._type == "weight":
|
||||
value = round(value / 1000, 2)
|
||||
|
||||
elif self._type == "nextAlarm":
|
||||
active_alarms = self.coordinator.data[self._type]
|
||||
elif sensor_type == "nextAlarm":
|
||||
active_alarms = self.coordinator.data[sensor_type]
|
||||
if active_alarms:
|
||||
_LOGGER.debug("Active alarms: %s", active_alarms)
|
||||
_LOGGER.debug("Next alarm: %s", active_alarms[0])
|
||||
@@ -233,14 +218,13 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
else:
|
||||
value = None
|
||||
|
||||
elif self._type == "stressQualifier":
|
||||
value = value.capitalize()
|
||||
|
||||
if self._device_class == SensorDeviceClass.TIMESTAMP:
|
||||
# Handle timestamp device class
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if value:
|
||||
value = datetime.datetime.fromisoformat(value).replace(
|
||||
tzinfo=ZoneInfo(self.coordinator.time_zone)
|
||||
)
|
||||
|
||||
return round(value, 2) if isinstance(value, Number) else value
|
||||
|
||||
@property
|
||||
@@ -248,67 +232,54 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
"""
|
||||
Return additional state attributes for the sensor entity.
|
||||
|
||||
Includes the last sync timestamp and, depending on the sensor type, recent activities (up to 5), badges (up to 10), alarms, or HRV status details (excluding the status string). Returns an empty dictionary if no coordinator data is available.
|
||||
Uses the entity description's attributes_fn if provided, otherwise
|
||||
returns sensor-specific attributes for backward compatibility.
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
# Use custom attributes function if provided in description
|
||||
if self.entity_description.attributes_fn:
|
||||
return self.entity_description.attributes_fn(self.coordinator.data)
|
||||
|
||||
# Base attributes
|
||||
attributes = {
|
||||
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
|
||||
}
|
||||
|
||||
sensor_type = self.entity_description.key
|
||||
|
||||
# Only keep the last 5 activities for performance reasons
|
||||
if self._type == "lastActivities":
|
||||
activities = self.coordinator.data.get(self._type, [])
|
||||
sorted_activities = sorted(
|
||||
activities, key=lambda x: x["activityId"])
|
||||
if sensor_type == "lastActivities":
|
||||
activities = self.coordinator.data.get(sensor_type, [])
|
||||
sorted_activities = sorted(activities, key=lambda x: x["activityId"])
|
||||
attributes["last_activities"] = sorted_activities[-5:]
|
||||
|
||||
if self._type == "lastActivity":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
elif sensor_type == "lastActivity":
|
||||
attributes = {**attributes, **self.coordinator.data[sensor_type]}
|
||||
|
||||
# Only keep the last 10 badges for performance reasons
|
||||
if self._type == "badges":
|
||||
badges = self.coordinator.data.get(self._type, [])
|
||||
elif sensor_type == "badges":
|
||||
badges = self.coordinator.data.get(sensor_type, [])
|
||||
sorted_badges = sorted(badges, key=lambda x: x["badgeEarnedDate"])
|
||||
attributes["badges"] = sorted_badges[-10:]
|
||||
|
||||
if self._type == "nextAlarm":
|
||||
attributes["next_alarms"] = self.coordinator.data[self._type]
|
||||
elif sensor_type == "nextAlarm":
|
||||
attributes["next_alarms"] = self.coordinator.data[sensor_type]
|
||||
|
||||
if self._type == "hrvStatus":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
del attributes["status"]
|
||||
|
||||
if self._type == "enduranceScore":
|
||||
attributes = {**attributes, **self.coordinator.data[self._type]}
|
||||
elif sensor_type == "enduranceScore":
|
||||
attributes = {**attributes, **self.coordinator.data[sensor_type]}
|
||||
del attributes["overallScore"]
|
||||
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return DeviceInfo(
|
||||
identifiers={(GARMIN_DOMAIN, self._unique_id)},
|
||||
name="Garmin Connect",
|
||||
manufacturer="Garmin",
|
||||
model="Garmin Connect",
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if the entity should be enabled when first added to the entity registry."""
|
||||
return self._enabled_default
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data
|
||||
and self._type in self.coordinator.data
|
||||
and self.entity_description.key in self.coordinator.data
|
||||
)
|
||||
|
||||
async def add_body_composition(self, **kwargs):
|
||||
@@ -336,9 +307,7 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Record a weigh in/body composition."""
|
||||
await self.hass.async_add_executor_job(
|
||||
@@ -380,9 +349,7 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Record a blood pressure measurement."""
|
||||
await self.hass.async_add_executor_job(
|
||||
@@ -455,9 +422,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
stats = self._stats()
|
||||
gear_defaults = self._gear_defaults()
|
||||
activity_types = self.coordinator.data["activityTypes"]
|
||||
default_for_activity = self._activity_names_for_gear_defaults(
|
||||
gear_defaults, activity_types
|
||||
)
|
||||
default_for_activity = self._activity_names_for_gear_defaults(gear_defaults, activity_types)
|
||||
|
||||
if not self.coordinator.data or not gear or not stats:
|
||||
return {}
|
||||
@@ -485,9 +450,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
|
||||
"""Get activity names for gear defaults."""
|
||||
activity_type_ids = [d["activityTypePk"] for d in gear_defaults]
|
||||
return [
|
||||
a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids
|
||||
]
|
||||
return [a["typeKey"] for a in activity_types if a["typeId"] in activity_type_ids]
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
@@ -552,9 +515,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
"""Check for login."""
|
||||
if not await self.coordinator.async_login():
|
||||
raise IntegrationError(
|
||||
"Failed to login to Garmin Connect, unable to update"
|
||||
)
|
||||
raise IntegrationError("Failed to login to Garmin Connect, unable to update")
|
||||
|
||||
"""Update Garmin Gear settings."""
|
||||
activity_type_id = next(
|
||||
|
||||
1044
custom_components/garmin_connect/sensor_descriptions.py
Normal file
1044
custom_components/garmin_connect/sensor_descriptions.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,5 +33,16 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configure Sensor Groups",
|
||||
"description": "Select which sensor groups to enable. Individual sensors within enabled groups can still be disabled in the entity settings. Changes will be applied after reloading the integration.",
|
||||
"data": {
|
||||
"sensor_groups": "Enabled Sensor Groups"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,308 @@
|
||||
"unknown": "Unexpected error",
|
||||
"invalid_mfa_code": "Invalid MFA code"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"total_steps": {
|
||||
"name": "Total steps"
|
||||
},
|
||||
"daily_step_goal": {
|
||||
"name": "Daily step goal"
|
||||
},
|
||||
"total_distance": {
|
||||
"name": "Total distance"
|
||||
},
|
||||
"floors_ascended": {
|
||||
"name": "Floors ascended"
|
||||
},
|
||||
"floors_descended": {
|
||||
"name": "Floors descended"
|
||||
},
|
||||
"floors_ascended_goal": {
|
||||
"name": "Floors ascended goal"
|
||||
},
|
||||
"floors_ascended_meters": {
|
||||
"name": "Floors ascended (meters)"
|
||||
},
|
||||
"floors_descended_meters": {
|
||||
"name": "Floors descended (meters)"
|
||||
},
|
||||
"total_calories": {
|
||||
"name": "Total calories"
|
||||
},
|
||||
"active_calories": {
|
||||
"name": "Active calories"
|
||||
},
|
||||
"bmr_calories": {
|
||||
"name": "BMR calories"
|
||||
},
|
||||
"burned_calories": {
|
||||
"name": "Burned calories"
|
||||
},
|
||||
"consumed_calories": {
|
||||
"name": "Consumed calories"
|
||||
},
|
||||
"remaining_calories": {
|
||||
"name": "Remaining calories"
|
||||
},
|
||||
"resting_heart_rate": {
|
||||
"name": "Resting heart rate"
|
||||
},
|
||||
"max_heart_rate": {
|
||||
"name": "Max heart rate"
|
||||
},
|
||||
"min_heart_rate": {
|
||||
"name": "Min heart rate"
|
||||
},
|
||||
"last_7_days_avg_resting_heart_rate": {
|
||||
"name": "Last 7 days avg resting heart rate"
|
||||
},
|
||||
"min_avg_heart_rate": {
|
||||
"name": "Min avg heart rate"
|
||||
},
|
||||
"max_avg_heart_rate": {
|
||||
"name": "Max avg heart rate"
|
||||
},
|
||||
"abnormal_hr_alerts": {
|
||||
"name": "Abnormal HR alerts"
|
||||
},
|
||||
"hrv_status": {
|
||||
"name": "HRV status"
|
||||
},
|
||||
"avg_stress_level": {
|
||||
"name": "Avg stress level"
|
||||
},
|
||||
"max_stress_level": {
|
||||
"name": "Max stress level"
|
||||
},
|
||||
"stress_qualifier": {
|
||||
"name": "Stress qualifier"
|
||||
},
|
||||
"total_stress_duration": {
|
||||
"name": "Total stress duration"
|
||||
},
|
||||
"rest_stress_duration": {
|
||||
"name": "Rest stress duration"
|
||||
},
|
||||
"activity_stress_duration": {
|
||||
"name": "Activity stress duration"
|
||||
},
|
||||
"low_stress_duration": {
|
||||
"name": "Low stress duration"
|
||||
},
|
||||
"medium_stress_duration": {
|
||||
"name": "Medium stress duration"
|
||||
},
|
||||
"high_stress_duration": {
|
||||
"name": "High stress duration"
|
||||
},
|
||||
"uncategorized_stress_duration": {
|
||||
"name": "Uncategorized stress duration"
|
||||
},
|
||||
"stress_duration": {
|
||||
"name": "Stress duration"
|
||||
},
|
||||
"stress_percentage": {
|
||||
"name": "Stress percentage"
|
||||
},
|
||||
"rest_stress_percentage": {
|
||||
"name": "Rest stress percentage"
|
||||
},
|
||||
"activity_stress_percentage": {
|
||||
"name": "Activity stress percentage"
|
||||
},
|
||||
"uncategorized_stress_percentage": {
|
||||
"name": "Uncategorized stress percentage"
|
||||
},
|
||||
"low_stress_percentage": {
|
||||
"name": "Low stress percentage"
|
||||
},
|
||||
"medium_stress_percentage": {
|
||||
"name": "Medium stress percentage"
|
||||
},
|
||||
"high_stress_percentage": {
|
||||
"name": "High stress percentage"
|
||||
},
|
||||
"sleeping_time": {
|
||||
"name": "Sleeping time"
|
||||
},
|
||||
"total_sleep_duration": {
|
||||
"name": "Total sleep duration"
|
||||
},
|
||||
"sleep_duration": {
|
||||
"name": "Sleep duration"
|
||||
},
|
||||
"awake_duration": {
|
||||
"name": "Awake duration"
|
||||
},
|
||||
"sleep_score": {
|
||||
"name": "Sleep score"
|
||||
},
|
||||
"body_battery_most_recent": {
|
||||
"name": "Body battery (most recent)"
|
||||
},
|
||||
"body_battery_highest": {
|
||||
"name": "Body battery (highest)"
|
||||
},
|
||||
"body_battery_lowest": {
|
||||
"name": "Body battery (lowest)"
|
||||
},
|
||||
"body_battery_charged": {
|
||||
"name": "Body battery charged"
|
||||
},
|
||||
"body_battery_drained": {
|
||||
"name": "Body battery drained"
|
||||
},
|
||||
"weight": {
|
||||
"name": "Weight"
|
||||
},
|
||||
"bmi": {
|
||||
"name": "BMI"
|
||||
},
|
||||
"body_fat": {
|
||||
"name": "Body fat"
|
||||
},
|
||||
"body_water": {
|
||||
"name": "Body water"
|
||||
},
|
||||
"bone_mass": {
|
||||
"name": "Bone mass"
|
||||
},
|
||||
"muscle_mass": {
|
||||
"name": "Muscle mass"
|
||||
},
|
||||
"physique_rating": {
|
||||
"name": "Physique rating"
|
||||
},
|
||||
"visceral_fat": {
|
||||
"name": "Visceral fat"
|
||||
},
|
||||
"metabolic_age": {
|
||||
"name": "Metabolic age"
|
||||
},
|
||||
"hydration": {
|
||||
"name": "Hydration"
|
||||
},
|
||||
"hydration_goal": {
|
||||
"name": "Hydration goal"
|
||||
},
|
||||
"hydration_daily_average": {
|
||||
"name": "Hydration daily average"
|
||||
},
|
||||
"hydration_sweat_loss": {
|
||||
"name": "Hydration sweat loss"
|
||||
},
|
||||
"hydration_activity_intake": {
|
||||
"name": "Hydration activity intake"
|
||||
},
|
||||
"active_time": {
|
||||
"name": "Active time"
|
||||
},
|
||||
"highly_active_time": {
|
||||
"name": "Highly active time"
|
||||
},
|
||||
"sedentary_time": {
|
||||
"name": "Sedentary time"
|
||||
},
|
||||
"moderate_intensity": {
|
||||
"name": "Moderate intensity"
|
||||
},
|
||||
"vigorous_intensity": {
|
||||
"name": "Vigorous intensity"
|
||||
},
|
||||
"intensity_goal": {
|
||||
"name": "Intensity goal"
|
||||
},
|
||||
"avg_spo2": {
|
||||
"name": "Avg SpO2"
|
||||
},
|
||||
"lowest_spo2": {
|
||||
"name": "Lowest SpO2"
|
||||
},
|
||||
"latest_spo2": {
|
||||
"name": "Latest SpO2"
|
||||
},
|
||||
"latest_spo2_time": {
|
||||
"name": "Latest SpO2 time"
|
||||
},
|
||||
"highest_respiration": {
|
||||
"name": "Highest respiration"
|
||||
},
|
||||
"lowest_respiration": {
|
||||
"name": "Lowest respiration"
|
||||
},
|
||||
"latest_respiration": {
|
||||
"name": "Latest respiration"
|
||||
},
|
||||
"latest_respiration_time": {
|
||||
"name": "Latest respiration time"
|
||||
},
|
||||
"avg_altitude": {
|
||||
"name": "Avg altitude"
|
||||
},
|
||||
"chronological_age": {
|
||||
"name": "Chronological age"
|
||||
},
|
||||
"fitness_age": {
|
||||
"name": "Fitness age"
|
||||
},
|
||||
"achievable_fitness_age": {
|
||||
"name": "Achievable fitness age"
|
||||
},
|
||||
"previous_fitness_age": {
|
||||
"name": "Previous fitness age"
|
||||
},
|
||||
"endurance_score": {
|
||||
"name": "Endurance score"
|
||||
},
|
||||
"next_alarm": {
|
||||
"name": "Next alarm"
|
||||
},
|
||||
"last_activity": {
|
||||
"name": "Last activity"
|
||||
},
|
||||
"last_activities": {
|
||||
"name": "Last activities"
|
||||
},
|
||||
"badges": {
|
||||
"name": "Badges"
|
||||
},
|
||||
"user_points": {
|
||||
"name": "User points"
|
||||
},
|
||||
"user_level": {
|
||||
"name": "User level"
|
||||
},
|
||||
"wellness_start_time": {
|
||||
"name": "Wellness start time"
|
||||
},
|
||||
"wellness_end_time": {
|
||||
"name": "Wellness end time"
|
||||
},
|
||||
"wellness_description": {
|
||||
"name": "Wellness description"
|
||||
},
|
||||
"wellness_distance": {
|
||||
"name": "Wellness distance"
|
||||
},
|
||||
"wellness_active_calories": {
|
||||
"name": "Wellness active calories"
|
||||
},
|
||||
"wellness_calories": {
|
||||
"name": "Wellness calories"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configure Sensor Groups",
|
||||
"description": "Select which sensor groups to enable. Individual sensors within enabled groups can still be disabled in the entity settings. Changes will be applied after reloading the integration.",
|
||||
"data": {
|
||||
"sensor_groups": "Enabled Sensor Groups"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user