Initial commit

This commit is contained in:
2026-06-13 23:22:53 +01:00
commit ea1eccb4a5
7 changed files with 389 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -0,0 +1,4 @@
.direnv/
*.mitm
token.txt
__pycache__/
+71
View File
@@ -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.
+147
View File
@@ -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
View File
@@ -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
}
+21
View File
@@ -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
];
};
});
}
+84
View File
@@ -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()