Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.direnv/
|
||||||
|
*.mitm
|
||||||
|
token.txt
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `api.md` — documented PerfectGym endpoints (login, club list, live occupancy),
|
||||||
|
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).
|
||||||
|
- `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.
|
||||||
|
- `token.txt` — a captured/extracted bearer token. Also gitignored.
|
||||||
|
- `flake.nix` / `.envrc` — Nix dev shell (provides `mitmproxy`).
|
||||||
|
|
||||||
|
## Dev environment
|
||||||
|
|
||||||
|
Nix flake + nix-direnv. `direnv allow` (or `nix develop`) enters a shell with
|
||||||
|
`mitmproxy`. There is no `python3` on PATH outside the shell — run Python via
|
||||||
|
`nix develop --command python ...`.
|
||||||
|
|
||||||
|
Gotchas:
|
||||||
|
- Nix flakes only see **git-tracked** files. `git add` new files before
|
||||||
|
`nix develop` / `nix flake lock`, or Nix errors with "not tracked by Git".
|
||||||
|
- `nix develop --command` may change cwd — use **absolute paths** when a script
|
||||||
|
opens `android-flows.mitm`.
|
||||||
|
|
||||||
|
## Working with the capture
|
||||||
|
|
||||||
|
Read flows with the mitmproxy Python API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mitmproxy.io import FlowReader
|
||||||
|
from mitmproxy.http import HTTPFlow
|
||||||
|
|
||||||
|
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:
|
||||||
|
... # flow.request / flow.response
|
||||||
|
```
|
||||||
|
|
||||||
|
When dumping flows, redact `Authorization` / `Cookie` headers and the login body
|
||||||
|
(email + password) before writing anything to a tracked file.
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
- 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).
|
||||||
|
- **Live occupancy:** `GET /v1/Clubs/WhoIsInCount` → `count` per `clubId` (the main
|
||||||
|
sensor signal). `clubId` maps to `id` from `GET /v1/Clubs/Clubs`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The capture and `token.txt` hold live credentials/tokens. Keep them gitignored,
|
||||||
|
and prefer the `WESTWOOD_EMAIL` / `WESTWOOD_PASSWORD` env vars (read by
|
||||||
|
`get-token.py`) over hardcoding credentials anywhere.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# West Wood Club API
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Base
|
||||||
|
|
||||||
|
- **Base URL:** `https://goapi2.perfectgym.com`
|
||||||
|
- All responses are wrapped as `{ "data": ..., "errors": ... }`.
|
||||||
|
- `errors` is `null` on success.
|
||||||
|
|
||||||
|
### Common request headers
|
||||||
|
|
||||||
|
The app sends the headers below on every request, but **most are not required**.
|
||||||
|
Testing `GET /v1/Clubs/Clubs` with only an `Authorization` header (no `X-Go-*`
|
||||||
|
headers and no app `User-Agent`) returned `200` with the full response. So for an
|
||||||
|
authenticated request, only `Authorization` is needed; the `X-Go-*` headers and
|
||||||
|
the app-specific `User-Agent` can be omitted.
|
||||||
|
|
||||||
|
| Header | Value | Required? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Authorization` | `bearer <token>` | Yes (authenticated endpoints) |
|
||||||
|
| `Accept` | `application/json` | No |
|
||||||
|
| `Content-Type` | `application/json` | No (GET); set it for POST bodies |
|
||||||
|
| `Accept-Language` | `en` | No |
|
||||||
|
| `X-Go-App-Platform` | `Android` | No |
|
||||||
|
| `X-Go-App-Version` | `1.28.3` | No |
|
||||||
|
| `X-Go-White-Label-ID` | `7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc` | No |
|
||||||
|
| `User-Agent` | `West Wood Club/1.28.3.0 (com.perfectgym.perfectgymgo2.westwoodclub; build:1028003000; Android 16)` | No |
|
||||||
|
|
||||||
|
Note: login is different — the white-label ID is passed in the request **body**
|
||||||
|
there (see below), not as a header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login
|
||||||
|
|
||||||
|
`POST /v1/Authorize/LogInWithEmail`
|
||||||
|
|
||||||
|
Unauthenticated. Returns a bearer token used for all subsequent requests.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "<password>",
|
||||||
|
"clientApplicationInfo": {
|
||||||
|
"type": "whitelabel",
|
||||||
|
"whiteLabelId": "7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"token": "<uuid>_<uuid>",
|
||||||
|
"action": "LogIn",
|
||||||
|
"expireTime": null,
|
||||||
|
"isEmailConfirmed": true,
|
||||||
|
"tokenType": "bearer",
|
||||||
|
"authorizationHeader": "bearer <token>"
|
||||||
|
},
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Club list
|
||||||
|
|
||||||
|
`GET /v1/Clubs/Clubs?timestamp=0`
|
||||||
|
|
||||||
|
Authenticated. Lists all clubs for the tenant. The `timestamp=0` query param
|
||||||
|
requests the full list (the API supports incremental sync — `timestamp` is a
|
||||||
|
per-record version, see the `timestamp` field on each item).
|
||||||
|
|
||||||
|
**Response (truncated):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"companyId": 251,
|
||||||
|
"name": "West Wood Club Clontarf",
|
||||||
|
"description": null,
|
||||||
|
"longitude": -6.228,
|
||||||
|
"latitude": 53.3634,
|
||||||
|
"address": {
|
||||||
|
"country": "Ireland",
|
||||||
|
"city": "Dublin",
|
||||||
|
"postalCode": "D03 T6T3",
|
||||||
|
"line1": "Clontarf Road, Dublin 3",
|
||||||
|
"line2": ""
|
||||||
|
},
|
||||||
|
"isHidden": false,
|
||||||
|
"qrCodeSuffixConfig": "EndsWithWindowsNewLine",
|
||||||
|
"id": 960,
|
||||||
|
"timestamp": 1692837739,
|
||||||
|
"isDeleted": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key field: `id` is the club ID referenced by other endpoints (e.g.
|
||||||
|
`WhoIsInCount`). Known club IDs from the capture: 959, 960, 961, 962, 963, 964.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current members (live occupancy)
|
||||||
|
|
||||||
|
`GET /v1/Clubs/WhoIsInCount`
|
||||||
|
|
||||||
|
Authenticated. Returns the number of members currently checked in at each club —
|
||||||
|
the live occupancy count. This is the primary signal for an occupancy sensor.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "count": 253, "clubId": 959 },
|
||||||
|
{ "count": 294, "clubId": 960 },
|
||||||
|
{ "count": 122, "clubId": 961 },
|
||||||
|
{ "count": 59, "clubId": 962 },
|
||||||
|
{ "count": 96, "clubId": 963 },
|
||||||
|
{ "count": 70, "clubId": 964 }
|
||||||
|
],
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`clubId` maps to `id` from the club list above.
|
||||||
|
|
||||||
|
> 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.
|
||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1781074563,
|
||||||
|
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
description = "Home Assistant integration for West Wood gyms (PerfectGym API)";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
mitmproxy
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Log in to the West Wood Club (PerfectGym) API and print the bearer token.
|
||||||
|
|
||||||
|
The token is written to stdout (and nothing else), so it can be piped to a file:
|
||||||
|
|
||||||
|
python login.py > token.txt
|
||||||
|
|
||||||
|
Credentials are read from the WESTWOOD_EMAIL and WESTWOOD_PASSWORD environment
|
||||||
|
variables; any that are missing are prompted for on the terminal. Prompts and
|
||||||
|
errors go to stderr, keeping stdout clean for the token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
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)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{BASE_URL}/v1/Authorize/LogInWithEmail",
|
||||||
|
data=body,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
|
||||||
|
if payload.get("errors"):
|
||||||
|
raise SystemExit(f"login failed: {payload['errors']}")
|
||||||
|
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
token = data.get("token")
|
||||||
|
if not token:
|
||||||
|
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: ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = log_in(email, password)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
print(token)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user