Skip to content

Commit 6a5fe51

Browse files
mikejhillCopilot
andcommitted
feat: add 11 new services, query endpoints, and integration test suite
New update services: - rouvy.update_profile (userName, firstName, team, accountPrivacy) - rouvy.update_units (METRIC/IMPERIAL) - rouvy.update_timezone (IANA timezone via resources/timezone.data) - rouvy.update_ftp (source + value via user-settings/zones.data) - rouvy.update_zones (power/HR zone boundaries) New query services (SupportsResponse): - rouvy.get_profile, get_events, get_challenges, get_routes, get_activities, get_career New API methods: - async_update_timezone, async_update_ftp, async_update_zones Integration test suite (tests/integration/): - 11 read tests across all API endpoints - 8 write tests with save/restore pattern - Marked with @pytest.mark.integration, excluded from default runs - GitHub Actions workflow (manual dispatch, rouvy-test environment) 468 unit tests passing, 20 integration tests deselected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 163f89f commit 6a5fe51

File tree

12 files changed

+914
-54
lines changed

12 files changed

+914
-54
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Integration Tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
test_filter:
7+
description: "Pytest filter expression (e.g., 'TestReadProfile' or 'test_update_weight')"
8+
required: false
9+
default: ""
10+
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: integration-tests
16+
cancel-in-progress: true
17+
18+
jobs:
19+
integration:
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 15
22+
environment: rouvy-test
23+
steps:
24+
- uses: actions/checkout@v6
25+
- uses: astral-sh/setup-uv@v7
26+
- run: uv sync --group dev
27+
28+
- name: Run integration tests
29+
env:
30+
ROUVY_TEST_EMAIL: ${{ secrets.ROUVY_TEST_EMAIL }}
31+
ROUVY_TEST_PASSWORD: ${{ secrets.ROUVY_TEST_PASSWORD }}
32+
run: >-
33+
uv run pytest tests/integration/ -m integration
34+
--override-ini='addopts='
35+
-v --tb=short
36+
${{ inputs.test_filter && format('-k "{0}"', inputs.test_filter) || '' }}

README.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
A custom [Home Assistant](https://www.home-assistant.io/) integration for
1111
the [Rouvy](https://rouvy.com/) indoor cycling platform, installable via
12-
[HACS](https://hacs.xyz/). Exposes 41 sensors and 6 services covering your
12+
[HACS](https://hacs.xyz/). Exposes 41 sensors and 17 services covering your
1313
Rouvy profile, activity history, training zones, challenges, routes, events,
1414
career progress, and social data.
1515

@@ -126,15 +126,41 @@ Current-week ride totals, refreshed each update cycle.
126126

127127
## Services
128128

129+
### Update Services
130+
131+
| Service | Parameters | Description |
132+
| --- | --- | --- |
133+
| `rouvy.update_weight` | `weight` (kg) | Update body weight |
134+
| `rouvy.update_height` | `height` (cm) | Update height |
135+
| `rouvy.update_units` | `units` (METRIC/IMPERIAL) | Switch unit system |
136+
| `rouvy.update_profile` | `userName`, `firstName`, `team`, `accountPrivacy` | Update profile fields |
137+
| `rouvy.update_timezone` | `timezone` (IANA) | Update timezone |
138+
| `rouvy.update_ftp` | `ftp_source` (MANUAL/ESTIMATED), `value` (W) | Update FTP source and value |
139+
| `rouvy.update_zones` | `zone_type` (power/heartRate), `zones` (list) | Update zone boundaries |
140+
| `rouvy.update_settings` | `settings` (object) | Update arbitrary settings |
141+
142+
### Action Services
143+
129144
| Service | Parameters | Description |
130145
| --- | --- | --- |
131-
| `rouvy.update_weight` | `weight` (kg) | Update body weight in Rouvy |
132-
| `rouvy.update_height` | `height` (cm) | Update height in Rouvy |
133-
| `rouvy.update_settings` | `settings` (object) | Update arbitrary profile settings |
134146
| `rouvy.register_challenge` | `slug` | Register for a challenge |
135147
| `rouvy.register_event` | `event_id` (UUID) | Register for an event |
136148
| `rouvy.unregister_event` | `event_id` (UUID) | Unregister from an event |
137149

150+
### Query Services
151+
152+
These services return data and can be used in automations via
153+
`response_variable`.
154+
155+
| Service | Returns | Description |
156+
| --- | --- | --- |
157+
| `rouvy.get_profile` | `{profile: {...}}` | Full user profile |
158+
| `rouvy.get_events` | `{events: [...]}` | Upcoming events |
159+
| `rouvy.get_challenges` | `{challenges: [...]}` | Available challenges |
160+
| `rouvy.get_routes` | `{routes: [...]}` | Favorite routes |
161+
| `rouvy.get_activities` | `{activities: [...]}` | Recent activities |
162+
| `rouvy.get_career` | `{career: {...}}` | Career progression stats |
163+
138164
## Logging
139165

140166
Add the following to your Home Assistant `configuration.yaml` to enable
@@ -184,11 +210,32 @@ uv run rouvy-api raw user-settings/zones.data # Raw decoded response
184210

185211
```bash
186212
uv sync # Install all dependencies
187-
uv run pytest -q # Run tests (457 tests)
213+
uv run pytest -q # Run tests (468 unit tests)
188214
uv run ruff check . # Lint
189215
uv run ruff format . # Format
190216
```
191217

218+
### Integration Tests
219+
220+
> **⚠️ Warning:** Integration tests run against the **real** Rouvy API and
221+
> modify account settings. Use a **dedicated test account** only — never a
222+
> real user account. Settings are restored on a best-effort basis.
223+
224+
**Local:**
225+
226+
```bash
227+
export ROUVY_TEST_EMAIL="test@example.com"
228+
export ROUVY_TEST_PASSWORD="password"
229+
uv run pytest tests/integration/ -m integration --override-ini='addopts=' -v
230+
```
231+
232+
**GitHub Actions:** Trigger the **Integration Tests** workflow manually from
233+
the Actions tab. Configure the following secrets in a `rouvy-test`
234+
environment:
235+
236+
- `ROUVY_TEST_EMAIL` — Test account email
237+
- `ROUVY_TEST_PASSWORD` — Test account password
238+
192239
### Project Structure
193240

194241
```text

custom_components/rouvy/__init__.py

Lines changed: 135 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -65,71 +65,163 @@ async def async_unload_entry(
6565

6666
def _register_services(hass: Any) -> None:
6767
"""Register Rouvy services (idempotent — safe to call multiple times)."""
68+
from dataclasses import asdict
69+
70+
from homeassistant.core import SupportsResponse
71+
6872
from .const import DOMAIN
6973

74+
def _first_client(hass: Any) -> Any:
75+
"""Return the first available RouvyAsyncApiClient, or raise."""
76+
for entry in hass.config_entries.async_entries(DOMAIN):
77+
if hasattr(entry, "runtime_data") and entry.runtime_data:
78+
return entry.runtime_data
79+
msg = "No Rouvy integration configured"
80+
raise ValueError(msg)
81+
7082
async def _handle_update_weight(call: Any) -> None:
7183
weight = call.data["weight"]
7284
_LOGGER.info("Service call: update_weight to %s", weight)
73-
for entry in hass.config_entries.async_entries(DOMAIN):
74-
if hasattr(entry, "runtime_data") and entry.runtime_data:
75-
client = entry.runtime_data.client
76-
await client.async_update_user_settings({"weight": weight})
77-
await entry.runtime_data.coordinator.async_request_refresh()
85+
rd = _first_client(hass)
86+
await rd.client.async_update_user_settings({"weight": weight})
87+
await rd.coordinator.async_request_refresh()
7888

7989
async def _handle_update_height(call: Any) -> None:
8090
height = call.data["height"]
8191
_LOGGER.info("Service call: update_height to %s", height)
82-
for entry in hass.config_entries.async_entries(DOMAIN):
83-
if hasattr(entry, "runtime_data") and entry.runtime_data:
84-
client = entry.runtime_data.client
85-
await client.async_update_user_settings({"height": height})
86-
await entry.runtime_data.coordinator.async_request_refresh()
92+
rd = _first_client(hass)
93+
await rd.client.async_update_user_settings({"height": height})
94+
await rd.coordinator.async_request_refresh()
8795

8896
async def _handle_update_settings(call: Any) -> None:
8997
settings = dict(call.data["settings"])
9098
_LOGGER.info("Service call: update_settings %s", settings)
91-
for entry in hass.config_entries.async_entries(DOMAIN):
92-
if hasattr(entry, "runtime_data") and entry.runtime_data:
93-
client = entry.runtime_data.client
94-
await client.async_update_user_settings(settings)
95-
await entry.runtime_data.coordinator.async_request_refresh()
99+
rd = _first_client(hass)
100+
await rd.client.async_update_user_settings(settings)
101+
await rd.coordinator.async_request_refresh()
102+
103+
async def _handle_update_profile(call: Any) -> None:
104+
updates: dict[str, Any] = {}
105+
for key in ("userName", "firstName", "team", "accountPrivacy"):
106+
if key in call.data:
107+
updates[key] = call.data[key]
108+
_LOGGER.info("Service call: update_profile %s", updates)
109+
rd = _first_client(hass)
110+
await rd.client.async_update_user_settings(updates)
111+
await rd.coordinator.async_request_refresh()
112+
113+
async def _handle_update_units(call: Any) -> None:
114+
units = call.data["units"]
115+
_LOGGER.info("Service call: update_units to %s", units)
116+
rd = _first_client(hass)
117+
await rd.client.async_update_user_settings({"units": units})
118+
await rd.coordinator.async_request_refresh()
119+
120+
async def _handle_update_timezone(call: Any) -> None:
121+
timezone = call.data["timezone"]
122+
_LOGGER.info("Service call: update_timezone to %s", timezone)
123+
rd = _first_client(hass)
124+
await rd.client.async_update_timezone(timezone)
125+
await rd.coordinator.async_request_refresh()
126+
127+
async def _handle_update_ftp(call: Any) -> None:
128+
ftp_source = call.data["ftp_source"]
129+
value = call.data.get("value")
130+
_LOGGER.info("Service call: update_ftp source=%s value=%s", ftp_source, value)
131+
rd = _first_client(hass)
132+
await rd.client.async_update_ftp(ftp_source, value)
133+
await rd.coordinator.async_request_refresh()
134+
135+
async def _handle_update_zones(call: Any) -> None:
136+
zone_type = call.data["zone_type"]
137+
zones = list(call.data["zones"])
138+
_LOGGER.info("Service call: update_zones type=%s zones=%s", zone_type, zones)
139+
rd = _first_client(hass)
140+
await rd.client.async_update_zones(zone_type, zones)
141+
await rd.coordinator.async_request_refresh()
96142

97143
async def _handle_register_challenge(call: Any) -> None:
98144
slug = call.data["slug"]
99145
_LOGGER.info("Service call: register_challenge for %s", slug)
100-
for entry in hass.config_entries.async_entries(DOMAIN):
101-
if hasattr(entry, "runtime_data") and entry.runtime_data:
102-
client = entry.runtime_data.client
103-
await client.async_register_challenge(slug)
104-
await entry.runtime_data.coordinator.async_request_refresh()
146+
rd = _first_client(hass)
147+
await rd.client.async_register_challenge(slug)
148+
await rd.coordinator.async_request_refresh()
105149

106150
async def _handle_register_event(call: Any) -> None:
107151
event_id = call.data["event_id"]
108152
_LOGGER.info("Service call: register_event for %s", event_id)
109-
for entry in hass.config_entries.async_entries(DOMAIN):
110-
if hasattr(entry, "runtime_data") and entry.runtime_data:
111-
client = entry.runtime_data.client
112-
await client.async_register_event(event_id)
113-
await entry.runtime_data.coordinator.async_request_refresh()
153+
rd = _first_client(hass)
154+
await rd.client.async_register_event(event_id)
155+
await rd.coordinator.async_request_refresh()
114156

115157
async def _handle_unregister_event(call: Any) -> None:
116158
event_id = call.data["event_id"]
117159
_LOGGER.info("Service call: unregister_event for %s", event_id)
118-
for entry in hass.config_entries.async_entries(DOMAIN):
119-
if hasattr(entry, "runtime_data") and entry.runtime_data:
120-
client = entry.runtime_data.client
121-
await client.async_unregister_event(event_id)
122-
await entry.runtime_data.coordinator.async_request_refresh()
123-
124-
if not hass.services.has_service(DOMAIN, "update_weight"):
125-
hass.services.async_register(DOMAIN, "update_weight", _handle_update_weight)
126-
if not hass.services.has_service(DOMAIN, "update_height"):
127-
hass.services.async_register(DOMAIN, "update_height", _handle_update_height)
128-
if not hass.services.has_service(DOMAIN, "update_settings"):
129-
hass.services.async_register(DOMAIN, "update_settings", _handle_update_settings)
130-
if not hass.services.has_service(DOMAIN, "register_challenge"):
131-
hass.services.async_register(DOMAIN, "register_challenge", _handle_register_challenge)
132-
if not hass.services.has_service(DOMAIN, "register_event"):
133-
hass.services.async_register(DOMAIN, "register_event", _handle_register_event)
134-
if not hass.services.has_service(DOMAIN, "unregister_event"):
135-
hass.services.async_register(DOMAIN, "unregister_event", _handle_unregister_event)
160+
rd = _first_client(hass)
161+
await rd.client.async_unregister_event(event_id)
162+
await rd.coordinator.async_request_refresh()
163+
164+
# Query services return data via SupportsResponse
165+
async def _handle_get_profile(_call: Any) -> dict[str, Any]:
166+
_LOGGER.info("Service call: get_profile")
167+
rd = _first_client(hass)
168+
profile = await rd.client.async_get_user_profile()
169+
return {"profile": asdict(profile)}
170+
171+
async def _handle_get_events(_call: Any) -> dict[str, Any]:
172+
_LOGGER.info("Service call: get_events")
173+
rd = _first_client(hass)
174+
events = await rd.client.async_get_events()
175+
return {"events": [asdict(e) for e in events]}
176+
177+
async def _handle_get_challenges(_call: Any) -> dict[str, Any]:
178+
_LOGGER.info("Service call: get_challenges")
179+
rd = _first_client(hass)
180+
challenges = await rd.client.async_get_challenges()
181+
return {"challenges": [asdict(c) for c in challenges]}
182+
183+
async def _handle_get_routes(_call: Any) -> dict[str, Any]:
184+
_LOGGER.info("Service call: get_routes")
185+
rd = _first_client(hass)
186+
routes = await rd.client.async_get_favorite_routes()
187+
return {"routes": [asdict(r) for r in routes]}
188+
189+
async def _handle_get_activities(_call: Any) -> dict[str, Any]:
190+
_LOGGER.info("Service call: get_activities")
191+
rd = _first_client(hass)
192+
summary = await rd.client.async_get_activity_summary()
193+
return {"activities": [asdict(a) for a in summary.recent_activities]}
194+
195+
async def _handle_get_career(_call: Any) -> dict[str, Any]:
196+
_LOGGER.info("Service call: get_career")
197+
rd = _first_client(hass)
198+
career = await rd.client.async_get_career()
199+
return {"career": asdict(career)}
200+
201+
# Register update services
202+
_svc = [
203+
("update_weight", _handle_update_weight, None),
204+
("update_height", _handle_update_height, None),
205+
("update_settings", _handle_update_settings, None),
206+
("update_profile", _handle_update_profile, None),
207+
("update_units", _handle_update_units, None),
208+
("update_timezone", _handle_update_timezone, None),
209+
("update_ftp", _handle_update_ftp, None),
210+
("update_zones", _handle_update_zones, None),
211+
("register_challenge", _handle_register_challenge, None),
212+
("register_event", _handle_register_event, None),
213+
("unregister_event", _handle_unregister_event, None),
214+
("get_profile", _handle_get_profile, SupportsResponse.ONLY),
215+
("get_events", _handle_get_events, SupportsResponse.ONLY),
216+
("get_challenges", _handle_get_challenges, SupportsResponse.ONLY),
217+
("get_routes", _handle_get_routes, SupportsResponse.ONLY),
218+
("get_activities", _handle_get_activities, SupportsResponse.ONLY),
219+
("get_career", _handle_get_career, SupportsResponse.ONLY),
220+
]
221+
222+
for name, handler, supports_response in _svc:
223+
if not hass.services.has_service(DOMAIN, name):
224+
kwargs: dict[str, Any] = {}
225+
if supports_response is not None:
226+
kwargs["supports_response"] = supports_response
227+
hass.services.async_register(DOMAIN, name, handler, **kwargs)

0 commit comments

Comments
 (0)