Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ homeassistant.components.duco.*
homeassistant.components.dunehd.*
homeassistant.components.duotecno.*
homeassistant.components.easyenergy.*
homeassistant.components.ecobulles.*
homeassistant.components.ecovacs.*
homeassistant.components.ecowitt.*
homeassistant.components.efergy.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions homeassistant/components/ecobulles/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""The Ecobulles integration."""

from pyecobulles import EcobullesClient

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.dt import now as hass_now

from .const import DOMAIN
from .coordinator import EcobullesCoordinator
from .device import mac_from_eco_ref, model_from_serial_number

PLATFORMS: list[Platform] = [Platform.SENSOR]

type EcobullesConfigEntry = ConfigEntry[EcobullesCoordinator]


async def async_setup_entry(hass: HomeAssistant, entry: EcobullesConfigEntry) -> bool:
"""Set up Ecobulles from a config entry."""
assert entry.unique_id is not None
eco_ref = entry.unique_id
boitier_name = entry.data.get("name")
num_serie = entry.data.get("num_serie")
firmware_version = entry.data.get("firmware_version")

device_registry = dr.async_get(hass)
mac_address = mac_from_eco_ref(eco_ref)

device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, eco_ref)},
name=boitier_name,
manufacturer="Ecobulles",
model=model_from_serial_number(num_serie),
sw_version=firmware_version,
serial_number=num_serie,
connections={(CONNECTION_NETWORK_MAC, mac_address)} if mac_address else set(),
)
Comment on lines +33 to +42

coordinator = EcobullesCoordinator(
hass,
EcobullesClient(session=async_get_clientsession(hass), now_fn=hass_now),
entry,
)
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: EcobullesConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
95 changes: 95 additions & 0 deletions homeassistant/components/ecobulles/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Config flow for Ecobulles integration."""

from collections.abc import Mapping
import logging
from typing import Any

from pyecobulles import EcobullesClient
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.dt import now as hass_now

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)


async def validate_input(
hass: HomeAssistant, data: Mapping[str, Any]
) -> Mapping[str, Any]:
"""Validate the user input allows us to connect."""
client = EcobullesClient(session=async_get_clientsession(hass), now_fn=hass_now)
try:
auth_success, user_id, eco_ref, boitier_name = await client.authenticate(
data[CONF_EMAIL], data[CONF_PASSWORD]
)
if not auth_success or eco_ref is None:
raise InvalidAuth
device_info_raw = await client.get_device_info(eco_ref)
except TimeoutError as err:
raise CannotConnect from err
except RuntimeError as err:
raise CannotConnect from err

device_name = (boitier_name or "").strip()
box = (device_info_raw or {}).get("data", {}).get("boite", {})
resolved_name = (box.get("name") or device_name or "").strip()
return {
"title": f"Ecobulles : {resolved_name}" if resolved_name else "Ecobulles",
"user_id": user_id,
"eco_ref": eco_ref,
"name": resolved_name,
"firmware_version": box.get("firm_ver"),
"num_serie": box.get("num_serie"),
}
Comment on lines +45 to +55


class EcobullesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ecobulles."""

VERSION = 1

async def async_step_user(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
entry_data = {**user_input, **info}
title = entry_data.pop("title")
await self.async_set_unique_id(info["eco_ref"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=title, data=entry_data)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)


class CannotConnect(Exception):
"""Error to indicate we cannot connect."""


class InvalidAuth(Exception):
"""Error to indicate invalid auth."""
3 changes: 3 additions & 0 deletions homeassistant/components/ecobulles/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Ecobulles integration."""

DOMAIN = "ecobulles"
79 changes: 79 additions & 0 deletions homeassistant/components/ecobulles/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Data update coordinator for Ecobulles."""

import asyncio
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING

from pyecobulles import EcobullesClient

from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

if TYPE_CHECKING:
from . import EcobullesConfigEntry
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True)
class EcobullesData:
"""Runtime data fetched from Ecobulles."""

water_liters: int
co2_injection_time_seconds: float
last_updated: str | None


class EcobullesCoordinator(DataUpdateCoordinator[EcobullesData]):
"""Fetch Ecobulles cloud data."""

def __init__(
self,
hass: HomeAssistant,
api: EcobullesClient,
config_entry: EcobullesConfigEntry,
) -> None:
"""Initialize the coordinator."""
assert config_entry.unique_id is not None
self.api = api
self.eco_ref = config_entry.unique_id
super().__init__(
hass,
_LOGGER,
name=f"Ecobulles {config_entry.data[CONF_EMAIL]}",
update_interval=timedelta(seconds=120),
config_entry=config_entry,
)

async def _async_update_data(self) -> EcobullesData:
"""Fetch Ecobulles data."""
try:
async with asyncio.timeout(15):
usage = await self.api.get_total_water_and_co2_usage(self.eco_ref)
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except RuntimeError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err

if usage is None or "total_eau" not in usage or "total_gas" not in usage:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_payload_incomplete",
)

return EcobullesData(
water_liters=usage["total_eau"],
co2_injection_time_seconds=round(int(usage["total_gas"]) / 1000, 3),
last_updated=usage.get("last_updated"),
)
32 changes: 32 additions & 0 deletions homeassistant/components/ecobulles/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Device metadata helpers for Ecobulles."""

from string import hexdigits

from homeassistant.helpers.device_registry import format_mac


def model_from_serial_number(serial_number: str | None) -> str:
"""Infer the Ecobulles model from the serial number prefix.

The API does not currently expose an explicit product model. Based on
observed serials, `X...` appears to identify Expert devices and `E...`
appears to identify Équilibre devices. Unknown prefixes deliberately fall
back to the generic brand name.
"""
if not serial_number:
return "Ecobulles"

prefix = serial_number.strip().upper()[:1]
if prefix == "X":
return "Ecobulles Expert"
if prefix == "E":
return "Ecobulles Équilibre"
return "Ecobulles"


def mac_from_eco_ref(eco_ref: str) -> str | None:
"""Return a canonical MAC address when the Ecobulles reference is one."""
raw = eco_ref.replace(":", "").replace("-", "").strip()
if len(raw) != 12 or any(char not in hexdigits for char in raw):
return None
return format_mac(raw)
9 changes: 9 additions & 0 deletions homeassistant/components/ecobulles/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"co2_injection_time": {
"default": "mdi:molecule-co2"
}
}
}
}
11 changes: 11 additions & 0 deletions homeassistant/components/ecobulles/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "ecobulles",
"name": "Ecobulles",
"codeowners": ["@jul-fls"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ecobulles",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyecobulles==0.1.1"]
}
26 changes: 26 additions & 0 deletions homeassistant/components/ecobulles/quality_scale.yaml
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please group per level

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities do not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
Loading
Loading