Add Home Assistant integration
Token-authenticated custom component exposing live per-club member counts as sensors under a single "West Wood Club" device, fed by one coordinator polling `/v1/Clubs/WhoIsInCount`. Packaged via `buildHomeAssistantComponent` with a flake package + overlay so it can be used in `services.home-assistant.customComponents`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""The West Wood Club integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .api import WestWoodClient
|
||||
from .const import CONF_TOKEN
|
||||
from .coordinator import WestWoodConfigEntry, WestWoodCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: WestWoodConfigEntry) -> bool:
|
||||
"""Set up West Wood Club from a config entry."""
|
||||
client = WestWoodClient(async_get_clientsession(hass), entry.data[CONF_TOKEN])
|
||||
coordinator = WestWoodCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: WestWoodConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Thin async client for the West Wood Club (PerfectGym) API.
|
||||
|
||||
Authentication is a long-lived bearer token supplied by the user (see
|
||||
``get-token.py`` in the repo root for how to obtain one). Only the
|
||||
``Authorization`` header is required by the backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import BASE_URL
|
||||
|
||||
|
||||
class WestWoodApiError(Exception):
|
||||
"""A request to the API failed."""
|
||||
|
||||
|
||||
class WestWoodAuthError(WestWoodApiError):
|
||||
"""The token was rejected (expired or revoked)."""
|
||||
|
||||
|
||||
class WestWoodClient:
|
||||
"""Minimal client wrapping the endpoints the integration needs."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str) -> None:
|
||||
self._session = session
|
||||
self._token = token
|
||||
|
||||
async def _get(self, path: str, **params: str) -> list[dict]:
|
||||
"""GET a wrapped endpoint and return its ``data`` list."""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f'{BASE_URL}{path}',
|
||||
params=params,
|
||||
headers={
|
||||
'Authorization': f'bearer {self._token}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
) as resp:
|
||||
if resp.status in (401, 403):
|
||||
raise WestWoodAuthError(f'token rejected (HTTP {resp.status})')
|
||||
resp.raise_for_status()
|
||||
payload = await resp.json()
|
||||
except aiohttp.ClientError as err:
|
||||
raise WestWoodApiError(str(err)) from err
|
||||
|
||||
if payload.get('errors'):
|
||||
raise WestWoodApiError(str(payload['errors']))
|
||||
return payload.get('data') or []
|
||||
|
||||
async def async_get_clubs(self) -> dict[int, str]:
|
||||
"""Return ``{club_id: name}`` for every club in the tenant."""
|
||||
data = await self._get('/v1/Clubs/Clubs', timestamp='0')
|
||||
return {club['id']: club['name'] for club in data}
|
||||
|
||||
async def async_get_member_counts(self) -> dict[int, int]:
|
||||
"""Return ``{club_id: live_member_count}``."""
|
||||
data = await self._get('/v1/Clubs/WhoIsInCount')
|
||||
return {row['clubId']: row['count'] for row in data}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Config flow for West Wood Club."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .api import WestWoodApiError, WestWoodAuthError, WestWoodClient
|
||||
from .const import CONF_CLUBS, CONF_TOKEN, DOMAIN
|
||||
|
||||
|
||||
class WestWoodConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the UI configuration flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._token: str | None = None
|
||||
self._clubs: dict[int, str] = {}
|
||||
|
||||
async def _validate_token(self, token: str) -> dict[int, str]:
|
||||
"""Return the club list if the token works, else raise."""
|
||||
client = WestWoodClient(async_get_clientsession(self.hass), token)
|
||||
return await client.async_get_clubs()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""First step: collect and validate the bearer token."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
token = user_input[CONF_TOKEN].strip()
|
||||
try:
|
||||
self._clubs = await self._validate_token(token)
|
||||
except WestWoodAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
except WestWoodApiError:
|
||||
errors['base'] = 'cannot_connect'
|
||||
else:
|
||||
self._token = token
|
||||
return await self.async_step_clubs()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='user',
|
||||
data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_clubs(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Second step: pick which clubs to create sensors for."""
|
||||
if user_input is not None:
|
||||
selected = {
|
||||
club_id: name
|
||||
for club_id, name in self._clubs.items()
|
||||
if str(club_id) in user_input[CONF_CLUBS]
|
||||
}
|
||||
return self.async_create_entry(
|
||||
title='West Wood Club',
|
||||
data={
|
||||
CONF_TOKEN: self._token,
|
||||
# Keys are stringified for JSON storage / multi_select.
|
||||
CONF_CLUBS: {str(cid): name for cid, name in selected.items()},
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='clubs',
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CLUBS): cv.multi_select(
|
||||
{str(cid): name for cid, name in self._clubs.items()}
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Start reauth when the stored token stops working."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Let the user paste a fresh token, keeping the existing clubs."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
token = user_input[CONF_TOKEN].strip()
|
||||
try:
|
||||
await self._validate_token(token)
|
||||
except WestWoodAuthError:
|
||||
errors['base'] = 'invalid_auth'
|
||||
except WestWoodApiError:
|
||||
errors['base'] = 'cannot_connect'
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='reauth_confirm',
|
||||
data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Constants for the West Wood Club integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = 'west_wood_club'
|
||||
|
||||
# PerfectGym Go backend (West Wood is a white-label tenant).
|
||||
BASE_URL = 'https://goapi2.perfectgym.com'
|
||||
|
||||
# Config entry keys.
|
||||
CONF_TOKEN = 'token'
|
||||
CONF_CLUBS = 'clubs'
|
||||
|
||||
# How often to poll live occupancy.
|
||||
UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Data update coordinator for West Wood Club occupancy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import WestWoodApiError, WestWoodAuthError, WestWoodClient
|
||||
from .const import DOMAIN, UPDATE_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type WestWoodConfigEntry = ConfigEntry[WestWoodCoordinator]
|
||||
|
||||
|
||||
class WestWoodCoordinator(DataUpdateCoordinator[dict[int, int]]):
|
||||
"""Polls live member counts for all clubs in one request."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: WestWoodConfigEntry,
|
||||
client: WestWoodClient,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=entry,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> dict[int, int]:
|
||||
try:
|
||||
return await self.client.async_get_member_counts()
|
||||
except WestWoodAuthError as err:
|
||||
# Triggers Home Assistant's reauth flow to paste a fresh token.
|
||||
raise ConfigEntryAuthFailed(str(err)) from err
|
||||
except WestWoodApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "west_wood_club",
|
||||
"name": "West Wood Club",
|
||||
"codeowners": ["@deplayer0"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://git.nul.ie/dev/hass-west-wood",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": [],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Live member-count sensors for West Wood Club."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_CLUBS, DOMAIN
|
||||
from .coordinator import WestWoodConfigEntry, WestWoodCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: WestWoodConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up one occupancy sensor per selected club."""
|
||||
coordinator = entry.runtime_data
|
||||
clubs: dict[str, str] = entry.data[CONF_CLUBS]
|
||||
async_add_entities(
|
||||
WestWoodOccupancySensor(coordinator, entry, int(club_id), name)
|
||||
for club_id, name in clubs.items()
|
||||
)
|
||||
|
||||
|
||||
class WestWoodOccupancySensor(CoordinatorEntity[WestWoodCoordinator], SensorEntity):
|
||||
"""Number of members currently checked in at a club."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_icon = 'mdi:account-group'
|
||||
_attr_native_unit_of_measurement = 'members'
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WestWoodCoordinator,
|
||||
entry: WestWoodConfigEntry,
|
||||
club_id: int,
|
||||
name: str,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._club_id = club_id
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f'{entry.entry_id}_{club_id}'
|
||||
# All club sensors share one device so they group together in the UI.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name='West Wood Club',
|
||||
manufacturer='PerfectGym',
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
return self.coordinator.data.get(self._club_id)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and self._club_id in self.coordinator.data
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "West Wood Club",
|
||||
"description": "Paste a bearer token for the West Wood Club app. You can generate one with get-token.py from the repository.",
|
||||
"data": {
|
||||
"token": "Bearer token"
|
||||
}
|
||||
},
|
||||
"clubs": {
|
||||
"title": "Select clubs",
|
||||
"description": "Choose the clubs to create member-count sensors for.",
|
||||
"data": {
|
||||
"clubs": "Clubs"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate West Wood Club",
|
||||
"description": "The stored token was rejected. Paste a fresh bearer token.",
|
||||
"data": {
|
||||
"token": "Bearer token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "The token was rejected. Generate a new one and try again.",
|
||||
"cannot_connect": "Could not reach the West Wood Club API."
|
||||
},
|
||||
"abort": {
|
||||
"reauth_successful": "Re-authentication was successful."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "West Wood Club",
|
||||
"description": "Paste a bearer token for the West Wood Club app. You can generate one with get-token.py from the repository.",
|
||||
"data": {
|
||||
"token": "Bearer token"
|
||||
}
|
||||
},
|
||||
"clubs": {
|
||||
"title": "Select clubs",
|
||||
"description": "Choose the clubs to create member-count sensors for.",
|
||||
"data": {
|
||||
"clubs": "Clubs"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate West Wood Club",
|
||||
"description": "The stored token was rejected. Paste a fresh bearer token.",
|
||||
"data": {
|
||||
"token": "Bearer token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "The token was rejected. Generate a new one and try again.",
|
||||
"cannot_connect": "Could not reach the West Wood Club API."
|
||||
},
|
||||
"abort": {
|
||||
"reauth_successful": "Re-authentication was successful."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user