Add Ecobulles integration#172636
Conversation
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
ecobullesintegration 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. |
| 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"], | ||
| ), |
| 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()) |
| 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)}, | ||
| ) |
| 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" |
| 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"] |
91b6711 to
3c31c91
Compare
|
Thanks, addressed:
|
|
This failure is in |
| if hasattr(entry, "runtime_data"): | ||
| coordinator_data = getattr(entry.runtime_data.coordinator, "data", {}) or {} |
| 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))}, |
| 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"), | ||
| ) |
| "last_date_receive": { | ||
| "name": "Last date receive" | ||
| }, |
| session = SimpleNamespace( | ||
| post=AsyncMock(return_value=_FakeResponse(200, {"status": 1})) | ||
| ) | ||
| session.post = lambda *args, **kwargs: _FakeResponse(200, {"status": 1}) |
joostlek
left a comment
There was a problem hiding this comment.
Helo 👋🏻,
Thanks for opening a PR. A few things to keep in mind:
- Let's keep the diagnostics out of it for now to keep the PR small
- Would it make sense to keep the Store part out of it? That way we can keep it simple for now
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
d200cc2 to
3ec4e1b
Compare
| @staticmethod | ||
| @callback | ||
| def async_get_options_flow( | ||
| config_entry: EcobullesConfigEntry, | ||
| ) -> OptionsFlowHandler: | ||
| """Get the options flow for this handler.""" | ||
| return OptionsFlowHandler() |
| return vol.Schema( | ||
| { | ||
| vol.Required(CONF_EMAIL, default=defaults.get(CONF_EMAIL, "")): str, | ||
| vol.Required(CONF_PASSWORD, default=defaults.get(CONF_PASSWORD, "")): str, |
| if auth_success: | ||
| return { | ||
| "title": "Ecobulles : " + (boitier_name or ""), | ||
| "user_id": user_id, | ||
| "eco_ref": eco_ref, | ||
| } |
| "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)" |
3ec4e1b to
e21017e
Compare
48290cb to
a762efc
Compare
|
Thanks for the review! I pushed a new update addressing the Copilot comments and reducing the initial PR scope:
Regarding the 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
This lets the integration keep a monotonic 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. |
| 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) |
| 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"), |
| open_minutes = int(total_gas) / 1000 / 60 | ||
| used_grams = open_minutes * flow_rate | ||
| return round((used_grams / (bottle_weight_kg * 1000)) * 100, 2) |
b7fa826 to
cd441cd
Compare
|
Thanks, addressed in the latest push. Changes made:
I also added targeted tests for these cases, and local coverage is now at 100% for the Ecobulles integration. |
|
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 |
cd441cd to
eaacdca
Compare
| 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" |
| 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") |
| 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) |
|
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:
We can revisit lifetime/monotonic water accounting in a separate follow-up PR if there is still interest after the initial integration lands. |
eaacdca to
f6ced23
Compare
|
Thanks, addressed. Changes made:
|
| @dataclass | ||
| class EcobullesRuntimeData: | ||
| """Runtime data stored on the config entry.""" | ||
|
|
||
| coordinator: EcobullesCoordinator | ||
|
|
||
|
|
||
| type EcobullesConfigEntry = ConfigEntry[EcobullesRuntimeData] |
There was a problem hiding this comment.
If the coordinator is the only thing we're storing, might as well just store the coordinator in the entry data directly
| if eco_ref and entry.unique_id != eco_ref: | ||
| hass.config_entries.async_update_entry(entry, unique_id=eco_ref) |
There was a problem hiding this comment.
why would we have no unique id? If we don't have the unique id the config entry shouldn't have been created
| 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, | ||
| ) |
There was a problem hiding this comment.
I am not sure why we have this, we can just pass both as parameters right?
| vol.Optional( | ||
| CONF_POLL_INTERVAL_SECONDS, | ||
| default=defaults.get(CONF_POLL_INTERVAL_SECONDS, 120), | ||
| ): vol.All(vol.Coerce(int), vol.Range(min=30)), |
There was a problem hiding this comment.
The polling interval should not be configurable. Home Assistant provides an action that you can use to trigger an update
| @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"), | ||
| } |
There was a problem hiding this comment.
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"), |
There was a problem hiding this comment.
last updated is already a state attribute
| 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", []), | ||
| } |
There was a problem hiding this comment.
Let's split this one out as well
| 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", | ||
| } |
| 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." | ||
| ), | ||
| } |
There was a problem hiding this comment.
Home Assistant integrations should expose device data, and not abstractions that take all these things into account. Can we split it from this PR?
f6ced23 to
ea02b37
Compare
|
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:
I also updated the documentation PR locally to match this reduced scope, so it no longer documents the removed options/entities. Validation run locally:
All pass locally. |
| "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": { |
| "active_alerts": { | ||
| "default": "mdi:alert-circle-outline" | ||
| }, |
| "estimated_co2_bottle_usage": { | ||
| "default": "mdi:gauge" | ||
| }, | ||
| "raw_co2_value": { | ||
| "default": "mdi:code-json" | ||
| } |
| 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"), | ||
| } |
96150d7 to
e549a21
Compare
|
Thanks, fixed.
I re-ran the local checks:
All pass locally. |
|
I would genuinely like to ask you to not use LLMs in communication as that will make the experience less joyful |
|
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. |
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
pyecobulleslibrary, 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
Additional information
Local checks run successfully:
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.Dependency added:
pyecobulles==0.1.1To help with the load of incoming pull requests: