From aafd487b83d0ecfa167b824deb7ed4164458204b Mon Sep 17 00:00:00 2001 From: Jack O'Sullivan Date: Sun, 14 Jun 2026 00:08:15 +0100 Subject: [PATCH] Add Home Assistant integration Token-authenticated custom component exposing live per-club member counts as sensors under a single "West Wood Club" device, fed by one coordinator polling `/v1/Clubs/WhoIsInCount`. Packaged via `buildHomeAssistantComponent` with a flake package + overlay so it can be used in `services.home-assistant.customComponents`. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 34 +++++- custom_components/west_wood_club/__init__.py | 29 +++++ custom_components/west_wood_club/api.py | 60 ++++++++++ .../west_wood_club/config_flow.py | 112 ++++++++++++++++++ custom_components/west_wood_club/const.py | 15 +++ .../west_wood_club/coordinator.py | 45 +++++++ .../west_wood_club/manifest.json | 11 ++ custom_components/west_wood_club/sensor.py | 64 ++++++++++ custom_components/west_wood_club/strings.json | 34 ++++++ .../west_wood_club/translations/en.json | 34 ++++++ flake.nix | 26 +++- get-token.py | 56 ++++----- 12 files changed, 488 insertions(+), 32 deletions(-) create mode 100644 custom_components/west_wood_club/__init__.py create mode 100644 custom_components/west_wood_club/api.py create mode 100644 custom_components/west_wood_club/config_flow.py create mode 100644 custom_components/west_wood_club/const.py create mode 100644 custom_components/west_wood_club/coordinator.py create mode 100644 custom_components/west_wood_club/manifest.json create mode 100644 custom_components/west_wood_club/sensor.py create mode 100644 custom_components/west_wood_club/strings.json create mode 100644 custom_components/west_wood_club/translations/en.json mode change 100644 => 100755 get-token.py diff --git a/AGENTS.md b/AGENTS.md index 12eb64c..4b0bf4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ Groundwork for a **Home Assistant integration for West Wood Club** (an Irish gym chain). The West Wood app is a white-label build of **PerfectGym Go**, so the integration targets PerfectGym's hosted backend at `https://goapi2.perfectgym.com`. -The integration itself isn't written yet — the repo currently holds a -reverse-engineering capture, the API docs derived from it, and a login helper. +The repo holds the reverse-engineering capture, the API docs derived from it, a +token helper, and the Home Assistant custom component itself (see Integration). ## Layout @@ -15,6 +15,7 @@ reverse-engineering capture, the API docs derived from it, and a login helper. secrets redacted. The source of truth for API behaviour; extend it as more endpoints are mapped. - `get-token.py` — logs in and prints a bearer token to stdout (stdlib only). +- `custom_components/west_wood_club/` — the Home Assistant integration. - `android-flows.mitm` — mitmproxy capture of the app's traffic. **Gitignored and untracked**: it contains real credentials and a bearer token in cleartext. Never commit it or copy its secrets into tracked files. @@ -33,6 +34,12 @@ Gotchas: - `nix develop --command` may change cwd — use **absolute paths** when a script opens `android-flows.mitm`. +## Code style + +Python uses **single-quoted strings** (`'...'`). Reformat with +`ruff format --config "format.quote-style='single'" ` (ruff is available via +`nix run nixpkgs#ruff`). Docstrings stay triple-double-quoted (`"""`). + ## Working with the capture Read flows with the mitmproxy Python API: @@ -64,6 +71,29 @@ Full detail in `api.md`. Quick reference: - **Live occupancy:** `GET /v1/Clubs/WhoIsInCount` → `count` per `clubId` (the main sensor signal). `clubId` maps to `id` from `GET /v1/Clubs/Clubs`. +## Integration + +`custom_components/west_wood_club/` is a UI-configured (config-flow) integration. + +- **Auth model:** the user pastes a long-lived bearer token (from `get-token.py`); + no credentials are stored. A rejected token (`WestWoodAuthError` → coordinator + raises `ConfigEntryAuthFailed`) triggers HA's reauth flow to paste a fresh one. +- **One device, N sensors:** a single `DataUpdateCoordinator` polls + `WhoIsInCount` once per interval for all clubs; one `SensorEntity` per selected + club reads its `club_id` out of `coordinator.data`. All sensors share one + device (`identifiers={(DOMAIN, entry.entry_id)}`) named "West Wood Club". +- **Config flow steps:** `user` and `reauth` are HA-fixed names (dispatched by the + flow *source*); `clubs` and `reauth_confirm` are reached because a form was shown + with that `step_id` (HA calls `async_step_` on submit). Every step name + must also have a matching key under `config.step` in `strings.json`. +- **Nix:** `flake.nix` exposes `packages.west_wood_club` (built with + `buildHomeAssistantComponent`) and `overlays.default`, which adds it to + `pkgs.home-assistant-custom-components`. On a NixOS host, apply the overlay and + list it in `services.home-assistant.customComponents`; it must be built against + the same Python as the host's `home-assistant`. Bump `version` in both + `manifest.json` and the flake on code changes. The integration has **no** + external `requirements`, so no extra Nix packaging is needed. + ## Security The capture and `token.txt` hold live credentials/tokens. Keep them gitignored, diff --git a/custom_components/west_wood_club/__init__.py b/custom_components/west_wood_club/__init__.py new file mode 100644 index 0000000..ec6b3c7 --- /dev/null +++ b/custom_components/west_wood_club/__init__.py @@ -0,0 +1,29 @@ +"""The West Wood Club integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import WestWoodClient +from .const import CONF_TOKEN +from .coordinator import WestWoodConfigEntry, WestWoodCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: WestWoodConfigEntry) -> bool: + """Set up West Wood Club from a config entry.""" + client = WestWoodClient(async_get_clientsession(hass), entry.data[CONF_TOKEN]) + coordinator = WestWoodCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WestWoodConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/west_wood_club/api.py b/custom_components/west_wood_club/api.py new file mode 100644 index 0000000..5ac7195 --- /dev/null +++ b/custom_components/west_wood_club/api.py @@ -0,0 +1,60 @@ +"""Thin async client for the West Wood Club (PerfectGym) API. + +Authentication is a long-lived bearer token supplied by the user (see +``get-token.py`` in the repo root for how to obtain one). Only the +``Authorization`` header is required by the backend. +""" + +from __future__ import annotations + +import aiohttp + +from .const import BASE_URL + + +class WestWoodApiError(Exception): + """A request to the API failed.""" + + +class WestWoodAuthError(WestWoodApiError): + """The token was rejected (expired or revoked).""" + + +class WestWoodClient: + """Minimal client wrapping the endpoints the integration needs.""" + + def __init__(self, session: aiohttp.ClientSession, token: str) -> None: + self._session = session + self._token = token + + async def _get(self, path: str, **params: str) -> list[dict]: + """GET a wrapped endpoint and return its ``data`` list.""" + try: + async with self._session.get( + f'{BASE_URL}{path}', + params=params, + headers={ + 'Authorization': f'bearer {self._token}', + 'Accept': 'application/json', + }, + ) as resp: + if resp.status in (401, 403): + raise WestWoodAuthError(f'token rejected (HTTP {resp.status})') + resp.raise_for_status() + payload = await resp.json() + except aiohttp.ClientError as err: + raise WestWoodApiError(str(err)) from err + + if payload.get('errors'): + raise WestWoodApiError(str(payload['errors'])) + return payload.get('data') or [] + + async def async_get_clubs(self) -> dict[int, str]: + """Return ``{club_id: name}`` for every club in the tenant.""" + data = await self._get('/v1/Clubs/Clubs', timestamp='0') + return {club['id']: club['name'] for club in data} + + async def async_get_member_counts(self) -> dict[int, int]: + """Return ``{club_id: live_member_count}``.""" + data = await self._get('/v1/Clubs/WhoIsInCount') + return {row['clubId']: row['count'] for row in data} diff --git a/custom_components/west_wood_club/config_flow.py b/custom_components/west_wood_club/config_flow.py new file mode 100644 index 0000000..c217583 --- /dev/null +++ b/custom_components/west_wood_club/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for West Wood Club.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import WestWoodApiError, WestWoodAuthError, WestWoodClient +from .const import CONF_CLUBS, CONF_TOKEN, DOMAIN + + +class WestWoodConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the UI configuration flow.""" + + def __init__(self) -> None: + self._token: str | None = None + self._clubs: dict[int, str] = {} + + async def _validate_token(self, token: str) -> dict[int, str]: + """Return the club list if the token works, else raise.""" + client = WestWoodClient(async_get_clientsession(self.hass), token) + return await client.async_get_clubs() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """First step: collect and validate the bearer token.""" + errors: dict[str, str] = {} + if user_input is not None: + token = user_input[CONF_TOKEN].strip() + try: + self._clubs = await self._validate_token(token) + except WestWoodAuthError: + errors['base'] = 'invalid_auth' + except WestWoodApiError: + errors['base'] = 'cannot_connect' + else: + self._token = token + return await self.async_step_clubs() + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + errors=errors, + ) + + async def async_step_clubs( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Second step: pick which clubs to create sensors for.""" + if user_input is not None: + selected = { + club_id: name + for club_id, name in self._clubs.items() + if str(club_id) in user_input[CONF_CLUBS] + } + return self.async_create_entry( + title='West Wood Club', + data={ + CONF_TOKEN: self._token, + # Keys are stringified for JSON storage / multi_select. + CONF_CLUBS: {str(cid): name for cid, name in selected.items()}, + }, + ) + + return self.async_show_form( + step_id='clubs', + data_schema=vol.Schema( + { + vol.Required(CONF_CLUBS): cv.multi_select( + {str(cid): name for cid, name in self._clubs.items()} + ) + } + ), + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Start reauth when the stored token stops working.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user paste a fresh token, keeping the existing clubs.""" + errors: dict[str, str] = {} + if user_input is not None: + token = user_input[CONF_TOKEN].strip() + try: + await self._validate_token(token) + except WestWoodAuthError: + errors['base'] = 'invalid_auth' + except WestWoodApiError: + errors['base'] = 'cannot_connect' + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_TOKEN: token}, + ) + + return self.async_show_form( + step_id='reauth_confirm', + data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + errors=errors, + ) diff --git a/custom_components/west_wood_club/const.py b/custom_components/west_wood_club/const.py new file mode 100644 index 0000000..48c6bd8 --- /dev/null +++ b/custom_components/west_wood_club/const.py @@ -0,0 +1,15 @@ +"""Constants for the West Wood Club integration.""" + +from datetime import timedelta + +DOMAIN = 'west_wood_club' + +# PerfectGym Go backend (West Wood is a white-label tenant). +BASE_URL = 'https://goapi2.perfectgym.com' + +# Config entry keys. +CONF_TOKEN = 'token' +CONF_CLUBS = 'clubs' + +# How often to poll live occupancy. +UPDATE_INTERVAL = timedelta(minutes=5) diff --git a/custom_components/west_wood_club/coordinator.py b/custom_components/west_wood_club/coordinator.py new file mode 100644 index 0000000..15bad10 --- /dev/null +++ b/custom_components/west_wood_club/coordinator.py @@ -0,0 +1,45 @@ +"""Data update coordinator for West Wood Club occupancy.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import WestWoodApiError, WestWoodAuthError, WestWoodClient +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type WestWoodConfigEntry = ConfigEntry[WestWoodCoordinator] + + +class WestWoodCoordinator(DataUpdateCoordinator[dict[int, int]]): + """Polls live member counts for all clubs in one request.""" + + def __init__( + self, + hass: HomeAssistant, + entry: WestWoodConfigEntry, + client: WestWoodClient, + ) -> None: + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=UPDATE_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> dict[int, int]: + try: + return await self.client.async_get_member_counts() + except WestWoodAuthError as err: + # Triggers Home Assistant's reauth flow to paste a fresh token. + raise ConfigEntryAuthFailed(str(err)) from err + except WestWoodApiError as err: + raise UpdateFailed(str(err)) from err diff --git a/custom_components/west_wood_club/manifest.json b/custom_components/west_wood_club/manifest.json new file mode 100644 index 0000000..46ad428 --- /dev/null +++ b/custom_components/west_wood_club/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "west_wood_club", + "name": "West Wood Club", + "codeowners": ["@deplayer0"], + "config_flow": true, + "documentation": "https://git.nul.ie/dev/hass-west-wood", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": [], + "version": "0.1.0" +} diff --git a/custom_components/west_wood_club/sensor.py b/custom_components/west_wood_club/sensor.py new file mode 100644 index 0000000..520f47f --- /dev/null +++ b/custom_components/west_wood_club/sensor.py @@ -0,0 +1,64 @@ +"""Live member-count sensors for West Wood Club.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorEntity, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_CLUBS, DOMAIN +from .coordinator import WestWoodConfigEntry, WestWoodCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WestWoodConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up one occupancy sensor per selected club.""" + coordinator = entry.runtime_data + clubs: dict[str, str] = entry.data[CONF_CLUBS] + async_add_entities( + WestWoodOccupancySensor(coordinator, entry, int(club_id), name) + for club_id, name in clubs.items() + ) + + +class WestWoodOccupancySensor(CoordinatorEntity[WestWoodCoordinator], SensorEntity): + """Number of members currently checked in at a club.""" + + _attr_has_entity_name = True + _attr_icon = 'mdi:account-group' + _attr_native_unit_of_measurement = 'members' + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, + coordinator: WestWoodCoordinator, + entry: WestWoodConfigEntry, + club_id: int, + name: str, + ) -> None: + super().__init__(coordinator) + self._club_id = club_id + self._attr_name = name + self._attr_unique_id = f'{entry.entry_id}_{club_id}' + # All club sensors share one device so they group together in the UI. + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name='West Wood Club', + manufacturer='PerfectGym', + ) + + @property + def native_value(self) -> int | None: + return self.coordinator.data.get(self._club_id) + + @property + def available(self) -> bool: + return super().available and self._club_id in self.coordinator.data diff --git a/custom_components/west_wood_club/strings.json b/custom_components/west_wood_club/strings.json new file mode 100644 index 0000000..517f030 --- /dev/null +++ b/custom_components/west_wood_club/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "title": "West Wood Club", + "description": "Paste a bearer token for the West Wood Club app. You can generate one with get-token.py from the repository.", + "data": { + "token": "Bearer token" + } + }, + "clubs": { + "title": "Select clubs", + "description": "Choose the clubs to create member-count sensors for.", + "data": { + "clubs": "Clubs" + } + }, + "reauth_confirm": { + "title": "Re-authenticate West Wood Club", + "description": "The stored token was rejected. Paste a fresh bearer token.", + "data": { + "token": "Bearer token" + } + } + }, + "error": { + "invalid_auth": "The token was rejected. Generate a new one and try again.", + "cannot_connect": "Could not reach the West Wood Club API." + }, + "abort": { + "reauth_successful": "Re-authentication was successful." + } + } +} diff --git a/custom_components/west_wood_club/translations/en.json b/custom_components/west_wood_club/translations/en.json new file mode 100644 index 0000000..517f030 --- /dev/null +++ b/custom_components/west_wood_club/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "title": "West Wood Club", + "description": "Paste a bearer token for the West Wood Club app. You can generate one with get-token.py from the repository.", + "data": { + "token": "Bearer token" + } + }, + "clubs": { + "title": "Select clubs", + "description": "Choose the clubs to create member-count sensors for.", + "data": { + "clubs": "Clubs" + } + }, + "reauth_confirm": { + "title": "Re-authenticate West Wood Club", + "description": "The stored token was rejected. Paste a fresh bearer token.", + "data": { + "token": "Bearer token" + } + } + }, + "error": { + "invalid_auth": "The token was rejected. Generate a new one and try again.", + "cannot_connect": "Could not reach the West Wood Club API." + }, + "abort": { + "reauth_successful": "Re-authentication was successful." + } + } +} diff --git a/flake.nix b/flake.nix index 919e519..c7493e8 100644 --- a/flake.nix +++ b/flake.nix @@ -7,11 +7,33 @@ }; outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: + let + # Adds the integration to pkgs.home-assistant-custom-components so it can be + # used directly in services.home-assistant.customComponents. + overlay = final: prev: { + home-assistant-custom-components = prev.home-assistant-custom-components // { + west_wood_club = final.buildHomeAssistantComponent { + owner = "deplayer0"; + domain = "west_wood_club"; + version = "0.1.0"; + src = ./.; + }; + }; + }; + in + { + overlays.default = overlay; + } + // flake-utils.lib.eachDefaultSystem (system: let - pkgs = nixpkgs.legacyPackages.${system}; + pkgs = nixpkgs.legacyPackages.${system}.extend overlay; in { + packages = rec { + west_wood_club = pkgs.home-assistant-custom-components.west_wood_club; + default = west_wood_club; + }; + devShells.default = pkgs.mkShell { packages = with pkgs; [ mitmproxy diff --git a/get-token.py b/get-token.py old mode 100644 new mode 100755 index 2d4fdb2..2e0fb7f --- a/get-token.py +++ b/get-token.py @@ -17,11 +17,11 @@ import sys import urllib.error import urllib.request -BASE_URL = "https://goapi2.perfectgym.com" -WHITE_LABEL_ID = "7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc" +BASE_URL = 'https://goapi2.perfectgym.com' +WHITE_LABEL_ID = '7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc' USER_AGENT = ( - "West Wood Club/1.28.3.0 " - "(com.perfectgym.perfectgymgo2.westwoodclub; build:1028003000; Android 16)" + 'West Wood Club/1.28.3.0 ' + '(com.perfectgym.perfectgymgo2.westwoodclub; build:1028003000; Android 16)' ) @@ -29,56 +29,56 @@ def log_in(email: str, password: str) -> str: """Return the bearer token (the value for the Authorization header).""" body = json.dumps( { - "email": email, - "password": password, - "clientApplicationInfo": { - "type": "whitelabel", - "whiteLabelId": WHITE_LABEL_ID, + 'email': email, + 'password': password, + 'clientApplicationInfo': { + 'type': 'whitelabel', + 'whiteLabelId': WHITE_LABEL_ID, }, } ).encode() request = urllib.request.Request( - f"{BASE_URL}/v1/Authorize/LogInWithEmail", + f'{BASE_URL}/v1/Authorize/LogInWithEmail', data=body, - method="POST", + method='POST', headers={ - "Accept": "application/json", - "Content-Type": "application/json; charset=UTF-8", - "Accept-Language": "en", - "X-Go-App-Platform": "Android", - "X-Go-App-Version": "1.28.3", - "X-Go-White-Label-ID": WHITE_LABEL_ID, - "User-Agent": USER_AGENT, + 'Accept': 'application/json', + 'Content-Type': 'application/json; charset=UTF-8', + 'Accept-Language': 'en', + 'X-Go-App-Platform': 'Android', + 'X-Go-App-Version': '1.28.3', + 'X-Go-White-Label-ID': WHITE_LABEL_ID, + 'User-Agent': USER_AGENT, }, ) with urllib.request.urlopen(request) as response: payload = json.load(response) - if payload.get("errors"): - raise SystemExit(f"login failed: {payload['errors']}") + if payload.get('errors'): + raise SystemExit(f'login failed: {payload["errors"]}') - data = payload.get("data") or {} - token = data.get("token") + data = payload.get('data') or {} + token = data.get('token') if not token: - raise SystemExit(f"no token in response: {payload}") + raise SystemExit(f'no token in response: {payload}') return token def main() -> None: - email = os.environ.get("WESTWOOD_EMAIL") or input("Email: ") - password = os.environ.get("WESTWOOD_PASSWORD") or getpass.getpass("Password: ") + email = os.environ.get('WESTWOOD_EMAIL') or input('Email: ') + password = os.environ.get('WESTWOOD_PASSWORD') or getpass.getpass('Password: ') try: token = log_in(email, password) except urllib.error.HTTPError as exc: - raise SystemExit(f"HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')}") + raise SystemExit(f'HTTP {exc.code}: {exc.read().decode("utf-8", "replace")}') except urllib.error.URLError as exc: - raise SystemExit(f"request failed: {exc.reason}") + raise SystemExit(f'request failed: {exc.reason}') print(token) -if __name__ == "__main__": +if __name__ == '__main__': main()