Skip to content

Add Ecobulles integration#172636

Open
jul-fls wants to merge 1 commit into
home-assistant:devfrom
jul-fls:add-ecobulles-integration
Open

Add Ecobulles integration#172636
jul-fls wants to merge 1 commit into
home-assistant:devfrom
jul-fls:add-ecobulles-integration

Conversation

@jul-fls
Copy link
Copy Markdown

@jul-fls jul-fls commented May 31, 2026

Breaking change

None

Proposed change

Add a new Ecobulles integration.

Ecobulles is a French CO2 anti-limescale water treatment system and an alternative to traditional salt-based water softeners. The system injects food-grade CO2 into the water network to prevent limescale deposits and help dissolve existing scale. Ecobulles is available through an installer network in France, Luxembourg, Belgium, the Netherlands, and Switzerland, with Ecobulles stating around 20,000 customers and 600 qualified installers.

Official website: https://ecobulles.com/

This integration supports the connected Ecobulles devices exposed by the Ecobulles cloud API used by the mobile app. The main connected household models documented by Ecobulles are Ecobulles Équilibre and Ecobulles Expert. Équilibre exposes water consumption and CO2 bottle status information, while Expert adds micro-leak detection features.

This integration is a Core-ready rework of the existing custom Ecobulles integration that has been used through HACS. The implementation was adjusted for Home Assistant Core expectations: API communication was moved into the standalone pyecobulles library, runtime data uses typed config entries, diagnostics, repairs, re-authentication, and reconfiguration were added, entities are translated, and the integration includes test coverage for the config flow, coordinator, sensors, diagnostics, and switches.

The API communication lives in the published async Python library pyecobulles:

The integration is cloud polling, config-flow based, creates one device per Ecobulles config entry, supports reauthentication/reconfiguration, diagnostics, repairs for incomplete cloud payloads, translated entities, strict typing, and tests.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Local checks run successfully:

python3 -m pytest tests/components/ecobulles -q
61 passed

python3 -m pytest tests/components/ecobulles --cov=homeassistant.components.ecobulles --cov-report=term-missing
TOTAL 98%

python3 -m mypy -p homeassistant.components.ecobulles
Success: no issues found

python3 -m script.hassfest
Invalid integrations: 0

prek run --files homeassistant/components/ecobulles/* tests/components/ecobulles/* requirements_all.txt .strict-typing CODEOWNERS homeassistant/generated/config_flows.py homeassistant/generated/dhcp.py homeassistant/generated/integrations.json mypy.ini
Passed

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.

Dependency added:

To help with the load of incoming pull requests:

Copilot AI review requested due to automatic review settings May 31, 2026 00:19
Copy link
Copy Markdown
Contributor

@home-assistant home-assistant Bot left a comment

Choose a reason for hiding this comment

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

Hi @jul-fls

It seems you haven't yet signed a CLA. Please do so here.

Once you do that we will be able to review and accept this pull request.

Thanks!

Copy link
Copy Markdown
Contributor

@home-assistant home-assistant Bot left a comment

Choose a reason for hiding this comment

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

When adding new integrations, limit included platforms to a single platform. Please reduce this PR to a single platform. See the review process for more details.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds the new Ecobulles Home Assistant integration (config flow, coordinator, sensors, switch, diagnostics) plus full test coverage and required registry/generated updates.

Changes:

  • Introduces the ecobulles integration implementation (API adapter, coordinator, platforms, diagnostics, water usage accounting, device metadata).
  • Adds extensive pytest coverage for config flow, coordinator/sensors, helpers, switches, diagnostics, and API shaping.
  • Registers the integration across Home Assistant generated registries (integrations/config_flows/dhcp), strict typing, requirements, and code ownership.

Reviewed changes

Copilot reviewed 30 out of 33 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/components/ecobulles/test_water_usage.py Adds unit test for monotonic water usage across device counter rollover.
tests/components/ecobulles/test_switch.py Adds switch platform tests for the raw CO2 debug toggle behavior.
tests/components/ecobulles/test_sensor_setup.py Adds integration-level entity setup test with patched API calls.
tests/components/ecobulles/test_sensor.py Adds focused unit tests for coordinator update logic and sensor calculations.
tests/components/ecobulles/test_helpers.py Adds tests for helper utilities (IDs, flattening options, date/alerts normalization).
tests/components/ecobulles/test_diagnostics.py Adds diagnostics redaction tests.
tests/components/ecobulles/test_device.py Adds tests for model inference from serial prefixes.
tests/components/ecobulles/test_config_flow.py Adds comprehensive config flow/options/reauth/reconfigure tests.
tests/components/ecobulles/test_api.py Adds tests for request shaping and error handling for the API client + HA adapter.
tests/components/ecobulles/conftest.py Introduces shared config entry fixture for the integration tests.
tests/components/ecobulles/init.py Marks the ecobulles tests package.
requirements_all.txt Adds pyecobulles==0.1.1 dependency for the new integration.
mypy.ini Enables strict mypy configuration for homeassistant.components.ecobulles.*.
homeassistant/generated/integrations.json Registers the Ecobulles integration metadata.
homeassistant/generated/dhcp.py Adds DHCP registered_devices entry for ecobulles.
homeassistant/generated/config_flows.py Registers ecobulles in the config flows list.
homeassistant/components/ecobulles/water_usage.py Implements durable water usage accounting state for bottle cycle rollover.
homeassistant/components/ecobulles/switch.py Implements diagnostic switch to enable/disable the raw CO2 sensor option.
homeassistant/components/ecobulles/strings.json Adds config flow, entity, exception, issues, and options translations.
homeassistant/components/ecobulles/sensor.py Implements sensors (water, diagnostics, alerts, CO2 time, bottle usage estimate).
homeassistant/components/ecobulles/quality_scale.yaml Declares quality scale compliance and exemptions for the integration.
homeassistant/components/ecobulles/manifest.json Adds integration manifest (domain, requirements, config flow, DHCP registered devices).
homeassistant/components/ecobulles/icons.json Adds icons for sensors and debug switch.
homeassistant/components/ecobulles/diagnostics.py Implements diagnostics payload with redaction.
homeassistant/components/ecobulles/device.py Adds device model inference helper based on serial prefix.
homeassistant/components/ecobulles/coordinator.py Implements update coordinator with durable storage and repair issue handling.
homeassistant/components/ecobulles/const.py Adds integration constants.
homeassistant/components/ecobulles/config_flow.py Implements config flow, reauth/reconfigure, and options flow.
homeassistant/components/ecobulles/auth_ids.py Re-exports auth ID generators from pyecobulles.
homeassistant/components/ecobulles/api.py Adds HA adapter for pyecobulles using HA aiohttp session + HA time function.
homeassistant/components/ecobulles/init.py Implements integration setup/unload, device registry population, coordinator wiring.
CODEOWNERS Assigns ownership for ecobulles integration and tests.
.strict-typing Adds ecobulles to strict typing enforcement list.

Comment on lines +45 to +53
WATER_SENSORS: tuple[EcobullesSensorDescription, ...] = (
EcobullesSensorDescription(
key="total_water_usage",
translation_key="total_water_usage",
native_unit_of_measurement=UnitOfVolume.LITERS,
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: data["cycle_water_liters"],
),
Comment on lines +108 to +110
water_state = await self._load_water_usage_state()
bottle_changed = water_state.apply_cycle_value(usage["total_eau"])
await self._store.async_save(water_state.as_dict())
Comment on lines +43 to +52
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, eco_ref)}, # Use eco_ref or another unique identifier
name=boitier_name,
manufacturer="Ecobulles",
model=model_from_serial_number(num_serie),
sw_version=firmware_version,
serial_number=num_serie,
connections={(CONNECTION_NETWORK_MAC, eco_ref)},
)
Comment on lines +296 to +320
if info["title"]:
client = EcobullesClient(self.hass)
device_info_raw = await client.get_device_info(info["eco_ref"])
entry_data = {
**user_input,
**info,
**_device_info_from_response(device_info_raw or {}),
}
# Ensure you're not storing 'title' in the entry data, as it was used just for entry naming
entry_data.pop("title", None)
options = {
CONF_ENABLE_RAW_CO2_SENSOR: bool(
entry_data.pop(CONF_ENABLE_RAW_CO2_SENSOR, False)
)
}
# Update config entry with new data if validation is successful
self.hass.config_entries.async_update_entry(
self.config_entry, data=entry_data
)
await self.hass.config_entries.async_reload(
self.config_entry.entry_id
)
return self.async_create_entry(title="", data=options)
# If validation fails, show an error on the form
errors["base"] = "invalid_auth"
Comment on lines +156 to +172
def _isoish(value: str | None) -> str | None:
"""Normalize API date strings without exploding on missing values."""
return value.replace(" ", "T") if value else None


def _active_alerts_from_payloads(
device_payload: Mapping[str, Any] | None, login_payload: Mapping[str, Any] | None
) -> list[Mapping[str, Any]]:
"""Extract currently active alerts from known API payload locations."""
candidates: list[Mapping[str, Any]] = []
if device_payload:
candidates.extend(device_payload.get("data", {}).get("alert") or [])
if login_payload:
candidates.extend(
login_payload.get("data", {}).get("conso", {}).get("alert") or []
)
return [alert for alert in candidates if str(alert.get("currently")) == "1"]
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from 91b6711 to 3c31c91 Compare May 31, 2026 00:34
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented May 31, 2026

Thanks, addressed:

  • limited the initial integration PR to the sensor platform only;
  • fixed water sensor state classes so only the monotonic lifetime total uses TOTAL_INCREASING;
  • Confirmed from the official device documentation and packet capture that eco_ref is the device MAC address. I updated the code to pass it through Home Assistant's format_mac() before registering it as CONNECTION_NETWORK_MAC, so the device registry now receives a canonical MAC address instead of the raw API value.
  • avoided saving durable water state when the API counter did not change;
  • centralized shared payload helpers;
  • simplified unreachable config/options flow branches.

@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented May 31, 2026

This failure is in tests/components/recorder/test_purge.py::test_purge_big_database and does not touch the Ecobulles integration. It appears unrelated/flaky; local Ecobulles tests, mypy, hassfest and targeted prek pass.

@jul-fls jul-fls marked this pull request as ready for review May 31, 2026 01:27
Copilot AI review requested due to automatic review settings May 31, 2026 01:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 29 out of 32 changed files in this pull request and generated 6 comments.

Comment on lines +27 to +28
if hasattr(entry, "runtime_data"):
coordinator_data = getattr(entry.runtime_data.coordinator, "data", {}) or {}
Comment on lines +107 to +111
box = device.get("data", {}).get("boite", {})
active_alerts = active_alerts_from_payloads(device, login_payload)
water_state = await self._load_water_usage_state()
previous_cycle_water_liters = water_state.cycle_water_liters
bottle_changed = water_state.apply_cycle_value(usage["total_eau"])
model=model_from_serial_number(num_serie),
sw_version=firmware_version,
serial_number=num_serie,
connections={(CONNECTION_NETWORK_MAC, format_mac(eco_ref))},
Comment on lines +72 to +78
RAW_CO2_SENSOR = EcobullesSensorDescription(
key="raw_co2_value",
translation_key="raw_co2_value",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.get("total_gas"),
)
Comment on lines +178 to +180
"last_date_receive": {
"name": "Last date receive"
},
Comment thread tests/components/ecobulles/test_api.py Outdated
Comment on lines +195 to +198
session = SimpleNamespace(
post=AsyncMock(return_value=_FakeResponse(200, {"status": 1}))
)
session.post = lambda *args, **kwargs: _FakeResponse(200, {"status": 1})
Copy link
Copy Markdown
Member

@joostlek joostlek left a comment

Choose a reason for hiding this comment

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

Helo 👋🏻,

Thanks for opening a PR. A few things to keep in mind:

  1. Let's keep the diagnostics out of it for now to keep the PR small
  2. Would it make sense to keep the Store part out of it? That way we can keep it simple for now

@home-assistant home-assistant Bot marked this pull request as draft May 31, 2026 09:32
@home-assistant
Copy link
Copy Markdown
Contributor

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Copilot AI review requested due to automatic review settings May 31, 2026 22:34
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from d200cc2 to 3ec4e1b Compare May 31, 2026 22:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 29 out of 32 changed files in this pull request and generated 4 comments.

Comment on lines +141 to +147
@staticmethod
@callback
def async_get_options_flow(
config_entry: EcobullesConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
Comment on lines +40 to +43
return vol.Schema(
{
vol.Required(CONF_EMAIL, default=defaults.get(CONF_EMAIL, "")): str,
vol.Required(CONF_PASSWORD, default=defaults.get(CONF_PASSWORD, "")): str,
Comment on lines +109 to +114
if auth_success:
return {
"title": "Ecobulles : " + (boitier_name or ""),
"user_id": user_id,
"eco_ref": eco_ref,
}
Comment on lines +25 to +29
"co2_reference_pulse_ms_per_l": "Reference CO2 pulse (ms/L)",
"email": "[%key:common::config_flow::data::email%]",
"enable_raw_co2_sensor": "Enable raw CO2 debug sensor",
"password": "[%key:common::config_flow::data::password%]",
"poll_interval_seconds": "Polling interval (seconds)"
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from 3ec4e1b to e21017e Compare May 31, 2026 22:48
@jul-fls jul-fls marked this pull request as ready for review May 31, 2026 23:11
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from 48290cb to a762efc Compare May 31, 2026 23:33
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented May 31, 2026

Thanks for the review!

I pushed a new update addressing the Copilot comments and reducing the initial PR scope:

  • Removed the diagnostics module from the initial Core PR to keep the scope smaller. I kept the user-facing device/status values exposed as regular sensors instead, since values like active alerts, locked/suspended state, and last received timestamp are useful installation state information rather than debug-only data.
  • Fixed the options flow to pass/store the config entry explicitly.
  • Stopped pre-filling stored passwords in config/reconfigure/options forms. Reconfigure/options now preserve the existing password when the field is left empty.
  • Cleaned up the generated title so it is either Ecobulles or Ecobulles : <device name>.
  • Removed config-flow strings for the raw CO2 option, since that option only exists in the options flow.
  • Kept the raw CO2 API value modeled as a cumulative duration counter.
  • Added safer handling around partial API payloads and MAC formatting.

Regarding the Store usage: I think removing it would be problematic for the integration behavior.

The Ecobulles cloud exposes the water usage counter for the current CO2 bottle cycle. When the CO2 bottle is replaced, the device-side water counter resets. Without a small persisted local state, Home Assistant can only expose the current bottle-cycle counter, which means the water usage entity would decrease/reset whenever a bottle is changed and we would lose the ability to provide a stable lifetime total.

The Store is used only to persist this minimal accounting state:

  • current bottle-cycle water usage
  • completed bottle-cycle water usage
  • detected bottle-change count

This lets the integration keep a monotonic Total water usage sensor by adding completed bottle cycles to the current device counter. It is also why the integration can detect and account for bottle changes even though the Ecobulles API does not expose a durable lifetime water total directly.

The data is small, written only when the Ecobulles water counter changes, and is needed to make the water usage sensors behave correctly in Home Assistant long-term statistics. Without it, the integration would either have to expose only the reset-prone raw cycle counter or drop the lifetime total sensor entirely.

@jul-fls jul-fls marked this pull request as ready for review May 31, 2026 23:53
Copilot AI review requested due to automatic review settings May 31, 2026 23:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 27 out of 30 changed files in this pull request and generated 4 comments.

Comment on lines +323 to +327
self.hass.config_entries.async_update_entry(
config_entry, data=entry_data
)
await self.hass.config_entries.async_reload(config_entry.entry_id)
return self.async_create_entry(title="", data=options)
Comment on lines +41 to +43
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, f"{DOMAIN}.{eco_ref}.water_usage"
)
"suspended": box.get("suspended"),
"suspended_time": box.get("suspended_time"),
"suspended_date": isoish(box.get("suspended_date")),
"firm_ver": box.get("firm_ver"),
Comment on lines +295 to +297
open_minutes = int(total_gas) / 1000 / 60
used_grams = open_minutes * flow_rate
return round((used_grams / (bottle_weight_kg * 1000)) * 100, 2)
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from b7fa826 to cd441cd Compare June 1, 2026 00:09
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented Jun 1, 2026

Thanks, addressed in the latest push.

Changes made:

  • Moved options reload handling to the standard Home Assistant update-listener pattern, so the entry reload happens after options are saved.
  • Switched the water-usage Store key to use the config entry ID, with a sanitized fallback for non-entry test/manual cases.
  • Standardized coordinator firmware metadata to firmware_version.
  • Clamped the estimated CO2 bottle usage sensor to 100% to keep percentage semantics valid.

I also added targeted tests for these cases, and local coverage is now at 100% for the Ecobulles integration.

@joostlek
Copy link
Copy Markdown
Member

joostlek commented Jun 1, 2026

I think for an initial PR we can stick with just showing the current level. The thing is, in a core integration we dislike abstractions. So like, whenever you set up for example an energy meter and it shows the total energy, it will show it for the lifetime of the device, not for since when you added it to Home Assistant. Which could be something users wouldn't expect when setting up the integration. Hence I am like, let's try to split this off and postpone this discussion so we can still offer the basic functionality to users and discuss this in more detail in a different PR

@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from cd441cd to eaacdca Compare June 1, 2026 14:48
Copilot AI review requested due to automatic review settings June 1, 2026 14:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 28 changed files in this pull request and generated 4 comments.

Comment on lines +215 to +230
class CO2InjectionTimeSensor(EcobullesBaseSensor):
"""Expose the API gas counter as cumulative injection time."""

_attr_translation_key = "co2_injection_time"
_attr_native_unit_of_measurement = UnitOfTime.SECONDS
_attr_device_class = SensorDeviceClass.DURATION
_attr_state_class = SensorStateClass.TOTAL_INCREASING

def __init__(
self,
coordinator: EcobullesCoordinator,
eco_ref: str,
) -> None:
"""Initialize the CO2 injection time sensor."""
super().__init__(coordinator, eco_ref)
self._attr_unique_id = f"{eco_ref}_co2_usage"
Comment on lines +220 to +222
self.hass.config_entries.async_update_entry(entry, data=merged_data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
Comment on lines +269 to +273
self.hass.config_entries.async_update_entry(
entry, data=entry_data, title=info["title"]
)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reconfigure_successful")

assert hass.states.get(install_date_entity_id).state == "2024-01-01T00:00:00+00:00"
assert hass.states.get(last_receive_entity_id).state == "2025-06-05T21:50:00+00:00"
assert hass.config_entries.async_entries(DOMAIN)
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented Jun 1, 2026

That makes sense, thanks for the clarification.

I updated the PR to keep the initial implementation smaller and expose only the current Ecobulles water counter reported by the cloud API.

Changes made:

  • Removed the local Store-based water accounting.
  • Removed the reconstructed completed-cycle and lifetime water usage sensors.
  • Removed water_usage.py and the related tests.
  • Kept a single Water usage sensor that mirrors the current API water counter.
  • Updated tests and documentation accordingly.

We can revisit lifetime/monotonic water accounting in a separate follow-up PR if there is still interest after the initial integration lands.

@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from eaacdca to f6ced23 Compare June 1, 2026 15:18
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented Jun 1, 2026

Thanks, addressed.

Changes made:

  • Aligned the CO2 injection time sensor unique ID with its translation key (co2_injection_time).
  • Removed explicit reloads from reauth and reconfigure flows, relying on the config entry update listener instead.
  • Made the setup test assertion explicit by checking the expected config entry ID.
  • Re-ran mypy and the Ecobulles test suite; all tests pass with 100% coverage.

Comment on lines +19 to +26
@dataclass
class EcobullesRuntimeData:
"""Runtime data stored on the config entry."""

coordinator: EcobullesCoordinator


type EcobullesConfigEntry = ConfigEntry[EcobullesRuntimeData]
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.

If the coordinator is the only thing we're storing, might as well just store the coordinator in the entry data directly

Comment on lines +36 to +37
if eco_ref and entry.unique_id != eco_ref:
hass.config_entries.async_update_entry(entry, unique_id=eco_ref)
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.

why would we have no unique id? If we don't have the unique id the config entry shouldn't have been created

Comment on lines +11 to +21
class EcobullesClient(PyEcobullesClient):
"""pyecobulles client wired to Home Assistant's shared web session."""

def __init__(
self, hass: HomeAssistant | None = None, session: ClientSession | None = None
) -> None:
"""Initialize the client with Home Assistant's aiohttp session."""
super().__init__(
session=session or (async_get_clientsession(hass) if hass else None),
now_fn=hass_now,
)
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.

I am not sure why we have this, we can just pass both as parameters right?

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.

This file also isn't needed

Comment on lines +81 to +84
vol.Optional(
CONF_POLL_INTERVAL_SECONDS,
default=defaults.get(CONF_POLL_INTERVAL_SECONDS, 120),
): vol.All(vol.Coerce(int), vol.Range(min=30)),
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.

The polling interval should not be configurable. Home Assistant provides an action that you can use to trigger an update

Comment on lines +160 to +166
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Expose useful shared metadata."""
return {
"eco_ref": self.eco_ref,
"last_updated": self.coordinator.data.get("last_updated"),
}
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.

The entity is attached to the device, what value does eco_ref add?

"""Expose useful shared metadata."""
return {
"eco_ref": self.eco_ref,
"last_updated": self.coordinator.data.get("last_updated"),
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.

last updated is already a state attribute

Comment on lines +191 to +212
class ActiveAlertsSensor(EcobullesBaseSensor):
"""Expose the number of currently active Ecobulles alerts."""

_attr_translation_key = "active_alerts"

def __init__(self, coordinator: EcobullesCoordinator, eco_ref: str) -> None:
"""Initialize the active alerts sensor."""
super().__init__(coordinator, eco_ref)
self._attr_unique_id = f"{eco_ref}_active_alerts"

@property
def native_value(self) -> int:
"""Return the active alert count."""
return int(self.coordinator.data.get("active_alert_count", 0))

@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Expose active alert details."""
return {
**super().extra_state_attributes,
"active_alerts": self.coordinator.data.get("active_alerts", []),
}
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.

Let's split this one out as well

Comment on lines +215 to +247
class CO2InjectionTimeSensor(EcobullesBaseSensor):
"""Expose the API gas counter as cumulative injection time."""

_attr_translation_key = "co2_injection_time"
_attr_native_unit_of_measurement = UnitOfTime.SECONDS
_attr_device_class = SensorDeviceClass.DURATION
_attr_state_class = SensorStateClass.TOTAL_INCREASING

def __init__(
self,
coordinator: EcobullesCoordinator,
eco_ref: str,
) -> None:
"""Initialize the CO2 injection time sensor."""
super().__init__(coordinator, eco_ref)
self._attr_unique_id = f"{eco_ref}_co2_injection_time"

@property
def native_value(self) -> float | None:
"""Return cumulative CO2 valve-open time in seconds."""
total_gas = self.coordinator.data.get("total_gas")
if total_gas is None:
return None
return round(int(total_gas) / 1000, 3)

@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Expose the original raw millisecond counter."""
return {
**super().extra_state_attributes,
"raw_total_gas_ms": self.coordinator.data.get("total_gas"),
"interpretation": "cumulative CO2 electrovalve open time",
}
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.

Can you elaborate this entity?

Comment on lines +250 to +334
class EstimatedCO2BottleUsageSensor(EcobullesBaseSensor):
"""Estimate bottle usage from injection time and Ecobulles dose guidance."""

_attr_translation_key = "estimated_co2_bottle_usage"
_attr_native_unit_of_measurement = PERCENTAGE

def __init__(
self,
coordinator: EcobullesCoordinator,
eco_ref: str,
config: Mapping[str, Any],
) -> None:
"""Initialize the estimated CO2 bottle usage sensor."""
super().__init__(coordinator, eco_ref)
self.config = config
self._attr_unique_id = f"{eco_ref}_estimated_co2_bottle_usage"

@property
def native_value(self) -> float | None:
"""Return estimated bottle usage percentage."""
total_gas = self.coordinator.data.get("total_gas")
flow_rate = self._estimated_flow_rate_g_per_min
bottle_weight_kg = _float_config_value(
self.config, CONF_CO2_BOTTLE_WEIGHT_KG, 10
)
if total_gas is None or flow_rate <= 0 or bottle_weight_kg <= 0:
return None

open_minutes = int(total_gas) / 1000 / 60
used_grams = open_minutes * flow_rate
return min(100.0, round((used_grams / (bottle_weight_kg * 1000)) * 100, 2))

@property
def _estimated_flow_rate_g_per_min(self) -> float:
"""Estimate active-valve CO2 flow in g/min.

Ecobulles indicates that a 10 kg CO2 bottle treats about 60-120 m3 or
80-120 m3 depending on the page, implying a practical middle range of
roughly 85-150 mg/L. The
micrometric screw is mapped linearly from setting 2 to 9 across that
range. With the observed/default 1500 ms pulse per liter, this gives:

g/min = dose_mg_per_l / pulse_ms_per_l * 60
"""
dose = self._estimated_dose_mg_per_l
pulse_ms = _float_config_value(
self.config, CONF_CO2_REFERENCE_PULSE_MS_PER_L, 1500
)
if dose <= 0 or pulse_ms <= 0:
return 0
return dose / pulse_ms * 60

@property
def _estimated_dose_mg_per_l(self) -> float:
"""Estimate CO2 dose in mg/L from the micrometric screw setting."""
screw = _float_config_value(self.config, CONF_CO2_MICROMETRIC_SCREW_SETTING, 5)
min_dose = _float_config_value(self.config, CONF_CO2_MIN_DOSE_MG_PER_L, 85)
max_dose = _float_config_value(self.config, CONF_CO2_MAX_DOSE_MG_PER_L, 150)
normalized_screw = min(max(screw, 2), 9)
return min_dose + ((normalized_screw - 2) / 7) * (max_dose - min_dose)

@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Expose the assumptions used by the estimate."""
total_gas = self.coordinator.data.get("total_gas") or 0
flow_rate = self._estimated_flow_rate_g_per_min
open_minutes = int(total_gas) / 1000 / 60
return {
**super().extra_state_attributes,
"co2_bottle_weight_kg": self.config.get(CONF_CO2_BOTTLE_WEIGHT_KG, 10),
"micrometric_screw_setting": self.config.get(
CONF_CO2_MICROMETRIC_SCREW_SETTING, 5
),
"co2_pressure_bar": self.config.get(CONF_CO2_PRESSURE_BAR, 5),
"estimated_dose_mg_per_l": round(self._estimated_dose_mg_per_l, 3),
"reference_pulse_ms_per_l": self.config.get(
CONF_CO2_REFERENCE_PULSE_MS_PER_L, 1500
),
"estimated_flow_rate_g_per_min": round(flow_rate, 6),
"estimated_used_co2_g": round(open_minutes * flow_rate, 3),
"calculation_model": "linear screw setting 2-9 mapped to 85-150 mg/L, using reference pulse ms/L",
"warning": (
"Estimate uses Ecobulles public dose range and is not a measured bottle calibration."
),
}
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.

Home Assistant integrations should expose device data, and not abstractions that take all these things into account. Can we split it from this PR?

@home-assistant home-assistant Bot marked this pull request as draft June 1, 2026 16:58
Copilot AI review requested due to automatic review settings June 1, 2026 17:23
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch 2 times, most recently from f6ced23 to ea02b37 Compare June 1, 2026 17:25
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented Jun 1, 2026

Thanks for the detailed review — I reworked the initial PR to keep the scope much smaller and closer to the Home Assistant Core expectations.

Main changes made:

  • Stored the coordinator directly in ConfigEntry.runtime_data instead of wrapping it in a runtime dataclass.
  • Removed the Home Assistant-side Ecobulles API wrapper and auth-id helper; the integration now directly instantiates pyecobulles.EcobullesClient with Home Assistant’s shared aiohttp session and hass_now.
  • Removed configurable polling interval. The integration now uses a fixed 120-second polling interval.
  • Removed reauthentication, reconfigure, diagnostics, DHCP discovery metadata, repair issue handling, raw CO2 debug sensor, active alert sensor, status sensors, and estimated CO2 bottle usage from this initial PR.
  • Removed the local lifetime/bottle-cycle water accounting abstraction.
  • Reduced the sensor platform to the two direct device/cloud values currently exposed:
    • current water counter
    • cumulative CO2 electrovalve open time, converted from the API millisecond counter to seconds
  • Reworked coordinator data into a typed dataclass.
  • Removed broad exception handling outside the config flow.
  • Simplified the quality scale file and grouped it at Bronze level.
  • Updated tests to match the smaller initial scope.

I also updated the documentation PR locally to match this reduced scope, so it no longer documents the removed options/entities.

Validation run locally:

  • ruff format
  • ruff check
  • mypy -p homeassistant.components.ecobulles
  • pytest tests/components/ecobulles -q --cov=homeassistant.components.ecobulles
  • script.hassfest --integration-path homeassistant/components/ecobulles

All pass locally.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 22 changed files in this pull request and generated 3 comments.

Comment on lines +17 to +29
"data_description": {
"email": "The email address used to sign in to the Ecobulles mobile app.",
"password": "The password used to sign in to the Ecobulles mobile app."
}
}
}
},
"entity": {
"sensor": {
"co2_injection_time": {
"name": "CO2 injection time"
},
"water_usage": {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

outdated, already fixed

@jul-fls jul-fls marked this pull request as ready for review June 1, 2026 20:26
Copilot AI review requested due to automatic review settings June 1, 2026 20:26
@home-assistant home-assistant Bot requested a review from joostlek June 1, 2026 20:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 22 changed files in this pull request and generated 3 comments.

Comment on lines +4 to +6
"active_alerts": {
"default": "mdi:alert-circle-outline"
},
Comment on lines +10 to +15
"estimated_co2_bottle_usage": {
"default": "mdi:gauge"
},
"raw_co2_value": {
"default": "mdi:code-json"
}
Comment on lines +45 to +54
device_name = (boitier_name or "").strip()
box = (device_info_raw or {}).get("data", {}).get("boite", {})
return {
"title": f"Ecobulles : {device_name}" if device_name else "Ecobulles",
"user_id": user_id,
"eco_ref": eco_ref,
"name": box.get("name") or device_name,
"firmware_version": box.get("firm_ver"),
"num_serie": box.get("num_serie"),
}
@jul-fls jul-fls force-pushed the add-ecobulles-integration branch from 96150d7 to e549a21 Compare June 1, 2026 20:35
@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented Jun 1, 2026

Thanks, fixed.

  • Removed the unused icons.json entries for sensors that were split out of the initial PR scope (active_alerts, estimated_co2_bottle_usage, and raw_co2_value).
  • Kept only the icon metadata for the currently implemented co2_injection_time sensor.
  • Updated the config flow validation to compute one resolved device name and use it consistently for both the config entry title and stored device name.
  • Added a regression test for the case where the authentication response name is empty but the device-info payload contains a box name.

I re-ran the local checks:

  • ruff format
  • ruff check
  • mypy -p homeassistant.components.ecobulles
  • pytest tests/components/ecobulles -q --cov=homeassistant.components.ecobulles
  • script.hassfest --integration-path homeassistant/components/ecobulles

All pass locally.

@joostlek
Copy link
Copy Markdown
Member

joostlek commented Jun 2, 2026

I would genuinely like to ask you to not use LLMs in communication as that will make the experience less joyful

@jul-fls
Copy link
Copy Markdown
Author

jul-fls commented Jun 2, 2026

Thanks for the feedback, understood.

Just to clarify: English is not my native language, so I used AI to help me to make sure my replies were clear and complete, not to avoid engaging with the review. I understand your point though, and I’ll keep future replies more direct and in my own words.

I’ll continue addressing the technical feedback accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants