From ea1eccb4a5caf2b008191f01e2818d26eccff63d Mon Sep 17 00:00:00 2001 From: Jack O'Sullivan Date: Sat, 13 Jun 2026 23:22:53 +0100 Subject: [PATCH] Initial commit --- .envrc | 1 + .gitignore | 4 ++ AGENTS.md | 71 +++++++++++++++++++++++++ api.md | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++ flake.lock | 61 +++++++++++++++++++++ flake.nix | 21 ++++++++ get-token.py | 84 +++++++++++++++++++++++++++++ 7 files changed, 389 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 api.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 get-token.py diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b397d1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.direnv/ +*.mitm +token.txt +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..12eb64c --- /dev/null +++ b/AGENTS.md @@ -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 ` 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. diff --git a/api.md b/api.md new file mode 100644 index 0000000..197fa7f --- /dev/null +++ b/api.md @@ -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 ` | 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": "", + "clientApplicationInfo": { + "type": "whitelabel", + "whiteLabelId": "7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc" + } +} +``` + +**Response:** + +```json +{ + "data": { + "token": "_", + "action": "LogIn", + "expireTime": null, + "isEmailConfirmed": true, + "tokenType": "bearer", + "authorizationHeader": "bearer " + }, + "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. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4e41dd2 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..919e519 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + }); +} diff --git a/get-token.py b/get-token.py new file mode 100644 index 0000000..2d4fdb2 --- /dev/null +++ b/get-token.py @@ -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()