diff --git a/README.md b/README.md index e027da4..da41ea2 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,30 @@ data: notes: Measured with Beurer BC54 ``` +### Create Activity + +Creates an activity in Garmin Connect: + +```yaml +action: garmin_connect.create_activity +data: + activity_name: "Morning Run" + activity_type: running + start_datetime: "2025-01-21T08:30:00" + duration_min: 30 + distance_km: 5.0 +``` + +### Upload Activity + +Uploads an activity file (FIT, GPX, TCX) to Garmin Connect: + +```yaml +action: garmin_connect.upload_activity +data: + file_path: "/config/activities/run.fit" +``` + ### Enable Debug Logging Add the relevant lines below to the `configuration.yaml`: diff --git a/custom_components/garmin_connect/services.py b/custom_components/garmin_connect/services.py index e3e30e6..f993f54 100644 --- a/custom_components/garmin_connect/services.py +++ b/custom_components/garmin_connect/services.py @@ -1,6 +1,9 @@ """Services for Garmin Connect integration.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall @@ -9,11 +12,15 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN +if TYPE_CHECKING: + from .coordinator import GarminConnectDataUpdateCoordinator + _LOGGER = logging.getLogger(__name__) # Service schemas SERVICE_ADD_BODY_COMPOSITION = "add_body_composition" SERVICE_ADD_BLOOD_PRESSURE = "add_blood_pressure" +SERVICE_CREATE_ACTIVITY = "create_activity" ADD_BODY_COMPOSITION_SCHEMA = vol.Schema( { @@ -43,8 +50,26 @@ ADD_BLOOD_PRESSURE_SCHEMA = vol.Schema( } ) +CREATE_ACTIVITY_SCHEMA = vol.Schema( + { + vol.Required("activity_name"): cv.string, + vol.Required("activity_type"): cv.string, + vol.Required("start_datetime"): cv.string, + vol.Required("duration_min"): int, + vol.Optional("distance_km", default=0.0): vol.Coerce(float), + vol.Optional("time_zone"): cv.string, + } +) -def _get_coordinator(hass: HomeAssistant): +SERVICE_UPLOAD_ACTIVITY = "upload_activity" +UPLOAD_ACTIVITY_SCHEMA = vol.Schema( + { + vol.Required("file_path"): cv.string, + } +) + + +def _get_coordinator(hass: HomeAssistant) -> GarminConnectDataUpdateCoordinator: """Get the first available coordinator from config entries.""" entries = hass.config_entries.async_entries(DOMAIN) if not entries: @@ -61,7 +86,7 @@ def _get_coordinator(hass: HomeAssistant): translation_key="integration_not_loaded", ) - return entry.runtime_data + return entry.runtime_data # type: ignore[no-any-return] async def async_setup_services(hass: HomeAssistant) -> None: @@ -147,6 +172,77 @@ async def async_setup_services(hass: HomeAssistant) -> None: translation_placeholders={"error": str(err)}, ) from err + async def handle_create_activity(call: ServiceCall) -> None: + """Handle create_activity service call.""" + coordinator = _get_coordinator(hass) + + activity_name = call.data.get("activity_name") + activity_type = call.data.get("activity_type") + start_datetime = call.data.get("start_datetime") + # API requires milliseconds format: "2023-12-02T10:00:00.000" + if start_datetime and "." not in start_datetime: + start_datetime = f"{start_datetime}.000" + duration_min = call.data.get("duration_min") + distance_km = call.data.get("distance_km", 0.0) + # Default to HA's configured timezone + time_zone = call.data.get("time_zone") or str(hass.config.time_zone) + + if not await coordinator.async_login(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="login_failed", + ) + + try: + await hass.async_add_executor_job( + coordinator.api.create_manual_activity, + start_datetime, + time_zone, + activity_type, + distance_km, + duration_min, + activity_name, + ) + except Exception as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_activity_failed", + translation_placeholders={"error": str(err)}, + ) from err + + async def handle_upload_activity(call: ServiceCall) -> None: + """Handle upload_activity service call.""" + coordinator = _get_coordinator(hass) + + file_path = call.data.get("file_path") + + if not await coordinator.async_login(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="login_failed", + ) + + # Check if file exists + import os + if not os.path.isfile(file_path): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"file_path": file_path}, + ) + + try: + await hass.async_add_executor_job( + coordinator.api.upload_activity, + file_path, + ) + except Exception as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_activity_failed", + translation_placeholders={"error": str(err)}, + ) from err + # Register services hass.services.async_register( DOMAIN, @@ -162,8 +258,24 @@ async def async_setup_services(hass: HomeAssistant) -> None: schema=ADD_BLOOD_PRESSURE_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_ACTIVITY, + handle_create_activity, + schema=CREATE_ACTIVITY_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_UPLOAD_ACTIVITY, + handle_upload_activity, + schema=UPLOAD_ACTIVITY_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) + hass.services.async_remove(DOMAIN, SERVICE_CREATE_ACTIVITY) + hass.services.async_remove(DOMAIN, SERVICE_UPLOAD_ACTIVITY) diff --git a/custom_components/garmin_connect/services.yaml b/custom_components/garmin_connect/services.yaml index 718d8ca..48ad532 100644 --- a/custom_components/garmin_connect/services.yaml +++ b/custom_components/garmin_connect/services.yaml @@ -223,5 +223,81 @@ add_blood_pressure: description: Additional notes for the measurement. required: false example: "Measured with Beurer BC54" + selector: + text: + +create_activity: + name: Create activity + description: Create an activity in Garmin Connect. + fields: + activity_name: + name: Activity name + description: Name of the activity. + required: true + example: "Morning Run" + selector: + text: + activity_type: + name: Activity type + description: Type of activity (e.g., running, cycling, walking). + required: true + example: running + default: running + selector: + select: + options: + - running + - cycling + - walking + - hiking + - swimming + - fitness_equipment + - other + start_datetime: + name: Start date and time + description: When the activity started (ISO format). + required: true + example: "2024-01-15T08:30:00" + selector: + text: + duration_min: + name: Duration + description: Duration of the activity in minutes. + required: true + example: 30 + selector: + number: + min: 1 + max: 1440 + step: 1 + unit_of_measurement: min + distance_km: + name: Distance + description: Distance covered in kilometers (optional). + required: false + example: 5.0 + selector: + number: + min: 0 + max: 1000 + step: 0.1 + unit_of_measurement: km + time_zone: + name: Time zone + description: Time zone for the activity (defaults to HA's configured timezone). + required: false + example: "Europe/Amsterdam" + selector: + text: + +upload_activity: + name: Upload activity + description: Upload an activity file (FIT, GPX, TCX) to Garmin Connect. + fields: + file_path: + name: File path + description: Path to the activity file on the Home Assistant system. + required: true + example: "/config/activities/run.fit" selector: text: \ No newline at end of file diff --git a/custom_components/garmin_connect/strings.json b/custom_components/garmin_connect/strings.json index 034ed0f..d359b7b 100644 --- a/custom_components/garmin_connect/strings.json +++ b/custom_components/garmin_connect/strings.json @@ -52,6 +52,15 @@ }, "integration_not_loaded": { "message": "Garmin Connect integration not fully loaded" + }, + "create_activity_failed": { + "message": "Failed to create activity: {error}" + }, + "upload_activity_failed": { + "message": "Failed to upload activity: {error}" + }, + "file_not_found": { + "message": "File not found: {file_path}" } } } \ No newline at end of file diff --git a/custom_components/garmin_connect/translations/en.json b/custom_components/garmin_connect/translations/en.json index 9c0276d..8c9c90e 100644 --- a/custom_components/garmin_connect/translations/en.json +++ b/custom_components/garmin_connect/translations/en.json @@ -395,5 +395,34 @@ "name": "Device last synced" } } + }, + "exceptions": { + "login_failed": { + "message": "Failed to login to Garmin Connect, unable to update" + }, + "add_body_composition_failed": { + "message": "Failed to add body composition: {error}" + }, + "add_blood_pressure_failed": { + "message": "Failed to add blood pressure: {error}" + }, + "set_active_gear_failed": { + "message": "Failed to set active gear: {error}" + }, + "no_integration_configured": { + "message": "No Garmin Connect integration configured" + }, + "integration_not_loaded": { + "message": "Garmin Connect integration not fully loaded" + }, + "create_activity_failed": { + "message": "Failed to create activity: {error}" + }, + "upload_activity_failed": { + "message": "Failed to upload activity: {error}" + }, + "file_not_found": { + "message": "File not found: {file_path}" + } } } \ No newline at end of file diff --git a/docs/garmin_connect.markdown b/docs/garmin_connect.markdown index c73b6d3..47906d2 100644 --- a/docs/garmin_connect.markdown +++ b/docs/garmin_connect.markdown @@ -154,6 +154,27 @@ Set a gear item as the default for an activity type. | `activity_type` | Yes | Activity type (e.g., running, cycling) | | `setting` | Yes | Setting option (set as default, unset default, set this as default unset others) | +### Create activity + +Create an activity in Garmin Connect. + +| Data attribute | Required | Description | +| ---------------------- | -------- | ----------- | +| `activity_name` | Yes | Name of the activity | +| `activity_type` | Yes | Activity type (e.g., running, cycling, walking) | +| `start_datetime` | Yes | Start time (ISO format) | +| `duration_min` | Yes | Duration in minutes | +| `distance_km` | No | Distance in kilometers | +| `time_zone` | No | Time zone (defaults to HA timezone) | + +### Upload activity + +Upload an activity file (FIT, GPX, TCX) to Garmin Connect. + +| Data attribute | Required | Description | +| ---------------------- | -------- | ----------- | +| `file_path` | Yes | Path to activity file on HA system | + ## Data updates Data is polled from Garmin Connect every 5 minutes. Due to API rate limits, more frequent polling is not recommended. diff --git a/requirements_lint.txt b/requirements_lint.txt index f6a1791..7ddaa40 100644 --- a/requirements_lint.txt +++ b/requirements_lint.txt @@ -1,4 +1,5 @@ codespell==2.4.1 +fit-tool==0.9.13 mypy==1.19.1 pre-commit==4.5.1 pre-commit-hooks==6.0.0