Skip to content

Commit 81f35f8

Browse files
committed
feat: add friends count sensors (Phase 9)
Add social sensors for friend tracking: - sensor.rouvy_friends_count (total friends) - sensor.rouvy_friends_online (currently online) Implementation: - FriendsSummary model for friends.data endpoint - extract_friends_model() parser - async_get_friends() API method - Coordinator fetches friends summary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e8325bc commit 81f35f8

File tree

10 files changed

+235
-6
lines changed

10 files changed

+235
-6
lines changed

custom_components/rouvy/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
Challenge,
2121
ConnectedApp,
2222
Event,
23+
FriendsSummary,
2324
Route,
2425
TrainingZones,
2526
UserProfile,
@@ -32,6 +33,7 @@
3233
extract_challenges_model,
3334
extract_connected_apps_model,
3435
extract_events_model,
36+
extract_friends_model,
3537
extract_routes_model,
3638
extract_training_zones_model,
3739
extract_user_profile_model,
@@ -228,6 +230,11 @@ async def async_get_activity_stats(self, year: int, month: int) -> list[WeeklyAc
228230
)
229231
return extract_activity_stats_model(text)
230232

233+
async def async_get_friends(self) -> FriendsSummary:
234+
"""Fetch friends summary."""
235+
text = await self._request("GET", "friends.data")
236+
return extract_friends_model(text)
237+
231238
async def async_get_challenges(self) -> list[Challenge]:
232239
"""Fetch available challenges."""
233240
text = await self._request("GET", "challenges/status/available.data")

custom_components/rouvy/api_client/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ class CareerStats:
177177
coins: int = 0
178178

179179

180+
@dataclass(frozen=True)
181+
class FriendsSummary:
182+
"""Summary of the user's friends list."""
183+
184+
total_friends: int = 0
185+
online_friends: int = 0
186+
187+
180188
@dataclass(frozen=True)
181189
class RouvyCoordinatorData:
182190
"""Composite data object held by the coordinator.
@@ -195,3 +203,4 @@ class RouvyCoordinatorData:
195203
favorite_routes: list[Route] = field(default_factory=list)
196204
upcoming_events: list[Event] = field(default_factory=list)
197205
career: CareerStats | None = None
206+
friends: FriendsSummary | None = None

custom_components/rouvy/api_client/parser.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
Challenge,
3030
ConnectedApp,
3131
Event,
32+
FriendsSummary,
3233
Route,
3334
TrainingZones,
3435
UserProfile,
@@ -835,3 +836,41 @@ def _pick_float(*keys: str) -> float:
835836
level=_pick_int("level"),
836837
coins=_pick_int("coins"),
837838
)
839+
840+
841+
def extract_friends_model(response_text: str) -> FriendsSummary:
842+
"""Extract a FriendsSummary from a friends.data response.
843+
844+
Searches for known keys that may contain a friends list, counts
845+
the total entries, and counts those with an online/status indicator.
846+
"""
847+
from .models import FriendsSummary
848+
849+
if not response_text or not response_text.strip():
850+
return FriendsSummary()
851+
852+
decoder = TurboStreamDecoder()
853+
decoded = decoder.decode(response_text)
854+
array_data: list[Any] = decoded if isinstance(decoded, list) else []
855+
856+
friends_list: list[Any] | None = None
857+
for key in ("friends", "friendList", "friendsList"):
858+
candidate = _find_key_value(array_data, key)
859+
if isinstance(candidate, list):
860+
friends_list = candidate
861+
break
862+
863+
if not friends_list:
864+
return FriendsSummary()
865+
866+
total = len(friends_list)
867+
online = 0
868+
for item in friends_list:
869+
resolved = _resolve_index(item, decoder.index_map)
870+
if not isinstance(resolved, dict):
871+
continue
872+
status = resolved.get("status") or resolved.get("online")
873+
if status in (True, "online", "active"):
874+
online += 1
875+
876+
return FriendsSummary(total_friends=total, online_friends=online)

custom_components/rouvy/coordinator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ 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 friends summary
113+
friends = None
114+
try:
115+
friends = await client.async_get_friends()
116+
except Exception:
117+
_LOGGER.debug("Failed to fetch friends, continuing without", exc_info=True)
118+
112119
self._consecutive_auth_failures = 0
113120
_LOGGER.debug("Coordinator update successful")
114121
return RouvyCoordinatorData(
@@ -121,6 +128,7 @@ async def _async_update_data(self) -> RouvyCoordinatorData:
121128
favorite_routes=favorite_routes,
122129
upcoming_events=upcoming_events,
123130
career=career,
131+
friends=friends,
124132
)
125133
except AuthenticationError as err:
126134
self._consecutive_auth_failures += 1

custom_components/rouvy/sensor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,19 @@ class RouvySensorDescription(SensorEntityDescription):
304304
else None
305305
),
306306
),
307+
# Friends sensors
308+
RouvySensorDescription(
309+
key="friends_count",
310+
translation_key="friends_count",
311+
state_class=SensorStateClass.MEASUREMENT,
312+
value_fn=lambda d: d.friends.total_friends if d.friends else None,
313+
),
314+
RouvySensorDescription(
315+
key="friends_online",
316+
translation_key="friends_online",
317+
state_class=SensorStateClass.MEASUREMENT,
318+
value_fn=lambda d: d.friends.online_friends if d.friends else None,
319+
),
307320
)
308321

309322

custom_components/rouvy/strings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
"career_level": { "name": "Career Level" },
6161
"total_xp": { "name": "Total XP" },
6262
"total_coins": { "name": "Total Coins" },
63-
"career_total_distance": { "name": "Career Total Distance" }
63+
"career_total_distance": { "name": "Career Total Distance" },
64+
"friends_count": { "name": "Friends" },
65+
"friends_online": { "name": "Online Friends" }
6466
}
6567
}
6668
}

custom_components/rouvy/translations/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
"career_level": { "name": "Career Level" },
6161
"total_xp": { "name": "Total XP" },
6262
"total_coins": { "name": "Total Coins" },
63-
"career_total_distance": { "name": "Career Total Distance" }
63+
"career_total_distance": { "name": "Career Total Distance" },
64+
"friends_count": { "name": "Friends" },
65+
"friends_online": { "name": "Online Friends" }
6466
}
6567
}
6668
}

tests/test_ha_integration.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from custom_components.rouvy.api_client.models import (
1919
ActivitySummary,
2020
CareerStats,
21+
FriendsSummary,
2122
TrainingZones,
2223
UserProfile,
2324
)
@@ -73,6 +74,7 @@ def _mock_client(profile: UserProfile | None = None) -> AsyncMock:
7374
client.async_get_connected_apps = AsyncMock(return_value=[])
7475
client.async_get_activity_summary = AsyncMock(return_value=ActivitySummary())
7576
client.async_get_favorite_routes = AsyncMock(return_value=[])
77+
client.async_get_friends = AsyncMock(return_value=FriendsSummary())
7678
client.async_update_user_settings = AsyncMock()
7779
client.async_get_events = AsyncMock(return_value=[])
7880
client.async_register_event = AsyncMock(return_value=True)
@@ -381,17 +383,17 @@ async def test_zero_weight_shows_unknown(self, hass: HomeAssistant) -> None:
381383
assert state.state == "unknown"
382384

383385
async def test_all_sensors_created(self, hass: HomeAssistant) -> None:
384-
"""Test that all 31 sensor entities are created.
386+
"""Test that all 33 sensor entities are created.
385387
386388
6 profile + 6 weekly + 2 challenges + 2 zones
387-
+ 2 connected apps + 5 activity + 2 routes + 2 events + 4 career.
389+
+ 2 connected apps + 5 activity + 2 routes + 2 events + 4 career + 2 friends.
388390
"""
389391
await self._setup(hass)
390392
sensor_states = [
391393
s for s in hass.states.async_all() if s.entity_id.startswith("sensor.rouvy")
392394
]
393-
assert len(sensor_states) == 31, (
394-
f"Expected 31 sensors, got {len(sensor_states)}: {[s.entity_id for s in sensor_states]}"
395+
assert len(sensor_states) == 33, (
396+
f"Expected 33 sensors, got {len(sensor_states)}: {[s.entity_id for s in sensor_states]}"
395397
)
396398

397399

tests/test_ha_sensor.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Challenge,
1414
ConnectedApp,
1515
Event,
16+
FriendsSummary,
1617
Route,
1718
RouvyCoordinatorData,
1819
TrainingZones,
@@ -528,6 +529,7 @@ def test_empty_returns_zero(self) -> None:
528529
assert desc.value_fn(d) == 0
529530

530531

532+
# ===================================================================
531533
# ===================================================================
532534
# ===================================================================
533535
# Activity summary sensor helpers
@@ -949,3 +951,65 @@ def test_no_career_returns_none(self) -> None:
949951
d = _make_data_with_career(None)
950952
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "career_total_distance")
951953
assert desc.value_fn(d) is None
954+
955+
956+
# ===================================================================
957+
# Friends sensor helpers
958+
# ===================================================================
959+
960+
961+
def _make_data_with_friends(
962+
friends: FriendsSummary | None = None,
963+
) -> RouvyCoordinatorData:
964+
"""Create coordinator data with friends summary."""
965+
return RouvyCoordinatorData(profile=_make_profile(), friends=friends)
966+
967+
968+
class TestFriendsCountSensor:
969+
"""Verify friends_count sensor value extraction."""
970+
971+
def test_returns_total(self) -> None:
972+
d = _make_data_with_friends(FriendsSummary(total_friends=42, online_friends=5))
973+
from custom_components.rouvy.sensor import SENSOR_DESCRIPTIONS
974+
975+
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "friends_count")
976+
assert desc.value_fn(d) == 42
977+
978+
def test_none_friends_returns_none(self) -> None:
979+
d = _make_data_with_friends(None)
980+
from custom_components.rouvy.sensor import SENSOR_DESCRIPTIONS
981+
982+
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "friends_count")
983+
assert desc.value_fn(d) is None
984+
985+
def test_zero_friends(self) -> None:
986+
d = _make_data_with_friends(FriendsSummary())
987+
from custom_components.rouvy.sensor import SENSOR_DESCRIPTIONS
988+
989+
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "friends_count")
990+
assert desc.value_fn(d) == 0
991+
992+
993+
class TestFriendsOnlineSensor:
994+
"""Verify friends_online sensor value extraction."""
995+
996+
def test_returns_online_count(self) -> None:
997+
d = _make_data_with_friends(FriendsSummary(total_friends=42, online_friends=5))
998+
from custom_components.rouvy.sensor import SENSOR_DESCRIPTIONS
999+
1000+
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "friends_online")
1001+
assert desc.value_fn(d) == 5
1002+
1003+
def test_none_friends_returns_none(self) -> None:
1004+
d = _make_data_with_friends(None)
1005+
from custom_components.rouvy.sensor import SENSOR_DESCRIPTIONS
1006+
1007+
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "friends_online")
1008+
assert desc.value_fn(d) is None
1009+
1010+
def test_zero_online(self) -> None:
1011+
d = _make_data_with_friends(FriendsSummary(total_friends=10, online_friends=0))
1012+
from custom_components.rouvy.sensor import SENSOR_DESCRIPTIONS
1013+
1014+
desc = next(s for s in SENSOR_DESCRIPTIONS if s.key == "friends_online")
1015+
assert desc.value_fn(d) == 0

tests/test_parser.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,7 @@ def test_missing_fields_use_defaults(self) -> None:
11211121
assert result[0].segments == []
11221122

11231123

1124+
# ===================================================================
11241125
# ===================================================================
11251126
# extract_routes_model
11261127
# ===================================================================
@@ -1447,3 +1448,85 @@ def test_partial_data_fills_defaults(self) -> None:
14471448
assert result.level == 5
14481449
assert result.coins == 0
14491450
assert result.total_distance_m == 0.0
1451+
1452+
1453+
# ===================================================================
1454+
# extract_friends_model
1455+
# ===================================================================
1456+
1457+
1458+
def _build_friends_response(
1459+
friends: list[dict[str, Any]] | None = None,
1460+
key: str = "friends",
1461+
) -> str:
1462+
"""Build a synthetic turbo-stream response for friends.data."""
1463+
if friends is None:
1464+
friends = [
1465+
{"name": "Alice", "status": "online"},
1466+
{"name": "Bob", "status": "offline"},
1467+
{"name": "Charlie", "online": True},
1468+
]
1469+
return json.dumps([key, friends])
1470+
1471+
1472+
class TestExtractFriends:
1473+
"""Verify extract_friends_model with synthetic data."""
1474+
1475+
def test_counts_total_friends(self) -> None:
1476+
from custom_components.rouvy.api_client.parser import extract_friends_model
1477+
1478+
result = extract_friends_model(_build_friends_response())
1479+
assert result.total_friends == 3
1480+
1481+
def test_counts_online_friends(self) -> None:
1482+
from custom_components.rouvy.api_client.parser import extract_friends_model
1483+
1484+
result = extract_friends_model(_build_friends_response())
1485+
assert result.online_friends == 2
1486+
1487+
def test_empty_list_returns_zero(self) -> None:
1488+
from custom_components.rouvy.api_client.parser import extract_friends_model
1489+
1490+
result = extract_friends_model(_build_friends_response(friends=[]))
1491+
assert result.total_friends == 0
1492+
assert result.online_friends == 0
1493+
1494+
def test_empty_response_returns_default(self) -> None:
1495+
from custom_components.rouvy.api_client.parser import extract_friends_model
1496+
1497+
result = extract_friends_model("")
1498+
assert result.total_friends == 0
1499+
assert result.online_friends == 0
1500+
1501+
def test_no_friends_key_returns_default(self) -> None:
1502+
from custom_components.rouvy.api_client.parser import extract_friends_model
1503+
1504+
result = extract_friends_model(json.dumps(["other_key", []]))
1505+
assert result.total_friends == 0
1506+
assert result.online_friends == 0
1507+
1508+
def test_friend_list_key_variant(self) -> None:
1509+
from custom_components.rouvy.api_client.parser import extract_friends_model
1510+
1511+
friends = [{"name": "X", "status": "online"}]
1512+
result = extract_friends_model(_build_friends_response(friends=friends, key="friendList"))
1513+
assert result.total_friends == 1
1514+
assert result.online_friends == 1
1515+
1516+
def test_active_status_counted_as_online(self) -> None:
1517+
from custom_components.rouvy.api_client.parser import extract_friends_model
1518+
1519+
friends = [{"name": "D", "status": "active"}]
1520+
result = extract_friends_model(_build_friends_response(friends=friends))
1521+
assert result.online_friends == 1
1522+
1523+
def test_all_offline(self) -> None:
1524+
from custom_components.rouvy.api_client.parser import extract_friends_model
1525+
1526+
friends = [
1527+
{"name": "A", "status": "offline"},
1528+
{"name": "B", "status": "away"},
1529+
]
1530+
result = extract_friends_model(_build_friends_response(friends=friends))
1531+
assert result.total_friends == 2
1532+
assert result.online_friends == 0

0 commit comments

Comments
 (0)