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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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`.
|
integration targets PerfectGym's hosted backend at `https://goapi2.perfectgym.com`.
|
||||||
|
|
||||||
The integration itself isn't written yet — the repo currently holds a
|
The repo holds the reverse-engineering capture, the API docs derived from it, a
|
||||||
reverse-engineering capture, the API docs derived from it, and a login helper.
|
token helper, and the Home Assistant custom component itself (see Integration).
|
||||||
|
|
||||||
## Layout
|
## 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
|
secrets redacted. The source of truth for API behaviour; extend it as more
|
||||||
endpoints are mapped.
|
endpoints are mapped.
|
||||||
- `get-token.py` — logs in and prints a bearer token to stdout (stdlib only).
|
- `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
|
- `android-flows.mitm` — mitmproxy capture of the app's traffic. **Gitignored and
|
||||||
untracked**: it contains real credentials and a bearer token in cleartext. Never
|
untracked**: it contains real credentials and a bearer token in cleartext. Never
|
||||||
commit it or copy its secrets into tracked files.
|
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
|
- `nix develop --command` may change cwd — use **absolute paths** when a script
|
||||||
opens `android-flows.mitm`.
|
opens `android-flows.mitm`.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
Python uses **single-quoted strings** (`'...'`). Reformat with
|
||||||
|
`ruff format --config "format.quote-style='single'" <paths>` (ruff is available via
|
||||||
|
`nix run nixpkgs#ruff`). Docstrings stay triple-double-quoted (`"""`).
|
||||||
|
|
||||||
## Working with the capture
|
## Working with the capture
|
||||||
|
|
||||||
Read flows with the mitmproxy Python API:
|
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
|
- **Live occupancy:** `GET /v1/Clubs/WhoIsInCount` → `count` per `clubId` (the main
|
||||||
sensor signal). `clubId` maps to `id` from `GET /v1/Clubs/Clubs`.
|
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_<step_id>` 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
|
## Security
|
||||||
|
|
||||||
The capture and `token.txt` hold live credentials/tokens. Keep them gitignored,
|
The capture and `token.txt` hold live credentials/tokens. Keep them gitignored,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,33 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
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
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system}.extend overlay;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
packages = rec {
|
||||||
|
west_wood_club = pkgs.home-assistant-custom-components.west_wood_club;
|
||||||
|
default = west_wood_club;
|
||||||
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
mitmproxy
|
mitmproxy
|
||||||
|
|||||||
Regular → Executable
+28
-28
@@ -17,11 +17,11 @@ import sys
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE_URL = "https://goapi2.perfectgym.com"
|
BASE_URL = 'https://goapi2.perfectgym.com'
|
||||||
WHITE_LABEL_ID = "7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc"
|
WHITE_LABEL_ID = '7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc'
|
||||||
USER_AGENT = (
|
USER_AGENT = (
|
||||||
"West Wood Club/1.28.3.0 "
|
'West Wood Club/1.28.3.0 '
|
||||||
"(com.perfectgym.perfectgymgo2.westwoodclub; build:1028003000; Android 16)"
|
'(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)."""
|
"""Return the bearer token (the value for the Authorization header)."""
|
||||||
body = json.dumps(
|
body = json.dumps(
|
||||||
{
|
{
|
||||||
"email": email,
|
'email': email,
|
||||||
"password": password,
|
'password': password,
|
||||||
"clientApplicationInfo": {
|
'clientApplicationInfo': {
|
||||||
"type": "whitelabel",
|
'type': 'whitelabel',
|
||||||
"whiteLabelId": WHITE_LABEL_ID,
|
'whiteLabelId': WHITE_LABEL_ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
).encode()
|
).encode()
|
||||||
|
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{BASE_URL}/v1/Authorize/LogInWithEmail",
|
f'{BASE_URL}/v1/Authorize/LogInWithEmail',
|
||||||
data=body,
|
data=body,
|
||||||
method="POST",
|
method='POST',
|
||||||
headers={
|
headers={
|
||||||
"Accept": "application/json",
|
'Accept': 'application/json',
|
||||||
"Content-Type": "application/json; charset=UTF-8",
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
"Accept-Language": "en",
|
'Accept-Language': 'en',
|
||||||
"X-Go-App-Platform": "Android",
|
'X-Go-App-Platform': 'Android',
|
||||||
"X-Go-App-Version": "1.28.3",
|
'X-Go-App-Version': '1.28.3',
|
||||||
"X-Go-White-Label-ID": WHITE_LABEL_ID,
|
'X-Go-White-Label-ID': WHITE_LABEL_ID,
|
||||||
"User-Agent": USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with urllib.request.urlopen(request) as response:
|
with urllib.request.urlopen(request) as response:
|
||||||
payload = json.load(response)
|
payload = json.load(response)
|
||||||
|
|
||||||
if payload.get("errors"):
|
if payload.get('errors'):
|
||||||
raise SystemExit(f"login failed: {payload['errors']}")
|
raise SystemExit(f'login failed: {payload["errors"]}')
|
||||||
|
|
||||||
data = payload.get("data") or {}
|
data = payload.get('data') or {}
|
||||||
token = data.get("token")
|
token = data.get('token')
|
||||||
if not token:
|
if not token:
|
||||||
raise SystemExit(f"no token in response: {payload}")
|
raise SystemExit(f'no token in response: {payload}')
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
email = os.environ.get("WESTWOOD_EMAIL") or input("Email: ")
|
email = os.environ.get('WESTWOOD_EMAIL') or input('Email: ')
|
||||||
password = os.environ.get("WESTWOOD_PASSWORD") or getpass.getpass("Password: ")
|
password = os.environ.get('WESTWOOD_PASSWORD') or getpass.getpass('Password: ')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = log_in(email, password)
|
token = log_in(email, password)
|
||||||
except urllib.error.HTTPError as exc:
|
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:
|
except urllib.error.URLError as exc:
|
||||||
raise SystemExit(f"request failed: {exc.reason}")
|
raise SystemExit(f'request failed: {exc.reason}')
|
||||||
|
|
||||||
print(token)
|
print(token)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user