diff --git a/README.md b/README.md index b536de1..1279f7f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ UK *BWT Perla Silk*: | Entity Id(s) | Information | | ------------- | ------------- | | total_output | Increasing value of the blended water = the total water consumed. Use this as water source on the energy dashboard. | -| errors, warnings | The fatal errors and non-fatal warnings. Comma separated list or empty if no value present. [List of values](https://github.com/dkarv/bwt_api/blob/main/src/bwt_api/error.py). | +| errors, warnings | The fatal errors and non-fatal warnings. Displays a comma-separated list of translated error/warning messages. Raw error codes are available in the entity attributes (`error_codes` or `warning_codes`) for use in automations. Empty if no errors/warnings present. [List of error codes](https://github.com/dkarv/bwt_api/blob/main/src/bwt_api/error.py). | | state | State of the device. Can be OK, WARNING, ERROR | | holiday_mode | If the holiday mode is active (true) or not (false) | | holiday_mode_start | Undefined or a timestamp if the holiday mode is set to start in the future | @@ -74,3 +74,24 @@ There are three different volume values, related to how the BWT operates interna * _blended water_: By mixing _incoming water_ and _fully desalinated water_ at a given ratio, the requested hardness is produced. A small example: Target of _blended water_ is 4dH, incoming 20dH. The BWT mixes now 20% of incoming water with 80% fully desalinated water: 0.2 * 20dH + 0.8 * 0dH = 4dH. + +#### How do I use error and warning codes in automations? + +Error and warning entities display translated, human-readable messages. For automations, you can access the raw error codes through entity attributes: + +```yaml +automation: + - alias: "Alert on specific BWT error" + trigger: + - platform: state + entity_id: sensor.bwt_perla_errors + condition: + - condition: template + value_template: "{{ 'OFFLINE_MOTOR_1' in state_attr('sensor.bwt_perla_errors', 'error_codes') }}" + action: + - service: notify.mobile_app + data: + message: "BWT Perla: Motor 1 is offline!" +``` + +Similarly for warnings, use `state_attr('sensor.bwt_perla_warnings', 'warning_codes')`. diff --git a/custom_components/bwt_perla/sensor.py b/custom_components/bwt_perla/sensor.py index 05b9d12..9d0813f 100644 --- a/custom_components/bwt_perla/sensor.py +++ b/custom_components/bwt_perla/sensor.py @@ -20,6 +20,7 @@ from .const import DOMAIN from .coordinator import BwtCoordinator from .sensors.base import * +from .sensors.error import * _GLASS = "mdi:cup-water" _COUNTER = "mdi:counter" diff --git a/custom_components/bwt_perla/sensors/base.py b/custom_components/bwt_perla/sensors/base.py index 76a496d..dd27a60 100644 --- a/custom_components/bwt_perla/sensors/base.py +++ b/custom_components/bwt_perla/sensors/base.py @@ -1,6 +1,8 @@ from datetime import datetime +import logging + from bwt_api.api import treated_to_blended from bwt_api.data import BwtStatus @@ -18,13 +20,14 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity + from ..const import DOMAIN from ..coordinator import BwtCoordinator +_LOGGER = logging.getLogger(__name__) + _FAUCET = "mdi:faucet" _WATER = "mdi:water" -_WARNING = "mdi:alert-circle" -_ERROR = "mdi:alert-decagram" _WATER_CHECK = "mdi:water-check" _HOLIDAY = "mdi:location-exit" _UNKNOWN = "mdi:help-circle" @@ -91,44 +94,6 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class ErrorSensor(BwtEntity, SensorEntity): - """Errors reported by the device.""" - - _attr_icon = _ERROR - - def __init__(self, coordinator, device_info, entry_id) -> None: - """Initialize the sensor with the common coordinator.""" - super().__init__(coordinator, device_info, entry_id, "errors") - values = [x.name for x in self.coordinator.data.errors() if x.is_fatal()] - self._attr_native_value = ",".join(values) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - values = [x.name for x in self.coordinator.data.errors() if x.is_fatal()] - self._attr_native_value = ",".join(values) - self.async_write_ha_state() - - -class WarningSensor(BwtEntity, SensorEntity): - """Warnings reported by the device.""" - - _attr_icon = _WARNING - - def __init__(self, coordinator, device_info, entry_id) -> None: - """Initialize the sensor with the common coordinator.""" - super().__init__(coordinator, device_info, entry_id, "warnings") - values = [x.name for x in self.coordinator.data.errors() if not x.is_fatal()] - self._attr_native_value = ",".join(values) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - values = [x.name for x in self.coordinator.data.errors() if not x.is_fatal()] - self._attr_native_value = ",".join(values) - self.async_write_ha_state() - - class SimpleSensor(BwtEntity, SensorEntity): """Simplest sensor with least configuration options.""" diff --git a/custom_components/bwt_perla/sensors/error.py b/custom_components/bwt_perla/sensors/error.py new file mode 100644 index 0000000..99a5409 --- /dev/null +++ b/custom_components/bwt_perla/sensors/error.py @@ -0,0 +1,126 @@ +from .base import BwtEntity +from homeassistant.components.sensor import ( + SensorEntity, +) +from ..const import DOMAIN +from ..util import truncate_value + +from homeassistant.core import callback +from homeassistant.helpers import translation + +from bwt_api.error import BwtError + +_WARNING = "mdi:alert-circle" +_ERROR = "mdi:alert-decagram" + +class TranslatableErrorMixin: + """Mixin for entities that need to translate error codes. + + This mixin provides translation functionality for entities that display + multiple error/warning codes. It loads translations when the entity is + added to Home Assistant and provides a method to translate individual codes. + + Attributes: + _translations: Dictionary of translations loaded from language files. + Initialized as None and populated in async_added_to_hass. + """ + + _translations = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass, load translations.""" + await super().async_added_to_hass() + # Load translations for the current language + self._translations = await translation.async_get_translations( + self.hass, + self.hass.config.language, + "entity_component", + {DOMAIN}, + ) + + def _translate_code(self, code_name: str) -> str: + """Translate an error/warning code to the user's language.""" + if self._translations is None: + return code_name + + key = f"component.{DOMAIN}.entity_component._.state.error_{code_name.lower()}" + return self._translations.get(key, code_name) + +class ErrorSensor(TranslatableErrorMixin, BwtEntity, SensorEntity): + """Errors reported by the device.""" + + _attr_icon = _ERROR + + def __init__(self, coordinator, device_info, entry_id) -> None: + """Initialize the sensor with the common coordinator.""" + super().__init__(coordinator, device_info, entry_id, "errors") + self._update_values(self._get_errors()) + + def _get_errors(self): + """Get the current list of fatal errors.""" + return [x for x in self.coordinator.data.errors() if x.is_fatal()] + + async def async_added_to_hass(self) -> None: + """When entity is added to hass, load translations.""" + await super().async_added_to_hass() + # Update values with translations now that they're loaded + self._update_values(self._get_errors()) + self.async_write_ha_state() + + def _update_values(self, errors) -> None: + """Update error values with translations.""" + raw_values = [x.name for x in errors] + # Store raw values as extra attributes for automation + self._attr_extra_state_attributes = {"error_codes": raw_values} + + # Translate error names for display + translated = [self._translate_code(x.name) for x in errors] + # Join translated parts and ensure it does not exceed 255 chars + joined = ", ".join(translated) + self._attr_native_value = truncate_value(joined, 255) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_values(self._get_errors()) + self.async_write_ha_state() + + +class WarningSensor(TranslatableErrorMixin, BwtEntity, SensorEntity): + """Warnings reported by the device.""" + + _attr_icon = _WARNING + + def __init__(self, coordinator, device_info, entry_id) -> None: + """Initialize the sensor with the common coordinator.""" + super().__init__(coordinator, device_info, entry_id, "warnings") + self._update_values(self._get_warnings()) + + def _get_warnings(self): + """Get the current list of non-fatal warnings.""" + return [x for x in self.coordinator.data.errors() if not x.is_fatal()] + + async def async_added_to_hass(self) -> None: + """When entity is added to hass, load translations.""" + await super().async_added_to_hass() + # Update values with translations now that they're loaded + self._update_values(self._get_warnings()) + self.async_write_ha_state() + + def _update_values(self, warnings) -> None: + """Update warning values with translations.""" + raw_values = [x.name for x in warnings] + # Store raw values as extra attributes for automation + self._attr_extra_state_attributes = {"warning_codes": raw_values} + + # Translate warning names for display + translated = [self._translate_code(x.name) for x in warnings] + # Join translated parts and ensure it does not exceed 255 chars + joined = ", ".join(translated) + self._attr_native_value = truncate_value(joined, 255) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_values(self._get_warnings()) + self.async_write_ha_state() \ No newline at end of file diff --git a/custom_components/bwt_perla/strings.json b/custom_components/bwt_perla/strings.json index f4444b2..9dfe572 100644 --- a/custom_components/bwt_perla/strings.json +++ b/custom_components/bwt_perla/strings.json @@ -1,90 +1,90 @@ { "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_code_or_api_disabled": "Invalid code or local API not enabled. Please check: 1) Device firmware is version 2.02xx or later, 2) Local API is enabled in device Settings > General > Connection, 3) Login code is correct (sent via email during registration)", + "unknown": "Unexpected error" + }, "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "code": "User-Code" + "code": "User-Code", + "host": "Host" } } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_code_or_api_disabled": "Invalid code or local API not enabled. Please check: 1) Device firmware is version 2.02xx or later, 2) Local API is enabled in device Settings > General > Connection, 3) Login code is correct (sent via email during registration)", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "entity": { "sensor": { - "regenerativ_mass": { - "name": "Total regeneration salt ever used" - }, - "regenerativ_days": { - "name": "Days left of regeneration salt" - }, - "regenerativ_level": { - "name": "Precentage of regeneration salt" + "capacity_1": { + "name": "Remaining capacity of column 1" }, - "state": { - "name": "State of the machine" + "capacity_2": { + "name": "Remaining capacity of column 1" }, - "errors": { - "name": "Active errors" + "counter_regeneration_1": { + "name": "Regeneration counter column 1" }, - "warnings": { - "name": "Active warnings" + "counter_regeneration_2": { + "name": "Regeneration counter column 2" }, - "technician_service": { - "name": "Last service by technician" + "current_flow": { + "name": "Current flow" }, "customer_service": { "name": "Last service by customer" }, - "hardness_out": { - "name": "Outgoing water hardness" + "day_output": { + "name": "Output of current day" + }, + "errors": { + "name": "Active errors" }, "hardness_in": { "name": "Incoming water hardness" }, - "total_output": { - "name": "Total water consumption" + "hardness_out": { + "name": "Outgoing water hardness" }, "holiday_mode_start": { "name": "Future start of holiday mode if available" }, - "counter_regeneration_1": { - "name": "Regeneration counter column 1" - }, - "counter_regeneration_2": { - "name": "Regeneration counter column 2" - }, "last_regeneration_1": { "name": "Last regeneration column 1" }, "last_regeneration_2": { "name": "Last regeneration column 2" }, - "capacity_1": { - "name": "Remaining capacity of column 1" + "month_output": { + "name": "Output of current month" }, - "capacity_2": { - "name": "Remaining capacity of column 2" + "regenerativ_days": { + "name": "Days left of regeneration salt" }, - "day_output": { - "name": "Output of current day" + "regenerativ_level": { + "name": "Percentage of regeneration salt" }, - "month_output": { - "name": "Output of current month" + "regenerativ_mass": { + "name": "Total regeneration salt ever used" + }, + "state": { + "name": "State of the machine" + }, + "technician_service": { + "name": "Last service by technician" + }, + "total_output": { + "name": "Total water consumption" + }, + "warnings": { + "name": "Active warnings" }, "year_output": { "name": "Output of current year" }, - "current_flow": { - "name": "Current flow" - }, "holiday_mode": { "name": "Holiday mode active" }, @@ -101,5 +101,55 @@ "name": "Total dosing means used" } } + }, + "entity_component": { + "_": { + "state": { + "error_unknown": "Unknown error", + "error_offline_motor_1": "Motor 1 offline", + "error_offline_motor_2": "Motor 2 offline", + "error_offline_motor_blend": "Blend motor offline", + "error_regenerativ_20": "Regeneration salt level < 20%", + "error_overcurrent_motor_1": "Overcurrent motor 1", + "error_overcurrent_motor_2": "Overcurrent motor 2", + "error_overcurrent_motor_3": "Overcurrent motor 3", + "error_overcurrent_valve": "Overcurrent valve", + "error_stop_volume": "Stop volume", + "error_stop_sensor": "Stop sensor", + "error_constant_flow": "Constant flow", + "error_low_pressure": "Low pressure", + "error_piston_position": "Piston position", + "error_electronic": "Electronic", + "error_insufficient_regenerativ": "Insufficient regeneration salt", + "error_stop_wireless_sensor": "Stop wireless sensor", + "error_regenerativ_0": "Regeneration salt empty", + "error_maintenance_customer": "Routine maintenance due", + "error_inspection_customer": "Customer inspection required", + "error_maintenance_service": "Technician maintenance due", + "error_minerals_low": "Minerals low", + "error_minerals_0": "Minerals empty", + "error_overcurrent_valve_1": "Overcurrent valve 1", + "error_overcurrent_valve_2": "Overcurrent valve 2", + "error_overcurrent_dosing": "Overcurrent dosing", + "error_overcurrent_valve_ball": "Overcurrent ball valve", + "error_meter_not_counting": "Water meter not counting", + "error_regeneration_drain": "Regeneration drain issue", + "error_init_pcb_0": "PCB initialization 0", + "error_init_pcb_1": "PCB initialization 1", + "error_position_motor_1": "Motor 1 position", + "error_position_motor_2": "Motor 2 position", + "error_conductivity_high": "Conductivity too high", + "error_conductivity_limit_1": "Conductivity limit 1 exceeded", + "error_conductivity_limit_2": "Conductivity limit 2 exceeded", + "error_conductivity_limit_water": "Water conductivity limit exceeded", + "error_no_function": "No function", + "error_temperature_disconnected": "Temperature sensor disconnected", + "error_temperature_high": "Temperature too high", + "error_offline_valve_ball": "Ball valve offline", + "error_external_filter_change": "External filter change required", + "error_brine_unsaturated": "Brine unsaturated", + "error_dosing_fault": "Dosing fault" + } + } } } \ No newline at end of file diff --git a/custom_components/bwt_perla/translations/de.json b/custom_components/bwt_perla/translations/de.json index c61bcf4..d7dba44 100644 --- a/custom_components/bwt_perla/translations/de.json +++ b/custom_components/bwt_perla/translations/de.json @@ -101,5 +101,55 @@ "name": "Verbrauchte Menge Dosiermittel seit Inbetriebnahme" } } + }, + "entity_component": { + "_": { + "state": { + "error_unknown": "Unbekannter Fehler", + "error_offline_motor_1": "Motor 1 offline", + "error_offline_motor_2": "Motor 2 offline", + "error_offline_motor_blend": "Mischmotor offline", + "error_regenerativ_20": "Regeneriersalz-Stand < 20%", + "error_overcurrent_motor_1": "Überstrom Motor 1", + "error_overcurrent_motor_2": "Überstrom Motor 2", + "error_overcurrent_motor_3": "Überstrom Motor 3", + "error_overcurrent_valve": "Überstrom Ventil", + "error_stop_volume": "Volumen-Stopp", + "error_stop_sensor": "Sensor-Stopp", + "error_constant_flow": "Konstanter Durchfluss", + "error_low_pressure": "Niedriger Druck", + "error_piston_position": "Kolbenpositionsfehler", + "error_electronic": "Elektronikfehler", + "error_insufficient_regenerativ": "Unzureichendes Regeneriersalz", + "error_stop_wireless_sensor": "Funk-Sensor-Stopp", + "error_regenerativ_0": "Regeneriersalz leer", + "error_maintenance_customer": "Kundenwartung erforderlich", + "error_inspection_customer": "Kundeninspektion erforderlich", + "error_maintenance_service": "Technikerwartung erforderlich", + "error_minerals_low": "Mineralien niedrig", + "error_minerals_0": "Mineralien leer", + "error_overcurrent_valve_1": "Überstrom Ventil 1", + "error_overcurrent_valve_2": "Überstrom Ventil 2", + "error_overcurrent_dosing": "Überstrom Dosierung", + "error_overcurrent_valve_ball": "Überstrom Kugelventil", + "error_meter_not_counting": "Wasserzähler zählt nicht", + "error_regeneration_drain": "Regenerationsabfluss-Problem", + "error_init_pcb_0": "PCB-Initialisierungsfehler 0", + "error_init_pcb_1": "PCB-Initialisierungsfehler 1", + "error_position_motor_1": "Positionsfehler Motor 1", + "error_position_motor_2": "Positionsfehler Motor 2", + "error_conductivity_high": "Leitfähigkeit zu hoch", + "error_conductivity_limit_1": "Leitfähigkeitsgrenze 1 überschritten", + "error_conductivity_limit_2": "Leitfähigkeitsgrenze 2 überschritten", + "error_conductivity_limit_water": "Wasser-Leitfähigkeitsgrenze überschritten", + "error_no_function": "Keine Funktion", + "error_temperature_disconnected": "Temperatursensor getrennt", + "error_temperature_high": "Temperatur zu hoch", + "error_offline_valve_ball": "Kugelventil offline", + "error_external_filter_change": "Externer Filterwechsel erforderlich", + "error_brine_unsaturated": "Sole ungesättigt", + "error_dosing_fault": "Dosierfehler" + } + } } } \ No newline at end of file diff --git a/custom_components/bwt_perla/translations/en.json b/custom_components/bwt_perla/translations/en.json index 3c2c5f6..fa861d5 100644 --- a/custom_components/bwt_perla/translations/en.json +++ b/custom_components/bwt_perla/translations/en.json @@ -65,7 +65,7 @@ "name": "Days left of regeneration salt" }, "regenerativ_level": { - "name": "Precentage of regeneration salt" + "name": "Percentage of regeneration salt" }, "regenerativ_mass": { "name": "Total regeneration salt ever used" @@ -101,5 +101,55 @@ "name": "Total dosing means used" } } + }, + "entity_component": { + "_": { + "state": { + "error_unknown": "Unknown error", + "error_offline_motor_1": "Motor 1 offline", + "error_offline_motor_2": "Motor 2 offline", + "error_offline_motor_blend": "Blend motor offline", + "error_regenerativ_20": "Regeneration salt level < 20%", + "error_overcurrent_motor_1": "Overcurrent motor 1", + "error_overcurrent_motor_2": "Overcurrent motor 2", + "error_overcurrent_motor_3": "Overcurrent motor 3", + "error_overcurrent_valve": "Overcurrent valve", + "error_stop_volume": "Stop volume", + "error_stop_sensor": "Stop sensor", + "error_constant_flow": "Constant flow", + "error_low_pressure": "Low pressure", + "error_piston_position": "Piston position", + "error_electronic": "Electronic", + "error_insufficient_regenerativ": "Insufficient regeneration salt", + "error_stop_wireless_sensor": "Stop wireless sensor", + "error_regenerativ_0": "Regeneration salt empty", + "error_maintenance_customer": "Routine maintenance due", + "error_inspection_customer": "Customer inspection required", + "error_maintenance_service": "Technician maintenance due", + "error_minerals_low": "Minerals low", + "error_minerals_0": "Minerals empty", + "error_overcurrent_valve_1": "Overcurrent valve 1", + "error_overcurrent_valve_2": "Overcurrent valve 2", + "error_overcurrent_dosing": "Overcurrent dosing", + "error_overcurrent_valve_ball": "Overcurrent ball valve", + "error_meter_not_counting": "Water meter not counting", + "error_regeneration_drain": "Regeneration drain issue", + "error_init_pcb_0": "PCB initialization 0", + "error_init_pcb_1": "PCB initialization 1", + "error_position_motor_1": "Motor 1 position", + "error_position_motor_2": "Motor 2 position", + "error_conductivity_high": "Conductivity too high", + "error_conductivity_limit_1": "Conductivity limit 1 exceeded", + "error_conductivity_limit_2": "Conductivity limit 2 exceeded", + "error_conductivity_limit_water": "Water conductivity limit exceeded", + "error_no_function": "No function", + "error_temperature_disconnected": "Temperature sensor disconnected", + "error_temperature_high": "Temperature too high", + "error_offline_valve_ball": "Ball valve offline", + "error_external_filter_change": "External filter change required", + "error_brine_unsaturated": "Brine unsaturated", + "error_dosing_fault": "Dosing fault" + } + } } } \ No newline at end of file diff --git a/custom_components/bwt_perla/util.py b/custom_components/bwt_perla/util.py new file mode 100644 index 0000000..1473cff --- /dev/null +++ b/custom_components/bwt_perla/util.py @@ -0,0 +1,11 @@ +def truncate_value(value: str, max_length: int = 255) -> str: + """Truncate a string to `max_length` characters, adding ellipsis if needed. + + Returns an empty string for None-like inputs and ensures a string is returned + even if the input cannot be converted to `str`. + """ + if value is None: + return "" + if len(value) <= max_length: + return value + return value[: max_length - 3] + "..." diff --git a/dev/configuration.yaml b/dev/configuration.yaml index 855d3da..36e193f 100644 --- a/dev/configuration.yaml +++ b/dev/configuration.yaml @@ -2,14 +2,6 @@ # Loads default set of integrations. Do not remove. default_config: -# Load frontend themes from the themes folder -frontend: - themes: !include_dir_merge_named themes - -automation: !include automations.yaml -script: !include scripts.yaml -scene: !include scenes.yaml - logger: default: info logs: