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 21a6016..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,23 +15,35 @@ 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** - 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** - 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 (%) +- **Fan Duty** *(real-time MQTT)* - Peltier fan output (%) +- **ESP Core Temperature** *(real-time MQTT)* - Internal controller temperature ## Installation @@ -67,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** @@ -79,7 +91,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.11.0`](https://github.com/stuartp44/pymbrewclient/releases/tag/v1.11.0) (automatically installed) ## Dependencies 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 b1dd30c..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"] @@ -24,15 +28,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..dba5846 100644 --- a/custom_components/minibrew/config_flow.py +++ b/custom_components/minibrew/config_flow.py @@ -1,12 +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 DOMAIN -from pymbrewclient import BreweryClient, Device +from .const import ( + CONF_REALTIME_POLL_INTERVAL, + DEFAULT_REALTIME_POLL_INTERVAL, + DOMAIN, +) +from pymbrewclient import BreweryClient _LOGGER = logging.getLogger(__name__) @@ -20,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: @@ -42,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: @@ -76,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.""" @@ -84,9 +153,12 @@ 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_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..5694088 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 = True +DEFAULT_REALTIME_POLL_INTERVAL = 300 diff --git a/custom_components/minibrew/manifest.json b/custom_components/minibrew/manifest.json index 19ee1e2..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" ], @@ -11,7 +11,7 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/stuartp44/hambrewclient/issues", "requirements": [ - "pymbrewclient>=1.8.1" + "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 new file mode 100644 index 0000000..5ea46c2 --- /dev/null +++ b/custom_components/minibrew/realtime.py @@ -0,0 +1,219 @@ +"""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 +from datetime import datetime + +_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_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. + + 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: + 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 + + +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._fingerprints = {} + self._last_update = {} + 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) + + 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.""" + 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 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): + """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 60ab489..ae16639 100644 --- a/custom_components/minibrew/sensor.py +++ b/custom_components/minibrew/sensor.py @@ -1,16 +1,85 @@ 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, 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 BreweryOverview, Device +from pymbrewclient import Device, ProcessPhase, SensorType -from .const import DOMAIN +from .const import ( + CONF_REALTIME_POLL_INTERVAL, + DEFAULT_REALTIME_POLL_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): @@ -23,23 +92,385 @@ 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 + + +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. + + 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 _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 + + 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}" + + +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 + + +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.""" - 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() + _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 + # 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}") + _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(): # 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: @@ -59,40 +490,62 @@ 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)) + 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(CraftSensorCurrentStageSensor(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(CraftEspCoreTempSensor(coordinator, device, state)) + new_sensors.append(CraftButtonSensor(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(KegOnlineStatusSensor(coordinator, device, state)) - sensors.append(KegIsUpdatingSensor(coordinator, device, state)) - sensors.append(KegNeedsCleaningSensor(coordinator, device, state)) - sensors.append(KegActionRequiredSensor(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(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(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) - + # 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) @@ -103,26 +556,225 @@ 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 = True + realtime_poll_interval = options.get( + CONF_REALTIME_POLL_INTERVAL, DEFAULT_REALTIME_POLL_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, _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.""" 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}") - return 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}") + # 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) + + 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.""" + 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)) + 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.""" @@ -165,40 +817,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 - -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" - + """Get the latest device data (REST overlaid with live telemetry).""" + return self.coordinator.get_merged_device(self.device_id, self.device_type) class CraftSensorCurrentTemperatureSensor(CraftSensor): """Sensor for the current temperature of the Craft device.""" @@ -206,11 +826,15 @@ class CraftSensorCurrentTemperatureSensor(CraftSensor): _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() @@ -244,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() @@ -274,96 +902,121 @@ 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" -class CraftSensorOnlineStatusSensor(CraftSensor): - """Sensor for the online status of the Craft device.""" - - _attr_translation_key = "cloud_connection" - + @property def name(self): """Return the name of the sensor.""" - return "Cloud Connection" + return "Beer Style" + @property def native_value(self): - """Return the online status.""" + """Return the beer style.""" device = self._get_latest_device() - return "online" if device and device.get("online") else "offline" + 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 entity_category(self): - """Return the entity category.""" - return EntityCategory.DIAGNOSTIC + 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:cloud-check" + return "mdi:beer" + @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_online_status" + return f"{self.device_id}_beer_style" -class CraftSensorIsUpdatingSensor(CraftSensor): - """Sensor for the update status of the Craft device.""" +class CraftBeerNameSensor(CraftSensor): + """Sensor for the beer name of the Craft device.""" - _attr_translation_key = "update_status" + _attr_translation_key = "beer_name" + @property def name(self): """Return the name of the sensor.""" - return "Update Status" + return "Beer Name" + @property def native_value(self): - """Return the update status.""" + """Return the beer name.""" device = self._get_latest_device() - return "updating" if device and device.get("updating") else "not_updating" + 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 entity_category(self): - """Return the entity category.""" - return EntityCategory.DIAGNOSTIC + 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:cloud-sync" + return "mdi:beer-outline" + @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_is_updating" + return f"{self.device_id}_beer_name" -class CraftUserActionRequiredSensor(CraftSensor): - """Sensor for user action required status of the Craft device.""" - _attr_translation_key = "user_action_required" + + +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 "User Action Required" + return "Cloud Connection" @property + + def native_value(self): - """Return the user action required status.""" + """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() - 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 "online" if device and device.get("online") else "offline" @property def entity_category(self): @@ -373,99 +1026,349 @@ 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: - return "mdi:alert" - else: - return "mdi:check-circle" + return "mdi:cloud-check" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_user_action_required" + return f"{self.device_id}_online_status" -class CraftSensorCurrentStageSensor(CraftSensor): - """Sensor for the current stage of the Craft device.""" +class CraftLastTimeOnlineSensor(CraftSensor): + """Sensor for the most recent online timestamp of the Craft device.""" - _attr_translation_key = "current_stage" + _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 "Current Stage" + return "Last Time Online" + @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 "unknown" + """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:beer" + return "mdi:clock-check-outline" + @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_current_stage" + return f"{self.device_id}_last_time_online" -class CraftSensorTimeInStageSensor(CraftSensor): - """Sensor for the time spent in the current stage of the Craft device.""" - _attr_translation_key = "time_in_stage" +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 "Time in Stage" + return "Update Status" @property + + def native_value(self): - """Return the time spent in the current stage.""" + """Return the update status.""" device = self._get_latest_device() - return device.get("status_time") if device else None + if not device or device.get("updating") is None: + return None + return "updating" if device.get("updating") else "not_updating" + @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return "seconds" + def available(self): + """Return True when update status is known from the device payload.""" + return self.native_value is not 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 + def entity_category(self): + """Return the entity category.""" + return EntityCategory.DIAGNOSTIC @property def icon(self): """Return the icon for the sensor.""" - return "mdi:clock" + return "mdi:cloud-sync" @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}_is_updating" -class CraftSensorNeedsCleaningSensor(CraftSensor): - """Sensor for the cleaning status of the Craft device.""" +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.""" + return _user_action_required_state(self._get_latest_device()) + + + @property + def entity_category(self): + """Return the entity category.""" + return EntityCategory.DIAGNOSTIC + + @property + def icon(self): + """Return the icon for the sensor.""" + if self.native_value == "action_required": + return "mdi:alert" + return "mdi:check-circle" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_user_action_required" + + +class CraftNextActionDateTimeSensor(CraftSensor): + """Sensor for the actual timestamp of the next required action.""" + + _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 next-action state; use \"now\" when imminently due.""" + device = self._get_latest_device() + if not device: + return None + + 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): + """Return the entity category.""" + return EntityCategory.DIAGNOSTIC + + @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_time_remaining" + + +class CraftSessionIdSensor(CraftSensor): + """Sensor for the active brew session ID of the Craft 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 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 "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 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 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() + 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}_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() @@ -528,13 +1431,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.""" @@ -542,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() @@ -579,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() @@ -616,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): @@ -643,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): @@ -664,85 +1606,91 @@ def unique_id(self): return f"{self.device_id}_{self.name}" -class KegOnlineStatusSensor(KegSensor): - """Sensor for the online status of the Keg device.""" +class KegCurrentStageSensor(KegSensor): + """Sensor for the current stage of the Keg device.""" - _attr_translation_key = "cloud_connection" + _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 "Cloud Connection" + return "Current Stage" @property def native_value(self): - """Return the online status.""" - device = self._get_latest_device() - return "online" if device and device.get("online") else "offline" - - @property - def entity_category(self): - """Return the entity category (diagnostic).""" - return EntityCategory.DIAGNOSTIC + """Return the current high-level stage from the overview group.""" + return _current_stage_group(self.coordinator, self.device_id) @property def icon(self): """Return the icon for the sensor.""" - return "mdi:cloud-check" + return "mdi:beer" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_{self.name}" + return f"{self.device_id}_current_stage" -class KegIsUpdatingSensor(KegSensor): - """Sensor for the update status of the Keg device.""" +class KegProcessPhaseSensor(KegSensor): + """Sensor for the current process phase of the Keg device.""" - _attr_translation_key = "update_status" + _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 "Update Status" + return "Process Phase" @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" + """Return current process phase label from telemetry.""" + return _process_phase_display(self._get_latest_device()) @property - def entity_category(self): - """Return the entity category (diagnostic).""" - return EntityCategory.DIAGNOSTIC + def available(self): + """Return True once process phase telemetry is available.""" + device = self._get_latest_device() + 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:cloud-sync" + return "mdi:timeline-clock" @property def unique_id(self): """Return the unique ID of the sensor.""" - return f"{self.device_id}_{self.name}" + return f"{self.device_id}_process_phase" -class KegNeedsCleaningSensor(KegSensor): - """Sensor for the cleaning status of the Keg device.""" +class KegOnlineStatusSensor(KegSensor): + """Sensor for the online status of the Keg device.""" - _attr_translation_key = "needs_cleaning" + _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 "Needs Cleaning" + return "Cloud Connection" @property + + def native_value(self): - """Return the cleaning status.""" + """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 "needs_cleaning" if device and device.get("needs_acid_cleaning") else "clean" + return "online" if device and device.get("online") else "offline" @property def entity_category(self): @@ -752,38 +1700,78 @@ def entity_category(self): @property def icon(self): """Return the icon for the sensor.""" - return "mdi:broom" + return "mdi:cloud-check" @property def unique_id(self): """Return the unique ID of the sensor.""" return f"{self.device_id}_{self.name}" -class KegActionRequiredSensor(KegSensor): - """Sensor for user action required status of the Keg device.""" - _attr_translation_key = "user_action_required" +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 "User Action Required" + return "Last Time Online" + @property def native_value(self): - """Return the user action required status.""" + """Return the last time this device was seen online.""" device = self._get_latest_device() if not device: - return "unknown" + 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" - action = device.get("user_action") + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_last_time_online" - if action != 0 and action is not None: - return "action_required" - elif action == 0: - return "no_action_required" - return "unknown" +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() + 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): @@ -793,15 +1781,614 @@ def entity_category(self): @property def icon(self): """Return the icon for the sensor.""" + return "mdi:cloud-sync" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_{self.name}" + + +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() - action = device.get("user_action") if device else None - - if action != 0 and action is not None: + return "needs_cleaning" if device and device.get("needs_acid_cleaning") else "clean" + + @property + def entity_category(self): + """Return the entity category (diagnostic).""" + return EntityCategory.DIAGNOSTIC + + @property + def icon(self): + """Return the icon for the sensor.""" + return "mdi:broom" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_{self.name}" + +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.""" + return _user_action_required_state(self._get_latest_device()) + + + @property + def entity_category(self): + """Return the entity category (diagnostic).""" + return EntityCategory.DIAGNOSTIC + + @property + def icon(self): + """Return the icon for the sensor.""" + if self.native_value == "action_required": return "mdi:alert" - else: - return "mdi:check-circle" + return "mdi:check-circle" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_{self.name}" + + +class KegNextActionDateTimeSensor(KegSensor): + """Sensor for the actual timestamp of the next required action.""" + + _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 next-action state; use \"now\" when imminently due.""" + device = self._get_latest_device() + if not device: + 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): + """Return the entity category (diagnostic).""" + return EntityCategory.DIAGNOSTIC + + @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_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).""" + + _attr_translation_key = "temp_control_power" + _attr_native_unit_of_measurement = "%" + _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 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.""" + 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:speedometer" + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.device_id}_temp_control_power" + + +class KegTempControlPowerSensor(KegSensor): + """Sensor for the temperature-control (Peltier) power of the Keg device (MQTT only).""" + + _attr_translation_key = "temp_control_power" + _attr_native_unit_of_measurement = "%" + _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 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.""" + 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:speedometer" + + @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 name(self): + """Return the name of the sensor.""" + return "Fan Duty" + + + + + @property + def native_value(self): + """Return Peltier fan power as a percentage.""" + 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): + """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 name(self): + """Return the name of the sensor.""" + return "Fan Duty" + + + + + @property + def native_value(self): + """Return Peltier fan power as a percentage.""" + 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): + """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 + _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.""" + 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 + _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.""" + 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 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}_{self.name}" \ No newline at end of file + return f"{self.device_id}_peltier_mode" diff --git a/custom_components/minibrew/strings.json b/custom_components/minibrew/strings.json index 0b2d26b..6b995d2 100644 --- a/custom_components/minibrew/strings.json +++ b/custom_components/minibrew/strings.json @@ -8,24 +8,34 @@ "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": { "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)" + "realtime_poll_interval": "Real-time discovery interval (seconds)" } } } @@ -48,6 +58,9 @@ "offline": "Offline" } }, + "last_time_online": { + "name": "Last time online" + }, "update_status": { "name": "Update status", "state": { @@ -63,6 +76,18 @@ "unknown": "Unknown" } }, + "next_action_time_remaining": { + "name": "Next action", + "state": { + "now": "Now" + } + }, + "session_id": { + "name": "Session ID" + }, + "session_started": { + "name": "Brew session started" + }, "current_stage": { "name": "Current stage", "state": { @@ -73,8 +98,8 @@ "unknown": "Unknown" } }, - "time_in_stage": { - "name": "Time in stage" + "process_phase": { + "name": "Process phase" }, "needs_cleaning": { "name": "Needs cleaning", @@ -91,6 +116,30 @@ }, "beer_name": { "name": "Beer name" + }, + "temp_control_power": { + "name": "Peltier power" + }, + "peltier_mode": { + "name": "Peltier mode", + "state": { + "cooling": "Cooling", + "warming": "Warming", + "idle": "Idle" + } + }, + "peltier_fan_power": { + "name": "Fan duty" + }, + "esp_core_temp": { + "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 0b2d26b..6b995d2 100644 --- a/custom_components/minibrew/translations/en.json +++ b/custom_components/minibrew/translations/en.json @@ -8,24 +8,34 @@ "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": { "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)" + "realtime_poll_interval": "Real-time discovery interval (seconds)" } } } @@ -48,6 +58,9 @@ "offline": "Offline" } }, + "last_time_online": { + "name": "Last time online" + }, "update_status": { "name": "Update status", "state": { @@ -63,6 +76,18 @@ "unknown": "Unknown" } }, + "next_action_time_remaining": { + "name": "Next action", + "state": { + "now": "Now" + } + }, + "session_id": { + "name": "Session ID" + }, + "session_started": { + "name": "Brew session started" + }, "current_stage": { "name": "Current stage", "state": { @@ -73,8 +98,8 @@ "unknown": "Unknown" } }, - "time_in_stage": { - "name": "Time in stage" + "process_phase": { + "name": "Process phase" }, "needs_cleaning": { "name": "Needs cleaning", @@ -91,6 +116,30 @@ }, "beer_name": { "name": "Beer name" + }, + "temp_control_power": { + "name": "Peltier power" + }, + "peltier_mode": { + "name": "Peltier mode", + "state": { + "cooling": "Cooling", + "warming": "Warming", + "idle": "Idle" + } + }, + "peltier_fan_power": { + "name": "Fan duty" + }, + "esp_core_temp": { + "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 new file mode 100644 index 0000000..e0aff62 --- /dev/null +++ b/tests/test_realtime_overlay.py @@ -0,0 +1,197 @@ +"""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_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, + } + 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_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()): + 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)