pfau-occupancy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: pfau-occupancy
3
+ Version: 0.1.0
4
+ Summary: Async client for Planet Fitness AU (PerfectGym) club occupancy
5
+ Author: Dan Morgan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dancmorgan/pfau-occupancy
8
+ Project-URL: Issues, https://github.com/dancmorgan/pfau-occupancy/issues
9
+ Keywords: planet fitness,perfectgym,home assistant,occupancy
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: aiohttp>=3.9
13
+
14
+ # pfau-occupancy
15
+
16
+ Async Python client for Planet Fitness Australia club occupancy, backing a Home
17
+ Assistant integration. Data comes from the member portal (PerfectGym
18
+ ClientPortal2), which exposes a live "members currently in club" count for every
19
+ club in a single authenticated call.
20
+
21
+ This library deliberately does only the API layer: login, session handling, and
22
+ fetching occupancy. All Home Assistant concerns (entities, config flow,
23
+ coordinator) live in the [pfau-occupancy-ha](https://github.com/dancmorgan/pfau-occupancy-ha)
24
+ integration that depends on this package.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install pfau-occupancy
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ import asyncio
36
+ from pfau_occupancy import PlanetFitnessClient
37
+
38
+ async def main():
39
+ async with PlanetFitnessClient("you@example.com", "password") as client:
40
+ clubs = await client.async_get_clubs()
41
+ for club in clubs:
42
+ print(club.name, club.occupancy)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ The client logs in automatically on first fetch and re-authenticates once if the
48
+ session expires, so a long-running poller keeps working without intervention.
49
+
50
+ ## Data notes
51
+
52
+ The occupancy endpoint returns every club in one response. Each club carries a
53
+ name, address, a currently-in-club count, and a capacity limit (often null). The
54
+ response has no club ID, so the (slugified) club name is the stable identity used
55
+ for entity `unique_id`s. A club being renamed therefore looks like one club
56
+ disappearing and another appearing.
57
+
58
+ ## Disclaimer
59
+
60
+ This uses the member portal's internal endpoints, which are undocumented and may
61
+ change or break without notice. It requires your own valid membership
62
+ credentials. Use it with your own account and keep polling gentle.
@@ -0,0 +1,49 @@
1
+ # pfau-occupancy
2
+
3
+ Async Python client for Planet Fitness Australia club occupancy, backing a Home
4
+ Assistant integration. Data comes from the member portal (PerfectGym
5
+ ClientPortal2), which exposes a live "members currently in club" count for every
6
+ club in a single authenticated call.
7
+
8
+ This library deliberately does only the API layer: login, session handling, and
9
+ fetching occupancy. All Home Assistant concerns (entities, config flow,
10
+ coordinator) live in the [pfau-occupancy-ha](https://github.com/dancmorgan/pfau-occupancy-ha)
11
+ integration that depends on this package.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install pfau-occupancy
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ import asyncio
23
+ from pfau_occupancy import PlanetFitnessClient
24
+
25
+ async def main():
26
+ async with PlanetFitnessClient("you@example.com", "password") as client:
27
+ clubs = await client.async_get_clubs()
28
+ for club in clubs:
29
+ print(club.name, club.occupancy)
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ The client logs in automatically on first fetch and re-authenticates once if the
35
+ session expires, so a long-running poller keeps working without intervention.
36
+
37
+ ## Data notes
38
+
39
+ The occupancy endpoint returns every club in one response. Each club carries a
40
+ name, address, a currently-in-club count, and a capacity limit (often null). The
41
+ response has no club ID, so the (slugified) club name is the stable identity used
42
+ for entity `unique_id`s. A club being renamed therefore looks like one club
43
+ disappearing and another appearing.
44
+
45
+ ## Disclaimer
46
+
47
+ This uses the member portal's internal endpoints, which are undocumented and may
48
+ change or break without notice. It requires your own valid membership
49
+ credentials. Use it with your own account and keep polling gentle.
@@ -0,0 +1,20 @@
1
+ """pfau_occupancy: async client for Planet Fitness AU club occupancy."""
2
+ from .client import PlanetFitnessClient, DEFAULT_BASE_URL
3
+ from .exceptions import (
4
+ PlanetFitnessError,
5
+ PlanetFitnessAuthError,
6
+ PlanetFitnessConnectionError,
7
+ )
8
+ from .models import Club, slugify_club
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "PlanetFitnessClient",
14
+ "DEFAULT_BASE_URL",
15
+ "PlanetFitnessError",
16
+ "PlanetFitnessAuthError",
17
+ "PlanetFitnessConnectionError",
18
+ "Club",
19
+ "slugify_club",
20
+ ]
@@ -0,0 +1,167 @@
1
+ """Async client for the Planet Fitness AU (PerfectGym ClientPortal2) portal."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+
9
+ from .exceptions import (
10
+ PlanetFitnessAuthError,
11
+ PlanetFitnessConnectionError,
12
+ )
13
+ from .models import Club
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ DEFAULT_BASE_URL = "https://planetfitness.perfectgym.com.au/clientportal2"
18
+
19
+ # Sent on every XHR by the browser. The backend checks x-requested-with;
20
+ # cp-lang / cp-mode are constants the portal expects.
21
+ _BASE_HEADERS = {
22
+ "x-requested-with": "XMLHttpRequest",
23
+ "cp-lang": "en",
24
+ "cp-mode": "desktop",
25
+ "accept": "application/json, text/plain, */*",
26
+ }
27
+
28
+
29
+ class PlanetFitnessClient:
30
+ """Talks to the member portal: login, then fetch club occupancy.
31
+
32
+ Holds one aiohttp session so the auth cookie persists across calls. Credentials
33
+ are kept so the client can transparently re-login when the session expires,
34
+ which for a long-running poller it eventually will.
35
+
36
+ The caller owns the aiohttp session lifecycle by passing one in (this is what
37
+ Home Assistant wants: it supplies its shared session). If none is given, one is
38
+ created and closed by `async with` / `close()`.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ email: str,
44
+ password: str,
45
+ session: aiohttp.ClientSession | None = None,
46
+ base_url: str = DEFAULT_BASE_URL,
47
+ ) -> None:
48
+ self._email = email
49
+ self._password = password
50
+ self._base_url = base_url.rstrip("/")
51
+ self._session = session
52
+ self._owns_session = session is None
53
+ self._authenticated = False
54
+
55
+ async def __aenter__(self) -> "PlanetFitnessClient":
56
+ return self
57
+
58
+ async def __aexit__(self, *exc: object) -> None:
59
+ await self.close()
60
+
61
+ async def close(self) -> None:
62
+ if self._owns_session and self._session is not None:
63
+ await self._session.close()
64
+
65
+ def _get_session(self) -> aiohttp.ClientSession:
66
+ if self._session is None:
67
+ # cookie_jar persists the session cookie across requests.
68
+ self._session = aiohttp.ClientSession()
69
+ return self._session
70
+
71
+ def _headers(self) -> dict[str, str]:
72
+ return {
73
+ **_BASE_HEADERS,
74
+ "origin": "https://planetfitness.perfectgym.com.au",
75
+ "referer": self._base_url + "/",
76
+ }
77
+
78
+ async def async_login(self) -> dict[str, Any]:
79
+ """Authenticate and return the member record.
80
+
81
+ Raises PlanetFitnessAuthError on bad credentials, PlanetFitnessConnectionError
82
+ on transport problems.
83
+ """
84
+ session = self._get_session()
85
+ payload = {"RememberMe": True, "Login": self._email, "Password": self._password}
86
+ try:
87
+ async with session.post(
88
+ f"{self._base_url}/Auth/Login",
89
+ json=payload,
90
+ headers=self._headers(),
91
+ timeout=aiohttp.ClientTimeout(total=20),
92
+ ) as resp:
93
+ if resp.status in (400, 401, 403):
94
+ raise PlanetFitnessAuthError(
95
+ f"Login rejected (HTTP {resp.status}); check credentials."
96
+ )
97
+ if resp.status != 200:
98
+ raise PlanetFitnessConnectionError(
99
+ f"Unexpected login status HTTP {resp.status}."
100
+ )
101
+ data = await resp.json()
102
+ except aiohttp.ClientError as err:
103
+ raise PlanetFitnessConnectionError(f"Login request failed: {err}") from err
104
+
105
+ member = _extract_member(data)
106
+ if not member:
107
+ # 200 but no member usually means the session cookie was not set.
108
+ raise PlanetFitnessAuthError("Login succeeded but no member returned.")
109
+ self._authenticated = True
110
+ _LOGGER.debug("Authenticated as member %s", member.get("Id"))
111
+ return member
112
+
113
+ async def async_get_clubs(self) -> list[Club]:
114
+ """Fetch occupancy for all clubs.
115
+
116
+ Logs in automatically if not yet authenticated, and re-logs-in once if the
117
+ session has expired (detected as an auth failure mid-fetch).
118
+ """
119
+ if not self._authenticated:
120
+ await self.async_login()
121
+ try:
122
+ return await self._fetch_clubs()
123
+ except PlanetFitnessAuthError:
124
+ # Session likely expired. Re-login once, then retry.
125
+ _LOGGER.debug("Session expired; re-authenticating.")
126
+ self._authenticated = False
127
+ await self.async_login()
128
+ return await self._fetch_clubs()
129
+
130
+ async def _fetch_clubs(self) -> list[Club]:
131
+ session = self._get_session()
132
+ try:
133
+ async with session.post(
134
+ f"{self._base_url}/Clubs/Clubs/GetMembersInClubs",
135
+ headers=self._headers(),
136
+ timeout=aiohttp.ClientTimeout(total=20),
137
+ ) as resp:
138
+ if resp.status in (401, 403):
139
+ raise PlanetFitnessAuthError("Not authenticated for club fetch.")
140
+ if resp.status != 200:
141
+ raise PlanetFitnessConnectionError(
142
+ f"Club fetch failed HTTP {resp.status}."
143
+ )
144
+ # An expired session can also return the login page (HTML) with a
145
+ # 200. Guard against that so we don't parse HTML as JSON.
146
+ ctype = resp.headers.get("content-type", "")
147
+ if "application/json" not in ctype:
148
+ raise PlanetFitnessAuthError(
149
+ "Club fetch returned non-JSON; session likely expired."
150
+ )
151
+ data = await resp.json()
152
+ except aiohttp.ClientError as err:
153
+ raise PlanetFitnessConnectionError(f"Club fetch failed: {err}") from err
154
+
155
+ raw_list = data.get("UsersInClubList") or []
156
+ return [Club.from_api(item) for item in raw_list]
157
+
158
+
159
+ def _extract_member(data: dict) -> dict:
160
+ """Login returns {"User": {"Member": {...}}}; Identity returns {"Member": {...}}.
161
+
162
+ Handle both so either response shape confirms authentication.
163
+ """
164
+ user = data.get("User")
165
+ if isinstance(user, dict) and user.get("Member"):
166
+ return user["Member"]
167
+ return data.get("Member") or {}
@@ -0,0 +1,18 @@
1
+ """Exceptions for pfau_occupancy."""
2
+
3
+
4
+ class PlanetFitnessError(Exception):
5
+ """Base error for all pfau_occupancy failures."""
6
+
7
+
8
+ class PlanetFitnessAuthError(PlanetFitnessError):
9
+ """Raised when login fails or the session is no longer authenticated.
10
+
11
+ The Home Assistant coordinator treats this specially: it triggers a
12
+ re-login (and, if that fails, a reauth flow) rather than marking sensors
13
+ unavailable, so a dropped session is never confused with an empty club.
14
+ """
15
+
16
+
17
+ class PlanetFitnessConnectionError(PlanetFitnessError):
18
+ """Raised on network/transport problems talking to the portal."""
@@ -0,0 +1,51 @@
1
+ """Data models for pfau_occupancy."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import unicodedata
6
+ from dataclasses import dataclass
7
+
8
+
9
+ def slugify_club(name: str) -> str:
10
+ """Produce a stable, filesystem/entity-safe key from a club name.
11
+
12
+ Used to build entity unique_ids. Since the occupancy endpoint exposes no
13
+ club ID, the (slugified) name is the only stable identity we have. Two
14
+ different clubs must not collide here, so we keep it close to the original:
15
+ lowercased, accents stripped, non-alphanumerics collapsed to underscores.
16
+ """
17
+ normalized = unicodedata.normalize("NFKD", name)
18
+ ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
19
+ slug = re.sub(r"[^a-z0-9]+", "_", ascii_name.lower()).strip("_")
20
+ return slug or "club"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Club:
25
+ """A single club's occupancy snapshot."""
26
+
27
+ name: str
28
+ address: str | None
29
+ occupancy: int | None
30
+ limit: int | None
31
+
32
+ @property
33
+ def key(self) -> str:
34
+ """Stable identity for this club (used for the entity unique_id)."""
35
+ return slugify_club(self.name)
36
+
37
+ @property
38
+ def percent_full(self) -> float | None:
39
+ """Occupancy as a percentage of the club's stated limit, if known."""
40
+ if not self.limit or self.occupancy is None:
41
+ return None
42
+ return round(self.occupancy / self.limit * 100, 1)
43
+
44
+ @classmethod
45
+ def from_api(cls, raw: dict) -> "Club":
46
+ return cls(
47
+ name=raw["ClubName"],
48
+ address=raw.get("ClubAddress"),
49
+ occupancy=raw.get("UsersCountCurrentlyInClub"),
50
+ limit=raw.get("UsersLimit"),
51
+ )
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: pfau-occupancy
3
+ Version: 0.1.0
4
+ Summary: Async client for Planet Fitness AU (PerfectGym) club occupancy
5
+ Author: Dan Morgan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dancmorgan/pfau-occupancy
8
+ Project-URL: Issues, https://github.com/dancmorgan/pfau-occupancy/issues
9
+ Keywords: planet fitness,perfectgym,home assistant,occupancy
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: aiohttp>=3.9
13
+
14
+ # pfau-occupancy
15
+
16
+ Async Python client for Planet Fitness Australia club occupancy, backing a Home
17
+ Assistant integration. Data comes from the member portal (PerfectGym
18
+ ClientPortal2), which exposes a live "members currently in club" count for every
19
+ club in a single authenticated call.
20
+
21
+ This library deliberately does only the API layer: login, session handling, and
22
+ fetching occupancy. All Home Assistant concerns (entities, config flow,
23
+ coordinator) live in the [pfau-occupancy-ha](https://github.com/dancmorgan/pfau-occupancy-ha)
24
+ integration that depends on this package.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install pfau-occupancy
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ import asyncio
36
+ from pfau_occupancy import PlanetFitnessClient
37
+
38
+ async def main():
39
+ async with PlanetFitnessClient("you@example.com", "password") as client:
40
+ clubs = await client.async_get_clubs()
41
+ for club in clubs:
42
+ print(club.name, club.occupancy)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ The client logs in automatically on first fetch and re-authenticates once if the
48
+ session expires, so a long-running poller keeps working without intervention.
49
+
50
+ ## Data notes
51
+
52
+ The occupancy endpoint returns every club in one response. Each club carries a
53
+ name, address, a currently-in-club count, and a capacity limit (often null). The
54
+ response has no club ID, so the (slugified) club name is the stable identity used
55
+ for entity `unique_id`s. A club being renamed therefore looks like one club
56
+ disappearing and another appearing.
57
+
58
+ ## Disclaimer
59
+
60
+ This uses the member portal's internal endpoints, which are undocumented and may
61
+ change or break without notice. It requires your own valid membership
62
+ credentials. Use it with your own account and keep polling gentle.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ pfau_occupancy/__init__.py
4
+ pfau_occupancy/client.py
5
+ pfau_occupancy/exceptions.py
6
+ pfau_occupancy/models.py
7
+ pfau_occupancy.egg-info/PKG-INFO
8
+ pfau_occupancy.egg-info/SOURCES.txt
9
+ pfau_occupancy.egg-info/dependency_links.txt
10
+ pfau_occupancy.egg-info/requires.txt
11
+ pfau_occupancy.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ aiohttp>=3.9
@@ -0,0 +1 @@
1
+ pfau_occupancy
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pfau-occupancy"
7
+ version = "0.1.0"
8
+ description = "Async client for Planet Fitness AU (PerfectGym) club occupancy"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Dan Morgan" }]
13
+ keywords = ["planet fitness", "perfectgym", "home assistant", "occupancy"]
14
+ dependencies = ["aiohttp>=3.9"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/dancmorgan/pfau-occupancy"
18
+ Issues = "https://github.com/dancmorgan/pfau-occupancy/issues"
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["pfau_occupancy*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+