From 4acafa6e09bd69096443bab7be5a96faf8d5e414 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 28 Mar 2026 13:26:04 +0000 Subject: [PATCH 1/6] chore: update dev settings --- .vscode/extensions.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..55d3c8f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "anthropic.claude-code" + ] +} From 603decb83db7b047a95dedf0bed635bed7ad9465 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 28 Mar 2026 13:27:22 +0000 Subject: [PATCH 2/6] fix: prevent infinite update loops in binary_sensor and sensor entities --- custom_components/clouding/binary_sensor.py | 21 +++++++++++---------- custom_components/clouding/sensor.py | 5 ++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/custom_components/clouding/binary_sensor.py b/custom_components/clouding/binary_sensor.py index ddde4d6..6c1778f 100644 --- a/custom_components/clouding/binary_sensor.py +++ b/custom_components/clouding/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import slugify from .const import ATTRIBUTION from .coordinator import CloudingConfigEntry, CloudingDataUpdateCoordinator @@ -115,8 +115,13 @@ def _handle_coordinator_update(self) -> None: """ + prev_is_on = self._attr_is_on + prev_extra = self._attr_extra_state_attributes + self._update_attr() - super()._handle_coordinator_update() + + if self._attr_is_on != prev_is_on or self._attr_extra_state_attributes != prev_extra: + super()._handle_coordinator_update() @property def is_on(self) -> bool: # pyright: ignore[reportIncompatibleVariableOverride] @@ -127,9 +132,7 @@ def is_on(self) -> bool: # pyright: ignore[reportIncompatibleVariableOverride] """ - return bool( - getattr(self.coordinator.api.servers[self._server_unique_id], "attr_" + self.entity_description.key) - ) + return bool(self._attr_is_on) @callback def _update_attr(self) -> None: @@ -144,16 +147,14 @@ def _update_attr(self) -> None: if self.entity_description.key == EnumCloudingBinarySensor.SERVER_RUNNING: self._attr_extra_state_attributes = { "Value": self.coordinator.api.servers[self._server_unique_id].attr_power_state, - "Last Refresh": dt_util.utcnow(), } - # Any is intentional: value type depends on the entity description key at runtime - self._attr_native_value = getattr( - self.coordinator.api.servers[self._server_unique_id], "attr_" + self.entity_description.key + self._attr_is_on = bool( + getattr(self.coordinator.api.servers[self._server_unique_id], "attr_" + self.entity_description.key) ) except KeyError: - self._attr_native_value = None + self._attr_is_on = None async def async_setup_entry( diff --git a/custom_components/clouding/sensor.py b/custom_components/clouding/sensor.py index b571843..13f6ea9 100644 --- a/custom_components/clouding/sensor.py +++ b/custom_components/clouding/sensor.py @@ -203,8 +203,11 @@ def _handle_coordinator_update(self) -> None: None. """ + prev_value = self._attr_native_value + prev_icon = self._attr_icon self._update_attr() - super()._handle_coordinator_update() + if self._attr_native_value != prev_value or self._attr_icon != prev_icon: + super()._handle_coordinator_update() @callback def _update_attr(self) -> None: From b53a532081a21bc1b4523ed591132e605d15a6be Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 28 Mar 2026 13:44:01 +0000 Subject: [PATCH 3/6] docs: add comprehensive project context for Claude Code --- CLAUDE.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e256b1d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# ha-clouding — Project context for Claude Code + +Home Assistant integration to monitor and control [Clouding.io](https://clouding.io) VMs. + +## File structure + +``` +custom_components/clouding/ +├── manifest.json domain=clouding, version=0.0.0, iot_class=cloud_polling, type=hub +├── const.py Global constants +├── __init__.py Entry setup, registers the 6 services +├── coordinator.py DataUpdateCoordinator → GET /servers every N seconds +├── config_flow.py UI config: api_key + name; options: update_interval (15-3600s) +├── sensor.py 12 sensors per server +├── binary_sensor.py 1 binary sensor per server (is_running) +├── services.py Dispatcher for the 6 server actions +├── device_info.py Builds HA DeviceInfo (manufacturer=Clouding.io) +├── helpers.py purge_entities() — exists but IS NOT CALLED +└── pythonclouding/ + ├── clouding.py aiohttp HTTP client, base_url=https://api.clouding.io/v1 + ├── models.py CloudingServer, CloudingServerImage, CloudingPublicPorts (mashumaro) + ├── const.py BASE_URL + └── exceptions.py CloudingError > Auth / BadRequest / Connection / InvalidAPIResponse +``` + +## Key constants (const.py) + +| Constant | Value | +|---|---| +| `DOMAIN` | `"clouding"` | +| `MIN_TIME_BETWEEN_UPDATES` | `timedelta(seconds=300)` | +| `CONF_UPDATE_INTERVAL` | `"update_interval"` | +| `DEFAULT_NAME` | `"Clouding.io"` | +| `PORTAL_URL` | `"https://portal.clouding.io"` | + +## Clouding.io API + +- **Base URL**: `https://api.clouding.io/v1` +- **Auth**: header `X-API-KEY: {api_key}` +- **Timeout**: 10s (aiohttp ClientTimeout) +- **Endpoints used**: + - `GET /servers` → lists all servers for the account + - `POST /servers/{server_id}/{action}` → action: `start`, `stop`, `reboot`, `hard-reboot`, `archive`, `unarchive` + +## Exposed entities (per server) + +### Sensors (12) +`flavor`, `hostname`, `private_ip`, `ram_gb` (DATA_SIZE, GB), `created_at` (TIMESTAMP), `dns_address`, `name`, `power_state`, `public_ip`, `status` (dynamic icon), `vcores`, `volume_size_gb` (DATA_SIZE, GB) + +**Status sensor** — dynamic icon based on value: +- `archived` → `mdi:archive-check-outline` +- `archiving`/`unarchiving` → `mdi:archive-clock-outline` +- `stopped` → `mdi:close-circle-outline` +- `starting`/`stopping` → `mdi:refresh-circle` +- others → `mdi:check-circle-outline` + +### Binary sensor (1) +- `is_running` — device_class=RUNNING, extra_attr: `{"Value": power_state_string}` + +## HA services (6, all device-targeted via device_id) + +| Service | API action | +|---|---| +| `start_server` | `start` | +| `stop_server` | `stop` | +| `reboot_server` | `reboot` | +| `hard_reboot_server` | `hard-reboot` | +| `archive_server` | `archive` | +| `unarchive_server` | `unarchive` | + +After each action → `coordinator.async_refresh()` to pull the updated state. + +## CloudingServer model (mashumaro DataClassDictMixin) + +JSON fields → `attr_*` properties: +- `id`, `name`, `hostname`, `flavor`, `status`, `powerState`, `privateIp`, `publicIp` +- `dnsAddress`, `ramGb`, `vCores`, `volumeSizeGb`, `createdAt` (UTC datetime) +- `image` (CloudingServerImage: id, name) +- `publicPorts` (list of CloudingPublicPorts) +- `attr_is_running` → bool: `powerState != "shutdown"` + +## Architectural patterns + +- **DataUpdateCoordinator**: centralised polling, all entities subscribe to it +- **CoordinatorEntity**: base class for both sensors and binary sensors +- **PARALLEL_UPDATES = 0**: sequential entity updates +- `_handle_coordinator_update()`: comparison guard — only calls `async_write_ha_state` when value, icon, or attributes actually changed (DB optimisation) +- **Config entry data**: `{CONF_NAME, CONF_API_KEY, CONF_UPDATE_INTERVAL}` +- **No external dependencies**: `requirements=[]` in manifest, aiohttp is provided by HA + +## Known issues / constraints + +1. **`purge_entities()` not called** — servers deleted in Clouding.io leave ghost devices in HA (manual removal required) +2. **No per-server filtering** — all servers in the API account are imported +3. **Reload required** — changing `update_interval` requires an integration reload or HA restart +4. **No webhooks** — pure polling, no event-driven updates +5. **English only** — only `translations/en.json` provided +6. **Server-side validation** — services do not check action feasibility before the API call (e.g. stopping an already-stopped server → 400) + +## Key decisions history + +- **Removed `Last Refresh`** from binary sensor extra_attributes: `dt_util.utcnow()` changed on every poll, forcing HA to write a new state row to the DB even when nothing had changed → DB bloat. +- **Comparison guard** in `_handle_coordinator_update()` (sensor + binary sensor): only triggers `async_write_ha_state` when the value or attributes have actually changed. +- **`_attr_is_on`** used in the binary sensor (native HA attribute) instead of `_attr_native_value`, which is reserved for `SensorEntity`. From d6221b733688d7c3180fe76dde5aa41654c961ea Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 28 Mar 2026 15:24:01 +0000 Subject: [PATCH 4/6] fix: add .local files to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index afa9529..6291566 100644 --- a/.gitignore +++ b/.gitignore @@ -144,6 +144,10 @@ dmypy.json # Cython debug symbols cython_debug/ +# Local-only files and directories (not versioned) +*.local +**/*.local + # Developper settings (vscode) **/.vscode/settings.json .orcommit.json From d6dc67ad6f71623d49e9affb6c76f8f46618be16 Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 28 Mar 2026 15:25:11 +0000 Subject: [PATCH 5/6] fix: remove redundant icon comparison in sensor update logic --- custom_components/clouding/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/clouding/sensor.py b/custom_components/clouding/sensor.py index 13f6ea9..9cb519a 100644 --- a/custom_components/clouding/sensor.py +++ b/custom_components/clouding/sensor.py @@ -204,9 +204,9 @@ def _handle_coordinator_update(self) -> None: """ prev_value = self._attr_native_value - prev_icon = self._attr_icon + self._update_attr() - if self._attr_native_value != prev_value or self._attr_icon != prev_icon: + if self._attr_native_value != prev_value: super()._handle_coordinator_update() @callback From 5f2f0bbbf8e49aa47ceb6e80e7aa7ca87aeb768d Mon Sep 17 00:00:00 2001 From: Bastien Gautier Date: Sat, 28 Mar 2026 15:34:48 +0000 Subject: [PATCH 6/6] feat: add last API call timestamp sensor per server --- custom_components/clouding/coordinator.py | 5 ++++- custom_components/clouding/sensor.py | 16 +++++++++++++--- custom_components/clouding/translations/en.json | 3 +++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/custom_components/clouding/coordinator.py b/custom_components/clouding/coordinator.py index 202cf74..4f20bf4 100644 --- a/custom_components/clouding/coordinator.py +++ b/custom_components/clouding/coordinator.py @@ -10,6 +10,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import DOMAIN from .pythonclouding import ( @@ -20,7 +21,7 @@ ) if TYPE_CHECKING: - from datetime import timedelta + from datetime import datetime, timedelta from homeassistant.core import HomeAssistant @@ -53,6 +54,7 @@ def __init__(self, hass: HomeAssistant, config_entry: CloudingConfigEntry, updat ) session = async_get_clientsession(hass) self.api = Clouding(session, config_entry.data[CONF_API_KEY]) + self.last_api_call: datetime | None = None async def _async_update_data(self) -> dict[str, CloudingServer]: """Fetch the latest data from Clouding.io. @@ -67,6 +69,7 @@ async def _async_update_data(self) -> dict[str, CloudingServer]: """ try: + self.last_api_call = dt_util.utcnow() servers: dict[str, CloudingServer] = await self.api.get_servers() except CloudingAuthenticationError as e: raise ConfigEntryAuthFailed( diff --git a/custom_components/clouding/sensor.py b/custom_components/clouding/sensor.py index 9cb519a..dad77c8 100644 --- a/custom_components/clouding/sensor.py +++ b/custom_components/clouding/sensor.py @@ -33,6 +33,7 @@ class EnumCloudingSensor(StrEnum): SERVER_FLAVOR = "flavor" SERVER_HOSTNAME = "hostname" + SERVER_LAST_API_CALL = "last_api_call" SERVER_PRIVATE_ID = "private_ip" SERVER_RAM_GB = "ram_gb" SERVER_CREATED_AT = "created_at" @@ -115,6 +116,12 @@ class CloudingSensorEntityDescription(SensorEntityDescription): suggested_display_precision=0, device_class=SensorDeviceClass.DATA_SIZE, ), + CloudingSensorEntityDescription( + key=EnumCloudingSensor.SERVER_LAST_API_CALL, + translation_key=EnumCloudingSensor.SERVER_LAST_API_CALL, + name_suffix="Last API Call", + device_class=SensorDeviceClass.TIMESTAMP, + ), ) @@ -218,9 +225,12 @@ def _update_attr(self) -> None: """ try: - self._attr_native_value = getattr( - self.coordinator.api.servers[self._server_unique_id], "attr_" + self.entity_description.key - ) + if self.entity_description.key == EnumCloudingSensor.SERVER_LAST_API_CALL: + self._attr_native_value = self.coordinator.last_api_call + else: + self._attr_native_value = getattr( + self.coordinator.api.servers[self._server_unique_id], "attr_" + self.entity_description.key + ) if self.entity_description.key == EnumCloudingSensor.SERVER_STATUS: value: str = str(self._attr_native_value).lower() diff --git a/custom_components/clouding/translations/en.json b/custom_components/clouding/translations/en.json index 9272710..c46fa74 100644 --- a/custom_components/clouding/translations/en.json +++ b/custom_components/clouding/translations/en.json @@ -95,6 +95,9 @@ }, "volume_size_gb": { "name": "SSD" + }, + "last_api_call": { + "name": "Last API Call" } } },