Skip to content

Commit eb99b2f

Browse files
committed
Added garmin connect integration
1 parent d3aef63 commit eb99b2f

14 files changed

Lines changed: 1920 additions & 21 deletions

File tree

.github/copilot-instructions.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Project Overview
44

5-
**Yume** (夢 - "dream" in Japanese) is an AI-powered personal assistant that integrates Matrix chat, Home Assistant, calendar events, public transport, and shopping list management. It features advanced memory management, intelligent scheduling, location-based triggers, and an AI agent architecture powered by langchain4j.
5+
**Yume** (夢 - "dream" in Japanese) is an AI-powered personal assistant that integrates Matrix chat, Home Assistant, calendar events, public transport, sports activities (Strava), health data (Garmin Connect), and shopping list management. It features advanced memory management, intelligent scheduling, location-based triggers, e-ink display support, and an AI agent architecture powered by langchain4j.
66

77
**Project Type**: Full-stack application (Spring Boot backend + Vue.js frontend)
88
**Size**: Medium (~3K lines Kotlin, ~500 lines Vue.js)
@@ -132,23 +132,24 @@ docker run -d --name yume -p 8079:8079 --env-file .env yume
132132
- See lines 1-101 for complete list of required settings
133133

134134
**Package Structure**:
135-
- `agent/` - AI agents (RequestRouterAgent, GenericChatAgent, MemoryManagerAgent, SchedulerAgent, DayPlanAgent, EfaAgent, KitchenOwlAgent, ConversationSummarizerAgent, MemorySummarizerAgent, EInkDisplayAgent)
136-
- `service/` - Business logic (matrix/, memory/, scheduler/, dayplan/, conversation/, calendar/, weather/, efa/, kitchenowl/, provider/, interaction/, location/, router/, eink/)
137-
- `tool/` - langchain4j tools (MemoryManagerTools, DayPlanTools, EfaTools, KitchenOwlTools, KitchenOwlReadTools)
135+
- `agent/` - AI agents (RequestRouterAgent, GenericChatAgent, MemoryManagerAgent, SchedulerAgent, DayPlanAgent, EfaAgent, KitchenOwlAgent, ConversationSummarizerAgent, MemorySummarizerAgent, EInkDisplayAgent, SportsActivityAgent)
136+
- `service/` - Business logic (matrix/, memory/, scheduler/, dayplan/, conversation/, calendar/, weather/, efa/, kitchenowl/, provider/, interaction/, location/, router/, eink/, strava/, garminconnect/)
137+
- `tool/` - langchain4j tools (MemoryManagerTools, DayPlanTools, EfaTools, KitchenOwlTools, KitchenOwlReadTools, StravaActivityTools)
138138
- `component/` - Data models (conversation/, calendar/, weather/)
139-
- `controller/` - REST endpoints (webhook handlers)
140-
- `repository/` - MongoDB repositories (memory/)
139+
- `controller/` - REST endpoints (webhook handlers, EInkDisplayController, StravaWebhookController)
140+
- `repository/` - MongoDB repositories (memory/, strava/)
141141
- `configuration/` - Spring configuration classes
142142
- `converter/` - Data converters
143-
- `client/` - External API clients
143+
- `client/` - External API clients (StravaClient)
144144
- `utils/` - Utility classes (timezone handling)
145145

146146
**Key Dependencies** (from `build.gradle.kts`):
147147
- Spring Boot 3.5.9 (web, data-mongodb, oauth2-resource-server, cache)
148148
- Kotlin 2.2.21 (with serialization)
149149
- langchain4j 1.10.0-beta18 (OpenAI, pgvector, Kotlin extensions)
150+
- Model Context Protocol (MCP) SDK (Garmin Connect integration)
150151
- Trixnity 4.22.7 (Matrix client)
151-
- Ktor 3.3.3 (HTTP client for Matrix)
152+
- Ktor 3.3.3 (HTTP client for Matrix and Strava)
152153
- caldav4j 1.0.5 (calendar integration)
153154
- Caffeine 3.2.3 (caching)
154155
- MockK 1.14.7 (testing)
@@ -224,6 +225,9 @@ docker run -d --name yume -p 8079:8079 --env-file .env yume
224225
- OpenWeatherMap (API key)
225226
- EFA public transport (API URL)
226227
- Location coordinates (home latitude/longitude)
228+
- **Strava Integration** (`yume.strava.client-id`, `yume.strava.client-secret`, `yume.strava.webhook-verify-token`, `yume.strava.webhook-url`)
229+
- **Garmin Connect** (`yume.garmin-connect.mcp-server-url` - MCP server must be running separately)
230+
- **E-Ink Display** (configured via resource provider - no additional config needed)
227231

228232
## Known Issues & Workarounds
229233

README.md

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
- 📍 **Geofence Events**: Location-based triggers with distance context from proximity sensors
1616
- 🚌 **Public Transport Departures**: Real-time transit information via EFA (Elektronisches Fahrplanauskun ftssystem) API with dynamic station lookup and line/direction filtering
1717
- 🛒 **KitchenOwl Integration**: Manage shopping lists and recipes with intelligent duplicate handling
18+
- 🏃 **Strava Integration**: Automatic cycling activity logging and analysis with webhook support
19+
- 💪 **Garmin Health Metrics**: Daily health insights including training load, sleep quality, and fitness status via Garmin Connect
20+
- 📱 **E-Ink Display Support**: Summarized daily briefings rendered for e-paper displays with optimal readability
1821
- 🧠 **Advanced Memory System**: Persistent storage with preferences, observations, and reminders
1922
- 📅 **AI-Powered Day Planner**: Automatic daily planning based on calendar and memories with high-confidence updates
2023
-**Intelligent AI Scheduler**: Context-aware scheduling with deferred execution and adaptive re-evaluation
21-
- 📊 **Vue.js Dashboard**: Real-time monitoring of memories, schedules, plans, and interactions
24+
- 📊 **Vue.js Dashboard**: Real-time monitoring of memories, schedules, plans, and interactions with Strava connection management
2225
- 🔐 **OpenID Connect Authentication**: Secure access to web interface
2326

2427
## Architecture
@@ -34,6 +37,9 @@ Yume is built with a modular architecture consisting of several key components:
3437
- **Home Assistant Service** (`service/provider/`): Integration with Home Assistant API
3538
- **Calendar & Weather Services** (`service/calendar/`, `service/weather/`): External service integrations
3639
- **Conversation Service** (`service/conversation/`): Message history and conversation management
40+
- **Strava Integration Service** (`service/strava/`): OAuth 2.0 authentication and webhook handling for Strava cycling activities
41+
- **Garmin Connect Service** (`service/garminconnect/`): Health metrics fetcher via Model Context Protocol (MCP)
42+
- **E-Ink Display Service** (`service/eink/`): Generates optimized content for e-paper displays
3743

3844
### AI Agents (langchain4j-powered)
3945

@@ -45,6 +51,8 @@ Yume is built with a modular architecture consisting of several key components:
4551
- **SchedulerAgent** (`agent/SchedulerAgent.kt`): Intelligent scheduling with deferred execution, automatic re-evaluation, and dual-approach timing optimization (deterministic + AI-powered). Receives execution summaries from scheduled/geofence events for improved future scheduling decisions.
4652
- **EfaAgent** (`agent/EfaAgent.kt`): Specialized agent for querying public transport departures. Parses natural language queries to extract station names, line numbers, and destination directions.
4753
- **KitchenOwlAgent** (`agent/KitchenOwlAgent.kt`): Manages shopping lists and recipes with autonomous decision-making. Intelligently handles duplicate items by checking the list before adding.
54+
- **SportsActivityAgent** (`agent/SportsActivityAgent.kt`): Analyzes cycling activities from Strava, tracks performance metrics, and provides personalized fitness insights. Triggered by Strava webhooks and scheduled events.
55+
- **EInkDisplayAgent** (`agent/EInkDisplayAgent.kt`): Generates concise, visually optimized summaries for e-ink display devices with support for Tailwind CSS styling and responsive layouts.
4856

4957
Event-triggered agent methods (`handleScheduledEvent`, `handleGeofenceEvent`) re-evaluate current context before acting, ensuring actions remain relevant and avoiding unnecessary messages.
5058

@@ -65,6 +73,7 @@ langchain4j-powered tools for AI agents:
6573
- **Home Assistant Tools**: Smart home control and sensor data
6674
- **EFA Tools**: Public transport departure queries with optional line and direction filtering
6775
- **KitchenOwl Tools**: Shopping list management and recipe access with batch operations
76+
- **Strava Activity Tools**: Fetch recent cycling activities and performance metrics
6877

6978
### Data Management
7079

@@ -226,6 +235,87 @@ rest_command:
226235
227236
The API validates that `eventType` is either "enter" or "leave" and returns a response indicating success or failure along with any AI-generated message.
228237

238+
#### Strava Integration
239+
240+
Yume can automatically fetch and analyze your Strava cycling activities through webhook integration:
241+
242+
**Setup:**
243+
244+
1. Configure Strava OAuth credentials in `application.properties`:
245+
```properties
246+
yume.strava.enabled=true
247+
yume.strava.client-id=YOUR_CLIENT_ID
248+
yume.strava.client-secret=YOUR_CLIENT_SECRET
249+
yume.strava.webhook-verify-token=YOUR_VERIFY_TOKEN
250+
yume.strava.webhook-url=http://your-yume-server:8079/api/strava/webhook
251+
yume.strava.oauth-redirect-url=http://localhost:3000/api/strava/oauth/callback
252+
```
253+
254+
2. Connect your Strava account via the web dashboard (Preferences → Strava Integration)
255+
256+
3. Yume will automatically register webhooks with Strava for activity creation events
257+
258+
**Features:**
259+
- Automatic cycling activity logging and analysis
260+
- Activity performance metrics (distance, duration, elevation, power, heart rate)
261+
- AI-powered fitness insights and personalized feedback
262+
- Strava connection management in the web dashboard
263+
- Webhook endpoint: `POST /api/strava/webhook`
264+
265+
#### Garmin Connect Integration
266+
267+
Yume fetches daily health metrics from Garmin Connect via Model Context Protocol (MCP):
268+
269+
**Setup:**
270+
271+
1. Configure Garmin MCP server URL in `application.properties`:
272+
```properties
273+
yume.garmin-connect.mcp-server-url=http://localhost:60380
274+
```
275+
276+
2. Ensure the Garmin Connect MCP server is running and authenticated
277+
278+
**Health Metrics Provided:**
279+
- **Training Balance**: Daily training load feedback and balance status
280+
- **Training Status**: Current training condition and acute training load
281+
- **Sleep Quality**: Sleep score, duration, stages (deep/light/REM), and nap data with timestamps
282+
- **Daily Activity**: Step count, intensity minutes, and body battery measurements
283+
- **Heart Rate Variability**: HRV status for recovery assessment
284+
285+
These metrics are automatically populated into the user context for memory management and scheduling decisions.
286+
287+
#### E-Ink Display Endpoint
288+
289+
Yume generates optimized summaries for e-ink display devices:
290+
291+
**Endpoint:** `GET /api/e-ink-display/content` (produces `text/plain`)
292+
293+
**Response Format:** Plain HTML with Tailwind CSS styling, optimized for small, low-resolution displays
294+
295+
**Features:**
296+
- Automatic content compilation from multiple sources:
297+
- Current date/time
298+
- User preferences and language
299+
- Weather forecast
300+
- Today's and tomorrow's day plan
301+
- User observations and preferences summary
302+
- AI-powered text generation with formatting rules:
303+
- Maximum 5-7 lines of text for readability from 1 meter distance
304+
- black & white only (no colors or shades)
305+
- Natural language date/time formatting
306+
- Concise, high-priority information only
307+
- Long-term relevance (no real-time updates)
308+
309+
**Integration Example** (for e-ink display device):
310+
311+
```bash
312+
# Fetch display content periodically (e.g., update once or twice daily)
313+
curl -H "Authorization: Bearer YOUR_TOKEN" \
314+
http://localhost:8079/api/e-ink-display/content
315+
```
316+
317+
The response is ready-to-render HTML that your e-ink device can display immediately.
318+
229319
### Vue.js Dashboard
230320

231321
Access at `http://localhost:8079` to monitor:

yume-spring/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ val calDav4jVersion = "1.0.5"
3131
val chromaClientVersion = "1.1.0"
3232
val caffeineCacheVersion = "3.2.3"
3333
val mockkVersion = "1.14.7"
34+
val mcpVersion = "0.4.0"
3435

3536
dependencies {
3637
implementation("org.springframework.boot:spring-boot-starter-web")
@@ -61,7 +62,6 @@ dependencies {
6162
// Kotlin logging
6263
implementation("io.github.oshai:kotlin-logging-jvm:$kotlinLoggingVersion")
6364

64-
6565
// Spring data
6666
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
6767

@@ -72,6 +72,9 @@ dependencies {
7272
implementation("org.springframework.boot:spring-boot-starter-cache")
7373
implementation("com.github.ben-manes.caffeine:caffeine:$caffeineCacheVersion")
7474

75+
// MCP Client
76+
implementation("io.modelcontextprotocol:kotlin-sdk:${mcpVersion}")
77+
7578
// Observability (Spring Boot 4)
7679
//implementation("org.springframework.boot:spring-boot-starter-actuator")
7780
//implementation("org.springframework.boot:spring-boot-starter-opentelemetry")

yume-spring/src/main/kotlin/eu/sendzik/yume/agent/model/YumeChatResource.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ enum class YumeChatResource {
44
WEATHER_FORECAST,
55
DAY_PLAN_TODAY,
66
DAY_PLAN_TOMORROW,
7+
USER_HEALTH_SNAPSHOT,
78
}

yume-spring/src/main/kotlin/eu/sendzik/yume/configuration/CacheConfiguration.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,21 @@ import java.util.concurrent.TimeUnit
1313
class CacheConfiguration {
1414
@Bean
1515
fun cacheManager(): CacheManager {
16-
return CaffeineCacheManager().apply {
17-
setCaffeine(
18-
Caffeine.newBuilder().expireAfterWrite(30, TimeUnit.SECONDS)
19-
)
20-
}
16+
val cacheManager = CaffeineCacheManager()
17+
18+
// Register garmin_snapshot cache with 10-minute TTL
19+
cacheManager.registerCustomCache(
20+
"garmin_snapshot",
21+
Caffeine.newBuilder()
22+
.expireAfterWrite(10, TimeUnit.MINUTES)
23+
.build()
24+
)
25+
26+
// Set default TTL for other caches
27+
cacheManager.setCaffeine(
28+
Caffeine.newBuilder().expireAfterWrite(30, TimeUnit.SECONDS)
29+
)
30+
31+
return cacheManager
2132
}
2233
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package eu.sendzik.yume.service.garminconnect
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import eu.sendzik.yume.service.garminconnect.model.GarminHealthStatus
5+
import io.ktor.client.HttpClient
6+
import io.ktor.client.plugins.sse.SSE
7+
import io.modelcontextprotocol.kotlin.sdk.Implementation
8+
import io.modelcontextprotocol.kotlin.sdk.TextContent
9+
import io.modelcontextprotocol.kotlin.sdk.client.Client
10+
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
11+
import kotlinx.coroutines.runBlocking
12+
import org.springframework.beans.factory.annotation.Value
13+
import org.springframework.cache.annotation.Cacheable
14+
import org.springframework.stereotype.Service
15+
import java.time.LocalDate
16+
17+
@Service
18+
class GarminConnectDataFetcherService(
19+
@param:Value("\${yume.garmin-connect.mcp-server-url}")
20+
private val mcpServerUrl: String,
21+
private val jsonMapper: ObjectMapper,
22+
) {
23+
24+
@Cacheable("garmin_snapshot", sync = true)
25+
fun getSnapshot(): Result<GarminHealthStatus?> = runBlocking {
26+
runCatching {
27+
fetchSnapshotData()?.let { content ->
28+
@Suppress("UNCHECKED_CAST")
29+
val contentText: Map<String, Any> = jsonMapper.readValue(content, Map::class.java) as Map<String, Any>
30+
GarminHealthStatus.fromApi(contentText)
31+
}
32+
}
33+
}
34+
35+
private suspend fun fetchSnapshotData(): String? {
36+
val httpClient = HttpClient { install(SSE) }
37+
val transport = SseClientTransport(httpClient, mcpServerUrl)
38+
val client = Client(clientInfo = Implementation("GarminConnectService", "1.0"))
39+
40+
try {
41+
client.connect(transport)
42+
val today = LocalDate.now()
43+
val result = client.callTool("snapshot", mapOf(
44+
"from_date" to today.toString(),
45+
"to_date" to today.toString(),
46+
))
47+
val textContent = result?.content?.firstOrNull() as? TextContent
48+
return textContent?.text
49+
} finally {
50+
client.close()
51+
httpClient.close()
52+
}
53+
}
54+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package eu.sendzik.yume.service.garminconnect
2+
3+
import eu.sendzik.yume.service.garminconnect.model.GarminHealthStatus
4+
import jakarta.annotation.PostConstruct
5+
import org.springframework.stereotype.Service
6+
import java.time.ZoneId
7+
import java.time.format.DateTimeFormatter
8+
9+
@Service
10+
class GarminConnectService(
11+
private val garminConnectDataFetcherService: GarminConnectDataFetcherService,
12+
) {
13+
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
14+
15+
fun getFormattedHealthSnapshot(): Result<String> {
16+
return garminConnectDataFetcherService.getSnapshot().map { healthStatus ->
17+
if (healthStatus != null) {
18+
formatHealthStatus(healthStatus)
19+
} else {
20+
"No health data available"
21+
}
22+
}
23+
}
24+
25+
private fun formatHealthStatus(healthStatus: GarminHealthStatus): String = buildString {
26+
appendLine("=== Training Balance ===")
27+
appendLine("Status: ${healthStatus.trainingBalance.trainingBalanceFeedback}")
28+
appendLine()
29+
30+
appendLine("=== Training Status ===")
31+
appendLine("Status: ${healthStatus.trainingStatus.trainingStatusFeedback}")
32+
appendLine("Acute Training Load: ${healthStatus.trainingStatus.acuteTrainingLoad}")
33+
appendLine()
34+
35+
appendLine("=== Sleep Summary ===")
36+
appendLine("Overall Sleep Score: ${healthStatus.sleepStatus.overallSleepScore}/100")
37+
38+
if (healthStatus.sleepStatus.sleepStartTimestampGMT != null && healthStatus.sleepStatus.sleepEndTimestampGMT != null) {
39+
val startTime = healthStatus.sleepStatus.sleepStartTimestampGMT
40+
.atZone(ZoneId.systemDefault())
41+
.format(dateTimeFormatter)
42+
val endTime = healthStatus.sleepStatus.sleepEndTimestampGMT
43+
.atZone(ZoneId.systemDefault())
44+
.format(dateTimeFormatter)
45+
appendLine("Sleep Period: $startTime to $endTime")
46+
}
47+
48+
appendLine("Total Sleep Duration: ${healthStatus.sleepStatus.sleepScores.sleepTimeMinutes} minutes")
49+
appendLine("Sleep Duration Quality: ${healthStatus.sleepStatus.sleepScores.totalDuration}")
50+
appendLine("Deep Sleep: ${healthStatus.sleepStatus.sleepScores.deepPercentage}")
51+
appendLine("Light Sleep: ${healthStatus.sleepStatus.sleepScores.lightPercentage}")
52+
appendLine("REM Sleep: ${healthStatus.sleepStatus.sleepScores.remPercentage}")
53+
appendLine("Awake Count: ${healthStatus.sleepStatus.sleepScores.awakeCount}")
54+
appendLine("Sleep Stress: ${healthStatus.sleepStatus.sleepScores.stress}")
55+
appendLine("Sleep Restlessness: ${healthStatus.sleepStatus.sleepScores.restlessness}")
56+
57+
if (healthStatus.sleepStatus.napTimeMinutes > 0) {
58+
appendLine()
59+
appendLine("Total Nap Time: ${healthStatus.sleepStatus.napTimeMinutes} minutes")
60+
if (healthStatus.sleepStatus.naps.isNotEmpty()) {
61+
appendLine("Naps:")
62+
healthStatus.sleepStatus.naps.forEachIndexed { index, nap ->
63+
val napStart = nap.startTimestamp.format(dateTimeFormatter)
64+
val napEnd = nap.endTimestamp.format(dateTimeFormatter)
65+
appendLine(" ${index + 1}. $napStart to $napEnd (${nap.durationMinutes} minutes) - ${nap.feedback}")
66+
}
67+
}
68+
}
69+
appendLine()
70+
71+
appendLine("=== Daily Activity Summary ===")
72+
appendLine("Total Steps: ${healthStatus.dailySummary.totalSteps}")
73+
appendLine("Moderate Intensity Minutes: ${healthStatus.dailySummary.moderateIntensityMinutes}")
74+
appendLine("Vigorous Intensity Minutes: ${healthStatus.dailySummary.vigorousIntensityMinutes}")
75+
appendLine("Body Battery (Most Recent): ${healthStatus.dailySummary.bodyBatteryMostRecentValue}/100")
76+
appendLine()
77+
78+
appendLine("=== Heart Rate Variability ===")
79+
appendLine("HRV Status: ${healthStatus.heartRateVariability.heartRateVariabilityStatus}")
80+
}
81+
}
82+

0 commit comments

Comments
 (0)