From 7b20559b15df29a5e7b56a118bd23d53845b2f89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:56:21 +0000 Subject: [PATCH 01/14] Initial plan From 5425e0ee2dc4ec97c4d3ec9819938626ae094d2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:59:13 +0000 Subject: [PATCH 02/14] feat(sensor): add next action date/time sensors --- README.md | 2 + custom_components/minibrew/sensor.py | 70 ++++++++++++++++++- custom_components/minibrew/strings.json | 3 + .../minibrew/translations/en.json | 3 + 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 21a6016..6850145 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **User Action Required** - Notifications for required user actions +- **Next Action Date and Time** - Date/time shown for the next required action ### Keg Device Sensors - **Current Temperature** - Real-time keg temperature @@ -32,6 +33,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **Action Required** - Notifications for required user actions +- **Next Action Date and Time** - Date/time shown for the next required action ## Installation diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 60ab489..7268750 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -68,6 +68,7 @@ def add_new_sensors(): sensors.append(CraftSensorCurrentStageSensor(coordinator, device, state)) sensors.append(CraftSensorNeedsCleaningSensor(coordinator, device, state)) sensors.append(CraftUserActionRequiredSensor(coordinator, device, state)) + sensors.append(CraftNextActionDateTimeSensor(coordinator, device, state)) # Add sensors for Keg devices elif device.device_type == 1: # Keg device sensors.append(KegCurrentTemperatureSensor(coordinator, device, state)) @@ -78,6 +79,7 @@ def add_new_sensors(): sensors.append(KegIsUpdatingSensor(coordinator, device, state)) sensors.append(KegNeedsCleaningSensor(coordinator, device, state)) sensors.append(KegActionRequiredSensor(coordinator, device, state)) + sensors.append(KegNextActionDateTimeSensor(coordinator, device, state)) # Mark the device as added added_devices.add(serial_number) @@ -387,6 +389,39 @@ def unique_id(self): return f"{self.device_id}_user_action_required" +class CraftNextActionDateTimeSensor(CraftSensor): + """Sensor for the date and time of the next required action.""" + + _attr_translation_key = "next_action_datetime" + + @property + def name(self): + """Return the name of the sensor.""" + return "Next Action Date and Time" + + @property + def native_value(self): + """Return the date and time for the next required action.""" + device = self._get_latest_device() + if not device: + return None + + action = device.get("user_action") + if action == 0 or action is None: + return None + return device.get("sub_title") + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:calendar-clock" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_next_action_datetime" + + class CraftSensorCurrentStageSensor(CraftSensor): """Sensor for the current stage of the Craft device.""" @@ -804,4 +839,37 @@ def icon(self): @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_{self.name}" \ No newline at end of file + return f"{self.device_id}_{self.name}" + + +class KegNextActionDateTimeSensor(KegSensor): + """Sensor for the date and time of the next required action.""" + + _attr_translation_key = "next_action_datetime" + + @property + def name(self): + """Return the name of the sensor.""" + return "Next Action Date and Time" + + @property + def native_value(self): + """Return the date and time for the next required action.""" + device = self._get_latest_device() + if not device: + return None + + action = device.get("user_action") + if action == 0 or action is None: + return None + return device.get("sub_title") + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:calendar-clock" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_next_action_datetime" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 0b2d26b..20fbfd1 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -63,6 +63,9 @@ "unknown": "Unknown" } }, + "next_action_datetime": { + "name": "Next action date and time" + }, "current_stage": { "name": "Current stage", "state": { diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index 0b2d26b..20fbfd1 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -63,6 +63,9 @@ "unknown": "Unknown" } }, + "next_action_datetime": { + "name": "Next action date and time" + }, "current_stage": { "name": "Current stage", "state": { From 7c02b92c44764f9fb64f511bce88b012b4abef25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:00:04 +0000 Subject: [PATCH 03/14] refactor(sensor): rely on translation key for next-action sensor names --- custom_components/minibrew/sensor.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 7268750..b885769 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -394,11 +394,6 @@ class CraftNextActionDateTimeSensor(CraftSensor): _attr_translation_key = "next_action_datetime" - @property - def name(self): - """Return the name of the sensor.""" - return "Next Action Date and Time" - @property def native_value(self): """Return the date and time for the next required action.""" @@ -847,11 +842,6 @@ class KegNextActionDateTimeSensor(KegSensor): _attr_translation_key = "next_action_datetime" - @property - def name(self): - """Return the name of the sensor.""" - return "Next Action Date and Time" - @property def native_value(self): """Return the date and time for the next required action.""" From 6841eca4bd75f44356e4fc69ab86d705ade902c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:00:51 +0000 Subject: [PATCH 04/14] refactor(sensor): mark next-action sensors as diagnostic --- custom_components/minibrew/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index b885769..7cbf0e5 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -406,6 +406,11 @@ def native_value(self): return None return device.get("sub_title") + @property + def entity_category(self): + """Return the entity category.""" + return EntityCategory.DIAGNOSTIC + @property def icon(self): """Return the icon for the sensor.""" @@ -854,6 +859,11 @@ def native_value(self): return None return device.get("sub_title") + @property + def entity_category(self): + """Return the entity category (diagnostic).""" + return EntityCategory.DIAGNOSTIC + @property def icon(self): """Return the icon for the sensor.""" From b37855263428ea9a8b98c44e2a2952af567fb900 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:24:16 +0200 Subject: [PATCH 05/14] feat(sensor): update next action timestamp handling and improve UTC conversion --- README.md | 6 ++-- custom_components/minibrew/manifest.json | 2 +- custom_components/minibrew/sensor.py | 36 ++++++++++++++++-------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6850145..a7126ae 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **User Action Required** - Notifications for required user actions -- **Next Action Date and Time** - Date/time shown for the next required action +- **Next Action Date and Time** - UTC timestamp for the next required action from MiniBrew's process estimate ### Keg Device Sensors - **Current Temperature** - Real-time keg temperature @@ -33,7 +33,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **Action Required** - Notifications for required user actions -- **Next Action Date and Time** - Date/time shown for the next required action +- **Next Action Date and Time** - UTC timestamp for the next required action from MiniBrew's process estimate ## Installation @@ -81,7 +81,7 @@ To access options: - Home Assistant 2023.1 or newer - MiniBrew account with registered devices - **MiniBrew Pro subscription** (required for API access) -- [`pymbrewclient>=1.0.10`](https://github.com/stuartp44/pymbrewclient) (automatically installed) +- [`pymbrewclient>=1.9.0`](https://github.com/stuartp44/pymbrewclient) (automatically installed) ## Dependencies diff --git a/custom_components/minibrew/manifest.json b/custom_components/minibrew/manifest.json index 19ee1e2..2a3a9dc 100644 --- a/custom_components/minibrew/manifest.json +++ b/custom_components/minibrew/manifest.json @@ -11,7 +11,7 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/stuartp44/hambrewclient/issues", "requirements": [ - "pymbrewclient>=1.8.1" + "pymbrewclient>=1.9.0" ], "version": "0.8.0" } \ No newline at end of file diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 7cbf0e5..0650d22 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -1,8 +1,8 @@ import logging from dataclasses import asdict, is_dataclass -from datetime import timedelta +from datetime import datetime, timedelta, timezone -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from pymbrewclient import BreweryOverview, Device @@ -23,6 +23,24 @@ def _device_to_dict(device): return device.__dict__ return {} + +def _coerce_timestamp(value): + if value in (None, ""): + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + return None + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MiniBrew sensors from a config entry.""" client = hass.data[DOMAIN][config_entry.entry_id] # Get the BreweryClient instance @@ -393,6 +411,7 @@ class CraftNextActionDateTimeSensor(CraftSensor): """Sensor for the date and time of the next required action.""" _attr_translation_key = "next_action_datetime" + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): @@ -400,11 +419,7 @@ def native_value(self): device = self._get_latest_device() if not device: return None - - action = device.get("user_action") - if action == 0 or action is None: - return None - return device.get("sub_title") + return _coerce_timestamp(device.get("process_estimate_remaining")) @property def entity_category(self): @@ -846,6 +861,7 @@ class KegNextActionDateTimeSensor(KegSensor): """Sensor for the date and time of the next required action.""" _attr_translation_key = "next_action_datetime" + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): @@ -853,11 +869,7 @@ def native_value(self): device = self._get_latest_device() if not device: return None - - action = device.get("user_action") - if action == 0 or action is None: - return None - return device.get("sub_title") + return _coerce_timestamp(device.get("process_estimate_remaining")) @property def entity_category(self): From 0c676ed454edd7a45aaae8d6d91e6732944222d9 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:35:33 +0200 Subject: [PATCH 06/14] feat(sensor): update next action sensor to display remaining time instead of timestamp --- README.md | 4 +-- custom_components/minibrew/sensor.py | 35 ++++++++++++------- custom_components/minibrew/strings.json | 4 +-- .../minibrew/translations/en.json | 4 +-- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a7126ae..9944a86 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **User Action Required** - Notifications for required user actions -- **Next Action Date and Time** - UTC timestamp for the next required action from MiniBrew's process estimate +- **Next Action Remaining Time** - Human-readable remaining time derived from MiniBrew's REST process estimate ### Keg Device Sensors - **Current Temperature** - Real-time keg temperature @@ -33,7 +33,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **Action Required** - Notifications for required user actions -- **Next Action Date and Time** - UTC timestamp for the next required action from MiniBrew's process estimate +- **Next Action Remaining Time** - Human-readable remaining time derived from MiniBrew's REST process estimate ## Installation diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 0650d22..dd6da4a 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -2,7 +2,7 @@ from dataclasses import asdict, is_dataclass from datetime import datetime, timedelta, timezone -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from pymbrewclient import BreweryOverview, Device @@ -41,6 +41,17 @@ def _coerce_timestamp(value): return parsed.astimezone(timezone.utc) return None + +def _format_remaining_time(value): + timestamp = _coerce_timestamp(value) + if timestamp is None: + return None + + remaining_seconds = max(0, int((timestamp - datetime.now(timezone.utc)).total_seconds())) + hours, remainder = divmod(remaining_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours}:{minutes:02d}:{seconds:02d}" + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MiniBrew sensors from a config entry.""" client = hass.data[DOMAIN][config_entry.entry_id] # Get the BreweryClient instance @@ -408,18 +419,17 @@ def unique_id(self): class CraftNextActionDateTimeSensor(CraftSensor): - """Sensor for the date and time of the next required action.""" + """Sensor for the remaining time until the next required action.""" - _attr_translation_key = "next_action_datetime" - _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_translation_key = "next_action_time_remaining" @property def native_value(self): - """Return the date and time for the next required action.""" + """Return a human-readable remaining time for the next required action.""" device = self._get_latest_device() if not device: return None - return _coerce_timestamp(device.get("process_estimate_remaining")) + return _format_remaining_time(device.get("process_estimate_remaining")) @property def entity_category(self): @@ -434,7 +444,7 @@ def icon(self): @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_next_action_datetime" + return f"{self.device_id}_next_action_time_remaining" class CraftSensorCurrentStageSensor(CraftSensor): @@ -858,18 +868,17 @@ def unique_id(self): class KegNextActionDateTimeSensor(KegSensor): - """Sensor for the date and time of the next required action.""" + """Sensor for the remaining time until the next required action.""" - _attr_translation_key = "next_action_datetime" - _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_translation_key = "next_action_time_remaining" @property def native_value(self): - """Return the date and time for the next required action.""" + """Return a human-readable remaining time for the next required action.""" device = self._get_latest_device() if not device: return None - return _coerce_timestamp(device.get("process_estimate_remaining")) + return _format_remaining_time(device.get("process_estimate_remaining")) @property def entity_category(self): @@ -884,4 +893,4 @@ def icon(self): @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_next_action_datetime" + return f"{self.device_id}_next_action_time_remaining" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 20fbfd1..9fa354c 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -63,8 +63,8 @@ "unknown": "Unknown" } }, - "next_action_datetime": { - "name": "Next action date and time" + "next_action_time_remaining": { + "name": "Next action remaining time" }, "current_stage": { "name": "Current stage", diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index 20fbfd1..9fa354c 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -63,8 +63,8 @@ "unknown": "Unknown" } }, - "next_action_datetime": { - "name": "Next action date and time" + "next_action_time_remaining": { + "name": "Next action remaining time" }, "current_stage": { "name": "Current stage", From 0d6843785b742b3d1cd6e54cdf32bf3989f4f130 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:36:10 +0200 Subject: [PATCH 07/14] refactor(sensor): remove unused import of BreweryOverview from pymbrewclient --- custom_components/minibrew/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index dd6da4a..3603077 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from pymbrewclient import BreweryOverview, Device +from pymbrewclient import Device from .const import DOMAIN From 4ac6c2b8b4cd5fa74fc421ca88ef33bd7cdc0715 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:40:10 +0200 Subject: [PATCH 08/14] feat(sensor): update time in stage sensor to return formatted duration and update translation key --- custom_components/minibrew/sensor.py | 25 +++++++++++++------ custom_components/minibrew/strings.json | 2 +- .../minibrew/translations/en.json | 2 +- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 3603077..0fd8966 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -479,9 +479,9 @@ def unique_id(self): return f"{self.device_id}_current_stage" class CraftSensorTimeInStageSensor(CraftSensor): - """Sensor for the time spent in the current stage of the Craft device.""" + """Sensor for the formatted time spent in the current stage of the Craft device.""" - _attr_translation_key = "time_in_stage" + _attr_translation_key = "time_in_stage_duration" @property def name(self): @@ -490,14 +490,23 @@ def name(self): @property def native_value(self): - """Return the time spent in the current stage.""" + """Return a human-readable duration for the time spent in the current stage.""" device = self._get_latest_device() - return device.get("status_time") if device else None + if not device: + return None - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return "seconds" + seconds = device.get("status_time") + if seconds is None: + return None + + try: + total_seconds = max(0, int(seconds)) + except (TypeError, ValueError): + return None + + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" @property def available(self): diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 9fa354c..8aec4dd 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -76,7 +76,7 @@ "unknown": "Unknown" } }, - "time_in_stage": { + "time_in_stage_duration": { "name": "Time in stage" }, "needs_cleaning": { diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index 9fa354c..8aec4dd 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -76,7 +76,7 @@ "unknown": "Unknown" } }, - "time_in_stage": { + "time_in_stage_duration": { "name": "Time in stage" }, "needs_cleaning": { From c0d371552bc04361d9685bee01b32c0c68feab70 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:51:45 +0200 Subject: [PATCH 09/14] feat(sensor): add KegTimeInStageSensor and update time format to H:MM:SS --- custom_components/minibrew/sensor.py | 78 ++++++++++++++++--- custom_components/minibrew/strings.json | 2 +- .../minibrew/translations/en.json | 2 +- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 0fd8966..2c6826f 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -52,6 +52,20 @@ def _format_remaining_time(value): minutes, seconds = divmod(remainder, 60) return f"{hours}:{minutes:02d}:{seconds:02d}" + +def _format_duration_seconds(value): + if value is None: + return None + + try: + total_seconds = max(0, int(value)) + except (TypeError, ValueError): + return None + + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours}:{minutes:02d}:{seconds:02d}" + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MiniBrew sensors from a config entry.""" client = hass.data[DOMAIN][config_entry.entry_id] # Get the BreweryClient instance @@ -104,6 +118,7 @@ def add_new_sensors(): sensors.append(KegTargetTemperatureSensor(coordinator, device, state)) sensors.append(KegBeerStyleSensor(coordinator, device, state)) sensors.append(KegBeerNameSensor(coordinator, device, state)) + sensors.append(KegTimeInStageSensor(coordinator, device, state)) sensors.append(KegOnlineStatusSensor(coordinator, device, state)) sensors.append(KegIsUpdatingSensor(coordinator, device, state)) sensors.append(KegNeedsCleaningSensor(coordinator, device, state)) @@ -482,11 +497,13 @@ class CraftSensorTimeInStageSensor(CraftSensor): """Sensor for the formatted time spent in the current stage of the Craft device.""" _attr_translation_key = "time_in_stage_duration" + _attr_native_unit_of_measurement = None + _attr_suggested_unit_of_measurement = None @property def name(self): """Return the name of the sensor.""" - return "Time in Stage" + return "Time in Stage (H:MM:SS)" @property def native_value(self): @@ -495,18 +512,12 @@ def native_value(self): if not device: return None - seconds = device.get("status_time") - if seconds is None: - return None - - try: - total_seconds = max(0, int(seconds)) - except (TypeError, ValueError): - return None + return _format_duration_seconds(device.get("status_time")) - hours, remainder = divmod(total_seconds, 3600) - minutes, secs = divmod(remainder, 60) - return f"{hours}:{minutes:02d}:{secs:02d}" + @property + def unit_of_measurement(self): + """Force no unit to avoid HA appending legacy seconds metadata.""" + return None @property def available(self): @@ -733,6 +744,49 @@ def unique_id(self): return f"{self.device_id}_{self.name}" +class KegTimeInStageSensor(KegSensor): + """Sensor for the formatted time spent in the current stage of the Keg device.""" + + _attr_translation_key = "time_in_stage_duration" + _attr_native_unit_of_measurement = None + _attr_suggested_unit_of_measurement = None + + @property + def name(self): + """Return the name of the sensor.""" + return "Time in Stage (H:MM:SS)" + + @property + def native_value(self): + """Return a human-readable duration for the time spent in the current stage.""" + device = self._get_latest_device() + if not device: + return None + + return _format_duration_seconds(device.get("status_time")) + + @property + def unit_of_measurement(self): + """Force no unit to avoid HA appending legacy seconds metadata.""" + return None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:clock-time-eight" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + device = self._get_latest_device() + return device is not None and device.get("status_time") is not None + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_time_in_stage" + + class KegOnlineStatusSensor(KegSensor): """Sensor for the online status of the Keg device.""" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 8aec4dd..c736c90 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -77,7 +77,7 @@ } }, "time_in_stage_duration": { - "name": "Time in stage" + "name": "Time in stage (H:MM:SS)" }, "needs_cleaning": { "name": "Needs cleaning", diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index 8aec4dd..c736c90 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -77,7 +77,7 @@ } }, "time_in_stage_duration": { - "name": "Time in stage" + "name": "Time in stage (H:MM:SS)" }, "needs_cleaning": { "name": "Needs cleaning", From d03840205f7d66a0342bb5c7a817352655362e9d Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:57:37 +0200 Subject: [PATCH 10/14] feat(sensor): update unique_id for time in stage sensors to include duration --- custom_components/minibrew/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 2c6826f..bde2c8f 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -533,7 +533,7 @@ def icon(self): @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_time_in_stage" + return f"{self.device_id}_time_in_stage_duration" class CraftSensorNeedsCleaningSensor(CraftSensor): """Sensor for the cleaning status of the Craft device.""" @@ -784,7 +784,7 @@ def available(self) -> bool: @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_time_in_stage" + return f"{self.device_id}_time_in_stage_duration" class KegOnlineStatusSensor(KegSensor): From 7bb265e78531647f47a99d65c6fd66d0ae6f301c Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:05:39 +0200 Subject: [PATCH 11/14] feat(sensor): update time in stage sensors to use new naming and expose additional state attributes --- custom_components/minibrew/sensor.py | 34 +++++++++++++++---- custom_components/minibrew/strings.json | 3 ++ .../minibrew/translations/en.json | 3 ++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index bde2c8f..aa79866 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -496,14 +496,14 @@ def unique_id(self): class CraftSensorTimeInStageSensor(CraftSensor): """Sensor for the formatted time spent in the current stage of the Craft device.""" - _attr_translation_key = "time_in_stage_duration" + _attr_translation_key = "time_in_stage" _attr_native_unit_of_measurement = None _attr_suggested_unit_of_measurement = None @property def name(self): """Return the name of the sensor.""" - return "Time in Stage (H:MM:SS)" + return "Time in Stage" @property def native_value(self): @@ -530,10 +530,21 @@ def icon(self): """Return the icon for the sensor.""" return "mdi:clock" + @property + def extra_state_attributes(self): + """Expose raw and formatted values for runtime verification.""" + device = self._get_latest_device() + raw_seconds = device.get("status_time") if device else None + return { + "raw_status_time_seconds": raw_seconds, + "formatted_status_time": _format_duration_seconds(raw_seconds), + "format_version": "hms-v2", + } + @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_time_in_stage_duration" + return f"{self.device_id}_time_in_stage" class CraftSensorNeedsCleaningSensor(CraftSensor): """Sensor for the cleaning status of the Craft device.""" @@ -747,14 +758,14 @@ def unique_id(self): class KegTimeInStageSensor(KegSensor): """Sensor for the formatted time spent in the current stage of the Keg device.""" - _attr_translation_key = "time_in_stage_duration" + _attr_translation_key = "time_in_stage" _attr_native_unit_of_measurement = None _attr_suggested_unit_of_measurement = None @property def name(self): """Return the name of the sensor.""" - return "Time in Stage (H:MM:SS)" + return "Time in Stage" @property def native_value(self): @@ -775,6 +786,17 @@ def icon(self): """Return the icon for the sensor.""" return "mdi:clock-time-eight" + @property + def extra_state_attributes(self): + """Expose raw and formatted values for runtime verification.""" + device = self._get_latest_device() + raw_seconds = device.get("status_time") if device else None + return { + "raw_status_time_seconds": raw_seconds, + "formatted_status_time": _format_duration_seconds(raw_seconds), + "format_version": "hms-v2", + } + @property def available(self) -> bool: """Return True if entity is available.""" @@ -784,7 +806,7 @@ def available(self) -> bool: @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_time_in_stage_duration" + return f"{self.device_id}_time_in_stage" class KegOnlineStatusSensor(KegSensor): diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index c736c90..9931768 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -76,6 +76,9 @@ "unknown": "Unknown" } }, + "time_in_stage": { + "name": "Time in stage" + }, "time_in_stage_duration": { "name": "Time in stage (H:MM:SS)" }, diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index c736c90..9931768 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -76,6 +76,9 @@ "unknown": "Unknown" } }, + "time_in_stage": { + "name": "Time in stage" + }, "time_in_stage_duration": { "name": "Time in stage (H:MM:SS)" }, From d53357381f39cb54e8d5edc464df36c85785e651 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:46:57 +0200 Subject: [PATCH 12/14] feat(sensor): add real-time MQTT telemetry support and new configuration options --- custom_components/minibrew/__init__.py | 18 +- custom_components/minibrew/config_flow.py | 25 +- custom_components/minibrew/const.py | 12 +- custom_components/minibrew/manifest.json | 2 +- custom_components/minibrew/realtime.py | 148 ++++++++++ custom_components/minibrew/sensor.py | 263 +++++++++++++----- custom_components/minibrew/strings.json | 11 +- .../minibrew/translations/en.json | 11 +- tests/test_realtime_overlay.py | 81 ++++++ 9 files changed, 496 insertions(+), 75 deletions(-) create mode 100644 custom_components/minibrew/realtime.py create mode 100644 tests/test_realtime_overlay.py diff --git a/custom_components/minibrew/__init__.py b/custom_components/minibrew/__init__.py index b1dd30c..c152788 100644 --- a/custom_components/minibrew/__init__.py +++ b/custom_components/minibrew/__init__.py @@ -24,15 +24,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b minibrew_client = BreweryClient(username=minibrew_username, password=minibrew_password) _LOGGER.debug(f"Minibrew initialized") hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = minibrew_client + hass.data[DOMAIN][config_entry.entry_id] = {"client": minibrew_client} except Exception as ex: _LOGGER.error("Could not connect to Minibrew: %s", ex) raise ConfigEntryNotReady from ex + # Reload the entry when options change (e.g. toggling real-time updates). + config_entry.async_on_unload(config_entry.add_update_listener(async_update_options)) + await hass.config_entries.async_forward_entry_setups(config_entry, ["sensor"]) return True +async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Reload the config entry when its options are updated.""" + await hass.config_entries.async_reload(config_entry.entry_id) + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + # Stop the real-time MQTT stream, if one was started. + store = hass.data.get(DOMAIN, {}).get(entry.entry_id) + if store: + coordinator = store.get("coordinator") + if coordinator is not None and getattr(coordinator, "realtime", None) is not None: + await coordinator.realtime.async_stop() + unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor"]) + if unload_ok: + hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) return unload_ok \ No newline at end of file diff --git a/custom_components/minibrew/config_flow.py b/custom_components/minibrew/config_flow.py index d1b968e..cbfa40c 100644 --- a/custom_components/minibrew/config_flow.py +++ b/custom_components/minibrew/config_flow.py @@ -5,7 +5,15 @@ from homeassistant.data_entry_flow import FlowResult from dataclasses import asdict -from .const import DOMAIN +from .const import ( + CONF_ENABLE_REALTIME, + CONF_REALTIME_POLL_INTERVAL, + CONF_REFRESH_INTERVAL, + DEFAULT_ENABLE_REALTIME, + DEFAULT_REALTIME_POLL_INTERVAL, + DEFAULT_REFRESH_INTERVAL, + DOMAIN, +) from pymbrewclient import BreweryClient, Device _LOGGER = logging.getLogger(__name__) @@ -84,9 +92,20 @@ async def async_step_init(self, user_input=None): if user_input is not None: return self.async_create_entry(title="", data=user_input) - # Default value from existing options or fallback to 60 + options = self.config_entry.options options_schema = vol.Schema({ - vol.Optional("refresh_interval", default=self.config_entry.options.get("refresh_interval", 60)): int, + vol.Optional( + CONF_ENABLE_REALTIME, + default=options.get(CONF_ENABLE_REALTIME, DEFAULT_ENABLE_REALTIME), + ): bool, + vol.Optional( + CONF_REFRESH_INTERVAL, + default=options.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL), + ): int, + vol.Optional( + CONF_REALTIME_POLL_INTERVAL, + default=options.get(CONF_REALTIME_POLL_INTERVAL, DEFAULT_REALTIME_POLL_INTERVAL), + ): int, }) return self.async_show_form( diff --git a/custom_components/minibrew/const.py b/custom_components/minibrew/const.py index b629493..7cd3377 100644 --- a/custom_components/minibrew/const.py +++ b/custom_components/minibrew/const.py @@ -1,2 +1,12 @@ DOMAIN = "minibrew" -MANUFACTURER = "MiniBrew" \ No newline at end of file +MANUFACTURER = "MiniBrew" + +# Options +CONF_REFRESH_INTERVAL = "refresh_interval" +CONF_ENABLE_REALTIME = "enable_realtime" +CONF_REALTIME_POLL_INTERVAL = "realtime_poll_interval" + +# Defaults +DEFAULT_REFRESH_INTERVAL = 60 +DEFAULT_ENABLE_REALTIME = False +DEFAULT_REALTIME_POLL_INTERVAL = 300 diff --git a/custom_components/minibrew/manifest.json b/custom_components/minibrew/manifest.json index 2a3a9dc..03b75f5 100644 --- a/custom_components/minibrew/manifest.json +++ b/custom_components/minibrew/manifest.json @@ -11,7 +11,7 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/stuartp44/hambrewclient/issues", "requirements": [ - "pymbrewclient>=1.9.0" + "pymbrewclient>=1.10.0" ], "version": "0.8.0" } \ No newline at end of file diff --git a/custom_components/minibrew/realtime.py b/custom_components/minibrew/realtime.py new file mode 100644 index 0000000..13d5ddd --- /dev/null +++ b/custom_components/minibrew/realtime.py @@ -0,0 +1,148 @@ +"""Real-time MQTT telemetry manager for the MiniBrew integration. + +Wraps ``pymbrewclient``'s MQTT-over-WebSocket client so the sensor coordinator +can overlay live device telemetry on top of the slower REST poll. + +The underlying ``MqttClient`` runs paho with its own network thread, so its +callbacks fire *off* the Home Assistant event loop. Every interaction with HA +(entity state writes via the coordinator listeners) is therefore marshalled +back onto the loop with ``hass.loop.call_soon_threadsafe``. +""" + +import logging +import threading + +_LOGGER = logging.getLogger(__name__) + +# Maps ``DeviceLogMessage`` attribute -> the REST device-dict key the existing +# sensors already read. Keeping it here (with no Home Assistant imports) lets the +# mapping be unit-tested without a running HA instance. +_TELEMETRY_TO_DEVICE_KEY = { + "current_temperature": "current_temp", + "target_temperature": "target_temp", + "user_action": "user_action", + "next_action_at": "process_estimate_remaining", +} + + +def overlay_mqtt(device: dict, telemetry) -> None: + """Overlay live MQTT telemetry onto a REST device dict, in place. + + Only non-``None`` telemetry values win, so a missing field never clobbers + the value from the last REST poll. + """ + if telemetry is None: + return + for attr, device_key in _TELEMETRY_TO_DEVICE_KEY.items(): + value = getattr(telemetry, attr, None) + if value is not None: + device[device_key] = value + + +class MiniBrewRealtimeManager: + """Owns the MQTT client lifecycle and a per-serial telemetry store.""" + + def __init__(self, hass, coordinator, client): + """Initialize the manager. + + :param hass: The Home Assistant instance. + :param coordinator: The MiniBrew ``DataUpdateCoordinator`` whose + listeners are notified when new telemetry arrives. + :param client: The ``BreweryClient`` used to mint an MQTT client. + """ + self.hass = hass + self.coordinator = coordinator + self._client = client + self._mqtt = None + self._telemetry = {} + self._telemetry_lock = threading.Lock() + self._subscribed = set() + self._connected = False + + async def async_start(self): + """Create and connect the MQTT client (runs blocking I/O in executor).""" + try: + self._mqtt = await self.hass.async_add_executor_job(self._client.create_mqtt_client) + except Exception as err: # noqa: BLE001 - never break REST polling + _LOGGER.warning("MiniBrew realtime: could not create MQTT client: %s", err) + self._mqtt = None + return + + self._mqtt.on_device_log(self._handle_device_log) + self._mqtt.on_connected(self._handle_connected) + self._mqtt.on_disconnected(self._handle_disconnected) + self._mqtt.on_error(self._handle_error) + + try: + await self.hass.async_add_executor_job(self._mqtt.connect) + except Exception as err: # noqa: BLE001 - never break REST polling + _LOGGER.warning("MiniBrew realtime: could not connect to MQTT broker: %s", err) + + def async_ensure_subscribed(self, serials): + """Subscribe to device-log topics for any not-yet-subscribed serials. + + Safe to call from the event loop. ``subscribe_device_logs`` remembers + topics and auto-resubscribes on reconnect, so this may run before the + connection is established. + """ + if self._mqtt is None: + return + for serial in serials: + if not serial or serial in self._subscribed: + continue + try: + self._mqtt.subscribe_device_logs(serial) + self._subscribed.add(serial) + _LOGGER.debug("MiniBrew realtime: subscribed to logs for %s", serial) + except Exception as err: # noqa: BLE001 + _LOGGER.warning("MiniBrew realtime: subscribe failed for %s: %s", serial, err) + + def get_telemetry(self, serial): + """Return the latest decoded telemetry for a serial, or ``None``.""" + with self._telemetry_lock: + return self._telemetry.get(serial) + + @property + def connected(self): + """Return whether the MQTT stream is currently connected.""" + return self._connected + + async def async_stop(self): + """Disconnect the MQTT client and stop its network thread.""" + if self._mqtt is None: + return + mqtt, self._mqtt = self._mqtt, None + try: + await self.hass.async_add_executor_job(mqtt.disconnect) + except Exception as err: # noqa: BLE001 + _LOGGER.debug("MiniBrew realtime: error during disconnect: %s", err) + self._connected = False + + # ------------------------------------------------------------------ + # paho-thread callbacks — marshal HA work back onto the event loop + # ------------------------------------------------------------------ + + def _handle_device_log(self, msg): + """Store telemetry and notify coordinator listeners (paho thread).""" + serial = msg.device_uuid + if not serial: + return + with self._telemetry_lock: + self._telemetry[serial] = msg + self.hass.loop.call_soon_threadsafe(self.coordinator.async_update_listeners) + + def _handle_connected(self): + """Mark connected and refresh entity availability (paho thread).""" + self._connected = True + _LOGGER.debug("MiniBrew realtime: MQTT connected") + self.hass.loop.call_soon_threadsafe(self.coordinator.async_update_listeners) + + def _handle_disconnected(self): + """Mark disconnected and refresh entity availability (paho thread).""" + self._connected = False + _LOGGER.debug("MiniBrew realtime: MQTT disconnected") + self.hass.loop.call_soon_threadsafe(self.coordinator.async_update_listeners) + + def _handle_error(self, exc): + """Log connection-level MQTT errors (paho thread). Token is never included.""" + _LOGGER.warning("MiniBrew realtime: MQTT error: %s", exc) diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index aa79866..a388d42 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -2,12 +2,21 @@ from dataclasses import asdict, is_dataclass from datetime import datetime, timedelta, timezone -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from pymbrewclient import Device -from .const import DOMAIN +from .const import ( + CONF_ENABLE_REALTIME, + CONF_REALTIME_POLL_INTERVAL, + CONF_REFRESH_INTERVAL, + DEFAULT_ENABLE_REALTIME, + DEFAULT_REALTIME_POLL_INTERVAL, + DEFAULT_REFRESH_INTERVAL, + DOMAIN, +) +from .realtime import MiniBrewRealtimeManager, overlay_mqtt _LOGGER = logging.getLogger(__name__) @@ -42,15 +51,27 @@ def _coerce_timestamp(value): return None -def _format_remaining_time(value): - timestamp = _coerce_timestamp(value) - if timestamp is None: - return None +def _overlay_mqtt(device: dict, telemetry) -> None: + """Overlay live MQTT telemetry onto a REST device dict, in place. - remaining_seconds = max(0, int((timestamp - datetime.now(timezone.utc)).total_seconds())) - hours, remainder = divmod(remaining_seconds, 3600) - minutes, seconds = divmod(remainder, 60) - return f"{hours}:{minutes:02d}:{seconds:02d}" + Thin re-export of :func:`realtime.overlay_mqtt`; kept for readability at the + call site in the coordinator. + """ + overlay_mqtt(device, telemetry) + + +def _collect_serials(coordinator, data=None): + """Return the set of device serial numbers present in the overview.""" + overview = data if data is not None else coordinator.data + serials = set() + if overview is None: + return serials + for devices in overview.__dict__.values(): + for device_data in devices: + serial = _device_to_dict(device_data).get("serial_number") + if serial: + serials.add(serial) + return serials def _format_duration_seconds(value): @@ -68,14 +89,23 @@ def _format_duration_seconds(value): async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MiniBrew sensors from a config entry.""" - client = hass.data[DOMAIN][config_entry.entry_id] # Get the BreweryClient instance - sensors = [] + store = hass.data[DOMAIN][config_entry.entry_id] + client = store["client"] # Get the BreweryClient instance added_devices = set() # Create a DataUpdateCoordinator coordinator = MiniBrewDataUpdateCoordinator(hass, client, config_entry) await coordinator.async_config_entry_first_refresh() + # Expose the coordinator for unload (so the MQTT stream can be stopped). + store["coordinator"] = coordinator + + # Start the optional real-time MQTT stream and subscribe to known devices. + if coordinator.realtime_enabled: + manager = MiniBrewRealtimeManager(hass, coordinator, client) + await manager.async_start() + coordinator.realtime = manager + manager.async_ensure_subscribed(_collect_serials(coordinator)) _LOGGER.debug(f"Brewery overview: {coordinator}") @@ -83,6 +113,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def add_new_sensors(): # Track devices currently in the API response current_devices = set() + new_sensors = [] for state, devices in coordinator.data.__dict__.items(): # Access states dynamically for device_data in devices: @@ -102,43 +133,51 @@ def add_new_sensors(): # Add sensors for MiniBrew devices if device.device_type == 0: # Craft device - sensors.append(CraftSensorCurrentTemperatureSensor(coordinator, device, state)) - sensors.append(CraftSensorTargetTemperatureSensor(coordinator, device, state)) - sensors.append(CraftSensorOnlineStatusSensor(coordinator, device, state)) - sensors.append(CraftSensorIsUpdatingSensor(coordinator, device, state)) - sensors.append(CraftSensorBrewStageSensor(coordinator, device, state)) - sensors.append(CraftSensorTimeInStageSensor(coordinator, device, state)) - sensors.append(CraftSensorCurrentStageSensor(coordinator, device, state)) - sensors.append(CraftSensorNeedsCleaningSensor(coordinator, device, state)) - sensors.append(CraftUserActionRequiredSensor(coordinator, device, state)) - sensors.append(CraftNextActionDateTimeSensor(coordinator, device, state)) + new_sensors.append(CraftSensorCurrentTemperatureSensor(coordinator, device, state)) + new_sensors.append(CraftSensorTargetTemperatureSensor(coordinator, device, state)) + new_sensors.append(CraftSensorOnlineStatusSensor(coordinator, device, state)) + new_sensors.append(CraftSensorIsUpdatingSensor(coordinator, device, state)) + new_sensors.append(CraftSensorBrewStageSensor(coordinator, device, state)) + new_sensors.append(CraftSensorTimeInStageSensor(coordinator, device, state)) + new_sensors.append(CraftSensorCurrentStageSensor(coordinator, device, state)) + new_sensors.append(CraftSensorNeedsCleaningSensor(coordinator, device, state)) + new_sensors.append(CraftUserActionRequiredSensor(coordinator, device, state)) + new_sensors.append(CraftNextActionDateTimeSensor(coordinator, device, state)) + if coordinator.realtime_enabled: + new_sensors.append(CraftWifiSignalSensor(coordinator, device, state)) # Add sensors for Keg devices elif device.device_type == 1: # Keg device - sensors.append(KegCurrentTemperatureSensor(coordinator, device, state)) - sensors.append(KegTargetTemperatureSensor(coordinator, device, state)) - sensors.append(KegBeerStyleSensor(coordinator, device, state)) - sensors.append(KegBeerNameSensor(coordinator, device, state)) - sensors.append(KegTimeInStageSensor(coordinator, device, state)) - sensors.append(KegOnlineStatusSensor(coordinator, device, state)) - sensors.append(KegIsUpdatingSensor(coordinator, device, state)) - sensors.append(KegNeedsCleaningSensor(coordinator, device, state)) - sensors.append(KegActionRequiredSensor(coordinator, device, state)) - sensors.append(KegNextActionDateTimeSensor(coordinator, device, state)) + new_sensors.append(KegCurrentTemperatureSensor(coordinator, device, state)) + new_sensors.append(KegTargetTemperatureSensor(coordinator, device, state)) + new_sensors.append(KegBeerStyleSensor(coordinator, device, state)) + new_sensors.append(KegBeerNameSensor(coordinator, device, state)) + new_sensors.append(KegTimeInStageSensor(coordinator, device, state)) + new_sensors.append(KegOnlineStatusSensor(coordinator, device, state)) + new_sensors.append(KegIsUpdatingSensor(coordinator, device, state)) + new_sensors.append(KegNeedsCleaningSensor(coordinator, device, state)) + new_sensors.append(KegActionRequiredSensor(coordinator, device, state)) + new_sensors.append(KegNextActionDateTimeSensor(coordinator, device, state)) + if coordinator.realtime_enabled: + new_sensors.append(KegWifiSignalSensor(coordinator, device, state)) # Mark the device as added added_devices.add(serial_number) - + # Remove devices that are no longer in the API response # This allows offline/reconnecting devices to be re-registered added_devices.intersection_update(current_devices) + return new_sensors + # Add initial sensors - add_new_sensors() - async_add_entities(sensors) + async_add_entities(add_new_sensors()) - # Listen for updates from the coordinator and add new sensors dynamically - async def handle_coordinator_update(): - add_new_sensors() - async_add_entities(sensors) + # Listen for updates from the coordinator and add any newly discovered + # devices. This fires on every coordinator update — including frequent + # real-time MQTT telemetry — so only newly created entities are added. + def handle_coordinator_update(): + new_sensors = add_new_sensors() + if new_sensors: + async_add_entities(new_sensors) coordinator.async_add_listener(handle_coordinator_update) @@ -149,14 +188,27 @@ def __init__(self, hass, client, config_entry): """Initialize the coordinator.""" self.client = client self.config_entry = config_entry - refresh_interval = config_entry.options.get("refresh_interval", 60) + + options = config_entry.options + self.realtime_enabled = options.get(CONF_ENABLE_REALTIME, DEFAULT_ENABLE_REALTIME) + refresh_interval = options.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL) + realtime_poll_interval = options.get( + CONF_REALTIME_POLL_INTERVAL, DEFAULT_REALTIME_POLL_INTERVAL + ) + + # When real-time is on, MQTT drives the fast fields, so poll slowly + # (discovery + slow fields only); otherwise poll at the normal interval. + poll_interval = realtime_poll_interval if self.realtime_enabled else refresh_interval + + # Set by async_setup_entry once the MQTT stream is started. + self.realtime = None + super().__init__( hass, _LOGGER, name="MiniBrew Data Update Coordinator", - update_interval=timedelta(seconds=refresh_interval), # Fetch data every 30 seconds + update_interval=timedelta(seconds=poll_interval), ) - self.client = client async def _async_update_data(self): """Fetch data from the API.""" @@ -164,11 +216,42 @@ async def _async_update_data(self): _LOGGER.debug("Fetching data from MiniBrew API...") data = await self.hass.async_add_executor_job(self.client.get_brewery_overview) _LOGGER.debug(f"Fetched data: {data}") - return data except Exception as err: _LOGGER.error(f"Error fetching data: {err}") raise UpdateFailed(f"Error fetching data: {err}") + # Subscribe the MQTT stream to any newly discovered devices. + if self.realtime is not None: + self.realtime.async_ensure_subscribed(_collect_serials(self, data)) + + return data + + def get_telemetry(self, serial): + """Return the latest MQTT telemetry for a serial, or ``None``.""" + if self.realtime is None: + return None + return self.realtime.get_telemetry(serial) + + @property + def realtime_connected(self): + """Return whether the real-time MQTT stream is connected.""" + return self.realtime is not None and self.realtime.connected + + def get_merged_device(self, serial, state): + """Return the REST device dict for a serial, overlaid with live telemetry. + + Returns ``None`` when the device is not present in the given state group. + """ + devices = getattr(self.data, state, []) + for dev in devices: + device_dict = _device_to_dict(dev) + if device_dict.get("serial_number") == serial: + merged = dict(device_dict) + if self.realtime_enabled: + _overlay_mqtt(merged, self.get_telemetry(serial)) + return merged + return None + class CraftSensor(SensorEntity): """Base class for MiniBrew sensors.""" @@ -211,13 +294,8 @@ async def async_added_to_hass(self): self.async_on_remove(self.coordinator.async_add_listener(self.async_write_ha_state)) def _get_latest_device(self): - """Get the latest device data from the coordinator.""" - devices = getattr(self.coordinator.data, self.device_type, []) - for dev in devices: - device_dict = _device_to_dict(dev) - if device_dict.get("serial_number") == self.device_id: - return device_dict - return None + """Get the latest device data (REST overlaid with live telemetry).""" + return self.coordinator.get_merged_device(self.device_id, self.device_type) class CraftSensorBrewStageSensor(CraftSensor): """Sensor for the current brew stage of the Craft device.""" @@ -434,17 +512,18 @@ def unique_id(self): class CraftNextActionDateTimeSensor(CraftSensor): - """Sensor for the remaining time until the next required action.""" + """Sensor for the actual timestamp of the next required action.""" _attr_translation_key = "next_action_time_remaining" + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): - """Return a human-readable remaining time for the next required action.""" + """Return the actual next-action timestamp (MQTT when realtime, else REST).""" device = self._get_latest_device() if not device: return None - return _format_remaining_time(device.get("process_estimate_remaining")) + return _coerce_timestamp(device.get("process_estimate_remaining")) @property def entity_category(self): @@ -619,13 +698,8 @@ async def async_added_to_hass(self): self.async_on_remove(self.coordinator.async_add_listener(self.async_write_ha_state)) def _get_latest_device(self): - """Get the latest device data from the coordinator.""" - devices = getattr(self.coordinator.data, self.device_type, []) - for dev in devices: - device_dict = _device_to_dict(dev) - if device_dict.get("serial_number") == self.device_id: - return device_dict - return None + """Get the latest device data (REST overlaid with live telemetry).""" + return self.coordinator.get_merged_device(self.device_id, self.device_type) class KegCurrentTemperatureSensor(KegSensor): """Sensor for the current temperature of the Keg device.""" @@ -953,17 +1027,18 @@ def unique_id(self): class KegNextActionDateTimeSensor(KegSensor): - """Sensor for the remaining time until the next required action.""" + """Sensor for the actual timestamp of the next required action.""" _attr_translation_key = "next_action_time_remaining" + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): - """Return a human-readable remaining time for the next required action.""" + """Return the actual next-action timestamp (MQTT when realtime, else REST).""" device = self._get_latest_device() if not device: return None - return _format_remaining_time(device.get("process_estimate_remaining")) + return _coerce_timestamp(device.get("process_estimate_remaining")) @property def entity_category(self): @@ -979,3 +1054,65 @@ def icon(self): def unique_id(self): """Return the unique ID of the sensor.""" return f"{self.device_id}_next_action_time_remaining" + + +class CraftWifiSignalSensor(CraftSensor): + """Sensor for the Wi-Fi signal strength of the Craft device (MQTT only).""" + + _attr_translation_key = "wifi_signal" + _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH + _attr_native_unit_of_measurement = "dBm" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self): + """Return the Wi-Fi RSSI in dBm from the latest telemetry.""" + telemetry = self.coordinator.get_telemetry(self.device_id) + return telemetry.wifi_rssi_dbm if telemetry else None + + @property + def available(self): + """Return True once real-time telemetry with an RSSI has arrived.""" + telemetry = self.coordinator.get_telemetry(self.device_id) + return telemetry is not None and telemetry.wifi_rssi_dbm is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:wifi" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_wifi_signal" + + +class KegWifiSignalSensor(KegSensor): + """Sensor for the Wi-Fi signal strength of the Keg device (MQTT only).""" + + _attr_translation_key = "wifi_signal" + _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH + _attr_native_unit_of_measurement = "dBm" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self): + """Return the Wi-Fi RSSI in dBm from the latest telemetry.""" + telemetry = self.coordinator.get_telemetry(self.device_id) + return telemetry.wifi_rssi_dbm if telemetry else None + + @property + def available(self): + """Return True once real-time telemetry with an RSSI has arrived.""" + telemetry = self.coordinator.get_telemetry(self.device_id) + return telemetry is not None and telemetry.wifi_rssi_dbm is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:wifi" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_wifi_signal" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 9931768..b5705c8 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -23,9 +23,11 @@ "step": { "init": { "title": "MiniBrew Options", - "description": "Configure how often the integration updates data from your MiniBrew devices.", + "description": "Configure how the integration updates data from your MiniBrew devices. Enable real-time updates to stream live telemetry over MQTT instead of frequent polling.", "data": { - "refresh_interval": "Update interval (seconds)" + "enable_realtime": "Enable real-time updates (MQTT)", + "refresh_interval": "Polling update interval (seconds)", + "realtime_poll_interval": "Real-time discovery interval (seconds)" } } } @@ -64,7 +66,7 @@ } }, "next_action_time_remaining": { - "name": "Next action remaining time" + "name": "Next action" }, "current_stage": { "name": "Current stage", @@ -97,6 +99,9 @@ }, "beer_name": { "name": "Beer name" + }, + "wifi_signal": { + "name": "WiFi signal" } } } diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index 9931768..b5705c8 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -23,9 +23,11 @@ "step": { "init": { "title": "MiniBrew Options", - "description": "Configure how often the integration updates data from your MiniBrew devices.", + "description": "Configure how the integration updates data from your MiniBrew devices. Enable real-time updates to stream live telemetry over MQTT instead of frequent polling.", "data": { - "refresh_interval": "Update interval (seconds)" + "enable_realtime": "Enable real-time updates (MQTT)", + "refresh_interval": "Polling update interval (seconds)", + "realtime_poll_interval": "Real-time discovery interval (seconds)" } } } @@ -64,7 +66,7 @@ } }, "next_action_time_remaining": { - "name": "Next action remaining time" + "name": "Next action" }, "current_stage": { "name": "Current stage", @@ -97,6 +99,9 @@ }, "beer_name": { "name": "Beer name" + }, + "wifi_signal": { + "name": "WiFi signal" } } } diff --git a/tests/test_realtime_overlay.py b/tests/test_realtime_overlay.py new file mode 100644 index 0000000..b96f058 --- /dev/null +++ b/tests/test_realtime_overlay.py @@ -0,0 +1,81 @@ +"""Unit tests for the MQTT telemetry overlay. + +These load ``realtime.py`` directly by path so they run without a Home Assistant +runtime (the module imports only the standard library). Run with pytest, or +directly: ``python3 tests/test_realtime_overlay.py``. +""" + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +_REALTIME_PATH = Path(__file__).resolve().parents[1] / "custom_components" / "minibrew" / "realtime.py" +_spec = importlib.util.spec_from_file_location("minibrew_realtime", _REALTIME_PATH) +realtime = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(realtime) + + +def _telemetry(**kwargs): + """Build a DeviceLogMessage-like stand-in with the overlaid attributes.""" + defaults = { + "current_temperature": None, + "target_temperature": None, + "user_action": None, + "next_action_at": None, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def test_none_telemetry_leaves_device_untouched(): + device = {"current_temp": 18.0, "target_temp": 19.0} + realtime.overlay_mqtt(device, None) + assert device == {"current_temp": 18.0, "target_temp": 19.0} + + +def test_non_none_values_win_over_rest(): + device = {"current_temp": 18.0, "target_temp": 19.0, "user_action": 0} + realtime.overlay_mqtt( + device, + _telemetry(current_temperature=19.2, target_temperature=19.0, user_action=3), + ) + assert device["current_temp"] == 19.2 + assert device["target_temp"] == 19.0 + assert device["user_action"] == 3 + + +def test_none_fields_do_not_clobber_rest(): + device = {"current_temp": 18.0, "target_temp": 19.0} + # Only current_temperature is present; target must be preserved from REST. + realtime.overlay_mqtt(device, _telemetry(current_temperature=20.5)) + assert device["current_temp"] == 20.5 + assert device["target_temp"] == 19.0 + + +def test_next_action_at_maps_to_process_estimate_remaining(): + from datetime import datetime, timezone + + when = datetime(2026, 8, 4, 16, 37, 6, tzinfo=timezone.utc) + device = {"process_estimate_remaining": None} + realtime.overlay_mqtt(device, _telemetry(next_action_at=when)) + assert device["process_estimate_remaining"] == when + + +def test_user_action_zero_is_applied_not_skipped(): + # 0 is falsy but not None, so it must still overlay. + device = {"user_action": 5} + realtime.overlay_mqtt(device, _telemetry(user_action=0)) + assert device["user_action"] == 0 + + +if __name__ == "__main__": + failures = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"PASS {name}") + except AssertionError as exc: # noqa: PERF203 + failures += 1 + print(f"FAIL {name}: {exc}") + raise SystemExit(1 if failures else 0) From 99b5e4466e66bb3926bb4b15a1a1588b4fb1762c Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:24:52 +0200 Subject: [PATCH 13/14] feat(sensor): add real-time telemetry for Peltier fan power and ESP core temperature; update changelog and README --- CHANGELOG.md | 7 + README.md | 12 +- custom_components/minibrew/manifest.json | 2 +- custom_components/minibrew/realtime.py | 1 + custom_components/minibrew/sensor.py | 192 +++++++++++++++--- custom_components/minibrew/strings.json | 10 +- .../minibrew/translations/en.json | 10 +- tests/test_realtime_overlay.py | 7 + 8 files changed, 207 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e7a71e..667f5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Require `pymbrewclient>=1.11.0` to align with SensorType telemetry support + ### Fixed - Handle Device objects returned by the API without setup errors +### Added +- Map real-time `seconds_until_next_action` to `process_estimate_remaining_seconds` +- Add real-time diagnostic sensors for Peltier fan power and ESP core temperature + ### Added - Initial release of MiniBrew Home Assistant integration - Support for MiniBrew Craft devices diff --git a/README.md b/README.md index 9944a86..df1a061 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **User Action Required** - Notifications for required user actions -- **Next Action Remaining Time** - Human-readable remaining time derived from MiniBrew's REST process estimate +- **Next Action** - Absolute UTC timestamp for the next required user action +- **Temp Control Power** *(real-time MQTT)* - Peltier heating/cooling power (%) +- **Peltier Fan Power** *(real-time MQTT)* - Peltier fan output (%) +- **ESP Core Temperature** *(real-time MQTT)* - Internal controller temperature ### Keg Device Sensors - **Current Temperature** - Real-time keg temperature @@ -33,7 +36,10 @@ This integration allows you to monitor and control your MiniBrew brewing devices - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **Action Required** - Notifications for required user actions -- **Next Action Remaining Time** - Human-readable remaining time derived from MiniBrew's REST process estimate +- **Next Action** - Absolute UTC timestamp for the next required user action +- **Temp Control Power** *(real-time MQTT)* - Peltier heating/cooling power (%) +- **Peltier Fan Power** *(real-time MQTT)* - Peltier fan output (%) +- **ESP Core Temperature** *(real-time MQTT)* - Internal controller temperature ## Installation @@ -81,7 +87,7 @@ To access options: - Home Assistant 2023.1 or newer - MiniBrew account with registered devices - **MiniBrew Pro subscription** (required for API access) -- [`pymbrewclient>=1.9.0`](https://github.com/stuartp44/pymbrewclient) (automatically installed) +- [`pymbrewclient>=1.11.0`](https://github.com/stuartp44/pymbrewclient/releases/tag/v1.11.0) (automatically installed) ## Dependencies diff --git a/custom_components/minibrew/manifest.json b/custom_components/minibrew/manifest.json index 03b75f5..3774621 100644 --- a/custom_components/minibrew/manifest.json +++ b/custom_components/minibrew/manifest.json @@ -11,7 +11,7 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/stuartp44/hambrewclient/issues", "requirements": [ - "pymbrewclient>=1.10.0" + "pymbrewclient>=1.11.0" ], "version": "0.8.0" } \ No newline at end of file diff --git a/custom_components/minibrew/realtime.py b/custom_components/minibrew/realtime.py index 13d5ddd..a73a5c4 100644 --- a/custom_components/minibrew/realtime.py +++ b/custom_components/minibrew/realtime.py @@ -22,6 +22,7 @@ "target_temperature": "target_temp", "user_action": "user_action", "next_action_at": "process_estimate_remaining", + "seconds_until_next_action": "process_estimate_remaining_seconds", } diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index a388d42..1f82814 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -2,10 +2,10 @@ from dataclasses import asdict, is_dataclass from datetime import datetime, timedelta, timezone -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from pymbrewclient import Device +from pymbrewclient import Device, SensorType from .const import ( CONF_ENABLE_REALTIME, @@ -87,6 +87,18 @@ def _format_duration_seconds(value): minutes, seconds = divmod(remainder, 60) return f"{hours}:{minutes:02d}:{seconds:02d}" + +def _telemetry_sensor_value(coordinator, serial, sensor_type: SensorType, *, hide_zero: bool = False): + """Return a typed measurement from the latest telemetry for a given serial.""" + telemetry = coordinator.get_telemetry(serial) + if telemetry is None: + return None + + value = telemetry.sensor(sensor_type) + if hide_zero and value == 0.0: + return None + return value + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MiniBrew sensors from a config entry.""" store = hass.data[DOMAIN][config_entry.entry_id] @@ -144,7 +156,9 @@ def add_new_sensors(): new_sensors.append(CraftUserActionRequiredSensor(coordinator, device, state)) new_sensors.append(CraftNextActionDateTimeSensor(coordinator, device, state)) if coordinator.realtime_enabled: - new_sensors.append(CraftWifiSignalSensor(coordinator, device, state)) + new_sensors.append(CraftTempControlPowerSensor(coordinator, device, state)) + new_sensors.append(CraftPeltierFanPowerSensor(coordinator, device, state)) + new_sensors.append(CraftEspCoreTempSensor(coordinator, device, state)) # Add sensors for Keg devices elif device.device_type == 1: # Keg device new_sensors.append(KegCurrentTemperatureSensor(coordinator, device, state)) @@ -158,7 +172,9 @@ def add_new_sensors(): new_sensors.append(KegActionRequiredSensor(coordinator, device, state)) new_sensors.append(KegNextActionDateTimeSensor(coordinator, device, state)) if coordinator.realtime_enabled: - new_sensors.append(KegWifiSignalSensor(coordinator, device, state)) + new_sensors.append(KegTempControlPowerSensor(coordinator, device, state)) + new_sensors.append(KegPeltierFanPowerSensor(coordinator, device, state)) + new_sensors.append(KegEspCoreTempSensor(coordinator, device, state)) # Mark the device as added added_devices.add(serial_number) @@ -1056,63 +1072,187 @@ def unique_id(self): return f"{self.device_id}_next_action_time_remaining" -class CraftWifiSignalSensor(CraftSensor): - """Sensor for the Wi-Fi signal strength of the Craft device (MQTT only).""" +class CraftTempControlPowerSensor(CraftSensor): + """Sensor for the temperature-control (Peltier) power of the Craft device (MQTT only).""" - _attr_translation_key = "wifi_signal" - _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH - _attr_native_unit_of_measurement = "dBm" + _attr_translation_key = "temp_control_power" + _attr_native_unit_of_measurement = "%" + _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC @property def native_value(self): - """Return the Wi-Fi RSSI in dBm from the latest telemetry.""" + """Return the temperature-control power (signed %, negative = cooling).""" telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry.wifi_rssi_dbm if telemetry else None + return telemetry.temp_control_power if telemetry else None @property def available(self): - """Return True once real-time telemetry with an RSSI has arrived.""" + """Return True once real-time telemetry with a control-power reading has arrived.""" telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry is not None and telemetry.wifi_rssi_dbm is not None + return telemetry is not None and telemetry.temp_control_power is not None @property def icon(self): """Return the icon for the sensor.""" - return "mdi:wifi" + return "mdi:snowflake-thermometer" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_wifi_signal" + return f"{self.device_id}_temp_control_power" -class KegWifiSignalSensor(KegSensor): - """Sensor for the Wi-Fi signal strength of the Keg device (MQTT only).""" +class KegTempControlPowerSensor(KegSensor): + """Sensor for the temperature-control (Peltier) power of the Keg device (MQTT only).""" - _attr_translation_key = "wifi_signal" - _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH - _attr_native_unit_of_measurement = "dBm" + _attr_translation_key = "temp_control_power" + _attr_native_unit_of_measurement = "%" + _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC @property def native_value(self): - """Return the Wi-Fi RSSI in dBm from the latest telemetry.""" + """Return the temperature-control power (signed %, negative = cooling).""" telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry.wifi_rssi_dbm if telemetry else None + return telemetry.temp_control_power if telemetry else None @property def available(self): - """Return True once real-time telemetry with an RSSI has arrived.""" + """Return True once real-time telemetry with a control-power reading has arrived.""" telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry is not None and telemetry.wifi_rssi_dbm is not None + return telemetry is not None and telemetry.temp_control_power is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:snowflake-thermometer" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_temp_control_power" + + +class CraftPeltierFanPowerSensor(CraftSensor): + """Sensor for the Peltier fan power of the Craft device (MQTT only).""" + + _attr_translation_key = "peltier_fan_power" + _attr_native_unit_of_measurement = "%" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self): + """Return Peltier fan power as a percentage.""" + return _telemetry_sensor_value( + self.coordinator, self.device_id, SensorType.PELTIER_FAN_POWER, hide_zero=True + ) + + @property + def available(self): + """Return True when a non-zero fan power telemetry value has arrived.""" + return self.native_value is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:fan" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_peltier_fan_power" + + +class KegPeltierFanPowerSensor(KegSensor): + """Sensor for the Peltier fan power of the Keg device (MQTT only).""" + + _attr_translation_key = "peltier_fan_power" + _attr_native_unit_of_measurement = "%" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self): + """Return Peltier fan power as a percentage.""" + return _telemetry_sensor_value( + self.coordinator, self.device_id, SensorType.PELTIER_FAN_POWER, hide_zero=True + ) + + @property + def available(self): + """Return True when a non-zero fan power telemetry value has arrived.""" + return self.native_value is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:fan" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_peltier_fan_power" + + +class CraftEspCoreTempSensor(CraftSensor): + """Sensor for ESP core temperature on the Craft device (MQTT only).""" + + _attr_translation_key = "esp_core_temp" + _attr_native_unit_of_measurement = "°C" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self): + """Return ESP core temperature in Celsius.""" + return _telemetry_sensor_value( + self.coordinator, self.device_id, SensorType.ESP_CORE_TEMP, hide_zero=True + ) + + @property + def available(self): + """Return True once an ESP core temperature reading has arrived.""" + return self.native_value is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:chip" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_esp_core_temp" + + +class KegEspCoreTempSensor(KegSensor): + """Sensor for ESP core temperature on the Keg device (MQTT only).""" + + _attr_translation_key = "esp_core_temp" + _attr_native_unit_of_measurement = "°C" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def native_value(self): + """Return ESP core temperature in Celsius.""" + return _telemetry_sensor_value( + self.coordinator, self.device_id, SensorType.ESP_CORE_TEMP, hide_zero=True + ) + + @property + def available(self): + """Return True once an ESP core temperature reading has arrived.""" + return self.native_value is not None @property def icon(self): """Return the icon for the sensor.""" - return "mdi:wifi" + return "mdi:chip" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_wifi_signal" + return f"{self.device_id}_esp_core_temp" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index b5705c8..1c45705 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -100,8 +100,14 @@ "beer_name": { "name": "Beer name" }, - "wifi_signal": { - "name": "WiFi signal" + "temp_control_power": { + "name": "Temp control power" + }, + "peltier_fan_power": { + "name": "Peltier fan power" + }, + "esp_core_temp": { + "name": "ESP core temperature" } } } diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index b5705c8..1c45705 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -100,8 +100,14 @@ "beer_name": { "name": "Beer name" }, - "wifi_signal": { - "name": "WiFi signal" + "temp_control_power": { + "name": "Temp control power" + }, + "peltier_fan_power": { + "name": "Peltier fan power" + }, + "esp_core_temp": { + "name": "ESP core temperature" } } } diff --git a/tests/test_realtime_overlay.py b/tests/test_realtime_overlay.py index b96f058..3c331ab 100644 --- a/tests/test_realtime_overlay.py +++ b/tests/test_realtime_overlay.py @@ -22,6 +22,7 @@ def _telemetry(**kwargs): "target_temperature": None, "user_action": None, "next_action_at": None, + "seconds_until_next_action": None, } defaults.update(kwargs) return SimpleNamespace(**defaults) @@ -68,6 +69,12 @@ def test_user_action_zero_is_applied_not_skipped(): assert device["user_action"] == 0 +def test_seconds_until_next_action_maps_to_remaining_seconds(): + device = {"process_estimate_remaining_seconds": 1200} + realtime.overlay_mqtt(device, _telemetry(seconds_until_next_action=420)) + assert device["process_estimate_remaining_seconds"] == 420 + + if __name__ == "__main__": failures = 0 for name, fn in sorted(globals().items()): From 0c48446aceeaf7129a8fbefcc1116a1e27147638 Mon Sep 17 00:00:00 2001 From: Stuart Pearson <1926002+stuartp44@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:41:18 +0200 Subject: [PATCH 14/14] Enhance MiniBrew integration with reauthentication support and improved telemetry - Added reauthentication confirmation dialog with updated credential fields. - Improved error messages for invalid authentication. - Added new telemetry attributes including process phase, session ID, and last time online. - Updated existing telemetry attributes for clarity (e.g., Peltier power, ESP32 core temperature). - Enhanced test coverage for new telemetry fields and ensured proper handling of duplicate packets in the realtime manager. --- README.md | 24 +- custom_components/minibrew/XXWgUmCW | 0 custom_components/minibrew/__init__.py | 4 + custom_components/minibrew/config_flow.py | 83 +- custom_components/minibrew/const.py | 2 +- custom_components/minibrew/manifest.json | 2 +- custom_components/minibrew/realtime.py | 72 +- custom_components/minibrew/sensor.py | 1478 +++++++++++++++-- custom_components/minibrew/strings.json | 56 +- .../minibrew/translations/en.json | 56 +- tests/test_realtime_overlay.py | 113 +- 11 files changed, 1665 insertions(+), 225 deletions(-) create mode 100644 custom_components/minibrew/XXWgUmCW diff --git a/README.md b/README.md index df1a061..67077ca 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Home Assistant custom component for integrating MiniBrew Craft and Keg devices ## Overview -This integration allows you to monitor and control your MiniBrew brewing devices through Home Assistant. It supports both MiniBrew Craft (brewing devices) and MiniBrew Keg (dispensing devices), providing real-time monitoring of temperatures, brew stages, and device status. +This integration allows you to monitor and control your MiniBrew brewing devices through Home Assistant. It supports both MiniBrew Craft (brewing devices) and MiniBrew Keg (dispensing devices), providing real-time monitoring of temperatures, process phases, and device status. **IMPORTANT:** A MiniBrew Pro subscription is required for this integration to function. The integration uses the MiniBrew API which requires an active Pro subscription to access device data and control features. @@ -15,30 +15,34 @@ This integration allows you to monitor and control your MiniBrew brewing devices ### Craft Device Sensors - **Current Temperature** - Real-time temperature monitoring - **Target Temperature** - Configured target temperature -- **Brew Stage** - Current brewing stage - **Time in Stage** - Duration in current brewing stage - **Current Stage** - Active stage information +- **Process Phase** - Current phase details from runtime telemetry - **Online Status** - Device connectivity status +- **Last Time Online** - Timestamp of the last known online heartbeat (diagnostic) - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **User Action Required** - Notifications for required user actions -- **Next Action** - Absolute UTC timestamp for the next required user action -- **Temp Control Power** *(real-time MQTT)* - Peltier heating/cooling power (%) -- **Peltier Fan Power** *(real-time MQTT)* - Peltier fan output (%) +- **Next Action** - Timestamp for the next required user action +- **Session ID** - Active brew session identifier (diagnostic) +- **Brew Session Started** - Session start timestamp from MiniBrew session metadata (diagnostic) - **ESP Core Temperature** *(real-time MQTT)* - Internal controller temperature ### Keg Device Sensors - **Current Temperature** - Real-time keg temperature - **Target Temperature** - Configured serving temperature -- **Beer Name** - Currently loaded beer name -- **Beer Style** - Currently loaded beer style +- **Beer Name** - Currently loaded beer name (shows `Custom Fermentation` in custom fermentation mode, including inferred fermenting/primary sessions with unknown beer metadata) +- **Beer Style** - Currently loaded beer style (`N/A` in custom fermentation mode) - **Online Status** - Device connectivity status +- **Last Time Online** - Timestamp of the last known online heartbeat (diagnostic) - **Is Updating** - Firmware update status - **Needs Cleaning** - Cleaning reminder indicator - **Action Required** - Notifications for required user actions -- **Next Action** - Absolute UTC timestamp for the next required user action +- **Next Action** - Timestamp for the next required user action +- **Session ID** - Active brew session identifier (diagnostic) +- **Brew Session Started** - Session start timestamp from MiniBrew session metadata (diagnostic) - **Temp Control Power** *(real-time MQTT)* - Peltier heating/cooling power (%) -- **Peltier Fan Power** *(real-time MQTT)* - Peltier fan output (%) +- **Fan Duty** *(real-time MQTT)* - Peltier fan output (%) - **ESP Core Temperature** *(real-time MQTT)* - Internal controller temperature ## Installation @@ -75,7 +79,7 @@ This integration allows you to monitor and control your MiniBrew brewing devices After adding the integration, you can configure additional options: -- **Refresh Interval**: How often to poll the MiniBrew API for updates (default: 60 seconds) +- **Real-time discovery interval**: How often to refresh discovery and fallback data from REST while MQTT drives live telemetry (default: 300 seconds) To access options: 1. Go to **Settings** → **Devices & Services** diff --git a/custom_components/minibrew/XXWgUmCW b/custom_components/minibrew/XXWgUmCW new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/minibrew/__init__.py b/custom_components/minibrew/__init__.py index c152788..c6f6918 100644 --- a/custom_components/minibrew/__init__.py +++ b/custom_components/minibrew/__init__.py @@ -7,6 +7,8 @@ _LOGGER = logging.getLogger(__name__) +_DISPLAY_TITLE = "MiniBrew Pro" +_LEGACY_TITLES = {"Minibrew", "Minibrew Pro", "MiniBrew"} CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -17,6 +19,8 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Minibrew from a config entry.""" + if config_entry.title in _LEGACY_TITLES: + hass.config_entries.async_update_entry(config_entry, title=_DISPLAY_TITLE) minibrew_username = config_entry.data["username"] minibrew_password = config_entry.data["password"] diff --git a/custom_components/minibrew/config_flow.py b/custom_components/minibrew/config_flow.py index cbfa40c..dba5846 100644 --- a/custom_components/minibrew/config_flow.py +++ b/custom_components/minibrew/config_flow.py @@ -1,20 +1,16 @@ import logging +import requests import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from dataclasses import asdict from .const import ( - CONF_ENABLE_REALTIME, CONF_REALTIME_POLL_INTERVAL, - CONF_REFRESH_INTERVAL, - DEFAULT_ENABLE_REALTIME, DEFAULT_REALTIME_POLL_INTERVAL, - DEFAULT_REFRESH_INTERVAL, DOMAIN, ) -from pymbrewclient import BreweryClient, Device +from pymbrewclient import BreweryClient _LOGGER = logging.getLogger(__name__) @@ -28,6 +24,22 @@ class PymbrewClientConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + + @staticmethod + def _is_auth_error(err: Exception) -> bool: + """Return True when an exception indicates invalid credentials.""" + response = getattr(err, "response", None) + status_code = getattr(response, "status_code", None) + if status_code in (401, 403): + return True + message = str(err).lower() + return ( + "401" in message + or "403" in message + or "unauthorized" in message + or "forbidden" in message + ) + async def async_step_user(self, user_input=None): """Handle the initial step for config flow.""" if user_input is not None: @@ -50,13 +62,17 @@ async def _handle_user_input(self, user_input: dict) -> FlowResult: return self._show_user_form(errors={"base": "no_devices_found"}) else: return self.async_create_entry( - title="Minibrew Pro", + title="MiniBrew Pro", data={ "username": username, "password": password, }, ) + except requests.exceptions.HTTPError as err: + if self._is_auth_error(err): + return self._show_user_form(errors={"base": "invalid_auth"}) + return self._show_user_form(errors={"base": "cannot_connect"}) except ConnectionError: return self._show_user_form(errors={"base": "cannot_connect"}) except Exception as err: @@ -84,6 +100,51 @@ def _is_existing_entry(self, unique_id: str) -> bool: def async_get_options_flow(config_entry): return PymbrewClientOptionsFlowHandler() + + async def async_step_reauth(self, entry_data): + """Start reauthentication flow when credentials are invalid.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Confirm reauthentication by validating and storing new credentials.""" + errors = {} + if user_input is not None: + username = user_input["username"] + password = user_input["password"] + try: + client = BreweryClient(username, password) + await self.hass.async_add_executor_job(client.get_brewery_overview) + + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if entry is None: + return self.async_abort(reason="unknown_error") + + updated_data = dict(entry.data) + updated_data.update({"username": username, "password": password}) + self.hass.config_entries.async_update_entry( + entry, + title="MiniBrew Pro", + data=updated_data, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + except requests.exceptions.HTTPError as err: + if self._is_auth_error(err): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ConnectionError: + errors["base"] = "cannot_connect" + except Exception as err: # noqa: BLE001 + _LOGGER.error("Unexpected reauth error: %s", err) + errors["base"] = "unknown_error" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=CONFIG_SCHEMA, + errors=errors, + ) + class PymbrewClientOptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow for PymbrewClient.""" @@ -94,14 +155,6 @@ async def async_step_init(self, user_input=None): options = self.config_entry.options options_schema = vol.Schema({ - vol.Optional( - CONF_ENABLE_REALTIME, - default=options.get(CONF_ENABLE_REALTIME, DEFAULT_ENABLE_REALTIME), - ): bool, - vol.Optional( - CONF_REFRESH_INTERVAL, - default=options.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL), - ): int, vol.Optional( CONF_REALTIME_POLL_INTERVAL, default=options.get(CONF_REALTIME_POLL_INTERVAL, DEFAULT_REALTIME_POLL_INTERVAL), diff --git a/custom_components/minibrew/const.py b/custom_components/minibrew/const.py index 7cd3377..5694088 100644 --- a/custom_components/minibrew/const.py +++ b/custom_components/minibrew/const.py @@ -8,5 +8,5 @@ # Defaults DEFAULT_REFRESH_INTERVAL = 60 -DEFAULT_ENABLE_REALTIME = False +DEFAULT_ENABLE_REALTIME = True DEFAULT_REALTIME_POLL_INTERVAL = 300 diff --git a/custom_components/minibrew/manifest.json b/custom_components/minibrew/manifest.json index 3774621..7d9c9de 100644 --- a/custom_components/minibrew/manifest.json +++ b/custom_components/minibrew/manifest.json @@ -1,6 +1,6 @@ { "domain": "minibrew", - "name": "Minibrew", + "name": "MiniBrew", "codeowners": [ "@stuartp44" ], diff --git a/custom_components/minibrew/realtime.py b/custom_components/minibrew/realtime.py index a73a5c4..5ea46c2 100644 --- a/custom_components/minibrew/realtime.py +++ b/custom_components/minibrew/realtime.py @@ -11,6 +11,7 @@ import logging import threading +from datetime import datetime _LOGGER = logging.getLogger(__name__) @@ -18,13 +19,53 @@ # sensors already read. Keeping it here (with no Home Assistant imports) lets the # mapping be unit-tested without a running HA instance. _TELEMETRY_TO_DEVICE_KEY = { + "current_state": "current_state", + "process_type": "process_type", + "process_state": "process_state", "current_temperature": "current_temp", "target_temperature": "target_temp", "user_action": "user_action", + "process_phase": "process_phase", + "session_id": "active_session", "next_action_at": "process_estimate_remaining", "seconds_until_next_action": "process_estimate_remaining_seconds", } +# Fingerprinting drives listener notifications. Exclude countdown fields that +# can tick every second without changing user-visible state. +_FINGERPRINT_ATTRS = tuple( + attr for attr in _TELEMETRY_TO_DEVICE_KEY if attr != "seconds_until_next_action" +) + +def _normalize_fingerprint_value(value, *, trim_to_minute=False): + """Normalize values for stable change detection.""" + if isinstance(value, datetime): + if trim_to_minute: + return value.replace(second=0, microsecond=0) + return value.replace(microsecond=0) + return value + + +def _telemetry_fingerprint(msg): + """Return the effective telemetry state used by entities.""" + attrs = {} + for attr in _FINGERPRINT_ATTRS: + attrs[attr] = _normalize_fingerprint_value( + getattr(msg, attr, None), + trim_to_minute=(attr == "next_action_at"), + ) + attrs["temp_control_power"] = _normalize_fingerprint_value( + getattr(msg, "temp_control_power", None) + ) + measurements = getattr(msg, "measurements", None) + if isinstance(measurements, dict): + attrs["measurements"] = tuple( + sorted((str(key), _normalize_fingerprint_value(val)) for key, val in measurements.items()) + ) + else: + attrs["measurements"] = None + return tuple(sorted(attrs.items())) + def overlay_mqtt(device: dict, telemetry) -> None: """Overlay live MQTT telemetry onto a REST device dict, in place. @@ -37,6 +78,12 @@ def overlay_mqtt(device: dict, telemetry) -> None: for attr, device_key in _TELEMETRY_TO_DEVICE_KEY.items(): value = getattr(telemetry, attr, None) if value is not None: + if attr == "user_action": + existing = device.get(device_key) + # Some MQTT payloads report 0 when action context is absent. + # Preserve a non-zero REST action in that case. + if value == 0 and isinstance(existing, (int, float)) and existing > 0: + continue device[device_key] = value @@ -56,6 +103,8 @@ def __init__(self, hass, coordinator, client): self._client = client self._mqtt = None self._telemetry = {} + self._fingerprints = {} + self._last_update = {} self._telemetry_lock = threading.Lock() self._subscribed = set() self._connected = False @@ -103,6 +152,11 @@ def get_telemetry(self, serial): with self._telemetry_lock: return self._telemetry.get(serial) + def get_last_update(self, serial): + """Return timestamp of last meaningful telemetry update for a serial.""" + with self._telemetry_lock: + return self._last_update.get(serial) + @property def connected(self): """Return whether the MQTT stream is currently connected.""" @@ -124,12 +178,28 @@ async def async_stop(self): # ------------------------------------------------------------------ def _handle_device_log(self, msg): - """Store telemetry and notify coordinator listeners (paho thread).""" + """Store telemetry and notify listeners only on meaningful changes.""" serial = msg.device_uuid if not serial: return + + fingerprint = _telemetry_fingerprint(msg) with self._telemetry_lock: + if self._fingerprints.get(serial) == fingerprint: + return self._telemetry[serial] = msg + self._fingerprints[serial] = fingerprint + self._last_update[serial] = getattr(msg, "received_at", None) + + _LOGGER.debug( + "MiniBrew realtime: message from %s (session=%s phase=%s target=%s current=%s action=%s)", + serial, + getattr(msg, "session_id", None), + getattr(msg, "process_phase", None), + getattr(msg, "target_temperature", None), + getattr(msg, "current_temperature", None), + getattr(msg, "user_action", None), + ) self.hass.loop.call_soon_threadsafe(self.coordinator.async_update_listeners) def _handle_connected(self): diff --git a/custom_components/minibrew/sensor.py b/custom_components/minibrew/sensor.py index 1f82814..ae16639 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -3,23 +3,83 @@ from datetime import datetime, timedelta, timezone from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_registry import RegistryEntryDisabler +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from pymbrewclient import Device, SensorType +from pymbrewclient import Device, ProcessPhase, SensorType from .const import ( - CONF_ENABLE_REALTIME, CONF_REALTIME_POLL_INTERVAL, - CONF_REFRESH_INTERVAL, - DEFAULT_ENABLE_REALTIME, DEFAULT_REALTIME_POLL_INTERVAL, - DEFAULT_REFRESH_INTERVAL, DOMAIN, ) from .realtime import MiniBrewRealtimeManager, overlay_mqtt _LOGGER = logging.getLogger(__name__) +_USER_ACTION_REQUIRED_OPTIONS = [ + "action_required", + "no_action_required", + "unknown", +] + +_CLOUD_CONNECTION_OPTIONS = [ + "online", + "offline", +] + +_UPDATE_STATUS_OPTIONS = [ + "updating", + "not_updating", +] + +_BUTTON_OPTIONS = [ + "on", + "off", +] + +_PELTIER_MODE_OPTIONS = [ + "cooling", + "warming", + "idle", +] + +_CURRENT_STAGE_OPTIONS = [ + "brew_clean_idle", + "fermenting", + "serving", + "brew_acid_clean_idle", + "unknown", +] + +_NEEDS_CLEANING_OPTIONS = [ + "needs_cleaning", + "clean", +] + + +def _build_process_phase_options() -> list[str]: + """Build stable display options from ProcessPhase enum names.""" + options: list[str] = [] + for member in ProcessPhase: + phase_name = member.name.lower() + if phase_name == "phase_none": + label = "None" + else: + for prefix in ("brew_", "ferm_", "serv_", "clean_mb_", "acid_clean_mb_"): + if phase_name.startswith(prefix): + phase_name = phase_name[len(prefix):] + break + label = phase_name.replace("_", " ").title() + if label not in options: + options.append(label) + return options + + +_PROCESS_PHASE_OPTIONS = _build_process_phase_options() + def _device_to_dict(device): if isinstance(device, dict): @@ -51,6 +111,97 @@ def _coerce_timestamp(value): return None +def _coerce_session_id(value): + """Return a normalized integer session ID or ``None``.""" + if value in (None, ""): + return None + try: + session_id = int(value) + except (TypeError, ValueError): + return None + if session_id <= 0: + return None + return session_id + +def _session_id_from_device_dict(device_dict): + """Return (session_id, key_present) from API device payload fields.""" + if not isinstance(device_dict, dict): + return None, False + if "active_session" in device_dict: + return _coerce_session_id(device_dict.get("active_session")), True + if "session_id" in device_dict: + return _coerce_session_id(device_dict.get("session_id")), True + return None, False + + + +def _coerce_epoch_timestamp(value): + """Return timezone-aware UTC datetime parsed from numeric epoch seconds.""" + if value in (None, ""): + return None + try: + ts = float(value) + except (TypeError, ValueError): + return None + if ts <= 0: + return None + try: + return datetime.fromtimestamp(ts, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + +def _session_started_from_session(session): + """Return best-effort session start timestamp from a Session payload.""" + if isinstance(session, dict): + created = _coerce_timestamp(session.get("created")) + if created is not None: + return created + + for attr in ("brew_timestamp", "timestamp_original_gravity"): + fallback = _coerce_epoch_timestamp(session.get(attr)) + if fallback is not None: + return fallback + return None + + created = _coerce_timestamp(getattr(session, "created", None)) + if created is not None: + return created + + # Fallback for payloads that omit "created" but include numeric epoch fields. + for attr in ("brew_timestamp", "timestamp_original_gravity"): + fallback = _coerce_epoch_timestamp(getattr(session, attr, None)) + if fallback is not None: + return fallback + + return None + + +def _next_action_state(value): + """Return a stable next-action timestamp for Home Assistant.""" + ts = _coerce_timestamp(value) + if ts is None: + return None + + # Keep minute-level precision to avoid noisy state churn from + # per-second server-side countdown recalculations. + return ts.replace(second=0, microsecond=0) + + + +def _effective_last_time_online(coordinator, serial, device): + """Return a UI-friendly last-online timestamp.""" + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + + if coordinator.realtime_enabled and coordinator.get_telemetry(serial) is not None: + return now + + online_flag = device.get("online") if isinstance(device, dict) else None + if online_flag is True: + return now + + return _coerce_timestamp(device.get("last_time_online")) if isinstance(device, dict) else None + def _overlay_mqtt(device: dict, telemetry) -> None: """Overlay live MQTT telemetry onto a REST device dict, in place. @@ -74,6 +225,54 @@ def _collect_serials(coordinator, data=None): return serials +def _is_auth_error(err: Exception) -> bool: + """Return True when an exception indicates invalid credentials.""" + response = getattr(err, "response", None) + status_code = getattr(response, "status_code", None) + if status_code in (401, 403): + return True + message = str(err).lower() + return ( + "401" in message + or "403" in message + or "unauthorized" in message + or "forbidden" in message + ) + + +def _merge_last_time_online_from_devices(overview, devices): + """Fill missing overview last_time_online values from /v1/devices by serial.""" + if overview is None or not devices: + return + + last_seen_by_serial = {} + for device in devices: + device_dict = _device_to_dict(device) + serial = device_dict.get("serial_number") + if not serial: + continue + last_seen = device_dict.get("last_time_online") + if last_seen is not None: + last_seen_by_serial[serial] = last_seen + + if not last_seen_by_serial: + return + + for grouped_devices in overview.__dict__.values(): + for device in grouped_devices: + device_dict = _device_to_dict(device) + serial = device_dict.get("serial_number") + if not serial: + continue + merged_last_seen = last_seen_by_serial.get(serial) + if merged_last_seen is None: + continue + if isinstance(device, dict): + device["last_time_online"] = merged_last_seen + else: + setattr(device, "last_time_online", merged_last_seen) + + def _format_duration_seconds(value): if value is None: return None @@ -99,6 +298,150 @@ def _telemetry_sensor_value(coordinator, serial, sensor_type: SensorType, *, hid return None return value + +def _telemetry_temp_control_power(coordinator, serial): + """Return signed Peltier power from telemetry, or ``None`` when unavailable.""" + telemetry = coordinator.get_telemetry(serial) + if telemetry is None: + return None + return telemetry.temp_control_power + + +def _peltier_mode(power): + """Map signed Peltier power to a human-readable mode.""" + if power is None: + return None + if power < 0: + return "cooling" + if power > 0: + return "warming" + return "idle" + + +def _button_state(value): + """Map button telemetry values to on/off tokens.""" + if value is None: + return None + try: + return "on" if float(value) >= 1.0 else "off" + except (TypeError, ValueError): + return None + + +def _current_stage_group(coordinator, device_id): + """Return the top-level overview group for a device serial.""" + for group_name, devices in coordinator.data.__dict__.items(): + for dev in devices: + device_dict = _device_to_dict(dev) + if device_dict.get("serial_number") == device_id: + return group_name + return "unknown" + + +def _process_phase_display(device): + """Return a readable ProcessPhase label from telemetry.""" + if not device: + return None + + raw_phase = device.get("process_phase") + if raw_phase is None: + return None + + try: + phase_name = ProcessPhase(int(raw_phase)).name.lower() + except (TypeError, ValueError): + return None + + if phase_name == "phase_none": + return "None" + + for prefix in ("brew_", "ferm_", "serv_", "clean_mb_", "acid_clean_mb_"): + if phase_name.startswith(prefix): + phase_name = phase_name[len(prefix):] + break + + return phase_name.replace("_", " ").title() + + +def _user_action_required_state(device): + """Map user_action/user_action_name to required-state token.""" + if not device: + return "unknown" + + action = device.get("user_action") + try: + action_value = int(float(action)) if action is not None else None + except (TypeError, ValueError): + action_value = None + + if action_value is not None: + return "action_required" if action_value > 0 else "no_action_required" + + action_name = str(device.get("user_action_name") or "").strip().lower() + if action_name in {"action_undefined", "action_undified", "undefined", ""}: + return "no_action_required" + return "action_required" + + +def _is_custom_fermentation_mode(device, *, current_stage=None): + """Return True when payload indicates or strongly implies custom fermentation mode.""" + if not device: + return False + + beer_name = str(device.get("beer_name") or "").strip().lower() + beer_style = str(device.get("beer_style") or "").strip().lower() + if beer_name == "custom fermentation" or beer_style == "custom fermentation": + return True + + unknown_tokens = {"", "unknown", "n/a", "none", "null"} + process_phase = str(_process_phase_display(device) or "").strip().lower() + stage = str(current_stage or "").strip().lower() + return ( + stage == "fermenting" + and process_phase == "primary" + and beer_name in unknown_tokens + and beer_style in unknown_tokens + ) + + +def _disable_legacy_esp_core_temp_entities(hass, config_entry): + """Disable previously enabled ESP32 core temperature entities.""" + registry = er.async_get(hass) + for entry in er.async_entries_for_config_entry(registry, config_entry.entry_id): + if not entry.unique_id.endswith("_esp_core_temp"): + continue + if entry.disabled_by == RegistryEntryDisabler.INTEGRATION: + continue + registry.async_update_entity( + entry.entity_id, + disabled_by=RegistryEntryDisabler.INTEGRATION, + ) + + +def _migrate_legacy_fan_entities(hass, config_entry): + """Migrate legacy numeric fan entities (e.g. ``..._4``) to stable IDs.""" + registry = er.async_get(hass) + entries = list(er.async_entries_for_config_entry(registry, config_entry.entry_id)) + existing_unique_ids = {entry.unique_id for entry in entries} + + for entry in entries: + unique_id = entry.unique_id or "" + if not unique_id.endswith("_4"): + continue + + base_unique_id = unique_id.rsplit("_", 1)[0] + new_unique_id = f"{base_unique_id}_peltier_fan_power" + if new_unique_id in existing_unique_ids: + continue + + update_kwargs = {"new_unique_id": new_unique_id} + if entry.entity_id.startswith("sensor.") and entry.entity_id.endswith("_4"): + update_kwargs["new_entity_id"] = f"{entry.entity_id[:-2]}_fan_duty" + + registry.async_update_entity(entry.entity_id, **update_kwargs) + existing_unique_ids.add(new_unique_id) + + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up MiniBrew sensors from a config entry.""" store = hass.data[DOMAIN][config_entry.entry_id] @@ -108,6 +451,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # Create a DataUpdateCoordinator coordinator = MiniBrewDataUpdateCoordinator(hass, client, config_entry) await coordinator.async_config_entry_first_refresh() + _disable_legacy_esp_core_temp_entities(hass, config_entry) + _migrate_legacy_fan_entities(hass, config_entry) # Expose the coordinator for unload (so the MQTT stream can be stopped). store["coordinator"] = coordinator @@ -119,7 +464,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator.realtime = manager manager.async_ensure_subscribed(_collect_serials(coordinator)) - _LOGGER.debug(f"Brewery overview: {coordinator}") + _LOGGER.debug("MiniBrew sensor setup complete: devices=%s realtime_enabled=%s", len(_collect_serials(coordinator)), coordinator.realtime_enabled) # Function to add new sensors dynamically def add_new_sensors(): @@ -147,34 +492,41 @@ def add_new_sensors(): if device.device_type == 0: # Craft device new_sensors.append(CraftSensorCurrentTemperatureSensor(coordinator, device, state)) new_sensors.append(CraftSensorTargetTemperatureSensor(coordinator, device, state)) + new_sensors.append(CraftBeerStyleSensor(coordinator, device, state)) + new_sensors.append(CraftBeerNameSensor(coordinator, device, state)) new_sensors.append(CraftSensorOnlineStatusSensor(coordinator, device, state)) + new_sensors.append(CraftLastTimeOnlineSensor(coordinator, device, state)) new_sensors.append(CraftSensorIsUpdatingSensor(coordinator, device, state)) - new_sensors.append(CraftSensorBrewStageSensor(coordinator, device, state)) - new_sensors.append(CraftSensorTimeInStageSensor(coordinator, device, state)) new_sensors.append(CraftSensorCurrentStageSensor(coordinator, device, state)) - new_sensors.append(CraftSensorNeedsCleaningSensor(coordinator, device, state)) + new_sensors.append(CraftProcessPhaseSensor(coordinator, device, state)) new_sensors.append(CraftUserActionRequiredSensor(coordinator, device, state)) new_sensors.append(CraftNextActionDateTimeSensor(coordinator, device, state)) + new_sensors.append(CraftSessionIdSensor(coordinator, device, state)) + new_sensors.append(CraftSessionStartedSensor(coordinator, device, state)) if coordinator.realtime_enabled: - new_sensors.append(CraftTempControlPowerSensor(coordinator, device, state)) - new_sensors.append(CraftPeltierFanPowerSensor(coordinator, device, state)) new_sensors.append(CraftEspCoreTempSensor(coordinator, device, state)) + new_sensors.append(CraftButtonSensor(coordinator, device, state)) # Add sensors for Keg devices elif device.device_type == 1: # Keg device new_sensors.append(KegCurrentTemperatureSensor(coordinator, device, state)) new_sensors.append(KegTargetTemperatureSensor(coordinator, device, state)) new_sensors.append(KegBeerStyleSensor(coordinator, device, state)) new_sensors.append(KegBeerNameSensor(coordinator, device, state)) - new_sensors.append(KegTimeInStageSensor(coordinator, device, state)) + new_sensors.append(KegCurrentStageSensor(coordinator, device, state)) + new_sensors.append(KegProcessPhaseSensor(coordinator, device, state)) new_sensors.append(KegOnlineStatusSensor(coordinator, device, state)) + new_sensors.append(KegLastTimeOnlineSensor(coordinator, device, state)) new_sensors.append(KegIsUpdatingSensor(coordinator, device, state)) - new_sensors.append(KegNeedsCleaningSensor(coordinator, device, state)) new_sensors.append(KegActionRequiredSensor(coordinator, device, state)) new_sensors.append(KegNextActionDateTimeSensor(coordinator, device, state)) + new_sensors.append(KegSessionIdSensor(coordinator, device, state)) + new_sensors.append(KegSessionStartedSensor(coordinator, device, state)) if coordinator.realtime_enabled: new_sensors.append(KegTempControlPowerSensor(coordinator, device, state)) + new_sensors.append(KegPeltierModeSensor(coordinator, device, state)) new_sensors.append(KegPeltierFanPowerSensor(coordinator, device, state)) new_sensors.append(KegEspCoreTempSensor(coordinator, device, state)) + new_sensors.append(KegButtonSensor(coordinator, device, state)) # Mark the device as added added_devices.add(serial_number) @@ -206,18 +558,22 @@ def __init__(self, hass, client, config_entry): self.config_entry = config_entry options = config_entry.options - self.realtime_enabled = options.get(CONF_ENABLE_REALTIME, DEFAULT_ENABLE_REALTIME) - refresh_interval = options.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL) + self.realtime_enabled = True realtime_poll_interval = options.get( CONF_REALTIME_POLL_INTERVAL, DEFAULT_REALTIME_POLL_INTERVAL ) - # When real-time is on, MQTT drives the fast fields, so poll slowly - # (discovery + slow fields only); otherwise poll at the normal interval. - poll_interval = realtime_poll_interval if self.realtime_enabled else refresh_interval + # MQTT is the primary source for fast-changing fields. REST polling is + # retained at a slower cadence for discovery and fallback fields. + poll_interval = realtime_poll_interval # Set by async_setup_entry once the MQTT stream is started. self.realtime = None + self._session_created_by_id = {} + self._session_created_by_serial = {} + self._active_session_by_serial = {} + self._session_lookup_inflight = set() + self._session_model_mismatch_logged = False super().__init__( hass, @@ -231,8 +587,13 @@ async def _async_update_data(self): try: _LOGGER.debug("Fetching data from MiniBrew API...") data = await self.hass.async_add_executor_job(self.client.get_brewery_overview) - _LOGGER.debug(f"Fetched data: {data}") + devices = await self.hass.async_add_executor_job(self.client.get_devices) + _merge_last_time_online_from_devices(data, devices) + await self._async_refresh_session_metadata(data) + _LOGGER.debug("Fetched MiniBrew overview: groups=%s active_sessions=%s", {k: len(v) for k, v in data.__dict__.items()}, sum(1 for session_id in self._active_session_by_serial.values() if session_id is not None)) except Exception as err: + if _is_auth_error(err): + raise ConfigEntryAuthFailed("MiniBrew credentials are invalid") from err _LOGGER.error(f"Error fetching data: {err}") raise UpdateFailed(f"Error fetching data: {err}") @@ -248,6 +609,20 @@ def get_telemetry(self, serial): return None return self.realtime.get_telemetry(serial) + def get_realtime_last_update(self, serial): + """Return timestamp of latest meaningful realtime telemetry update.""" + if self.realtime is None: + return None + return self.realtime.get_last_update(serial) + + def get_session_id(self, serial): + """Return active session ID for a serial, or ``None``.""" + return self._active_session_by_serial.get(serial) + + def get_session_created(self, serial): + """Return active session created timestamp for a serial, or ``None``.""" + return self._session_created_by_serial.get(serial) + @property def realtime_connected(self): """Return whether the real-time MQTT stream is connected.""" @@ -265,9 +640,141 @@ def get_merged_device(self, serial, state): merged = dict(device_dict) if self.realtime_enabled: _overlay_mqtt(merged, self.get_telemetry(serial)) + if serial in self._active_session_by_serial: + merged["active_session"] = self._active_session_by_serial[serial] + active_session, _ = _session_id_from_device_dict(merged) + if active_session is not None: + merged["active_session"] = active_session + else: + merged.pop("active_session", None) + merged.pop("session_created", None) + + session_created = self._session_created_by_serial.get(serial) + if session_created is None and active_session is not None: + session_created = self._session_created_by_id.get(active_session) + if session_created is None: + self.hass.async_create_task( + self._async_ensure_session_created(serial, active_session) + ) + if session_created is not None: + merged["session_created"] = session_created return merged return None + async def _async_ensure_session_created(self, serial, session_id): + """Populate session start cache for an active session when missing.""" + lookup_key = (serial, session_id) + if lookup_key in self._session_lookup_inflight: + return + self._session_lookup_inflight.add(lookup_key) + + try: + cached_created = self._session_created_by_id.get(session_id) + if cached_created is not None: + self._session_created_by_serial[serial] = cached_created + return + + try: + created = await self._async_fetch_session_started(session_id) + except Exception as err: # noqa: BLE001 + _LOGGER.debug("MiniBrew lazy session lookup failed for %s: %s", session_id, err) + return + if created is None: + _LOGGER.debug("MiniBrew lazy session lookup returned no start timestamp for %s", session_id) + return + + self._session_created_by_id[session_id] = created + self._session_created_by_serial[serial] = created + _LOGGER.debug("MiniBrew cached session start for %s (%s): %s", serial, session_id, created) + self.async_update_listeners() + finally: + self._session_lookup_inflight.discard(lookup_key) + + def _sync_fetch_session_started(self, session_id): + """Fetch session-start timestamp with compatibility fallback.""" + try: + session = self.client.get_session_info(session_id) + return _session_started_from_session(session) + except TypeError as err: + message = str(err) + if "unexpected keyword argument" not in message: + raise + if not self._session_model_mismatch_logged: + _LOGGER.debug( + "MiniBrew session model mismatch for %s (%s); falling back to raw REST payload (further repeats suppressed)", + session_id, + err, + ) + self._session_model_mismatch_logged = True + + rest_client = getattr(self.client, "client", None) + get_method = getattr(rest_client, "get", None) + if get_method is None: + raise RuntimeError("MiniBrew REST fallback unavailable: missing client.get") + + response = get_method(f"v1/sessions/{session_id}") + payload = response.json() if hasattr(response, "json") else None + return _session_started_from_session(payload) + + async def _async_fetch_session_started(self, session_id): + """Async wrapper for session-start fetch with compatibility fallback.""" + return await self.hass.async_add_executor_job(self._sync_fetch_session_started, session_id) + + async def _async_refresh_session_metadata(self, overview): + """Refresh active-session IDs and start timestamps for known serials.""" + session_by_serial = {} + for grouped_devices in overview.__dict__.values(): + for device in grouped_devices: + device_dict = _device_to_dict(device) + serial = device_dict.get("serial_number") + if not serial: + continue + session_id, session_key_present = _session_id_from_device_dict(device_dict) + if session_key_present: + # REST is authoritative when any known session key is present. + pass + else: + session_id = None + if self.realtime_enabled: + telemetry = self.get_telemetry(serial) + if telemetry is not None: + telemetry_session = getattr(telemetry, "session_id", None) + if telemetry_session is not None: + session_id = _coerce_session_id(telemetry_session) + session_by_serial[serial] = session_id + + self._active_session_by_serial = session_by_serial + + for serial, session_id in session_by_serial.items(): + if session_id is None: + self._session_created_by_serial.pop(serial, None) + continue + + cached_created = self._session_created_by_id.get(session_id) + if cached_created is not None: + self._session_created_by_serial[serial] = cached_created + continue + + try: + created = await self._async_fetch_session_started(session_id) + except Exception as err: # noqa: BLE001 + _LOGGER.debug("MiniBrew session lookup failed for %s: %s", session_id, err) + self._session_created_by_serial.pop(serial, None) + continue + if created is not None: + self._session_created_by_id[session_id] = created + self._session_created_by_serial[serial] = created + else: + # Do not cache missing timestamps permanently; retry on next refresh. + self._session_created_by_serial.pop(serial, None) + + active_serials = { + serial for serial, session_id in session_by_serial.items() if session_id is not None + } + for serial in list(self._session_created_by_serial): + if serial not in active_serials: + self._session_created_by_serial.pop(serial, None) + class CraftSensor(SensorEntity): """Base class for MiniBrew sensors.""" @@ -313,44 +820,21 @@ def _get_latest_device(self): """Get the latest device data (REST overlaid with live telemetry).""" return self.coordinator.get_merged_device(self.device_id, self.device_type) -class CraftSensorBrewStageSensor(CraftSensor): - """Sensor for the current brew stage of the Craft device.""" - - _attr_translation_key = "brew_stage" - - @property - def name(self): - """Return the name of the sensor.""" - return "Brew Stage" - - @property - def native_value(self): - """Return the current brew stage.""" - device = self._get_latest_device() - return device.get("stage") if device else None - - @property - def icon(self): - """Return the icon for the sensor.""" - return "mdi:routes-clock" - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return f"{self.device_id}_brew_stage" - - class CraftSensorCurrentTemperatureSensor(CraftSensor): """Sensor for the current temperature of the Craft device.""" _attr_translation_key = "current_temperature" @property + + def name(self): """Return the name of the sensor.""" return "Current Temperature" @property + + def native_value(self): """Return the current temperature.""" device = self._get_latest_device() @@ -384,11 +868,15 @@ class CraftSensorTargetTemperatureSensor(CraftSensor): _attr_translation_key = "target_temperature" @property + + def name(self): """Return the name of the sensor.""" return "Target Temperature" @property + + def native_value(self): """Return the target temperature.""" device = self._get_latest_device() @@ -414,21 +902,119 @@ def available(self): def unique_id(self): """Return the unique ID of the sensor.""" return f"{self.device_id}_target_temperature" +class CraftBeerStyleSensor(CraftSensor): + """Sensor for the beer style of the Craft device.""" + + _attr_translation_key = "beer_style" + + + @property + def name(self): + """Return the name of the sensor.""" + return "Beer Style" + + + @property + def native_value(self): + """Return the beer style.""" + device = self._get_latest_device() + if not device: + return None + current_stage = _current_stage_group(self.coordinator, self.device_id) + if _is_custom_fermentation_mode(device, current_stage=current_stage): + return "N/A" + value = device.get("beer_style") + if isinstance(value, str): + value = value.strip() + return value or None + + @property + def available(self): + """Return True when beer style is known.""" + return self.native_value is not None + + + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:beer" + + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_beer_style" + + +class CraftBeerNameSensor(CraftSensor): + """Sensor for the beer name of the Craft device.""" + + _attr_translation_key = "beer_name" + + + @property + def name(self): + """Return the name of the sensor.""" + return "Beer Name" + + + @property + def native_value(self): + """Return the beer name.""" + device = self._get_latest_device() + if not device: + return None + current_stage = _current_stage_group(self.coordinator, self.device_id) + if _is_custom_fermentation_mode(device, current_stage=current_stage): + return "Custom Fermentation" + value = device.get("beer_name") + if isinstance(value, str): + value = value.strip() + return value or None + + @property + def available(self): + """Return True when beer name is known.""" + return self.native_value is not None + + + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:beer-outline" + + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_beer_name" + + class CraftSensorOnlineStatusSensor(CraftSensor): """Sensor for the online status of the Craft device.""" _attr_translation_key = "cloud_connection" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _CLOUD_CONNECTION_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "Cloud Connection" @property + + def native_value(self): """Return the online status.""" + if self.coordinator.realtime_enabled and self.coordinator.get_telemetry(self.device_id) is not None: + return "online" device = self._get_latest_device() return "online" if device and device.get("online") else "offline" @@ -448,21 +1034,70 @@ def unique_id(self): return f"{self.device_id}_online_status" +class CraftLastTimeOnlineSensor(CraftSensor): + """Sensor for the most recent online timestamp of the Craft device.""" + + _attr_translation_key = "last_time_online" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_category = EntityCategory.DIAGNOSTIC + + + @property + def name(self): + """Return the name of the sensor.""" + return "Last Time Online" + + + @property + def native_value(self): + """Return the last time this device was seen online.""" + device = self._get_latest_device() + if not device: + return None + return _effective_last_time_online(self.coordinator, self.device_id, device) + + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:clock-check-outline" + + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_last_time_online" + + class CraftSensorIsUpdatingSensor(CraftSensor): """Sensor for the update status of the Craft device.""" _attr_translation_key = "update_status" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _UPDATE_STATUS_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "Update Status" @property + + def native_value(self): """Return the update status.""" device = self._get_latest_device() - return "updating" if device and device.get("updating") else "not_updating" + if not device or device.get("updating") is None: + return None + return "updating" if device.get("updating") else "not_updating" + + + @property + def available(self): + """Return True when update status is known from the device payload.""" + return self.native_value is not None @property def entity_category(self): @@ -483,26 +1118,22 @@ class CraftUserActionRequiredSensor(CraftSensor): """Sensor for user action required status of the Craft device.""" _attr_translation_key = "user_action_required" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _USER_ACTION_REQUIRED_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "User Action Required" @property + + def native_value(self): """Return the user action required status.""" - device = self._get_latest_device() - if not device: - return "unknown" - - action = device.get("user_action") - - if action != 0 and action is not None: - return "action_required" - elif action == 0: - return "no_action_required" - return "unknown" + return _user_action_required_state(self._get_latest_device()) @property @@ -513,13 +1144,9 @@ def entity_category(self): @property def icon(self): """Return the icon for the sensor.""" - device = self._get_latest_device() - action = device.get("user_action") if device else None - - if action != 0 and action is not None: + if self.native_value == "action_required": return "mdi:alert" - else: - return "mdi:check-circle" + return "mdi:check-circle" @property def unique_id(self): @@ -533,13 +1160,32 @@ class CraftNextActionDateTimeSensor(CraftSensor): _attr_translation_key = "next_action_time_remaining" _attr_device_class = SensorDeviceClass.TIMESTAMP + @property - def native_value(self): - """Return the actual next-action timestamp (MQTT when realtime, else REST).""" - device = self._get_latest_device() + def name(self): + """Return the name of the sensor.""" + return "Next Action" + + + @property + def native_value(self): + """Return next-action state; use \"now\" when imminently due.""" + device = self._get_latest_device() if not device: return None - return _coerce_timestamp(device.get("process_estimate_remaining")) + + current_stage = _current_stage_group(self.coordinator, self.device_id) + user_action_required = _user_action_required_state(device) + if current_stage in {"brew_clean_idle", "brew_acid_clean_idle"} and user_action_required != "action_required": + return None + + return _next_action_state(device.get("process_estimate_remaining")) + + @property + def available(self): + """Return True when a next action timestamp is available.""" + return self.native_value is not None + @property def entity_category(self): @@ -557,101 +1203,172 @@ def unique_id(self): return f"{self.device_id}_next_action_time_remaining" -class CraftSensorCurrentStageSensor(CraftSensor): - """Sensor for the current stage of the Craft device.""" +class CraftSessionIdSensor(CraftSensor): + """Sensor for the active brew session ID of the Craft device.""" - _attr_translation_key = "current_stage" + _attr_translation_key = "session_id" + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def name(self): """Return the name of the sensor.""" - return "Current Stage" + return "Session ID" @property def native_value(self): - """Return a human-readable phase name based on the device's group.""" - for group_name, devices in self.coordinator.data.__dict__.items(): - for dev in devices: - device_dict = _device_to_dict(dev) - if device_dict.get("serial_number") == self.device_id: - return group_name + """Return active session ID from merged device/coordinator state.""" + device = self._get_latest_device() + if not device: + return None + session_id, _ = _session_id_from_device_dict(device) + return session_id - return "unknown" + @property + def available(self): + """Return True when an active session is present.""" + return self.native_value is not None @property def icon(self): """Return the icon for the sensor.""" - return "mdi:beer" + return "mdi:identifier" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_current_stage" + return f"{self.device_id}_session_id" -class CraftSensorTimeInStageSensor(CraftSensor): - """Sensor for the formatted time spent in the current stage of the Craft device.""" - _attr_translation_key = "time_in_stage" - _attr_native_unit_of_measurement = None - _attr_suggested_unit_of_measurement = None +class CraftSessionStartedSensor(CraftSensor): + """Sensor for the active brew session start timestamp of the Craft device.""" + + _attr_translation_key = "session_started" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def name(self): """Return the name of the sensor.""" - return "Time in Stage" + return "Brew Session Started" @property def native_value(self): - """Return a human-readable duration for the time spent in the current stage.""" + """Return active session start timestamp from session.created.""" device = self._get_latest_device() if not device: return None - return _format_duration_seconds(device.get("status_time")) + session_id, _ = _session_id_from_device_dict(device) + if session_id is None: + return None - @property - def unit_of_measurement(self): - """Force no unit to avoid HA appending legacy seconds metadata.""" + started = _coerce_timestamp(device.get("session_created")) + if started is not None: + return started + + self.coordinator.hass.async_create_task( + self.coordinator._async_ensure_session_created(self.device_id, session_id) + ) return None @property def available(self): - """Return True if the sensor has data.""" - device = self._get_latest_device() - return device is not None and device.get("status_time") is not None + """Return True when active session start is known.""" + return self.native_value is not None @property def icon(self): """Return the icon for the sensor.""" - return "mdi:clock" + return "mdi:calendar-start" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_session_started" + + +class CraftSensorCurrentStageSensor(CraftSensor): + """Sensor for the current stage of the Craft device.""" + + _attr_translation_key = "current_stage" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _CURRENT_STAGE_OPTIONS @property - def extra_state_attributes(self): - """Expose raw and formatted values for runtime verification.""" + + + def name(self): + """Return the name of the sensor.""" + return "Current Stage" + + @property + + + def native_value(self): + """Return a human-readable phase name based on the device's group.""" + return _current_stage_group(self.coordinator, self.device_id) + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:beer" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_current_stage" + +class CraftProcessPhaseSensor(CraftSensor): + """Sensor for the current process phase of the Craft device.""" + + _attr_translation_key = "process_phase" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _PROCESS_PHASE_OPTIONS + + @property + def name(self): + """Return the name of the sensor.""" + return "Process Phase" + + @property + def native_value(self): + """Return current process phase label from telemetry.""" + return _process_phase_display(self._get_latest_device()) + + @property + def available(self): + """Return True once process phase telemetry is available.""" device = self._get_latest_device() - raw_seconds = device.get("status_time") if device else None - return { - "raw_status_time_seconds": raw_seconds, - "formatted_status_time": _format_duration_seconds(raw_seconds), - "format_version": "hms-v2", - } + return device is not None and device.get("process_phase") is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:timeline-clock" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_time_in_stage" + return f"{self.device_id}_process_phase" + class CraftSensorNeedsCleaningSensor(CraftSensor): """Sensor for the cleaning status of the Craft device.""" _attr_translation_key = "needs_cleaning" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _NEEDS_CLEANING_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "Needs Cleaning" @property + + def native_value(self): """Return the cleaning status.""" device = self._get_latest_device() @@ -723,11 +1440,15 @@ class KegCurrentTemperatureSensor(KegSensor): _attr_translation_key = "temperature" @property + + def name(self): """Return the name of the sensor.""" return "Temperature" @property + + def native_value(self): """Return the current temperature.""" device = self._get_latest_device() @@ -760,11 +1481,15 @@ class KegTargetTemperatureSensor(KegSensor): _attr_translation_key = "target_temperature" @property + + def name(self): """Return the name of the sensor.""" return "Target Temperature" @property + + def native_value(self): """Return the target temperature.""" device = self._get_latest_device() @@ -797,15 +1522,33 @@ class KegBeerStyleSensor(KegSensor): _attr_translation_key = "beer_style" @property + + def name(self): """Return the name of the sensor.""" return "Beer Style" @property + + def native_value(self): """Return the beer style.""" device = self._get_latest_device() - return device.get("beer_style") if device else None + if not device: + return None + current_stage = _current_stage_group(self.coordinator, self.device_id) + if _is_custom_fermentation_mode(device, current_stage=current_stage): + return "N/A" + value = device.get("beer_style") + if isinstance(value, str): + value = value.strip() + return value or None + + @property + def available(self): + """Return True when beer style is known.""" + return self.native_value is not None + @property def icon(self): @@ -824,15 +1567,33 @@ class KegBeerNameSensor(KegSensor): _attr_translation_key = "beer_name" @property + + def name(self): """Return the name of the sensor.""" return "Beer Name" @property + + def native_value(self): """Return the beer name.""" device = self._get_latest_device() - return device.get("beer_name") or "N/A" if device else "N/A" + if not device: + return None + current_stage = _current_stage_group(self.coordinator, self.device_id) + if _is_custom_fermentation_mode(device, current_stage=current_stage): + return "Custom Fermentation" + value = device.get("beer_name") + if isinstance(value, str): + value = value.strip() + return value or None + + @property + def available(self): + """Return True when beer name is known.""" + return self.native_value is not None + @property def icon(self): @@ -845,73 +1606,89 @@ def unique_id(self): return f"{self.device_id}_{self.name}" -class KegTimeInStageSensor(KegSensor): - """Sensor for the formatted time spent in the current stage of the Keg device.""" +class KegCurrentStageSensor(KegSensor): + """Sensor for the current stage of the Keg device.""" - _attr_translation_key = "time_in_stage" - _attr_native_unit_of_measurement = None - _attr_suggested_unit_of_measurement = None + _attr_translation_key = "current_stage" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _CURRENT_STAGE_OPTIONS @property def name(self): """Return the name of the sensor.""" - return "Time in Stage" + return "Current Stage" @property def native_value(self): - """Return a human-readable duration for the time spent in the current stage.""" - device = self._get_latest_device() - if not device: - return None + """Return the current high-level stage from the overview group.""" + return _current_stage_group(self.coordinator, self.device_id) - return _format_duration_seconds(device.get("status_time")) + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:beer" @property - def unit_of_measurement(self): - """Force no unit to avoid HA appending legacy seconds metadata.""" - return None + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_current_stage" + + +class KegProcessPhaseSensor(KegSensor): + """Sensor for the current process phase of the Keg device.""" + + _attr_translation_key = "process_phase" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _PROCESS_PHASE_OPTIONS @property - def icon(self): - """Return the icon for the sensor.""" - return "mdi:clock-time-eight" + def name(self): + """Return the name of the sensor.""" + return "Process Phase" @property - def extra_state_attributes(self): - """Expose raw and formatted values for runtime verification.""" - device = self._get_latest_device() - raw_seconds = device.get("status_time") if device else None - return { - "raw_status_time_seconds": raw_seconds, - "formatted_status_time": _format_duration_seconds(raw_seconds), - "format_version": "hms-v2", - } + def native_value(self): + """Return current process phase label from telemetry.""" + return _process_phase_display(self._get_latest_device()) @property - def available(self) -> bool: - """Return True if entity is available.""" + def available(self): + """Return True once process phase telemetry is available.""" device = self._get_latest_device() - return device is not None and device.get("status_time") is not None + return device is not None and device.get("process_phase") is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:timeline-clock" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_time_in_stage" + return f"{self.device_id}_process_phase" class KegOnlineStatusSensor(KegSensor): """Sensor for the online status of the Keg device.""" _attr_translation_key = "cloud_connection" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _CLOUD_CONNECTION_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "Cloud Connection" @property + + def native_value(self): """Return the online status.""" + if self.coordinator.realtime_enabled and self.coordinator.get_telemetry(self.device_id) is not None: + return "online" device = self._get_latest_device() return "online" if device and device.get("online") else "offline" @@ -931,21 +1708,70 @@ def unique_id(self): return f"{self.device_id}_{self.name}" +class KegLastTimeOnlineSensor(KegSensor): + """Sensor for the most recent online timestamp of the Keg device.""" + + _attr_translation_key = "last_time_online" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_category = EntityCategory.DIAGNOSTIC + + + @property + def name(self): + """Return the name of the sensor.""" + return "Last Time Online" + + + @property + def native_value(self): + """Return the last time this device was seen online.""" + device = self._get_latest_device() + if not device: + return None + return _effective_last_time_online(self.coordinator, self.device_id, device) + + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:clock-check-outline" + + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_last_time_online" + + class KegIsUpdatingSensor(KegSensor): """Sensor for the update status of the Keg device.""" _attr_translation_key = "update_status" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _UPDATE_STATUS_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "Update Status" @property + + def native_value(self): """Return the update status.""" device = self._get_latest_device() - return "updating" if device and device.get("updating") else "not_updating" + if not device or device.get("updating") is None: + return None + return "updating" if device.get("updating") else "not_updating" + + + @property + def available(self): + """Return True when update status is known from the device payload.""" + return self.native_value is not None @property def entity_category(self): @@ -967,13 +1793,19 @@ class KegNeedsCleaningSensor(KegSensor): """Sensor for the cleaning status of the Keg device.""" _attr_translation_key = "needs_cleaning" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _NEEDS_CLEANING_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "Needs Cleaning" @property + + def native_value(self): """Return the cleaning status.""" device = self._get_latest_device() @@ -998,26 +1830,22 @@ class KegActionRequiredSensor(KegSensor): """Sensor for user action required status of the Keg device.""" _attr_translation_key = "user_action_required" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _USER_ACTION_REQUIRED_OPTIONS @property + + def name(self): """Return the name of the sensor.""" return "User Action Required" @property + + def native_value(self): """Return the user action required status.""" - device = self._get_latest_device() - if not device: - return "unknown" - - action = device.get("user_action") - - if action != 0 and action is not None: - return "action_required" - elif action == 0: - return "no_action_required" - return "unknown" + return _user_action_required_state(self._get_latest_device()) @property @@ -1028,13 +1856,9 @@ def entity_category(self): @property def icon(self): """Return the icon for the sensor.""" - device = self._get_latest_device() - action = device.get("user_action") if device else None - - if action != 0 and action is not None: + if self.native_value == "action_required": return "mdi:alert" - else: - return "mdi:check-circle" + return "mdi:check-circle" @property def unique_id(self): @@ -1048,13 +1872,26 @@ class KegNextActionDateTimeSensor(KegSensor): _attr_translation_key = "next_action_time_remaining" _attr_device_class = SensorDeviceClass.TIMESTAMP + + @property + def name(self): + """Return the name of the sensor.""" + return "Next Action" + + @property def native_value(self): - """Return the actual next-action timestamp (MQTT when realtime, else REST).""" + """Return next-action state; use \"now\" when imminently due.""" device = self._get_latest_device() if not device: return None - return _coerce_timestamp(device.get("process_estimate_remaining")) + return _next_action_state(device.get("process_estimate_remaining")) + + @property + def available(self): + """Return True when a next action timestamp is available.""" + return self.native_value is not None + @property def entity_category(self): @@ -1072,6 +1909,90 @@ def unique_id(self): return f"{self.device_id}_next_action_time_remaining" +class KegSessionIdSensor(KegSensor): + """Sensor for the active brew session ID of the Keg device.""" + + _attr_translation_key = "session_id" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def name(self): + """Return the name of the sensor.""" + return "Session ID" + + @property + def native_value(self): + """Return active session ID from merged device/coordinator state.""" + device = self._get_latest_device() + if not device: + return None + session_id, _ = _session_id_from_device_dict(device) + return session_id + + @property + def available(self): + """Return True when an active session is present.""" + return self.native_value is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:identifier" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_session_id" + + +class KegSessionStartedSensor(KegSensor): + """Sensor for the active brew session start timestamp of the Keg device.""" + + _attr_translation_key = "session_started" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def name(self): + """Return the name of the sensor.""" + return "Brew Session Started" + + @property + def native_value(self): + """Return active session start timestamp from session.created.""" + device = self._get_latest_device() + if not device: + return None + + session_id, _ = _session_id_from_device_dict(device) + if session_id is None: + return None + + started = _coerce_timestamp(device.get("session_created")) + if started is not None: + return started + + self.coordinator.hass.async_create_task( + self.coordinator._async_ensure_session_created(self.device_id, session_id) + ) + return None + + @property + def available(self): + """Return True when active session start is known.""" + return self.native_value is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:calendar-start" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_session_started" + + class CraftTempControlPowerSensor(CraftSensor): """Sensor for the temperature-control (Peltier) power of the Craft device (MQTT only).""" @@ -1080,22 +2001,32 @@ class CraftTempControlPowerSensor(CraftSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC + + + + @property + def name(self): + """Return the name of the sensor.""" + return "Peltier Power" + + + + @property def native_value(self): - """Return the temperature-control power (signed %, negative = cooling).""" - telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry.temp_control_power if telemetry else None + """Return the absolute Peltier power percentage.""" + power = _telemetry_temp_control_power(self.coordinator, self.device_id) + return abs(power) if power is not None else None @property def available(self): """Return True once real-time telemetry with a control-power reading has arrived.""" - telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry is not None and telemetry.temp_control_power is not None + return _telemetry_temp_control_power(self.coordinator, self.device_id) is not None @property def icon(self): """Return the icon for the sensor.""" - return "mdi:snowflake-thermometer" + return "mdi:speedometer" @property def unique_id(self): @@ -1111,22 +2042,32 @@ class KegTempControlPowerSensor(KegSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC + + + + @property + def name(self): + """Return the name of the sensor.""" + return "Peltier Power" + + + + @property def native_value(self): - """Return the temperature-control power (signed %, negative = cooling).""" - telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry.temp_control_power if telemetry else None + """Return the absolute Peltier power percentage.""" + power = _telemetry_temp_control_power(self.coordinator, self.device_id) + return abs(power) if power is not None else None @property def available(self): """Return True once real-time telemetry with a control-power reading has arrived.""" - telemetry = self.coordinator.get_telemetry(self.device_id) - return telemetry is not None and telemetry.temp_control_power is not None + return _telemetry_temp_control_power(self.coordinator, self.device_id) is not None @property def icon(self): """Return the icon for the sensor.""" - return "mdi:snowflake-thermometer" + return "mdi:speedometer" @property def unique_id(self): @@ -1142,12 +2083,24 @@ class CraftPeltierFanPowerSensor(CraftSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC + + + + @property + def name(self): + """Return the name of the sensor.""" + return "Fan Duty" + + + + @property def native_value(self): """Return Peltier fan power as a percentage.""" - return _telemetry_sensor_value( + value = _telemetry_sensor_value( self.coordinator, self.device_id, SensorType.PELTIER_FAN_POWER, hide_zero=True ) + return round(value) if value is not None else None @property def available(self): @@ -1173,12 +2126,24 @@ class KegPeltierFanPowerSensor(KegSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC + + + + @property + def name(self): + """Return the name of the sensor.""" + return "Fan Duty" + + + + @property def native_value(self): """Return Peltier fan power as a percentage.""" - return _telemetry_sensor_value( + value = _telemetry_sensor_value( self.coordinator, self.device_id, SensorType.PELTIER_FAN_POWER, hide_zero=True ) + return round(value) if value is not None else None @property def available(self): @@ -1203,7 +2168,19 @@ class CraftEspCoreTempSensor(CraftSensor): _attr_native_unit_of_measurement = "°C" _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + + + @property + def name(self): + """Return the name of the sensor.""" + return "ESP32 Core Temperature" + + + + @property def native_value(self): """Return ESP core temperature in Celsius.""" @@ -1234,7 +2211,19 @@ class KegEspCoreTempSensor(KegSensor): _attr_native_unit_of_measurement = "°C" _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + + + + @property + def name(self): + """Return the name of the sensor.""" + return "ESP32 Core Temperature" + + + @property def native_value(self): """Return ESP core temperature in Celsius.""" @@ -1256,3 +2245,150 @@ def icon(self): def unique_id(self): """Return the unique ID of the sensor.""" return f"{self.device_id}_esp_core_temp" + +class CraftButtonSensor(CraftSensor): + """Sensor for the button telemetry on the Craft device (MQTT only).""" + + _attr_translation_key = "button" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _BUTTON_OPTIONS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + + @property + def name(self): + """Return the name of the sensor.""" + return "Button" + + + @property + def native_value(self): + """Return button telemetry value.""" + value = _telemetry_sensor_value(self.coordinator, self.device_id, SensorType.BUTTON) + return _button_state(value) + + + @property + def available(self): + """Return True once a button telemetry value has arrived.""" + return self.native_value is not None + + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:gesture-tap-button" + + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_button" + + +class KegButtonSensor(KegSensor): + """Sensor for the button telemetry on the Keg device (MQTT only).""" + + _attr_translation_key = "button" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _BUTTON_OPTIONS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + + @property + def name(self): + """Return the name of the sensor.""" + return "Button" + + + @property + def native_value(self): + """Return button telemetry value.""" + value = _telemetry_sensor_value(self.coordinator, self.device_id, SensorType.BUTTON) + return _button_state(value) + + + @property + def available(self): + """Return True once a button telemetry value has arrived.""" + return self.native_value is not None + + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:gesture-tap-button" + + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_button" + + +class CraftPeltierModeSensor(CraftSensor): + """Sensor for the current Peltier mode of the Craft device (MQTT only).""" + + _attr_translation_key = "peltier_mode" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _PELTIER_MODE_OPTIONS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def name(self): + """Return the name of the sensor.""" + return "Peltier Mode" + + @property + def native_value(self): + """Return Peltier mode (cooling, warming, idle).""" + return _peltier_mode(_telemetry_temp_control_power(self.coordinator, self.device_id)) + + @property + def available(self): + """Return True when Peltier telemetry has arrived.""" + return _telemetry_temp_control_power(self.coordinator, self.device_id) is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:thermostat" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_peltier_mode" + + +class KegPeltierModeSensor(KegSensor): + """Sensor for the current Peltier mode of the Keg device (MQTT only).""" + + _attr_translation_key = "peltier_mode" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = _PELTIER_MODE_OPTIONS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def name(self): + """Return the name of the sensor.""" + return "Peltier Mode" + + @property + def native_value(self): + """Return Peltier mode (cooling, warming, idle).""" + return _peltier_mode(_telemetry_temp_control_power(self.coordinator, self.device_id)) + + @property + def available(self): + """Return True when Peltier telemetry has arrived.""" + return _telemetry_temp_control_power(self.coordinator, self.device_id) is not None + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:thermostat" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_peltier_mode" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 1c45705..6b995d2 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -8,15 +8,25 @@ "username": "Username", "password": "Password" } + }, + "reauth_confirm": { + "title": "Reauthenticate MiniBrew", + "description": "Your MiniBrew credentials are no longer valid. Enter updated credentials.", + "data": { + "username": "Username", + "password": "Password" + } } }, "error": { + "invalid_auth": "Invalid credentials. Please check your username and password.", "cannot_connect": "Failed to connect to MiniBrew. Please check your credentials and try again.", "no_devices_found": "No MiniBrew devices found in your account.", "unknown_error": "An unexpected error occurred. Please try again." }, "abort": { - "already_configured": "This MiniBrew account is already configured." + "already_configured": "This MiniBrew account is already configured.", + "reauth_successful": "Reauthentication successful." } }, "options": { @@ -25,8 +35,6 @@ "title": "MiniBrew Options", "description": "Configure how the integration updates data from your MiniBrew devices. Enable real-time updates to stream live telemetry over MQTT instead of frequent polling.", "data": { - "enable_realtime": "Enable real-time updates (MQTT)", - "refresh_interval": "Polling update interval (seconds)", "realtime_poll_interval": "Real-time discovery interval (seconds)" } } @@ -50,6 +58,9 @@ "offline": "Offline" } }, + "last_time_online": { + "name": "Last time online" + }, "update_status": { "name": "Update status", "state": { @@ -66,7 +77,16 @@ } }, "next_action_time_remaining": { - "name": "Next action" + "name": "Next action", + "state": { + "now": "Now" + } + }, + "session_id": { + "name": "Session ID" + }, + "session_started": { + "name": "Brew session started" }, "current_stage": { "name": "Current stage", @@ -78,11 +98,8 @@ "unknown": "Unknown" } }, - "time_in_stage": { - "name": "Time in stage" - }, - "time_in_stage_duration": { - "name": "Time in stage (H:MM:SS)" + "process_phase": { + "name": "Process phase" }, "needs_cleaning": { "name": "Needs cleaning", @@ -101,13 +118,28 @@ "name": "Beer name" }, "temp_control_power": { - "name": "Temp control power" + "name": "Peltier power" + }, + "peltier_mode": { + "name": "Peltier mode", + "state": { + "cooling": "Cooling", + "warming": "Warming", + "idle": "Idle" + } }, "peltier_fan_power": { - "name": "Peltier fan power" + "name": "Fan duty" }, "esp_core_temp": { - "name": "ESP core temperature" + "name": "ESP32 core temperature" + }, + "button": { + "name": "Button", + "state": { + "on": "On", + "off": "Off" + } } } } diff --git a/custom_components/minibrew/translations/en.json b/custom_components/minibrew/translations/en.json index 1c45705..6b995d2 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -8,15 +8,25 @@ "username": "Username", "password": "Password" } + }, + "reauth_confirm": { + "title": "Reauthenticate MiniBrew", + "description": "Your MiniBrew credentials are no longer valid. Enter updated credentials.", + "data": { + "username": "Username", + "password": "Password" + } } }, "error": { + "invalid_auth": "Invalid credentials. Please check your username and password.", "cannot_connect": "Failed to connect to MiniBrew. Please check your credentials and try again.", "no_devices_found": "No MiniBrew devices found in your account.", "unknown_error": "An unexpected error occurred. Please try again." }, "abort": { - "already_configured": "This MiniBrew account is already configured." + "already_configured": "This MiniBrew account is already configured.", + "reauth_successful": "Reauthentication successful." } }, "options": { @@ -25,8 +35,6 @@ "title": "MiniBrew Options", "description": "Configure how the integration updates data from your MiniBrew devices. Enable real-time updates to stream live telemetry over MQTT instead of frequent polling.", "data": { - "enable_realtime": "Enable real-time updates (MQTT)", - "refresh_interval": "Polling update interval (seconds)", "realtime_poll_interval": "Real-time discovery interval (seconds)" } } @@ -50,6 +58,9 @@ "offline": "Offline" } }, + "last_time_online": { + "name": "Last time online" + }, "update_status": { "name": "Update status", "state": { @@ -66,7 +77,16 @@ } }, "next_action_time_remaining": { - "name": "Next action" + "name": "Next action", + "state": { + "now": "Now" + } + }, + "session_id": { + "name": "Session ID" + }, + "session_started": { + "name": "Brew session started" }, "current_stage": { "name": "Current stage", @@ -78,11 +98,8 @@ "unknown": "Unknown" } }, - "time_in_stage": { - "name": "Time in stage" - }, - "time_in_stage_duration": { - "name": "Time in stage (H:MM:SS)" + "process_phase": { + "name": "Process phase" }, "needs_cleaning": { "name": "Needs cleaning", @@ -101,13 +118,28 @@ "name": "Beer name" }, "temp_control_power": { - "name": "Temp control power" + "name": "Peltier power" + }, + "peltier_mode": { + "name": "Peltier mode", + "state": { + "cooling": "Cooling", + "warming": "Warming", + "idle": "Idle" + } }, "peltier_fan_power": { - "name": "Peltier fan power" + "name": "Fan duty" }, "esp_core_temp": { - "name": "ESP core temperature" + "name": "ESP32 core temperature" + }, + "button": { + "name": "Button", + "state": { + "on": "On", + "off": "Off" + } } } } diff --git a/tests/test_realtime_overlay.py b/tests/test_realtime_overlay.py index 3c331ab..e0aff62 100644 --- a/tests/test_realtime_overlay.py +++ b/tests/test_realtime_overlay.py @@ -18,9 +18,14 @@ def _telemetry(**kwargs): """Build a DeviceLogMessage-like stand-in with the overlaid attributes.""" defaults = { + "current_state": None, + "process_type": None, + "process_state": None, "current_temperature": None, "target_temperature": None, "user_action": None, + "process_phase": None, + "session_id": None, "next_action_at": None, "seconds_until_next_action": None, } @@ -62,19 +67,123 @@ def test_next_action_at_maps_to_process_estimate_remaining(): assert device["process_estimate_remaining"] == when -def test_user_action_zero_is_applied_not_skipped(): - # 0 is falsy but not None, so it must still overlay. +def test_user_action_zero_does_not_clobber_non_zero_rest(): device = {"user_action": 5} realtime.overlay_mqtt(device, _telemetry(user_action=0)) + assert device["user_action"] == 5 + + +def test_user_action_zero_applies_when_rest_is_empty_or_zero(): + device = {"user_action": 0} + realtime.overlay_mqtt(device, _telemetry(user_action=0)) assert device["user_action"] == 0 +def test_process_phase_is_mapped_to_device(): + device = {"process_phase": 0} + realtime.overlay_mqtt(device, _telemetry(process_phase=12)) + assert device["process_phase"] == 12 + + def test_seconds_until_next_action_maps_to_remaining_seconds(): device = {"process_estimate_remaining_seconds": 1200} realtime.overlay_mqtt(device, _telemetry(seconds_until_next_action=420)) assert device["process_estimate_remaining_seconds"] == 420 +def test_core_process_fields_are_mapped_from_mqtt(): + device = { + "current_state": 0, + "process_type": 0, + "process_state": 0, + "active_session": None, + } + realtime.overlay_mqtt( + device, + _telemetry(current_state=2, process_type=4, process_state=80, session_id=80851), + ) + assert device["current_state"] == 2 + assert device["process_type"] == 4 + assert device["process_state"] == 80 + assert device["active_session"] == 80851 + + +def test_realtime_manager_skips_duplicate_packets(): + calls = [] + + class _Loop: + def call_soon_threadsafe(self, callback): + calls.append(callback) + + coordinator = SimpleNamespace(async_update_listeners=lambda: None) + hass = SimpleNamespace(loop=_Loop()) + manager = realtime.MiniBrewRealtimeManager(hass, coordinator, client=None) + + msg = SimpleNamespace( + device_uuid="serial-1", + current_state=2, + process_type=2, + process_state=101, + current_temperature=20.0, + target_temperature=19.0, + user_action=2, + process_phase=8, + session_id=80885, + next_action_at=None, + seconds_until_next_action=120, + temp_control_power=45.0, + measurements={}, + ) + + manager._handle_device_log(msg) + manager._handle_device_log(msg) + + assert len(calls) == 1 + + +def test_realtime_manager_ignores_countdown_only_changes(): + from datetime import datetime, timezone + + calls = [] + + class _Loop: + def call_soon_threadsafe(self, callback): + calls.append(callback) + + coordinator = SimpleNamespace(async_update_listeners=lambda: None) + hass = SimpleNamespace(loop=_Loop()) + manager = realtime.MiniBrewRealtimeManager(hass, coordinator, client=None) + + common = { + "device_uuid": "serial-1", + "current_state": 2, + "process_type": 2, + "process_state": 101, + "current_temperature": 20.0, + "target_temperature": 19.0, + "user_action": 2, + "process_phase": 8, + "session_id": 80885, + "temp_control_power": 45.0, + "measurements": {}, + } + + first = SimpleNamespace( + **common, + next_action_at=datetime(2026, 7, 26, 18, 26, 4, tzinfo=timezone.utc), + seconds_until_next_action=120, + ) + second = SimpleNamespace( + **common, + next_action_at=datetime(2026, 7, 26, 18, 26, 44, tzinfo=timezone.utc), + seconds_until_next_action=119, + ) + + manager._handle_device_log(first) + manager._handle_device_log(second) + + assert len(calls) == 1 + if __name__ == "__main__": failures = 0 for name, fn in sorted(globals().items()):