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