Compare commits
9 Commits
792d5e176b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fd43bede6e | |||
| 3e6ef7a908 | |||
| 07804e8ae8 | |||
| 73cf5d2ce4 | |||
| 0fd2393a3d | |||
| 55b4a17a26 | |||
| f4d89c06c9 | |||
| fce36e22b9 | |||
| 475ccf21e6 |
@@ -16,6 +16,8 @@ token helper, and the Home Assistant custom component itself (see Integration).
|
||||
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.
|
||||
Its `brand/` dir holds the integration icon/logo, extracted from the capture's
|
||||
`westwoodclub-ie.png` asset.
|
||||
- `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.
|
||||
@@ -49,6 +51,11 @@ 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 (`"""`).
|
||||
|
||||
In prose (commit messages, docs, comments), backtick-quote anything code-like —
|
||||
paths, filenames, identifiers, commands, endpoints, HTTP headers, field/JSON
|
||||
keys, UUIDs/IDs, env vars — rather than plain or double-quoted text. If in doubt
|
||||
and it's a literal token from code or an API, backtick it.
|
||||
|
||||
## Working with the capture
|
||||
|
||||
Read flows with the mitmproxy Python API:
|
||||
@@ -57,23 +64,62 @@ Read flows with the mitmproxy Python API:
|
||||
from mitmproxy.io import FlowReader
|
||||
from mitmproxy.http import HTTPFlow
|
||||
|
||||
with open("/abs/path/android-flows.mitm", "rb") as f:
|
||||
with open('/abs/path/android-flows.mitm', 'rb') as f:
|
||||
for flow in FlowReader(f).stream():
|
||||
if isinstance(flow, HTTPFlow) and "perfectgym.com" in flow.request.host:
|
||||
if isinstance(flow, HTTPFlow) and 'perfectgym.com' in flow.request.host:
|
||||
... # flow.request / flow.response
|
||||
```
|
||||
|
||||
When dumping flows, redact `Authorization` / `Cookie` headers and the login body
|
||||
(email + password) before writing anything to a tracked file.
|
||||
|
||||
Some questions the capture can't answer (token lifecycle, the white-label ID,
|
||||
error codes) need the app itself. If you're pointed at **decompiled APK output**
|
||||
(e.g. apktool `smali/`), grep it there — but it's R8-obfuscated: class names are
|
||||
mangled and library types (e.g. OkHttp, `androidx.security.crypto`) may be
|
||||
shrunk/repackaged, so a missing grep hit is not proof of absence. No such
|
||||
decompilation lives in this repo (the user can supply a local apktool dump on
|
||||
request; app package `com.perfectgym.perfectgymgo2.westwoodclub`). The original
|
||||
ELPassion source package names survive obfuscation under
|
||||
`smali/com/elpassion/perfectgym/`, which is the useful entry point.
|
||||
|
||||
### How the app stores the bearer token
|
||||
|
||||
Confirmed from the decompilation (relevant because it answers the token-lifecycle
|
||||
question and shows there's no second auth secret to capture):
|
||||
|
||||
- The login response DTO (`AccountAuthorizationGoApiDto`) carries `token`,
|
||||
`tokenType`, `authorizationHeader`, and a **nullable `expireTime`**.
|
||||
`DtoMapperKt.asAuthorizeResponse` keeps **only the bare `token`** string;
|
||||
`tokenType`/`authorizationHeader`/`expireTime` are discarded. The app
|
||||
reconstructs `Authorization: bearer <token>` itself per request.
|
||||
- The token is **persisted in `EncryptedSharedPreferences`** (androidx
|
||||
security-crypto, R8-repackaged to `l3.*`; AES-256-GCM master key in the Android
|
||||
Keystore). The store class is `f6/o` (interface `f6/p`); the backing file is
|
||||
named `wevgebvre` and values are Moshi-JSON-encoded. Token key: **`"token"`**
|
||||
(writer `f6/o.h(String)`, reader `f6/o.r()`).
|
||||
- **Load path:** at DI-graph construction the provider (`androidx/room/v0`) calls
|
||||
`f6/p.r()` and passes the stored token into the `appmodel/s0` AppModel
|
||||
constructor as its initial value, which seeds the reactive `tokenS`
|
||||
(`Optional<String>`) stream — so the app comes up already authenticated.
|
||||
Login writes the new token back via `f6/p.h(...)` (dispatcher `z4/c`).
|
||||
- **Legacy + migration:** older builds kept the same `"token"` key in the
|
||||
*plaintext* default `SharedPreferences` (`f6/b0`, via
|
||||
`PreferenceManager.getDefaultSharedPreferences`). `PerfectGymApplication` runs a
|
||||
one-time migration gated by an `isMigrated` flag: copy from `f6/b0` into the
|
||||
encrypted `f6/o`, then `clear()` + `deleteSharedPreferences()` the plaintext
|
||||
file. So the token is no longer recoverable in cleartext on current installs.
|
||||
|
||||
## API essentials
|
||||
|
||||
Full detail in `api.md`. Quick reference:
|
||||
|
||||
- Responses are wrapped `{ "data": ..., "errors": ... }`; `errors` is `null` on success.
|
||||
- **Auth:** `POST /v1/Authorize/LogInWithEmail` (white-label ID goes in the body)
|
||||
→ reuse the returned `bearer <token>` as the `Authorization` header. Token
|
||||
expiry is unconfirmed (`expireTime` was `null`).
|
||||
→ reuse the returned `bearer <token>` as the `Authorization` header. Treat the
|
||||
token as long-lived: there's no refresh token, the app persists only the token
|
||||
(not the email/password) and discards the response's `expireTime`, so refetch
|
||||
reactively on `401`/`403`. See `api.md` and the token-storage notes above.
|
||||
- Authenticated endpoints need **only** the `Authorization` header — the `X-Go-*`
|
||||
headers and app `User-Agent` the app sends are not required (verified against
|
||||
the clubs endpoint).
|
||||
@@ -103,6 +149,16 @@ Full detail in `api.md`. Quick reference:
|
||||
`manifest.json` and the flake on code changes. The integration has **no**
|
||||
external `requirements`, so no extra Nix packaging is needed.
|
||||
|
||||
## Checks
|
||||
|
||||
There is no test suite. Verify changes with:
|
||||
|
||||
- `direnv exec . python -m py_compile custom_components/west_wood_club/*.py` — fast
|
||||
syntax check of the component.
|
||||
- `nix build .#west_wood_club` — builds the component and runs nixpkgs' manifest
|
||||
and import checks (the closest thing to CI here). Remember to `git add` new files
|
||||
first, or the flake won't see them.
|
||||
|
||||
## Security
|
||||
|
||||
The capture and `token.txt` hold live credentials/tokens. Keep them gitignored,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# West Wood Club for Home Assistant
|
||||
|
||||
A [Home Assistant](https://www.home-assistant.io/) integration that exposes the
|
||||
**live member count** of [West Wood Club](https://westwood.ie/) gyms as sensors.
|
||||
|
||||
The West Wood app is a white-label build of **PerfectGym Go**, so this integration
|
||||
talks to PerfectGym's backend (`https://goapi2.perfectgym.com`). The API was
|
||||
reverse-engineered from a capture of the app's traffic; see [`api.md`](api.md) for
|
||||
the documented endpoints.
|
||||
|
||||
## Features
|
||||
|
||||
- One occupancy sensor per club, reporting the number of members currently checked
|
||||
in (`GET /v1/Clubs/WhoIsInCount`).
|
||||
- All selected clubs are grouped under a single **West Wood Club** device.
|
||||
- `measurement` state class, so Home Assistant records long-term statistics and
|
||||
history graphs per club.
|
||||
- A single poll per update interval feeds every sensor.
|
||||
|
||||
## Getting a token
|
||||
|
||||
Authentication is a long-lived bearer token. Generate one with `get-token.py`
|
||||
(stdlib only — needs no dependencies):
|
||||
|
||||
```bash
|
||||
WESTWOOD_EMAIL=you@example.com WESTWOOD_PASSWORD=... python get-token.py
|
||||
```
|
||||
|
||||
It prints the token to stdout (credentials can also be entered interactively).
|
||||
|
||||
## Installation
|
||||
|
||||
### NixOS (flake)
|
||||
|
||||
This repo's flake exposes the integration as a package and an overlay. Add it as a
|
||||
flake input on your Home Assistant host, apply the overlay, and list it in
|
||||
`customComponents`:
|
||||
|
||||
```nix
|
||||
# flake inputs
|
||||
inputs.hass-west-wood.url = "git+ssh://git@git.nul.ie/dev/hass-west-wood.git";
|
||||
|
||||
# NixOS module
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
nixpkgs.overlays = [ inputs.hass-west-wood.overlays.default ];
|
||||
|
||||
services.home-assistant = {
|
||||
enable = true;
|
||||
extraComponents = [ "default_config" ];
|
||||
customComponents = [ pkgs.home-assistant-custom-components.west_wood_club ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
Copy `custom_components/west_wood_club/` into your Home Assistant `config/custom_components/`
|
||||
directory and restart Home Assistant.
|
||||
|
||||
## Configuration
|
||||
|
||||
The integration is configured through the UI:
|
||||
|
||||
1. **Settings → Devices & Services → Add Integration → West Wood Club**.
|
||||
2. Paste a bearer token (from `get-token.py`).
|
||||
3. Select the clubs to create sensors for.
|
||||
|
||||
If the token is later rejected, Home Assistant starts a reauth flow to paste a
|
||||
fresh one.
|
||||
|
||||
## Development
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) for the dev environment (Nix flake + nix-direnv),
|
||||
code style, and working with the traffic capture.
|
||||
@@ -4,6 +4,11 @@ The West Wood Club Android app is a white-label build of **PerfectGym Go**. It
|
||||
talks to PerfectGym's hosted backend. Details below were reverse-engineered from
|
||||
`android-flows.mitm` (app version 1.28.3).
|
||||
|
||||
> **Caveat:** this document was written by an AI agent from a single traffic
|
||||
> capture and a decompiled APK. It reflects what was observed, not official docs —
|
||||
> treat field meanings, requirements, and especially inferred behaviour as
|
||||
> best-effort and verify before relying on anything.
|
||||
|
||||
## Base
|
||||
|
||||
- **Base URL:** `https://goapi2.perfectgym.com`
|
||||
@@ -70,8 +75,40 @@ Unauthenticated. Returns a bearer token used for all subsequent requests.
|
||||
```
|
||||
|
||||
Use `data.authorizationHeader` verbatim as the `Authorization` header on
|
||||
subsequent calls (i.e. `bearer ` + `data.token`). `expireTime` was `null` in the
|
||||
capture; expiry behaviour is not yet confirmed.
|
||||
subsequent calls (i.e. `bearer ` + `data.token`).
|
||||
|
||||
**The token most likely does not expire.** `expireTime` was `null` in the capture,
|
||||
the login response carries no refresh token, and the app appears not to store any
|
||||
credentials to silently re-login — it seems to just hold the one bearer token. So
|
||||
treating it as long-lived is reasonable. This is an inference, not a guarantee: the backend has
|
||||
`TokenExpired` / `InvalidToken` error codes, so it can still invalidate a token
|
||||
server-side. Handle a `401`/`403` by obtaining a fresh token.
|
||||
|
||||
---
|
||||
|
||||
## White-label ID
|
||||
|
||||
`7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc` identifies the West Wood **tenant**. It is
|
||||
not a secret — it appears in deep-link URLs — and is **hardcoded into the app
|
||||
binary** (confirmed by decompiling the APK: it is a string literal in the smali,
|
||||
not in resources/assets or fetched at runtime). A different white-label brand is a
|
||||
different build with a different UUID.
|
||||
|
||||
The app uses it three ways:
|
||||
|
||||
- **`X-Go-White-Label-ID` request header** on API calls (not required by the
|
||||
server, per the header table above, but the app always sends it).
|
||||
- **`clientApplicationInfo.whiteLabelId`** in the login body, paired with
|
||||
`type: whitelabel`. PerfectGym Go also has a `universal` mode (the generic
|
||||
multi-tenant app) that omits the white-label ID; white-label builds pin one
|
||||
tenant via this UUID.
|
||||
- **`pgg-<uuid>`** in the in-app web-flow deep links (e.g.
|
||||
`https://goapi2.perfectgym.com/contract/purchase/pgg-7d073db5-...`).
|
||||
|
||||
It corresponds to `companyId 251` ("West Wood Club") server-side: the UUID is the
|
||||
public tenant key, `companyId` the internal numeric id (see
|
||||
[Other endpoints](#other-endpoints)). `GET /v1/Companies/Companies` lists every
|
||||
tenant on the platform (the universal-mode operator list).
|
||||
|
||||
---
|
||||
|
||||
@@ -145,3 +182,405 @@ the live occupancy count. This is the primary signal for an occupancy sensor.
|
||||
> Note: a related endpoint `GET /v1/Classes/WhoIsIn` returns the named list of
|
||||
> members booked into classes (first/last name, `classId`). That is per-class
|
||||
> booking data, not live building occupancy.
|
||||
|
||||
---
|
||||
|
||||
## Other endpoints
|
||||
|
||||
Every other endpoint seen in the capture is catalogued below, grouped by area.
|
||||
Personal data, IDs, and amounts are anonymised. Samples show a single
|
||||
representative `data[]` item (the wrapper and `timestamp`/`isDeleted` fields are
|
||||
omitted for brevity). "Empty in capture" means the endpoint returned `data: []`
|
||||
for this account, so the item shape is unknown.
|
||||
|
||||
Most are `GET`, authenticated with the bearer header, and return the standard
|
||||
`{ "data": [...], "errors": null }` wrapper. Many take `timestamp=0` (full list)
|
||||
and some take `companyId`.
|
||||
|
||||
`companyId` is the PerfectGym **tenant** (the gym operator), not an individual
|
||||
gym — `251` is West Wood Club (from `GET /v1/Companies/Companies`, which lists
|
||||
every operator on the platform). It's effectively a constant here. An individual
|
||||
gym is a `clubId` (= `id` in `Clubs/Clubs`); every record carries both.
|
||||
|
||||
### Opening hours
|
||||
|
||||
`GET /v1/Clubs/OpeningHours?companyId=251×tamp=0`
|
||||
|
||||
Per-club weekly hours — one row per club per `dayOfWeekOrHoliday`. Good for an
|
||||
"open now" binary sensor. `OpeningHoursExceptions` (same params) holds holiday
|
||||
overrides and was empty in the capture.
|
||||
|
||||
```json
|
||||
{
|
||||
"clubId": 959,
|
||||
"dayOfWeekOrHoliday": "Monday",
|
||||
"isClosed": false,
|
||||
"openFrom": "06:00",
|
||||
"openUntil": "23:00",
|
||||
"openTwentyFourSeven": false,
|
||||
"isOpenTwentyFourHours": false,
|
||||
"companyId": 251,
|
||||
"id": 5323
|
||||
}
|
||||
```
|
||||
|
||||
### Personal training bookings
|
||||
|
||||
`GET /v1/PersonalTrainings/Bookings?timestamp=0`
|
||||
|
||||
The account's own PT sessions — **past and future in one list**, newest-booked
|
||||
last. The endpoint does not filter by date; to surface *upcoming* bookings,
|
||||
filter client-side on `startDate > now` (and typically `not isCanceled` /
|
||||
`not isCompleted`). `Classes/BookingsV2` (same shape, for class bookings) was
|
||||
empty in the capture.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "New - 4th Program- Review",
|
||||
"startDate": "2026-05-26T08:30:00+01:00",
|
||||
"endDate": "2026-05-26T09:00:00+01:00",
|
||||
"isCanceled": false,
|
||||
"isCompleted": true,
|
||||
"instructorId": 0,
|
||||
"clubId": 962,
|
||||
"personalTrainingTypeId": 0,
|
||||
"remoteAccountId": 0,
|
||||
"companyId": 251,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `name` — already human-readable and self-contained (e.g.
|
||||
`"New - 1st Consultation"`, `"New - 4th Program- Review"`). A "next PT booking"
|
||||
sensor needs **only this endpoint** — the lookups below are enrichment.
|
||||
- `startDate` / `endDate` — ISO-8601 **with** offset (`+01:00`); the duration is
|
||||
implied (no separate field on the booking).
|
||||
- `isCanceled` / `isCompleted` — booleans. A future session has both `false`.
|
||||
- `instructorId` → `Instructors/Instructors`; `personalTrainingTypeId` →
|
||||
`PersonalTrainings/PersonalTrainingsTypes`; `clubId` → the club list. Note the
|
||||
booking's own `name` does **not** match the type's `name`.
|
||||
|
||||
**Delta sync:** like other catalogue endpoints, passing the largest `timestamp`
|
||||
seen in a previous response (instead of `0`) returns only rows changed since —
|
||||
`data: []` when nothing changed. A simple poller can ignore this and always send
|
||||
`timestamp=0` to get the full list each time.
|
||||
|
||||
### Membership contract
|
||||
|
||||
`GET /v1/RemoteAccounts/Contracts?timestamp=0`
|
||||
|
||||
The account's membership contract(s). `status` (e.g. `Current`), `startDate`,
|
||||
`cancelDate`, `endDate` — useful for a membership-status sensor.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "Current",
|
||||
"startDate": "2026-04-20T00:00:00+00:00",
|
||||
"cancelDate": null,
|
||||
"endDate": null,
|
||||
"paymentPlanId": 0,
|
||||
"accountId": 0,
|
||||
"remoteAccountId": 0,
|
||||
"companyId": 251,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Upcoming charges
|
||||
|
||||
`GET /v1/RemoteAccounts/ContractsCharges?timestamp=0`
|
||||
|
||||
Scheduled membership charges — `dueDate` + `amountGross`/`toPay` (value +
|
||||
`currencyIso`). Useful for a "next payment" sensor.
|
||||
|
||||
```json
|
||||
{
|
||||
"dueDate": "2026-07-01T00:00:00+00:00",
|
||||
"amountGross": { "value": "0.0000", "currencyIso": "EUR" },
|
||||
"toPay": { "value": "0.0000", "currencyIso": "EUR" },
|
||||
"description": "<plan name> (31 days) in 2026-07",
|
||||
"type": "Membership",
|
||||
"contractId": 0,
|
||||
"accountId": 0,
|
||||
"companyId": 251,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Perfect Score
|
||||
|
||||
`GET /v1/PerfectScore/PerfectScore`
|
||||
|
||||
A single gamification points value for the account. `PerfectScoreLevels` lists the
|
||||
level thresholds. `Goals/GoalsProgresses` (goal tracking) was empty in the capture.
|
||||
|
||||
```json
|
||||
{ "data": [ { "points": 175 } ], "errors": null }
|
||||
```
|
||||
|
||||
`GET /v1/PerfectScore/PerfectScoreLevels` — the level ladder (`type` is a colour
|
||||
band) with promotion/demotion dates per level:
|
||||
|
||||
```json
|
||||
{ "type": "Green", "points": 0, "promotionDate": "2026-04-20T00:00:00+00:00", "demotionDate": null, "id": 0 }
|
||||
```
|
||||
|
||||
### Account & profile
|
||||
|
||||
`GET /v1/Accounts/Account?timestamp=0` — the signed-in user's profile (PII).
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"isEmailConfirmed": true,
|
||||
"firstName": "<first>",
|
||||
"lastName": "<last>",
|
||||
"nickName": null,
|
||||
"birthdate": "1990-01-01",
|
||||
"phoneNumber": "<phone>",
|
||||
"gender": "Male",
|
||||
"photoUrl": null,
|
||||
"instagramUrl": null,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
`GET /v1/Accounts/AccountNotificationsSettings?timestamp=0` — per-channel
|
||||
notification toggles (`isClubNotificationsActive`, `isBookingsNotificationsActive`,
|
||||
`isClassReminderActive`, `is{Sms,Email,Push}NotificationsChannelActive`,
|
||||
`minutesBeforeClassReminderConfiguration`, …).
|
||||
|
||||
`GET /v1/Accounts/AccountPrivacySettings?timestamp=0` — leaderboard/booking
|
||||
visibility flags (`showUserClassBookings`, `showUserOnPerfectScoreLeaderboard`,
|
||||
`showUserOnClubGamesLeaderboards`).
|
||||
|
||||
`GET /v1/Accounts/FamilyMembers?timestamp=0` — linked family members. Empty in
|
||||
capture.
|
||||
|
||||
### Remote accounts (membership identity)
|
||||
|
||||
A "remote account" links the app user to a membership at a company. Most
|
||||
membership endpoints key off `remoteAccountId`.
|
||||
|
||||
`GET /v1/RemoteAccounts/Accounts?timestamp=0`:
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": 0,
|
||||
"companyId": 251,
|
||||
"remoteId": 0,
|
||||
"homeClubId": 962,
|
||||
"isSelected": true,
|
||||
"businessNumber": "<membership-no>",
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
`GET /v1/RemoteAccounts/PaymentPlans?timestamp=0` — membership plan definitions
|
||||
(`name`, `priceGross`, `commitmentPeriodMonths`, `paymentIntervalMonths`). Amount
|
||||
redacted:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "<plan name>",
|
||||
"priceGross": { "value": "0.0000", "currencyIso": "EUR" },
|
||||
"commitmentPeriodMonths": 24,
|
||||
"paymentIntervalMonths": 1,
|
||||
"companyId": 251,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Classes catalogue
|
||||
|
||||
`GET /v1/Classes/Classes?timestamp=0` — scheduled class instances (the timetable):
|
||||
|
||||
```json
|
||||
{
|
||||
"startDate": "2026-05-26T10:45:00+01:00",
|
||||
"endDate": "2026-05-26T11:15:00+01:00",
|
||||
"attendeesCount": 0,
|
||||
"attendeesLimit": null,
|
||||
"standbyListLimit": 0,
|
||||
"isReservationRequired": true,
|
||||
"isStreamingAvailable": false,
|
||||
"clubZone": "Main gym floor",
|
||||
"instructorId": 0,
|
||||
"classTypeId": 20193,
|
||||
"clubId": 960,
|
||||
"companyId": 251,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
`GET /v1/Classes/ClassesTypes?timestamp=0` — class-type catalogue (`name`,
|
||||
`description`, `photoUrl`, `isAvailableInMobileApp`). `classTypeId` on a class
|
||||
points here.
|
||||
|
||||
`GET /v1/Classes/Tags?timestamp=0` — the tag vocabulary (`type`, `name`,
|
||||
`photoUrl`), e.g. `Strength`, `Yoga`, `Cardio`.
|
||||
|
||||
`GET /v1/Classes/ClassesTypesTags?timestamp=0` — many-to-many join of `tagId` ↔
|
||||
`classTypeId`.
|
||||
|
||||
`GET /v1/Classes/ClassesTypesRatingSummaries?timestamp=0` — aggregate `rating` +
|
||||
`ratingsCount` per `classTypeId`.
|
||||
|
||||
`GET /v1/Classes/ClassesRatings`, `GET /v1/Classes/ClassesVisits`,
|
||||
`GET /v1/Classes/Favourites` — per-account ratings, visit history, and favourited
|
||||
classes. All empty in capture.
|
||||
|
||||
### Instructors
|
||||
|
||||
`GET /v1/Instructors/Instructors?timestamp=0` — instructor directory. Large list
|
||||
(hundreds of rows), mostly `isActive: false` and/or `isDeleted: true` legacy
|
||||
staff; `position` is a department label (`Swim`, `Tennis`, `Sales`, …). Only
|
||||
needed to resolve a booking's `instructorId` to a name.
|
||||
|
||||
```json
|
||||
{
|
||||
"firstName": "<first>",
|
||||
"lastName": "<last>",
|
||||
"displayName": "<name>",
|
||||
"position": "Swim",
|
||||
"sex": "Female",
|
||||
"isActive": false,
|
||||
"photoUrl": null,
|
||||
"description": null,
|
||||
"companyId": 251,
|
||||
"id": 0,
|
||||
"isDeleted": false
|
||||
}
|
||||
```
|
||||
|
||||
`GET /v1/Instructors/InstructorsClubs?timestamp=0` — join of `instructorId` ↔
|
||||
`clubId`. `GET /v1/Instructors/Favourites` — favourited instructors (empty in
|
||||
capture).
|
||||
|
||||
`GET /v1/PersonalTrainings/PersonalTrainingsTypes?timestamp=0` — PT session-type
|
||||
catalogue; `personalTrainingTypeId` on a PT booking points here. This is where the
|
||||
session `duration` lives (the booking itself carries only start/end). `name`
|
||||
ranges over paid sessions (`"60 min PT session"`, `"45 min PT session FREE"`),
|
||||
consultations/reviews, and non-session blocks (`"Lunch 30 min"`, `"Shower 15
|
||||
min"`). Many rows are `isDeleted: true` legacy types.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "60 min PT session",
|
||||
"duration": "01:00",
|
||||
"productId": 105674,
|
||||
"companyId": 251,
|
||||
"id": 0,
|
||||
"isDeleted": false
|
||||
}
|
||||
```
|
||||
|
||||
### Products & pricing
|
||||
|
||||
`GET /v1/Products/Products?timestamp=0` — purchasable products/services:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Squash 1 hour",
|
||||
"description": "",
|
||||
"type": "Service",
|
||||
"availableFor": "Everyone",
|
||||
"defaultPriceGross": { "value": "50.00", "currencyIso": "EUR" },
|
||||
"validityPeriodInSeconds": null,
|
||||
"isAvailable": true,
|
||||
"isVisibleForSale": true,
|
||||
"companyId": 251,
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `GET /v1/Products/ProductsCategories?timestamp=0` — category tree (`name`,
|
||||
`order`, `parentCategoryId`).
|
||||
- `GET /v1/Products/ProductsProductsCategories?timestamp=0` — join `productId` ↔
|
||||
`productCategoryId`.
|
||||
- `GET /v1/Products/ProductsClubs?timestamp=0` — per-club price overrides
|
||||
(`priceGross`, `productId`, `clubId`).
|
||||
- `GET /v1/Products/AccountProducts?timestamp=0` — products the account owns
|
||||
(`quantity.{initialQuantity,currentQuantity}`, `purchaseDateUtc`,
|
||||
`expireDateUtc`).
|
||||
- `GET /v1/Products/DiscountedProductPrice?...` — computed price for a product:
|
||||
`{ "productId": 0, "clubId": 962, "gross": 3.0, "net": 2.44, "vat": 0.56 }`.
|
||||
- `GET /v1/Products/ProductsPaymentsPlans` — empty in capture.
|
||||
|
||||
### Engagement & timeline
|
||||
|
||||
`GET /v1/Timeline/Timeline?timestamp=0` — the account's activity feed:
|
||||
|
||||
```json
|
||||
{ "accountId": 0, "activityType": "ClubVisit", "trackingServiceId": 16037, "startDate": "2026-04-20T09:31:58+00:00", "id": 0 }
|
||||
```
|
||||
|
||||
`GET /v1/Timeline/TimelineElementsDetails?timestamp=0` — key/value detail rows for
|
||||
a timeline element (`timelineElementId`, `type`, `value`, `valueType`), e.g.
|
||||
`type: "ClubName", value: "West Wood Club Dun Laoghaire"`.
|
||||
|
||||
`GET /v1/Referrals/ReferralRule?timestamp=0` — referral programme copy (`title`,
|
||||
`description`). `GET /v1/Referrals/ReferralsPrizes`, `GET /v1/Campaigns/Banners`,
|
||||
`GET /v1/PushNotifications/Notifications`, `GET /v1/Goals/Goals` — empty in
|
||||
capture.
|
||||
|
||||
### Fitness tracking (third-party)
|
||||
|
||||
These drive connections to external wearables/services — the OAuth flows behind
|
||||
the `refreshToken`/`oauthToken` classes in the app, unrelated to the PerfectGym
|
||||
session.
|
||||
|
||||
`GET /v1/TrackingServices/Services?timestamp=0` — available services and their
|
||||
OAuth config:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Fitbit",
|
||||
"description": "The most popular fitness wearable",
|
||||
"connectionDetails": {
|
||||
"authMethod": "OAuth2",
|
||||
"authUrl": "https://www.fitbit.com/oauth2/authorize?client_id=...&redirect_uri=pgg://fitbit.callback&scope=activity%20profile%20weight",
|
||||
"redirectUrl": "pgg://fitbit.callback"
|
||||
},
|
||||
"color": "#00B0B8",
|
||||
"iconUrl": "https://.../fitbit_icon.png",
|
||||
"id": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `GET /v1/TrackingServices/ServicesActivities?timestamp=0` — activity types per
|
||||
service (`activityType`, `trackingServiceId`).
|
||||
- `GET /v1/TrackingServices/ServicesActivitiesConfigurations?timestamp=0` —
|
||||
per-account on/off per activity (`trackingServiceActivityId`, `isTurnedOn`).
|
||||
- `GET /v1/TrackingServices/ServicesConnections` — the account's active
|
||||
connections. Empty in capture.
|
||||
|
||||
### Settings, auth & lifecycle
|
||||
|
||||
`GET /v1/FeaturesSettings/FeaturesSettings?timestamp=0` — per-tenant feature
|
||||
flags; worth checking before assuming a feature works:
|
||||
|
||||
```json
|
||||
{ "featureName": "ClubWhoIsIn", "isAvailable": true, "companyId": 251, "id": 0 }
|
||||
```
|
||||
|
||||
Observed flags include `MobileCheckIn`, `Classes`, `ClubWhoIsIn`, `Ratings`,
|
||||
`PersonalTrainings`, `FacilityBooking`, `Goals`, `PerfectScore`, `Instructors`
|
||||
(available) and `FamilyBooking`, `ContractPayments`, `ProductPayments`, `Courses`
|
||||
(unavailable).
|
||||
|
||||
`GET /v1/Clubs/Contacts`, `Clubs/Equipment`, `Clubs/Photos`, `Clubs/Urls`,
|
||||
`Clubs/Favourites` (all `companyId`+`timestamp`) — per-club detail lists, all
|
||||
empty in capture.
|
||||
|
||||
`GET /v1/Authorize/OnlineJoining` — returns `{ "onlineJoiningUrl": null }` (sign-up
|
||||
web flow, disabled here).
|
||||
|
||||
`POST /v1/Authorize/VerifyEmail` — pre-login check; returns `{ "action": "LogIn" }`
|
||||
(vs a sign-up action) to decide whether an email already has an account.
|
||||
|
||||
`POST /v1/ApplicationLifetime/ApplicationStarted` and
|
||||
`POST /v1/ApplicationLifetime/ApplicationNeedsRefreshedUserData` — telemetry/sync
|
||||
pings; both return `null` data. Not needed by the integration.
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import BASE_URL
|
||||
from .const import BASE_URL, WHITE_LABEL_ID
|
||||
|
||||
|
||||
class WestWoodApiError(Exception):
|
||||
@@ -36,6 +36,7 @@ class WestWoodClient:
|
||||
headers={
|
||||
'Authorization': f'bearer {self._token}',
|
||||
'Accept': 'application/json',
|
||||
'X-Go-White-Label-ID': WHITE_LABEL_ID,
|
||||
},
|
||||
) as resp:
|
||||
if resp.status in (401, 403):
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -4,9 +4,18 @@ from datetime import timedelta
|
||||
|
||||
DOMAIN = 'west_wood_club'
|
||||
|
||||
# Shown as the device name; also the prefix the API puts on every club name
|
||||
# (e.g. 'West Wood Club Dun Laoghaire'), stripped from per-club entity names.
|
||||
DEVICE_NAME = 'West Wood Club'
|
||||
|
||||
# PerfectGym Go backend (West Wood is a white-label tenant).
|
||||
BASE_URL = 'https://goapi2.perfectgym.com'
|
||||
|
||||
# Hardcoded West Wood white-label tenant ID (baked into the app binary). Sent as
|
||||
# the X-Go-White-Label-ID header. The server doesn't require it, but it matches
|
||||
# what the app sends. See api.md.
|
||||
WHITE_LABEL_ID = '7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc'
|
||||
|
||||
# Config entry keys.
|
||||
CONF_TOKEN = 'token'
|
||||
CONF_CLUBS = 'clubs'
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": [],
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.3"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 .const import CONF_CLUBS, DEVICE_NAME, DOMAIN
|
||||
from .coordinator import WestWoodConfigEntry, WestWoodCoordinator
|
||||
|
||||
|
||||
@@ -46,12 +46,14 @@ class WestWoodOccupancySensor(CoordinatorEntity[WestWoodCoordinator], SensorEnti
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._club_id = club_id
|
||||
self._attr_name = name
|
||||
# has_entity_name prepends the device name, so drop the duplicate prefix
|
||||
# the API includes (e.g. 'West Wood Club Dun Laoghaire' -> 'Dun Laoghaire').
|
||||
self._attr_name = name.removeprefix(f'{DEVICE_NAME} ') or 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',
|
||||
name=DEVICE_NAME,
|
||||
manufacturer='PerfectGym',
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user