Skip to content

Commit 96dc3a9

Browse files
mikejhillCopilot
andcommitted
feat: add achievements and trophies endpoints, disable unavailable sensors
- Add extract_achievements_model() parser for profile/achievements.data endpoint. Counts earned vs total achievements and sums coins/XP from completed achievements across all categories. - Add extract_trophies_model() parser for profile/trophies.data endpoint. Counts total trophies earned. - Add AchievementsSummary and TrophiesSummary models. - Add async_get_achievements() and async_get_trophies() API methods. - Wire achievements and trophies into coordinator update cycle. - Rewire career_achievements sensor to use dedicated achievements endpoint (was speculative via CareerStats; now uses real earned_achievements count). - Rewire career_trophies sensor to use dedicated trophies endpoint. - Disable 5 sensors with no known API endpoint (total_coins, career_total_distance, career_total_elevation, career_total_time, career_total_activities) with TODO(unavailable-endpoint) markers. - Update CLI career command to fetch and display achievements/trophies from their dedicated endpoints. - Add 12 new parser unit tests for achievements and trophies extraction. - Update all affected tests (CLI, HA integration, sensor tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2988c0f commit 96dc3a9

File tree

10 files changed

+395
-176
lines changed

10 files changed

+395
-176
lines changed

custom_components/rouvy/api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from .api_client.errors import AuthenticationError, RouvyApiError
1717
from .api_client.models import (
18+
AchievementsSummary,
1819
ActivitySummary,
1920
CareerStats,
2021
Challenge,
@@ -23,10 +24,12 @@
2324
FriendsSummary,
2425
Route,
2526
TrainingZones,
27+
TrophiesSummary,
2628
UserProfile,
2729
WeeklyActivityStats,
2830
)
2931
from .api_client.parser import (
32+
extract_achievements_model,
3033
extract_activities_model,
3134
extract_activity_stats_model,
3235
extract_career_model,
@@ -36,6 +39,7 @@
3639
extract_friends_model,
3740
extract_routes_model,
3841
extract_training_zones_model,
42+
extract_trophies_model,
3943
extract_user_profile_model,
4044
)
4145

@@ -245,6 +249,16 @@ async def async_get_career(self) -> CareerStats:
245249
text = await self._request("GET", "profile/career.data")
246250
return extract_career_model(text)
247251

252+
async def async_get_achievements(self) -> AchievementsSummary:
253+
"""Fetch achievements summary."""
254+
text = await self._request("GET", "profile/achievements.data")
255+
return extract_achievements_model(text)
256+
257+
async def async_get_trophies(self) -> TrophiesSummary:
258+
"""Fetch trophies summary."""
259+
text = await self._request("GET", "profile/trophies.data")
260+
return extract_trophies_model(text)
261+
248262
async def async_register_challenge(self, slug: str) -> bool:
249263
"""Register for a challenge by slug. Returns True on success."""
250264
import json as _json

custom_components/rouvy/api_client/__main__.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from ..api import RouvyAsyncApiClient
2525
from .models import (
26+
AchievementsSummary,
2627
Activity,
2728
ActivitySummary,
2829
CareerStats,
@@ -32,6 +33,7 @@
3233
FriendsSummary,
3334
Route,
3435
TrainingZones,
36+
TrophiesSummary,
3537
UserProfile,
3638
WeeklyActivityStats,
3739
)
@@ -459,23 +461,29 @@ async def _cmd_apps(client: RouvyAsyncApiClient, args: argparse.Namespace) -> No
459461

460462
async def _cmd_career(client: RouvyAsyncApiClient, args: argparse.Namespace) -> None:
461463
career: CareerStats = await client.async_get_career()
464+
achievements: AchievementsSummary = await client.async_get_achievements()
465+
trophies: TrophiesSummary = await client.async_get_trophies()
462466

463467
if args.json_output:
464-
_json_out(_as_dict(career))
468+
career_dict = _as_dict(career)
469+
assert isinstance(career_dict, dict) # single dataclass, never list
470+
combined = {
471+
**career_dict,
472+
"achievements": _as_dict(achievements),
473+
"trophies": _as_dict(trophies),
474+
}
475+
_json_out(combined)
465476
return
466477

467478
print("=" * 70)
468479
print("CAREER STATS")
469480
print("=" * 70)
470481
print(f" Level : {career.level}")
471482
print(f" Experience : {career.experience_points:,} XP")
472-
print(f" Coins : {career.coins:,}")
473-
print(f" Total Activities : {career.total_activities:,}")
474-
print(f" Total Distance : {career.total_distance_m / 1000.0:,.1f} km")
475-
print(f" Total Elevation : {career.total_elevation_m:,.0f} m")
476-
print(f" Total Time : {_format_time(career.total_time_seconds)}")
477-
print(f" Achievements : {career.total_achievements}")
478-
print(f" Trophies : {career.total_trophies}")
483+
earned = achievements.earned_achievements
484+
total = achievements.total_achievements
485+
print(f" Achievements : {earned}/{total}")
486+
print(f" Trophies : {trophies.total_trophies}")
479487
print("=" * 70)
480488

481489

custom_components/rouvy/api_client/models.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,40 @@ class Event:
164164

165165
@dataclass(frozen=True)
166166
class CareerStats:
167-
"""Career progression stats from the profile/career endpoint."""
167+
"""Career progression stats from the profile/career endpoint.
168168
169+
Only ``experience_points`` and ``level`` are available from the career
170+
endpoint. Lifetime totals (distance, elevation, time, activities) and
171+
coins are **not** exposed by any known Rouvy API endpoint and are left
172+
at their defaults.
173+
"""
174+
175+
experience_points: int = 0
176+
level: int = 0
177+
# TODO(unavailable-endpoint): lifetime totals not exposed by Rouvy API
169178
total_distance_m: float = 0.0
170179
total_elevation_m: float = 0.0
171180
total_time_seconds: int = 0
172181
total_activities: int = 0
182+
# TODO(unavailable-endpoint): total coins not exposed by Rouvy API
183+
coins: int = 0
184+
185+
186+
@dataclass(frozen=True)
187+
class AchievementsSummary:
188+
"""Summary of user achievements from profile/achievements.data."""
189+
173190
total_achievements: int = 0
191+
earned_achievements: int = 0
192+
total_coins_from_achievements: int = 0
193+
total_xp_from_achievements: int = 0
194+
195+
196+
@dataclass(frozen=True)
197+
class TrophiesSummary:
198+
"""Summary of user trophies from profile/trophies.data."""
199+
174200
total_trophies: int = 0
175-
experience_points: int = 0
176-
level: int = 0
177-
coins: int = 0
178201

179202

180203
@dataclass(frozen=True)
@@ -203,4 +226,6 @@ class RouvyCoordinatorData:
203226
favorite_routes: list[Route] = field(default_factory=list)
204227
upcoming_events: list[Event] = field(default_factory=list)
205228
career: CareerStats | None = None
229+
achievements: AchievementsSummary | None = None
230+
trophies: TrophiesSummary | None = None
206231
friends: FriendsSummary | None = None

custom_components/rouvy/api_client/parser.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
if TYPE_CHECKING:
2525
from .models import (
26+
AchievementsSummary,
2627
ActivitySummary,
2728
ActivityTypeStats,
2829
CareerStats,
@@ -32,6 +33,7 @@
3233
FriendsSummary,
3334
Route,
3435
TrainingZones,
36+
TrophiesSummary,
3537
UserProfile,
3638
WeeklyActivityStats,
3739
)
@@ -934,3 +936,78 @@ def extract_friends_model(response_text: str) -> FriendsSummary:
934936
online += 1
935937

936938
return FriendsSummary(total_friends=total, online_friends=online)
939+
940+
941+
def extract_achievements_model(response_text: str) -> AchievementsSummary:
942+
"""Extract an AchievementsSummary from a profile/achievements.data response.
943+
944+
The achievements endpoint returns a dict keyed by category (e.g.
945+
``workoutsAchievements``, ``distanceAchievements``). Each category is a
946+
list of achievement objects, each having a ``goal`` with ``isCompleted``,
947+
plus ``coins`` and ``experience`` reward fields.
948+
"""
949+
from .models import AchievementsSummary
950+
951+
if not response_text or not response_text.strip():
952+
return AchievementsSummary()
953+
954+
decoder = TurboStreamDecoder()
955+
decoded = decoder.decode(response_text)
956+
array_data: list[Any] = decoded if isinstance(decoded, list) else []
957+
958+
achievements = _find_key_value(array_data, "achievements")
959+
if not isinstance(achievements, dict):
960+
return AchievementsSummary()
961+
962+
total = 0
963+
earned = 0
964+
coins = 0
965+
xp = 0
966+
967+
for category_items in achievements.values():
968+
resolved_items = _resolve_index(category_items, decoder.index_map)
969+
if not isinstance(resolved_items, list):
970+
continue
971+
for raw_item in resolved_items:
972+
item = _resolve_index(raw_item, decoder.index_map)
973+
if not isinstance(item, dict):
974+
continue
975+
total += 1
976+
goal = item.get("goal", {})
977+
if isinstance(goal, dict) and goal.get("isCompleted"):
978+
earned += 1
979+
coins += _safe_int(item.get("coins", 0))
980+
xp += _safe_int(item.get("experience", 0))
981+
982+
return AchievementsSummary(
983+
total_achievements=total,
984+
earned_achievements=earned,
985+
total_coins_from_achievements=coins,
986+
total_xp_from_achievements=xp,
987+
)
988+
989+
990+
def extract_trophies_model(response_text: str) -> TrophiesSummary:
991+
"""Extract a TrophiesSummary from a profile/trophies.data response.
992+
993+
The trophies endpoint returns a ``trophies`` key containing a list of
994+
trophy objects (each with routeId, type, value, etc.). We count the total.
995+
"""
996+
from .models import TrophiesSummary
997+
998+
if not response_text or not response_text.strip():
999+
return TrophiesSummary()
1000+
1001+
decoder = TurboStreamDecoder()
1002+
decoded = decoder.decode(response_text)
1003+
array_data: list[Any] = decoded if isinstance(decoded, list) else []
1004+
1005+
trophies = _find_key_value(array_data, "trophies")
1006+
if not isinstance(trophies, list):
1007+
return TrophiesSummary()
1008+
1009+
# Resolve the list; each item is a trophy object
1010+
resolved = _resolve_index(trophies, decoder.index_map)
1011+
count = len(resolved) if isinstance(resolved, list) else len(trophies)
1012+
1013+
return TrophiesSummary(total_trophies=count)

custom_components/rouvy/coordinator.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ async def _async_update_data(self) -> RouvyCoordinatorData:
109109
except Exception:
110110
_LOGGER.debug("Failed to fetch career stats, continuing without", exc_info=True)
111111

112+
# Fetch achievements summary
113+
achievements = None
114+
try:
115+
achievements = await client.async_get_achievements()
116+
except Exception:
117+
_LOGGER.debug("Failed to fetch achievements, continuing without", exc_info=True)
118+
119+
# Fetch trophies summary
120+
trophies = None
121+
try:
122+
trophies = await client.async_get_trophies()
123+
except Exception:
124+
_LOGGER.debug("Failed to fetch trophies, continuing without", exc_info=True)
125+
112126
# Fetch friends summary
113127
friends = None
114128
try:
@@ -128,6 +142,8 @@ async def _async_update_data(self) -> RouvyCoordinatorData:
128142
favorite_routes=favorite_routes,
129143
upcoming_events=upcoming_events,
130144
career=career,
145+
achievements=achievements,
146+
trophies=trophies,
131147
friends=friends,
132148
)
133149
except AuthenticationError as err:

custom_components/rouvy/sensor.py

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -310,60 +310,66 @@ class RouvySensorDescription(SensorEntityDescription):
310310
state_class=SensorStateClass.TOTAL,
311311
value_fn=lambda d: d.career.experience_points if d.career else None,
312312
),
313-
RouvySensorDescription(
314-
key="total_coins",
315-
translation_key="total_coins",
316-
state_class=SensorStateClass.TOTAL,
317-
value_fn=lambda d: d.career.coins if d.career else None,
318-
),
319-
RouvySensorDescription(
320-
key="career_total_distance",
321-
translation_key="career_total_distance",
322-
native_unit_of_measurement=UnitOfLength.KILOMETERS,
323-
device_class=SensorDeviceClass.DISTANCE,
324-
state_class=SensorStateClass.TOTAL,
325-
suggested_display_precision=1,
326-
value_fn=lambda d: (
327-
round(d.career.total_distance_m / 1000, 1)
328-
if d.career and d.career.total_distance_m
329-
else None
330-
),
331-
),
332-
RouvySensorDescription(
333-
key="career_total_elevation",
334-
translation_key="career_total_elevation",
335-
native_unit_of_measurement=UnitOfLength.METERS,
336-
device_class=SensorDeviceClass.DISTANCE,
337-
state_class=SensorStateClass.TOTAL,
338-
suggested_display_precision=0,
339-
value_fn=lambda d: round(d.career.total_elevation_m) if d.career else None,
340-
),
341-
RouvySensorDescription(
342-
key="career_total_time",
343-
translation_key="career_total_time",
344-
native_unit_of_measurement=UnitOfTime.HOURS,
345-
device_class=SensorDeviceClass.DURATION,
346-
state_class=SensorStateClass.TOTAL,
347-
suggested_display_precision=1,
348-
value_fn=lambda d: round(d.career.total_time_seconds / 3600, 1) if d.career else None,
349-
),
350-
RouvySensorDescription(
351-
key="career_total_activities",
352-
translation_key="career_total_activities",
353-
state_class=SensorStateClass.TOTAL,
354-
value_fn=lambda d: d.career.total_activities if d.career else None,
355-
),
313+
# TODO(unavailable-endpoint): total coins not exposed by any known Rouvy API endpoint
314+
# RouvySensorDescription(
315+
# key="total_coins",
316+
# translation_key="total_coins",
317+
# state_class=SensorStateClass.TOTAL,
318+
# value_fn=lambda d: d.career.coins if d.career else None,
319+
# ),
320+
# TODO(unavailable-endpoint): lifetime distance not exposed by any known Rouvy API endpoint
321+
# RouvySensorDescription(
322+
# key="career_total_distance",
323+
# translation_key="career_total_distance",
324+
# native_unit_of_measurement=UnitOfLength.KILOMETERS,
325+
# device_class=SensorDeviceClass.DISTANCE,
326+
# state_class=SensorStateClass.TOTAL,
327+
# suggested_display_precision=1,
328+
# value_fn=lambda d: (
329+
# round(d.career.total_distance_m / 1000, 1)
330+
# if d.career and d.career.total_distance_m
331+
# else None
332+
# ),
333+
# ),
334+
# TODO(unavailable-endpoint): lifetime elevation not exposed by any known Rouvy API endpoint
335+
# RouvySensorDescription(
336+
# key="career_total_elevation",
337+
# translation_key="career_total_elevation",
338+
# native_unit_of_measurement=UnitOfLength.METERS,
339+
# device_class=SensorDeviceClass.DISTANCE,
340+
# state_class=SensorStateClass.TOTAL,
341+
# suggested_display_precision=0,
342+
# value_fn=lambda d: round(d.career.total_elevation_m) if d.career else None,
343+
# ),
344+
# TODO(unavailable-endpoint): lifetime time not exposed by any known Rouvy API endpoint
345+
# RouvySensorDescription(
346+
# key="career_total_time",
347+
# translation_key="career_total_time",
348+
# native_unit_of_measurement=UnitOfTime.HOURS,
349+
# device_class=SensorDeviceClass.DURATION,
350+
# state_class=SensorStateClass.TOTAL,
351+
# suggested_display_precision=1,
352+
# value_fn=lambda d: round(d.career.total_time_seconds / 3600, 1) if d.career else None,
353+
# ),
354+
# TODO(unavailable-endpoint): lifetime activities not exposed by Rouvy API
355+
# RouvySensorDescription(
356+
# key="career_total_activities",
357+
# translation_key="career_total_activities",
358+
# state_class=SensorStateClass.TOTAL,
359+
# value_fn=lambda d: d.career.total_activities if d.career else None,
360+
# ),
361+
# Achievement and trophy sensors (from dedicated endpoints)
356362
RouvySensorDescription(
357363
key="career_achievements",
358364
translation_key="career_achievements",
359365
state_class=SensorStateClass.TOTAL,
360-
value_fn=lambda d: d.career.total_achievements if d.career else None,
366+
value_fn=lambda d: d.achievements.earned_achievements if d.achievements else None,
361367
),
362368
RouvySensorDescription(
363369
key="career_trophies",
364370
translation_key="career_trophies",
365371
state_class=SensorStateClass.TOTAL,
366-
value_fn=lambda d: d.career.total_trophies if d.career else None,
372+
value_fn=lambda d: d.trophies.total_trophies if d.trophies else None,
367373
),
368374
# Friends sensors
369375
RouvySensorDescription(

0 commit comments

Comments
 (0)