diff --git a/apps/predbat/config.py b/apps/predbat/config.py index b3e621f89..6eb4305ba 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -1963,7 +1963,7 @@ "has_rest_api": False, "has_mqtt_api": False, "output_charge_control": "power", - "charge_control_immediate": True, + "charge_control_immediate": False, "has_charge_enable_time": True, "has_discharge_enable_time": True, "has_target_soc": True, @@ -1978,8 +1978,8 @@ "clock_time_format": "%H:%M:%S", "write_and_poll_sleep": 2, "has_time_window": True, - "support_charge_freeze": False, - "support_discharge_freeze": False, + "support_charge_freeze": True, + "support_discharge_freeze": True, "has_idle_time": False, "can_span_midnight": True, "charge_discharge_with_rate": False, diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index b364580ca..52e7fb3d3 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -14,6 +14,7 @@ import time import uuid import traceback +from utils import calc_percent_limit from component_base import ComponentBase @@ -43,50 +44,85 @@ if not HAS_PROTOBUF: raise ImportError("GatewayMQTT requires the 'protobuf' package: pip install protobuf") - -# Entity mapping: protobuf field path → entity name -ENTITY_MAP = { - # Battery - "battery.soc_percent": "predbat_gateway_soc", - "battery.power_w": "predbat_gateway_battery_power", - "battery.voltage_v": "predbat_gateway_battery_voltage", - "battery.current_a": "predbat_gateway_battery_current", - "battery.temperature_c": "predbat_gateway_battery_temp", - "battery.soh_percent": "predbat_gateway_battery_soh", - "battery.cycle_count": "predbat_gateway_battery_cycles", - "battery.capacity_wh": "predbat_gateway_battery_capacity", - "battery.rate_max_w": "predbat_gateway_battery_rate_max", - # Power flows - "pv.power_w": "predbat_gateway_pv_power", - "grid.power_w": "predbat_gateway_grid_power", - "grid.voltage_v": "predbat_gateway_grid_voltage", - "grid.frequency_hz": "predbat_gateway_grid_frequency", - "load.power_w": "predbat_gateway_load_power", - "inverter.active_power_w": "predbat_gateway_inverter_power", - "inverter.temperature_c": "predbat_gateway_inverter_temp", - # Control - "control.mode": "predbat_gateway_mode", - "control.charge_enabled": "predbat_gateway_charge_enabled", - "control.discharge_enabled": "predbat_gateway_discharge_enabled", - "control.charge_rate_w": "predbat_gateway_charge_rate", - "control.discharge_rate_w": "predbat_gateway_discharge_rate", - "control.reserve_soc": "predbat_gateway_reserve", - "control.target_soc": "predbat_gateway_target_soc", - "control.force_power_w": "predbat_gateway_force_power", - "control.command_expires": "predbat_gateway_command_expires", - # Schedule - "schedule.charge_start": "predbat_gateway_charge_start", - "schedule.charge_end": "predbat_gateway_charge_end", - "schedule.discharge_start": "predbat_gateway_discharge_start", - "schedule.discharge_end": "predbat_gateway_discharge_end", -} - # Plan re-publish interval (seconds) _PLAN_REPUBLISH_INTERVAL = 5 * 60 # Telemetry staleness threshold (seconds) _TELEMETRY_STALE_THRESHOLD = 120 +# Time options for schedule select entities (HH:MM:SS, one per minute across 24 h) +_GATEWAY_BASE_TIME = datetime.datetime.strptime("00:00", "%H:%M") +_GATEWAY_OPTIONS_TIME = [(_GATEWAY_BASE_TIME + datetime.timedelta(seconds=m * 60)).strftime("%H:%M:%S") for m in range(0, 24 * 60, 5)] + + +# Operating mode selector (0=AUTO, 1=MANUAL; higher values reserved) +GATEWAY_OPERATING_MODE_NAMES = {0: "AUTO", 1: "MANUAL"} +GATEWAY_OPERATING_MODE_VALUES = {"AUTO": 0, "MANUAL": 1} +GATEWAY_OPERATING_MODE_OPTIONS = ["AUTO", "MANUAL"] + +PLAN_MODE_AUTO = 0 +PLAN_MODE_CHARGE = 1 +PLAN_MODE_DISCHARGE = 2 + +# Entity attribute table — keyed by the semantic suffix used in dashboard_item calls +GATEWAY_ATTRIBUTE_TABLE = { + # Binary sensors + "gateway_online": {"friendly_name": "Gateway Online", "device_class": "connectivity"}, + # Timestamps + "inverter_time": {"friendly_name": "Inverter Time", "icon": "mdi:clock", "device_class": "timestamp"}, + # Battery state + "soc": {"friendly_name": "Battery SOC", "icon": "mdi:battery", "unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement"}, + "battery_power": {"friendly_name": "Battery Power", "icon": "mdi:battery", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "battery_voltage": {"friendly_name": "Battery Voltage", "icon": "mdi:battery", "unit_of_measurement": "V", "device_class": "voltage", "state_class": "measurement"}, + "battery_current": {"friendly_name": "Battery Current", "icon": "mdi:battery", "unit_of_measurement": "A", "device_class": "current", "state_class": "measurement"}, + "battery_temperature": {"friendly_name": "Battery Temperature", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, + "battery_capacity": {"friendly_name": "Battery Capacity", "icon": "mdi:battery", "unit_of_measurement": "kWh", "device_class": "energy"}, + "battery_soh": {"friendly_name": "Battery State of Health", "icon": "mdi:battery-heart", "unit_of_measurement": "%", "state_class": "measurement"}, + "battery_rate_max": {"friendly_name": "Battery Max Charge Rate", "icon": "mdi:battery", "unit_of_measurement": "W", "device_class": "power"}, + "battery_dod": {"friendly_name": "Battery Depth of Discharge", "icon": "mdi:battery", "unit_of_measurement": "*"}, + "battery_charge_today": {"friendly_name": "Battery Charge Today", "icon": "mdi:battery-plus", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"}, + "battery_discharge_today": {"friendly_name": "Battery Discharge Today", "icon": "mdi:battery-minus", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"}, + # PV + "pv_power": {"friendly_name": "PV Power", "icon": "mdi:solar-power", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "pv_today": {"friendly_name": "PV Today", "icon": "mdi:solar-power", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"}, + # Grid + "grid_power": {"friendly_name": "Grid Power", "icon": "mdi:transmission-tower", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "grid_voltage": {"friendly_name": "Grid Voltage", "icon": "mdi:transmission-tower", "unit_of_measurement": "V", "device_class": "voltage", "state_class": "measurement"}, + "grid_frequency": {"friendly_name": "Grid Frequency", "icon": "mdi:transmission-tower", "unit_of_measurement": "Hz", "device_class": "frequency", "state_class": "measurement"}, + "import_today": {"friendly_name": "Grid Import Today", "icon": "mdi:transmission-tower", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"}, + "export_today": {"friendly_name": "Grid Export Today", "icon": "mdi:transmission-tower", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"}, + # Load + "load_power": {"friendly_name": "Load Power", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "load_today": {"friendly_name": "Load Today", "icon": "mdi:flash", "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "total"}, + # Inverter + "inverter_power": {"friendly_name": "Inverter Power", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "inverter_temperature": {"friendly_name": "Inverter Temperature", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, + # Control switches + "charge_enabled": {"friendly_name": "Charge Enabled", "icon": "mdi:battery-plus"}, + "discharge_enabled": {"friendly_name": "Discharge Enabled", "icon": "mdi:battery-minus"}, + # Operating mode selector + "mode_select": {"friendly_name": "Operating Mode", "icon": "mdi:cog", "options": GATEWAY_OPERATING_MODE_OPTIONS}, + # Control numbers + "charge_rate": {"friendly_name": "Charge Rate", "icon": "mdi:battery-plus", "unit_of_measurement": "W", "min": 0, "max": 10000, "step": 10}, + "discharge_rate": {"friendly_name": "Discharge Rate", "icon": "mdi:battery-minus", "unit_of_measurement": "W", "min": 0, "max": 10000, "step": 10}, + "reserve_soc": {"friendly_name": "Reserve SOC", "icon": "mdi:battery-lock", "unit_of_measurement": "%", "min": 0, "max": 100, "step": 1}, + "target_soc": {"friendly_name": "Target SOC", "icon": "mdi:battery-arrow-up", "unit_of_measurement": "%", "min": 0, "max": 100, "step": 1}, + # Schedule selects (HH:MM:SS options) + "charge_slot1_start": {"friendly_name": "Charge Slot 1 Start", "icon": "mdi:clock-start", "options": _GATEWAY_OPTIONS_TIME}, + "charge_slot1_end": {"friendly_name": "Charge Slot 1 End", "icon": "mdi:clock-end", "options": _GATEWAY_OPTIONS_TIME}, + "discharge_slot1_start": {"friendly_name": "Discharge Slot 1 Start", "icon": "mdi:clock-start", "options": _GATEWAY_OPTIONS_TIME}, + "discharge_slot1_end": {"friendly_name": "Discharge Slot 1 End", "icon": "mdi:clock-end", "options": _GATEWAY_OPTIONS_TIME}, + # EMS aggregate entities + "ems_total_soc": {"friendly_name": "EMS Total SOC", "icon": "mdi:battery", "unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement"}, + "ems_total_charge": {"friendly_name": "EMS Total Charge Power", "icon": "mdi:battery-plus", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "ems_total_discharge": {"friendly_name": "EMS Total Discharge Power", "icon": "mdi:battery-minus", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "ems_total_grid": {"friendly_name": "EMS Total Grid Power", "icon": "mdi:transmission-tower", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "ems_total_pv": {"friendly_name": "EMS Total PV Power", "icon": "mdi:solar-power", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + "ems_total_load": {"friendly_name": "EMS Total Load Power", "icon": "mdi:flash", "unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"}, + # Sub-inverter temperature (entity suffix is "_temp") + "temp": {"friendly_name": "Sub-Inverter Temperature", "icon": "mdi:thermometer", "unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement"}, +} + class GatewayMQTT(ComponentBase): """ESP32 Gateway MQTT component for PredBat. @@ -146,7 +182,7 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt if hasattr(self.base, "register_hook"): self.base.register_hook("on_plan_executed", self._on_plan_executed) - def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_windows=None, export_limits=None, charge_rate_w=0, discharge_rate_w=0, timezone="Europe/London"): + def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_windows=None, export_limits=None, charge_rate_w=0, discharge_rate_w=0, soc_max=10, reserve=0, timezone="Europe/London"): """Handle plan execution hook — convert PredBat plan to gateway protobuf format. Called by the plugin system after execute_plan() completes. Converts @@ -157,19 +193,30 @@ def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_wind # Convert charge windows to plan entries for i, window in enumerate(charge_windows or []): - limit = charge_limits[i] if i < len(charge_limits or []) else 100 - if limit <= 0: + limit_kwh = charge_limits[i] if i < len(charge_limits or []) else soc_max + if limit_kwh <= 0: continue + # XXX: If limit_khw == reserve then its a hold charge, need logic for this to be added + limit = calc_percent_limit(limit_kwh, soc_max) start_minutes = window.get("start", 0) end_minutes = window.get("end", 0) + # Work out hours and minutes + start_hour = start_minutes // 60 + start_minute = start_minutes % 60 + end_hour = end_minutes // 60 + end_minute = end_minutes % 60 + # Skip zero-duration windows (start == end after midnight wrap) + if start_hour == end_hour and start_minute == end_minute: + self.log(f"Warn: GatewayMQTT: Skipping zero-duration charge window {i} ({start_minutes}-{end_minutes})") + continue plan_entries.append( { "enabled": True, - "start_hour": start_minutes // 60, - "start_minute": start_minutes % 60, - "end_hour": end_minutes // 60, - "end_minute": end_minutes % 60, - "mode": 1, # charge + "start_hour": start_hour, + "start_minute": start_minute, + "end_hour": end_hour, + "end_minute": end_minute, + "mode": PLAN_MODE_CHARGE, # charge "power_w": charge_rate_w, "target_soc": int(limit), "days_of_week": 0x7F, @@ -184,14 +231,23 @@ def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_wind continue start_minutes = window.get("start", 0) end_minutes = window.get("end", 0) + # Work out hours and minutes + start_hour = start_minutes // 60 + start_minute = start_minutes % 60 + end_hour = end_minutes // 60 + end_minute = end_minutes % 60 + # Skip zero-duration windows + if start_hour == end_hour and start_minute == end_minute: + self.log(f"Warn: GatewayMQTT: Skipping zero-duration discharge window {i} ({start_minutes}-{end_minutes})") + continue plan_entries.append( { "enabled": True, - "start_hour": start_minutes // 60, - "start_minute": start_minutes % 60, - "end_hour": end_minutes // 60, - "end_minute": end_minutes % 60, - "mode": 2, # discharge + "start_hour": start_hour, + "start_minute": start_minute, + "end_hour": end_hour, + "end_minute": end_minute, + "mode": PLAN_MODE_DISCHARGE, # discharge "power_w": discharge_rate_w, "target_soc": int(limit), "days_of_week": 0x7F, @@ -205,6 +261,8 @@ def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_wind self.log(f"Warn: GatewayMQTT: Plan has {len(plan_entries)} entries, capping to {MAX_PLAN_ENTRIES}") plan_entries = plan_entries[:MAX_PLAN_ENTRIES] + self.log(f"Info: GatewayMQTT: Plan entries ({len(plan_entries)}): " + ", ".join(f"mode={e['mode']} {e['start_hour']:02d}:{e['start_minute']:02d}-{e['end_hour']:02d}:{e['end_minute']:02d}" for e in plan_entries)) + # Queue plan for async publishing (picked up by run() cycle) if self._plan_changed(plan_entries): self._pending_plan = (plan_entries, timezone) @@ -352,10 +410,11 @@ async def _handle_message(self, message): self._gateway_online = payload == "1" if self._gateway_online != was_online: self.log(f"Info: GatewayMQTT: Gateway is {'online' if self._gateway_online else 'offline'}") - self.set_state_wrapper( + self.dashboard_item( f"binary_sensor.{self.prefix}_gateway_online", self._gateway_online, - attributes={"friendly_name": "Gateway Online"}, + attributes=GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), + app="gateway", ) except Exception as e: self._error_count += 1 @@ -401,20 +460,23 @@ def _inject_entities(self, status): device_id = status.device_id firmware = status.firmware - self.set_state_wrapper( + self.dashboard_item( f"binary_sensor.{self.prefix}_gateway_online", True, - attributes={"device_id": device_id, "firmware": firmware}, + attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware}, + app="gateway", ) # Inverter time from gateway timestamp — use first primary inverter's serial if status.timestamp > 0 and len(status.inverters) > 0: primary_inv = next((inv for inv in status.inverters if inv.primary), status.inverters[0]) ts_suffix = primary_inv.serial[-6:].lower() if len(primary_inv.serial) > 6 else primary_inv.serial.lower() - dt = datetime.datetime.fromtimestamp(status.timestamp) - self.set_state_wrapper( + dt = datetime.datetime.fromtimestamp(status.timestamp, tz=datetime.timezone.utc) + self.dashboard_item( f"sensor.{self.prefix}_gateway_{ts_suffix}_inverter_time", - dt.strftime("%Y-%m-%d %H:%M:%S"), + dt.strftime("%Y-%m-%dT%H:%M:%S%z"), + attributes=GATEWAY_ATTRIBUTE_TABLE.get("inverter_time", {}), + app="gateway", ) for inv in status.inverters: @@ -429,20 +491,20 @@ def _inject_entities(self, status): inv0 = status.inverters[0] if inv0.type == pb.INVERTER_TYPE_GIVENERGY_EMS and inv0.ems.num_inverters > 0: pfx = f"{self.prefix}_gateway" - self.set_state_wrapper(f"sensor.{pfx}_ems_total_soc", inv0.ems.total_soc) - self.set_state_wrapper(f"sensor.{pfx}_ems_total_charge", inv0.ems.total_charge_w) - self.set_state_wrapper(f"sensor.{pfx}_ems_total_discharge", inv0.ems.total_discharge_w) - self.set_state_wrapper(f"sensor.{pfx}_ems_total_grid", inv0.ems.total_grid_w) - self.set_state_wrapper(f"sensor.{pfx}_ems_total_pv", inv0.ems.total_pv_w) - self.set_state_wrapper(f"sensor.{pfx}_ems_total_load", inv0.ems.total_load_w) + self.dashboard_item(f"sensor.{pfx}_ems_total_soc", inv0.ems.total_soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ems_total_soc", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_ems_total_charge", inv0.ems.total_charge_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ems_total_charge", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_ems_total_discharge", inv0.ems.total_discharge_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ems_total_discharge", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_ems_total_grid", inv0.ems.total_grid_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ems_total_grid", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_ems_total_pv", inv0.ems.total_pv_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ems_total_pv", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_ems_total_load", inv0.ems.total_load_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ems_total_load", {}), app="gateway") for idx, sub in enumerate(inv0.ems.sub_inverters): sp = f"sensor.{pfx}_sub{idx}" - self.set_state_wrapper(f"{sp}_soc", sub.soc) - self.set_state_wrapper(f"{sp}_battery_power", sub.battery_w) - self.set_state_wrapper(f"{sp}_pv_power", sub.pv_w) - self.set_state_wrapper(f"{sp}_grid_power", sub.grid_w) - self.set_state_wrapper(f"{sp}_temp", sub.temp_c) + self.dashboard_item(f"{sp}_soc", sub.soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("soc", {}), app="gateway") + self.dashboard_item(f"{sp}_battery_power", sub.battery_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_power", {}), app="gateway") + self.dashboard_item(f"{sp}_pv_power", sub.pv_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("pv_power", {}), app="gateway") + self.dashboard_item(f"{sp}_grid_power", sub.grid_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("grid_power", {}), app="gateway") + self.dashboard_item(f"{sp}_temp", sub.temp_c, attributes=GATEWAY_ATTRIBUTE_TABLE.get("temp", {}), app="gateway") def _inject_inverter_entities(self, inv, suffix): """Inject entities for a single inverter using HA-style naming. @@ -452,41 +514,43 @@ def _inject_inverter_entities(self, inv, suffix): pfx = f"{self.prefix}_gateway_{suffix}" bat = inv.battery - self.set_state_wrapper(f"sensor.{pfx}_soc", bat.soc_percent) + self.dashboard_item(f"sensor.{pfx}_soc", bat.soc_percent, attributes=GATEWAY_ATTRIBUTE_TABLE.get("soc", {}), app="gateway") # Negate battery_power: firmware uses +ve=charging, PredBat uses +ve=discharging - self.set_state_wrapper(f"sensor.{pfx}_battery_power", -bat.power_w) - self.set_state_wrapper(f"sensor.{pfx}_battery_voltage", bat.voltage_v) - self.set_state_wrapper(f"sensor.{pfx}_battery_current", bat.current_a) - self.set_state_wrapper(f"sensor.{pfx}_battery_temperature", bat.temperature_c) + self.dashboard_item(f"sensor.{pfx}_battery_power", -bat.power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_power", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_battery_voltage", bat.voltage_v, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_voltage", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_battery_current", bat.current_a, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_current", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_battery_temperature", bat.temperature_c, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_temperature", {}), app="gateway") if bat.capacity_wh: - self.set_state_wrapper(f"sensor.{pfx}_battery_capacity", round(bat.capacity_wh / 1000.0, 2)) + self.dashboard_item(f"sensor.{pfx}_battery_capacity", round(bat.capacity_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_capacity", {}), app="gateway") if bat.soh_percent > 0: - self.set_state_wrapper(f"sensor.{pfx}_battery_soh", bat.soh_percent) + self.dashboard_item(f"sensor.{pfx}_battery_soh", bat.soh_percent, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_soh", {}), app="gateway") if bat.rate_max_w > 0: - self.set_state_wrapper(f"sensor.{pfx}_battery_rate_max", bat.rate_max_w) + self.dashboard_item(f"sensor.{pfx}_battery_rate_max", bat.rate_max_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_rate_max", {}), app="gateway") - self.set_state_wrapper(f"sensor.{pfx}_pv_power", inv.pv.power_w) + self.dashboard_item(f"sensor.{pfx}_pv_power", inv.pv.power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("pv_power", {}), app="gateway") grid = inv.grid - self.set_state_wrapper(f"sensor.{pfx}_grid_power", grid.power_w) + self.dashboard_item(f"sensor.{pfx}_grid_power", grid.power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("grid_power", {}), app="gateway") if grid.voltage_v: - self.set_state_wrapper(f"sensor.{pfx}_grid_voltage", grid.voltage_v) + self.dashboard_item(f"sensor.{pfx}_grid_voltage", grid.voltage_v, attributes=GATEWAY_ATTRIBUTE_TABLE.get("grid_voltage", {}), app="gateway") if grid.frequency_hz: - self.set_state_wrapper(f"sensor.{pfx}_grid_frequency", grid.frequency_hz) + self.dashboard_item(f"sensor.{pfx}_grid_frequency", grid.frequency_hz, attributes=GATEWAY_ATTRIBUTE_TABLE.get("grid_frequency", {}), app="gateway") - self.set_state_wrapper(f"sensor.{pfx}_load_power", inv.load.power_w) + self.dashboard_item(f"sensor.{pfx}_load_power", inv.load.power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("load_power", {}), app="gateway") - self.set_state_wrapper(f"sensor.{pfx}_inverter_power", inv.inverter.active_power_w) + self.dashboard_item(f"sensor.{pfx}_inverter_power", inv.inverter.active_power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("inverter_power", {}), app="gateway") if inv.inverter.temperature_c: - self.set_state_wrapper(f"sensor.{pfx}_inverter_temperature", inv.inverter.temperature_c) + self.dashboard_item(f"sensor.{pfx}_inverter_temperature", inv.inverter.temperature_c, attributes=GATEWAY_ATTRIBUTE_TABLE.get("inverter_temperature", {}), app="gateway") control = inv.control - self.set_state_wrapper(f"switch.{pfx}_charge_enabled", control.charge_enabled) - self.set_state_wrapper(f"switch.{pfx}_discharge_enabled", control.discharge_enabled) - self.set_state_wrapper(f"number.{pfx}_charge_rate", control.charge_rate_w) - self.set_state_wrapper(f"number.{pfx}_discharge_rate", control.discharge_rate_w) - self.set_state_wrapper(f"number.{pfx}_reserve_soc", control.reserve_soc) - self.set_state_wrapper(f"number.{pfx}_target_soc", control.target_soc) + self.dashboard_item(f"switch.{pfx}_charge_enabled", "on" if control.charge_enabled else "off", attributes=GATEWAY_ATTRIBUTE_TABLE.get("charge_enabled", {}), app="gateway") + self.dashboard_item(f"switch.{pfx}_discharge_enabled", "on" if control.discharge_enabled else "off", attributes=GATEWAY_ATTRIBUTE_TABLE.get("discharge_enabled", {}), app="gateway") + self.dashboard_item(f"number.{pfx}_charge_rate", control.charge_rate_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("charge_rate", {}), app="gateway") + self.dashboard_item(f"number.{pfx}_discharge_rate", control.discharge_rate_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("discharge_rate", {}), app="gateway") + self.dashboard_item(f"number.{pfx}_reserve_soc", control.reserve_soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("reserve_soc", {}), app="gateway") + self.dashboard_item(f"number.{pfx}_target_soc", control.target_soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("target_soc", {}), app="gateway") + mode_name = GATEWAY_OPERATING_MODE_NAMES.get(getattr(control, "mode", 0), "AUTO") + self.dashboard_item(f"select.{pfx}_mode_select", mode_name, attributes=GATEWAY_ATTRIBUTE_TABLE.get("mode_select", {}), app="gateway") # Schedule times (convert HHMM uint32 → HH:MM:SS string) # Always set with defaults so PredBat doesn't crash on missing charge_start_time @@ -503,12 +567,12 @@ def _inject_inverter_entities(self, inv, suffix): if hours >= 24: hours = 0 # firmware sends 2400 for midnight end-of-day time_str = f"{hours:02d}:{minutes:02d}:00" - self.set_state_wrapper(f"select.{pfx}_{name}", time_str) + self.dashboard_item(f"select.{pfx}_{name}", time_str, attributes=GATEWAY_ATTRIBUTE_TABLE.get(name, {}), app="gateway") # Inverter time (from GatewayStatus timestamp for clock drift detection) if self._last_status and self._last_status.timestamp: dt = datetime.datetime.fromtimestamp(self._last_status.timestamp, tz=datetime.timezone.utc) - self.set_state_wrapper(f"sensor.{pfx}_inverter_time", dt.strftime("%Y-%m-%d %H:%M:%S")) + self.dashboard_item(f"sensor.{pfx}_inverter_time", dt.strftime("%Y-%m-%dT%H:%M:%S%z"), attributes=GATEWAY_ATTRIBUTE_TABLE.get("inverter_time", {}), app="gateway") # Battery scaling (depth of discharge) — from firmware pct, apps.yaml override, or 0.95 default dod_pct = 0 @@ -516,18 +580,18 @@ def _inject_inverter_entities(self, inv, suffix): dod_pct = inv.battery.depth_of_discharge_pct if dod_pct <= 0: dod_pct = int(self.args.get("gateway_battery_dod_pct", 95)) if isinstance(self.args, dict) else 95 - self.set_state_wrapper(f"sensor.{pfx}_battery_dod", round(dod_pct / 100.0, 3)) + self.dashboard_item(f"sensor.{pfx}_battery_dod", round(dod_pct / 100.0, 3), attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_dod", {}), app="gateway") # Energy counters (Wh → kWh) # Always set with defaults so PredBat doesn't crash on missing load_today energy = inv.energy if inv.energy.ByteSize() > 0 else None - self.set_state_wrapper(f"sensor.{pfx}_pv_today", round(getattr(energy, "pv_today_wh", 0) / 1000.0, 2) if energy else 0) - self.set_state_wrapper(f"sensor.{pfx}_import_today", round(getattr(energy, "grid_import_today_wh", 0) / 1000.0, 2) if energy else 0) - self.set_state_wrapper(f"sensor.{pfx}_export_today", round(getattr(energy, "grid_export_today_wh", 0) / 1000.0, 2) if energy else 0) - self.set_state_wrapper(f"sensor.{pfx}_load_today", round(getattr(energy, "consumption_today_wh", 0) / 1000.0, 2) if energy else 0) + self.dashboard_item(f"sensor.{pfx}_pv_today", round(getattr(energy, "pv_today_wh", 0) / 1000.0, 2) if energy else 0, attributes=GATEWAY_ATTRIBUTE_TABLE.get("pv_today", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_import_today", round(getattr(energy, "grid_import_today_wh", 0) / 1000.0, 2) if energy else 0, attributes=GATEWAY_ATTRIBUTE_TABLE.get("import_today", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_export_today", round(getattr(energy, "grid_export_today_wh", 0) / 1000.0, 2) if energy else 0, attributes=GATEWAY_ATTRIBUTE_TABLE.get("export_today", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_load_today", round(getattr(energy, "consumption_today_wh", 0) / 1000.0, 2) if energy else 0, attributes=GATEWAY_ATTRIBUTE_TABLE.get("load_today", {}), app="gateway") if energy: - self.set_state_wrapper(f"sensor.{pfx}_battery_charge_today", round(energy.battery_charge_today_wh / 1000.0, 2)) - self.set_state_wrapper(f"sensor.{pfx}_battery_discharge_today", round(energy.battery_discharge_today_wh / 1000.0, 2)) + self.dashboard_item(f"sensor.{pfx}_battery_charge_today", round(energy.battery_charge_today_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_charge_today", {}), app="gateway") + self.dashboard_item(f"sensor.{pfx}_battery_discharge_today", round(energy.battery_discharge_today_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_discharge_today", {}), app="gateway") def automatic_config(self): """Register gateway entities with PredBat's inverter model. @@ -878,14 +942,15 @@ async def select_event(self, entity_id, value): Args: entity_id: The entity ID that changed. - value: The new selected value (HH:MM:SS for times). + value: The new selected value (HH:MM:SS for times, or mode name). """ - if "gateway_mode" in entity_id: - mode_map = {"auto": 0, "charge": 1, "discharge": 2, "idle": 3} - mode_val = mode_map.get(str(value).lower()) - if mode_val is not None: - await self.publish_command("set_mode", mode=mode_val) - self.log(f"Info: GatewayMQTT: Mode set to {value} ({mode_val})") + + self.log("Info: GatewayMQTT: select_event: entity_id={}, value={}".format(entity_id, value)) + # Operating mode selector + if "_mode_select" in entity_id: + mode_int = GATEWAY_OPERATING_MODE_VALUES.get(str(value).strip(), 0) + await self.publish_command("set_mode", mode=mode_int) + self.log(f"Info: GatewayMQTT: Operating mode set to {value} ({mode_int})") return # Schedule time changes — convert HH:MM:SS to HHMM and send slot command @@ -897,16 +962,16 @@ async def select_event(self, entity_id, value): except (ValueError, IndexError): return - if "charge_slot1_start" in entity_id or "charge_slot1_end" in entity_id: + if "_discharge_slot1_start" in entity_id or "_discharge_slot1_end" in entity_id: + await self._update_discharge_slot(entity_id, hhmm) + elif "_charge_slot1_start" in entity_id or "_charge_slot1_end" in entity_id: # Read current charge slot times to send both start and end await self._update_charge_slot(entity_id, hhmm) - elif "discharge_slot1_start" in entity_id or "discharge_slot1_end" in entity_id: - await self._update_discharge_slot(entity_id, hhmm) async def _update_charge_slot(self, entity_id, hhmm): """Send set_charge_slot command with updated start or end time.""" # Determine which field changed - if "start" in entity_id: + if "_start" in entity_id: schedule = {"start": hhmm} else: schedule = {"end": hhmm} @@ -915,7 +980,7 @@ async def _update_charge_slot(self, entity_id, hhmm): async def _update_discharge_slot(self, entity_id, hhmm): """Send set_discharge_slot command with updated start or end time.""" - if "start" in entity_id: + if "_start" in entity_id: schedule = {"start": hhmm} else: schedule = {"end": hhmm} @@ -929,60 +994,51 @@ async def number_event(self, entity_id, value): entity_id: The entity ID that changed. value: The new numeric value. """ + + self.log("Info: GatewayMQTT: number_event: entity_id={}, value={}".format(entity_id, value)) try: val = int(float(value)) except (ValueError, TypeError): self.log(f"Warn: GatewayMQTT: Invalid number value: {value}") return - if "charge_rate" in entity_id: - await self.publish_command("set_charge_rate", power_w=val) - elif "discharge_rate" in entity_id: + if "_discharge_rate" in entity_id: await self.publish_command("set_discharge_rate", power_w=val) - elif "reserve" in entity_id: + elif "_charge_rate" in entity_id: + await self.publish_command("set_charge_rate", power_w=val) + elif "_reserve" in entity_id: await self.publish_command("set_reserve", target_soc=val) - elif "target_soc" in entity_id: + elif "_target_soc" in entity_id: await self.publish_command("set_target_soc", target_soc=val) async def switch_event(self, entity_id, service): """Handle switch entity service calls (charge/discharge enable). - Maps enable/disable to set_mode commands: - - charge_enabled off → idle mode - - discharge_enabled off → charge mode (hold, no discharge) - - either on → auto mode (resume normal operation) - Args: entity_id: The entity ID being controlled. service: The service being called (turn_on/turn_off). """ - is_on = service == "turn_on" - if "charge_enabled" in entity_id: - if is_on: - await self.publish_command("set_mode", mode=0) # auto - self.log("Info: GatewayMQTT: Charge enabled → AUTO mode") - else: - await self.publish_command("set_mode", mode=3) # idle - self.log("Info: GatewayMQTT: Charge disabled → IDLE mode") - elif "discharge_enabled" in entity_id: - if is_on: - await self.publish_command("set_mode", mode=0) # auto - self.log("Info: GatewayMQTT: Discharge enabled → AUTO mode") - else: - await self.publish_command("set_mode", mode=1) # charge (prevents discharge) - self.log("Info: GatewayMQTT: Discharge disabled → CHARGE mode") + self.log("Info: GatewayMQTT: switch_event: entity_id={}, service={}".format(entity_id, service)) - async def final(self): - """Cleanup: send AUTO mode, cancel listener task, disconnect.""" - try: - # Send AUTO mode before disconnecting - if self._mqtt_connected: - await self.publish_command("set_mode", mode=0) - self.log("Info: GatewayMQTT: Sent AUTO mode on shutdown") - except Exception as e: - self.log(f"Warn: GatewayMQTT: Error sending final AUTO mode: {e}") + old_value = self.get_state_wrapper(entity_id) + old_value = True if old_value in [True, "on"] else False + if service == "turn_on": + is_on = True + elif service == "turn_off": + is_on = False + elif service == "toggle": + is_on = not old_value + + if "_charge_enabled" in entity_id: + await self.publish_command("set_charge_enable", enable=is_on) + self.log(f"Info: GatewayMQTT: Charge {'enabled' if is_on else 'disabled'}") + elif "_discharge_enabled" in entity_id: + await self.publish_command("set_discharge_enable", enable=is_on) + self.log(f"Info: GatewayMQTT: Discharge {'enabled' if is_on else 'disabled'}") + async def final(self): + """Cleanup: cancel listener task, disconnect.""" # Cancel the MQTT listener task if self._mqtt_task and not self._mqtt_task.done(): self._mqtt_task.cancel() @@ -1078,58 +1134,6 @@ async def _check_token_refresh(self): finally: self._refresh_in_progress = False - @staticmethod - def decode_telemetry(data): - """Decode protobuf GatewayStatus -> dict of entity_name: value. - - Args: - data: Raw protobuf bytes from /status topic. - - Returns: - Dict mapping entity names to values. Uses first inverter entry. - """ - status = pb.GatewayStatus() - status.ParseFromString(data) - - if len(status.inverters) == 0: - return {} - - inv = status.inverters[0] - entities = {} - - for field_path, entity_name in ENTITY_MAP.items(): - parts = field_path.split(".") - obj = inv - for part in parts: - obj = getattr(obj, part, None) - if obj is None: - break - if obj is not None: - # Convert Wh to kWh for capacity - if field_path == "battery.capacity_wh" and obj: - obj = round(obj / 1000.0, 2) - entities[entity_name] = obj - - # EMS aggregate entities (when type is GIVENERGY_EMS) - if inv.type == pb.INVERTER_TYPE_GIVENERGY_EMS and inv.ems.num_inverters > 0: - entities["predbat_gateway_ems_total_soc"] = inv.ems.total_soc - entities["predbat_gateway_ems_total_charge"] = inv.ems.total_charge_w - entities["predbat_gateway_ems_total_discharge"] = inv.ems.total_discharge_w - entities["predbat_gateway_ems_total_grid"] = inv.ems.total_grid_w - entities["predbat_gateway_ems_total_pv"] = inv.ems.total_pv_w - entities["predbat_gateway_ems_total_load"] = inv.ems.total_load_w - - # Per-sub-inverter entities - for idx, sub in enumerate(inv.ems.sub_inverters): - prefix = f"predbat_gateway_sub{idx}" - entities[f"{prefix}_soc"] = sub.soc - entities[f"{prefix}_battery_power"] = sub.battery_w - entities[f"{prefix}_pv_power"] = sub.pv_w - entities[f"{prefix}_grid_power"] = sub.grid_w - entities[f"{prefix}_temp"] = sub.temp_c - - return entities - @staticmethod def build_execution_plan(entries, plan_version, timezone): """Build protobuf ExecutionPlan from a list of plan entry dicts. @@ -1202,7 +1206,7 @@ def build_command(command, **kwargs): """Build JSON command string for ad-hoc control. Args: - command: Command name (set_mode, set_charge_rate, etc.) + command: Command name (set_charge_enable, set_charge_rate, etc.) **kwargs: Command-specific fields (mode, power_w, target_soc). Returns: @@ -1221,9 +1225,7 @@ def build_command(command, **kwargs): cmd["target_soc"] = kwargs["target_soc"] if "schedule_json" in kwargs: cmd["schedule_json"] = kwargs["schedule_json"] - - # Mode commands need expires_at (5-minute deadman) - if command == "set_mode": - cmd["expires_at"] = int(time.time()) + 300 + if "enable" in kwargs: + cmd["enable"] = bool(kwargs["enable"]) return json.dumps(cmd) diff --git a/apps/predbat/gateway_status.proto b/apps/predbat/gateway_status.proto index ebd1574ae..29ebbea36 100644 --- a/apps/predbat/gateway_status.proto +++ b/apps/predbat/gateway_status.proto @@ -57,7 +57,7 @@ message InverterData { } message ControlStatus { - uint32 mode = 1; // OperatingMode enum (0=auto,1=charge,2=discharge,3=idle) + uint32 mode = 1; // Plan Mode enum (0=auto,1=charge,2=discharge) bool charge_enabled = 2; bool discharge_enabled = 3; uint32 charge_rate_w = 4; diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index e657b6d9d..a649b54fb 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -849,6 +849,8 @@ def update_pred(self, scheduled=True): export_limits=self.export_limits_best, charge_rate_w=int(self.battery_rate_max_charge * MINUTE_WATT), discharge_rate_w=int(self.battery_rate_max_discharge * MINUTE_WATT), + soc_max=self.soc_max, + reserve=self.reserve, timezone=str(self.local_tz), ) diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 6c5ab0490..dc9c3c32c 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -1,8 +1,4 @@ """Tests for GatewayMQTT component.""" -try: - import pytest -except ImportError: - pytest = None import sys import os import math @@ -87,41 +83,6 @@ def test_serialize_deserialize_roundtrip(self): assert decoded.inverters[0].control.charge_enabled is True assert decoded.inverters[0].battery.soh_percent == 98 - def test_entity_mapping(self): - from gateway import GatewayMQTT - - status = self._make_status() - data = status.SerializeToString() - - entities = GatewayMQTT.decode_telemetry(data) - - assert entities["predbat_gateway_soc"] == 50 - assert entities["predbat_gateway_battery_power"] == 1000 - assert entities["predbat_gateway_pv_power"] == 2000 - assert entities["predbat_gateway_grid_power"] == -500 - assert entities["predbat_gateway_load_power"] == 1500 - assert approx_equal(entities["predbat_gateway_battery_voltage"], 51.2, abs_tol=0.1) - assert approx_equal(entities["predbat_gateway_battery_current"], 19.5, abs_tol=0.1) - assert approx_equal(entities["predbat_gateway_battery_temp"], 22.5, abs_tol=0.1) - assert entities["predbat_gateway_battery_soh"] == 98 - assert entities["predbat_gateway_battery_cycles"] == 150 - assert entities["predbat_gateway_battery_capacity"] == 9.5 - assert approx_equal(entities["predbat_gateway_grid_voltage"], 242.5, abs_tol=0.1) - assert approx_equal(entities["predbat_gateway_grid_frequency"], 50.01, abs_tol=0.01) - assert entities["predbat_gateway_inverter_power"] == 1800 - assert approx_equal(entities["predbat_gateway_inverter_temp"], 35.0, abs_tol=0.1) - assert entities["predbat_gateway_mode"] == 0 - assert entities["predbat_gateway_charge_enabled"] is True - assert entities["predbat_gateway_discharge_enabled"] is True - assert entities["predbat_gateway_charge_rate"] == 3000 - assert entities["predbat_gateway_discharge_rate"] == 3000 - assert entities["predbat_gateway_reserve"] == 4 - assert entities["predbat_gateway_target_soc"] == 100 - assert entities["predbat_gateway_charge_start"] == 130 - assert entities["predbat_gateway_charge_end"] == 430 - assert entities["predbat_gateway_discharge_start"] == 1600 - assert entities["predbat_gateway_discharge_end"] == 1900 - class TestPlanSerialization: def test_plan_roundtrip(self): @@ -183,19 +144,13 @@ class TestCommandFormat: def test_set_mode_command(self): from gateway import GatewayMQTT - cmd = GatewayMQTT.build_command("set_mode", mode=1, power_w=3000, target_soc=100) + cmd = GatewayMQTT.build_command("set_mode", mode=1) import json parsed = json.loads(cmd) assert parsed["command"] == "set_mode" assert parsed["mode"] == 1 - assert parsed["power_w"] == 3000 - assert parsed["target_soc"] == 100 assert "command_id" in parsed - assert "expires_at" in parsed - import time - - assert abs(parsed["expires_at"] - int(time.time())) < 310 def test_set_charge_rate_command(self): from gateway import GatewayMQTT @@ -240,49 +195,272 @@ def test_set_discharge_slot_command(self): assert parsed["schedule_json"] == '{"start": 1600}' -class TestEMSEntities: - def test_ems_aggregate_entities(self): - """EMS type produces aggregate entities.""" +class TestInjectEntities: + """Tests for GatewayMQTT._inject_entities() and GATEWAY_ATTRIBUTE_TABLE lookups.""" + + def _make_gateway(self): + from gateway import GatewayMQTT + from unittest.mock import MagicMock + + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.base = MagicMock() + gw.log = MagicMock() + gw.prefix = "predbat" + gw._last_status = None + gw.args = {} + gw._dashboard_calls = {} # entity_id → (state, attributes) + + def capture_dashboard(entity_id, state=None, attributes=None, app=None): + gw._dashboard_calls[entity_id] = (state, attributes) + + gw.dashboard_item = capture_dashboard + return gw + + def _make_status(self, soc=50, battery_power=1000, pv_power=2000, grid_power=-500, load_power=1500, primary=True): status = pb.GatewayStatus() - status.device_id = "pbgw_ems_test" + status.device_id = "pbgw_abc123" + status.firmware = "1.2.3" status.timestamp = 1741789200 status.schema_version = 1 - status.dongle_count = 1 inv = status.inverters.add() - inv.type = pb.INVERTER_TYPE_GIVENERGY_EMS - inv.serial = "EM1234" + inv.type = pb.INVERTER_TYPE_GIVENERGY + inv.serial = "CE123456789" + inv.primary = primary inv.connected = True inv.active = True + inv.battery.soc_percent = soc + inv.battery.power_w = battery_power + inv.battery.voltage_v = 51.2 + inv.battery.current_a = 19.5 + inv.battery.temperature_c = 22.5 + inv.battery.soh_percent = 98 + inv.battery.capacity_wh = 9500 + inv.battery.rate_max_w = 5000 + inv.battery.depth_of_discharge_pct = 95 + + inv.pv.power_w = pv_power + inv.grid.power_w = grid_power + inv.grid.voltage_v = 242.5 + inv.grid.frequency_hz = 50.01 + inv.load.power_w = load_power + + inv.inverter.active_power_w = 1800 + inv.inverter.temperature_c = 35.0 + + inv.control.charge_enabled = True + inv.control.discharge_enabled = True + inv.control.charge_rate_w = 3000 + inv.control.discharge_rate_w = 3000 + inv.control.reserve_soc = 4 + inv.control.target_soc = 100 + + inv.schedule.charge_start = 130 + inv.schedule.charge_end = 430 + inv.schedule.discharge_start = 1600 + inv.schedule.discharge_end = 1900 + + inv.energy.pv_today_wh = 5000 + inv.energy.grid_import_today_wh = 1000 + inv.energy.grid_export_today_wh = 2000 + inv.energy.consumption_today_wh = 8000 + inv.energy.battery_charge_today_wh = 3000 + inv.energy.battery_discharge_today_wh = 2500 + + return status + + def test_gateway_online_entity(self): + """binary_sensor.predbat_gateway_online is published True with device_id, firmware, and table attributes merged.""" + from gateway import GATEWAY_ATTRIBUTE_TABLE + + gw = self._make_gateway() + status = self._make_status() + gw._inject_entities(status) + + entity = "binary_sensor.predbat_gateway_online" + assert entity in gw._dashboard_calls + state, attrs = gw._dashboard_calls[entity] + assert state is True + assert attrs["device_id"] == "pbgw_abc123" + assert attrs["firmware"] == "1.2.3" + # Table attributes should also be merged in + for k, v in GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}).items(): + assert attrs[k] == v + + def test_inverter_time_sensor(self): + """Inverter time sensor is published using the primary inverter serial suffix.""" + gw = self._make_gateway() + status = self._make_status() + gw._inject_entities(status) + + # Serial "CE123456789" (len > 6) → last 6 chars lowercase = "456789" + entity = "sensor.predbat_gateway_456789_inverter_time" + assert entity in gw._dashboard_calls + state, attrs = gw._dashboard_calls[entity] + assert state # non-empty datetime string e.g. "2025-03-12 09:00:00" + + def test_non_primary_inverter_skipped(self): + """Inverters with primary=False are not injected via _inject_inverter_entities.""" + gw = self._make_gateway() + # First inverter is non-primary + status = self._make_status(primary=False) + # Second inverter is primary — should be the only one injected + inv2 = status.inverters.add() + inv2.type = pb.INVERTER_TYPE_GIVENERGY + inv2.serial = "CE000000001" + inv2.primary = True + inv2.battery.soc_percent = 75 + inv2.battery.capacity_wh = 9500 + inv2.battery.depth_of_discharge_pct = 95 + + gw._inject_entities(status) + + # Non-primary suffix "456789" should NOT appear as a sensor entity + assert "sensor.predbat_gateway_456789_soc" not in gw._dashboard_calls + # Primary suffix "000001" (last 6 of "CE000000001") SHOULD appear + assert "sensor.predbat_gateway_000001_soc" in gw._dashboard_calls + + def test_battery_power_negated(self): + """Battery power sign is inverted: firmware +ve=charging → PredBat +ve=discharging.""" + gw = self._make_gateway() + gw._inject_entities(self._make_status(battery_power=1000)) + + state, _ = gw._dashboard_calls["sensor.predbat_gateway_456789_battery_power"] + assert state == -1000 + + def test_sensor_attributes_from_table(self): + """Sensor entities carry attributes looked up from GATEWAY_ATTRIBUTE_TABLE.""" + from gateway import GATEWAY_ATTRIBUTE_TABLE + + gw = self._make_gateway() + gw._inject_entities(self._make_status()) + + suffix = "456789" + _, attrs = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_soc"] + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("soc", {}) + + _, attrs = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_pv_power"] + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("pv_power", {}) + + _, attrs = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_grid_power"] + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("grid_power", {}) + + _, attrs = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_battery_temperature"] + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("battery_temperature", {}) + + def test_schedule_select_entities(self): + """Schedule select entities are published with correct HH:MM:SS state and table attributes.""" + from gateway import GATEWAY_ATTRIBUTE_TABLE + + gw = self._make_gateway() + gw._inject_entities(self._make_status()) + + suffix = "456789" + # charge_start = 130 → 01:30:00 + state, attrs = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_charge_slot1_start"] + assert state == "01:30:00" + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("charge_slot1_start", {}) + + # charge_end = 430 → 04:30:00 + state, _ = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_charge_slot1_end"] + assert state == "04:30:00" + + # discharge_start = 1600 → 16:00:00 + state, _ = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_discharge_slot1_start"] + assert state == "16:00:00" + + # discharge_end = 1900 → 19:00:00 + state, _ = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_discharge_slot1_end"] + assert state == "19:00:00" + + def test_energy_counters_wh_to_kwh(self): + """Energy counters are converted from Wh to kWh correctly.""" + gw = self._make_gateway() + gw._inject_entities(self._make_status()) + + suffix = "456789" + state, _ = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_pv_today"] + assert approx_equal(state, 5.0) + + state, _ = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_import_today"] + assert approx_equal(state, 1.0) + + state, _ = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_export_today"] + assert approx_equal(state, 2.0) + + state, _ = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_load_today"] + assert approx_equal(state, 8.0) + + state, _ = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_battery_charge_today"] + assert approx_equal(state, 3.0) + + state, _ = gw._dashboard_calls[f"sensor.predbat_gateway_{suffix}_battery_discharge_today"] + assert approx_equal(state, 2.5) + + def test_battery_dod_entity(self): + """Battery DoD is published as a fraction (firmware pct / 100).""" + gw = self._make_gateway() + gw._inject_entities(self._make_status()) + + state, _ = gw._dashboard_calls["sensor.predbat_gateway_456789_battery_dod"] + assert approx_equal(state, 0.95) + + def test_ems_aggregate_entities(self): + """EMS aggregate and sub-inverter entities are published with table attributes.""" + from gateway import GATEWAY_ATTRIBUTE_TABLE + + status = pb.GatewayStatus() + status.device_id = "pbgw_ems" + status.firmware = "1.0.0" + status.timestamp = 1741789200 + status.schema_version = 1 + + inv = status.inverters.add() + inv.type = pb.INVERTER_TYPE_GIVENERGY_EMS + inv.serial = "EM123456" + inv.primary = True inv.ems.num_inverters = 2 - inv.ems.total_soc = 60 - inv.ems.total_charge_w = 3000 - inv.ems.total_pv_w = 5000 - inv.ems.total_grid_w = -1000 - inv.ems.total_load_w = 4000 + inv.ems.total_soc = 70 + inv.ems.total_charge_w = 4000 + inv.ems.total_discharge_w = 0 + inv.ems.total_grid_w = -2000 + inv.ems.total_pv_w = 6000 + inv.ems.total_load_w = 5000 sub0 = inv.ems.sub_inverters.add() - sub0.soc = 55 - sub0.battery_w = 1500 - sub0.pv_w = 2500 + sub0.soc = 65 + sub0.battery_w = 2000 + sub0.pv_w = 3000 + sub0.grid_w = -1000 + sub0.temp_c = 28.0 + sub1 = inv.ems.sub_inverters.add() - sub1.soc = 65 - sub1.battery_w = 1500 - sub1.pv_w = 2500 + sub1.soc = 75 + sub1.battery_w = 2000 - from gateway import GatewayMQTT + gw = self._make_gateway() + gw._inject_entities(status) - entities = GatewayMQTT.decode_telemetry(status.SerializeToString()) + pfx = "predbat_gateway" + # Aggregate entities + assert gw._dashboard_calls[f"sensor.{pfx}_ems_total_soc"][0] == 70 + assert gw._dashboard_calls[f"sensor.{pfx}_ems_total_charge"][0] == 4000 + assert gw._dashboard_calls[f"sensor.{pfx}_ems_total_pv"][0] == 6000 + assert gw._dashboard_calls[f"sensor.{pfx}_ems_total_load"][0] == 5000 + assert gw._dashboard_calls[f"sensor.{pfx}_ems_total_grid"][0] == -2000 - # EMS aggregate entities - assert entities.get("predbat_gateway_ems_total_soc") == 60 - assert entities.get("predbat_gateway_ems_total_pv") == 5000 - assert entities.get("predbat_gateway_ems_total_load") == 4000 - # Per-sub-inverter - assert entities.get("predbat_gateway_sub0_soc") == 55 - assert entities.get("predbat_gateway_sub1_soc") == 65 - assert entities.get("predbat_gateway_sub0_battery_power") == 1500 + # Sub-inverter entities + assert gw._dashboard_calls[f"sensor.{pfx}_sub0_soc"][0] == 65 + assert gw._dashboard_calls[f"sensor.{pfx}_sub0_battery_power"][0] == 2000 + assert gw._dashboard_calls[f"sensor.{pfx}_sub0_pv_power"][0] == 3000 + assert gw._dashboard_calls[f"sensor.{pfx}_sub1_soc"][0] == 75 + + # Attributes from table + _, attrs = gw._dashboard_calls[f"sensor.{pfx}_ems_total_soc"] + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("ems_total_soc", {}) + _, attrs = gw._dashboard_calls[f"sensor.{pfx}_sub0_temp"] + assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("temp", {}) class TestTokenRefresh: @@ -512,6 +690,543 @@ def test_plan_publish_format(self): assert plan2.plan_version > plan.plan_version +class TestAutomaticConfig: + """Tests for GatewayMQTT.automatic_config() entity-to-arg mapping.""" + + def _make_gateway(self): + from gateway import GatewayMQTT + from unittest.mock import MagicMock + + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.base = MagicMock() + gw.log = MagicMock() + gw.prefix = "predbat" + gw._last_status = None + gw._auto_configured = False + gw.args = {} + gw._args = {} + + def capture_set_arg(key, value): + gw._args[key] = value + + gw.set_arg = capture_set_arg + gw.dashboard_item = MagicMock() + return gw + + def _make_inverter(self, status, serial="CE123456789", primary=True, capacity_wh=9500, rate_max_w=5000, inv_type=None): + """Add an inverter to *status* and return it.""" + import gateway_status_pb2 as _pb + + inv = status.inverters.add() + inv.type = inv_type if inv_type is not None else _pb.INVERTER_TYPE_GIVENERGY + inv.serial = serial + inv.primary = primary + inv.connected = True + inv.active = True + inv.battery.soc_percent = 50 + inv.battery.capacity_wh = capacity_wh + inv.battery.rate_max_w = rate_max_w + return inv + + def _basic_status(self, serial="CE123456789", primary=True, capacity_wh=9500, rate_max_w=5000): + status = pb.GatewayStatus() + status.device_id = "pbgw_test" + status.firmware = "1.0.0" + status.timestamp = 1741789200 + status.schema_version = 1 + self._make_inverter(status, serial=serial, primary=primary, capacity_wh=capacity_wh, rate_max_w=rate_max_w) + return status + + # ------------------------------------------------------------------ + # Guard-clause tests + # ------------------------------------------------------------------ + + def test_no_status_does_nothing(self): + """Returns early without setting _auto_configured when _last_status is None.""" + gw = self._make_gateway() + gw.automatic_config() + assert not gw._auto_configured + assert gw._args == {} + + def test_no_inverters_does_nothing(self): + """Returns early without setting _auto_configured when inverter list is empty.""" + gw = self._make_gateway() + status = pb.GatewayStatus() + status.device_id = "pbgw_empty" + gw._last_status = status + gw.automatic_config() + assert not gw._auto_configured + assert gw._args == {} + + # ------------------------------------------------------------------ + # Single-inverter (old firmware — no primary flag) + # ------------------------------------------------------------------ + + def test_single_inverter_entity_mapping(self): + """All expected per-inverter entity IDs are registered as PredBat args.""" + gw = self._make_gateway() + gw._last_status = self._basic_status(serial="CE123456789", primary=False) + gw.automatic_config() + + assert gw._auto_configured + suffix = "456789" # last 6 chars of serial, lower-case + base = f"predbat_gateway_{suffix}" + + assert gw._args["soc_percent"] == [f"sensor.{base}_soc"] + assert gw._args["battery_power"] == [f"sensor.{base}_battery_power"] + assert gw._args["pv_power"] == [f"sensor.{base}_pv_power"] + assert gw._args["grid_power"] == [f"sensor.{base}_grid_power"] + assert gw._args["load_power"] == [f"sensor.{base}_load_power"] + assert gw._args["charge_rate"] == [f"number.{base}_charge_rate"] + assert gw._args["discharge_rate"] == [f"number.{base}_discharge_rate"] + assert gw._args["reserve"] == [f"number.{base}_reserve_soc"] + assert gw._args["charge_limit"] == [f"number.{base}_target_soc"] + assert gw._args["battery_temperature"] == [f"sensor.{base}_battery_temperature"] + assert gw._args["charge_start_time"] == [f"select.{base}_charge_slot1_start"] + assert gw._args["charge_end_time"] == [f"select.{base}_charge_slot1_end"] + assert gw._args["discharge_start_time"] == [f"select.{base}_discharge_slot1_start"] + assert gw._args["discharge_end_time"] == [f"select.{base}_discharge_slot1_end"] + assert gw._args["scheduled_charge_enable"] == [f"switch.{base}_charge_enabled"] + assert gw._args["scheduled_discharge_enable"] == [f"switch.{base}_discharge_enabled"] + assert gw._args["soc_max"] == [f"sensor.{base}_battery_capacity"] + assert gw._args["num_inverters"] == 1 + assert gw._args["inverter_type"] == ["GWMQTT"] + + def test_single_inverter_energy_and_health_args(self): + """Energy counter, battery health, and inverter_time args use first inverter's suffix.""" + gw = self._make_gateway() + gw._last_status = self._basic_status(serial="CE123456789", primary=False) + gw.automatic_config() + + suffix = "456789" + base = f"predbat_gateway_{suffix}" + assert gw._args["pv_today"] == [f"sensor.{base}_pv_today"] + assert gw._args["import_today"] == [f"sensor.{base}_import_today"] + assert gw._args["export_today"] == [f"sensor.{base}_export_today"] + assert gw._args["load_today"] == [f"sensor.{base}_load_today"] + assert gw._args["battery_temperature_history"] == f"sensor.{base}_battery_temperature" + assert gw._args["battery_scaling"] == [f"sensor.{base}_battery_dod"] + assert gw._args["battery_rate_max"] == [f"sensor.{base}_battery_rate_max"] + assert gw._args["inverter_time"] == [f"sensor.{base}_inverter_time"] + + def test_no_rate_max_falls_back_to_6000(self): + """When firmware reports no battery_rate_max, a 6000 W default is used.""" + gw = self._make_gateway() + gw._last_status = self._basic_status(serial="CE123456789", primary=False, rate_max_w=0) + gw.automatic_config() + + assert gw._args["battery_rate_max"] == [6000] + + # ------------------------------------------------------------------ + # Primary-flag filtering + # ------------------------------------------------------------------ + + def test_primary_flag_filters_non_primary(self): + """When any inverter has primary=True, non-primary inverters are excluded.""" + gw = self._make_gateway() + status = pb.GatewayStatus() + status.device_id = "pbgw_multi" + status.firmware = "1.0.0" + status.schema_version = 1 + # Primary inverter with battery + self._make_inverter(status, serial="SERIAL000001", primary=True) + # Non-primary inverter — should be excluded + self._make_inverter(status, serial="SERIAL000002", primary=False) + gw._last_status = status + gw.automatic_config() + + assert gw._args["num_inverters"] == 1 + # Only primary suffix "000001" should appear + assert any("000001" in e for e in gw._args["soc_percent"]) + assert not any("000002" in e for e in gw._args["soc_percent"]) + + def test_multi_inverter_produces_multiple_entity_lists(self): + """Two primary inverters produce entity list args with two entries each.""" + gw = self._make_gateway() + status = pb.GatewayStatus() + status.device_id = "pbgw_multi" + status.firmware = "1.0.0" + status.schema_version = 1 + self._make_inverter(status, serial="CE000000AA1", primary=True) + self._make_inverter(status, serial="CE000000BB2", primary=True) + gw._last_status = status + gw.automatic_config() + + assert gw._args["num_inverters"] == 2 + assert len(gw._args["soc_percent"]) == 2 + assert len(gw._args["battery_power"]) == 2 + assert "000aa1" in gw._args["soc_percent"][0] + assert "000bb2" in gw._args["soc_percent"][1] + + # ------------------------------------------------------------------ + # Secondary (cloud) and unsupported feature args + # ------------------------------------------------------------------ + + def test_disabled_cloud_and_unsupported_args(self): + """ge_cloud_data, ge_cloud_direct are False; unsupported inverter features are None.""" + gw = self._make_gateway() + gw._last_status = self._basic_status() + gw.automatic_config() + + assert gw._args["ge_cloud_data"] is False + assert gw._args["ge_cloud_direct"] is False + assert gw._args["givtcp_rest"] is None + assert gw._args["pause_mode"] is None + assert gw._args["charge_rate_percent"] is None + assert gw._args["discharge_rate_percent"] is None + + # ------------------------------------------------------------------ + # EMS mode + # ------------------------------------------------------------------ + + def test_ems_mode_sets_aggregate_args(self): + """GivEnergy EMS inverters register ems_total_* and idle_*_time args.""" + gw = self._make_gateway() + status = pb.GatewayStatus() + status.device_id = "pbgw_ems" + status.firmware = "1.0.0" + status.schema_version = 1 + + inv = self._make_inverter(status, serial="EM123456", primary=True, inv_type=pb.INVERTER_TYPE_GIVENERGY_EMS) + inv.ems.num_inverters = 2 + + gw._last_status = status + gw.automatic_config() + + pfx = "predbat_gateway" + assert gw._args["ems_total_soc"] == f"sensor.{pfx}_ems_total_soc" + assert gw._args["ems_total_charge"] == f"sensor.{pfx}_ems_total_charge" + assert gw._args["ems_total_discharge"] == f"sensor.{pfx}_ems_total_discharge" + assert gw._args["ems_total_grid"] == f"sensor.{pfx}_ems_total_grid" + assert gw._args["ems_total_pv"] == f"sensor.{pfx}_ems_total_pv" + assert gw._args["ems_total_load"] == f"sensor.{pfx}_ems_total_load" + + # idle_*_time should have one entry per inverter (1 in this case) + assert len(gw._args["idle_start_time"]) == 1 + assert len(gw._args["idle_end_time"]) == 1 + assert "discharge_slot1_start" in gw._args["idle_start_time"][0] + assert "discharge_slot1_end" in gw._args["idle_end_time"][0] + + def test_non_ems_does_not_set_aggregate_args(self): + """Standard GivEnergy inverter does NOT register ems_* args.""" + gw = self._make_gateway() + gw._last_status = self._basic_status() + gw.automatic_config() + + assert "ems_total_soc" not in gw._args + assert "idle_start_time" not in gw._args + + +class TestSelectEvent: + """Tests for GatewayMQTT.select_event() — mode and schedule-time routing.""" + + def _make_gateway(self): + from gateway import GatewayMQTT + from unittest.mock import MagicMock + + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.log = MagicMock() + gw.prefix = "predbat" + gw._mqtt_connected = True + gw._mqtt_client = MagicMock() + gw.topic_command = "predbat/devices/pbgw_test/command" + gw._published = [] # capture (command, kwargs) tuples + + async def fake_publish_command(command, **kwargs): + gw._published.append((command, kwargs)) + + gw.publish_command = fake_publish_command + return gw + + def _run(self, coro): + """Run a coroutine synchronously.""" + import asyncio + + return asyncio.run(coro) + + # ------------------------------------------------------------------ + # Charge slot time routing + # ------------------------------------------------------------------ + + def test_charge_slot_start(self): + """charge_slot1_start publishes set_charge_slot with start HHMM.""" + import json + + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_gateway_456789_charge_slot1_start", "01:30:00")) + assert len(gw._published) == 1 + cmd, kwargs = gw._published[0] + assert cmd == "set_charge_slot" + parsed = json.loads(kwargs["schedule_json"]) + assert parsed == {"start": 130} + + def test_charge_slot_end(self): + """charge_slot1_end publishes set_charge_slot with end HHMM.""" + import json + + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_gateway_456789_charge_slot1_end", "04:30:00")) + cmd, kwargs = gw._published[0] + assert cmd == "set_charge_slot" + assert json.loads(kwargs["schedule_json"]) == {"end": 430} + + # ------------------------------------------------------------------ + # Discharge slot time routing + # ------------------------------------------------------------------ + + def test_discharge_slot_start(self): + """discharge_slot1_start publishes set_discharge_slot with start HHMM.""" + import json + + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_gateway_456789_discharge_slot1_start", "16:00:00")) + cmd, kwargs = gw._published[0] + assert cmd == "set_discharge_slot" + assert json.loads(kwargs["schedule_json"]) == {"start": 1600} + + def test_discharge_slot_end(self): + """discharge_slot1_end publishes set_discharge_slot with end HHMM.""" + import json + + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_gateway_456789_discharge_slot1_end", "19:00:00")) + cmd, kwargs = gw._published[0] + assert cmd == "set_discharge_slot" + assert json.loads(kwargs["schedule_json"]) == {"end": 1900} + + # ------------------------------------------------------------------ + # Edge cases + # ------------------------------------------------------------------ + + def test_midnight_time_converts_correctly(self): + """00:00:00 → HHMM 0 (midnight).""" + import json + + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_gateway_456789_charge_slot1_start", "00:00:00")) + parsed = json.loads(gw._published[0][1]["schedule_json"]) + assert parsed == {"start": 0} + + def test_invalid_time_string_no_command(self): + """Malformed time values (non-numeric) do not publish any command.""" + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_gateway_456789_charge_slot1_start", "bad_value")) + assert gw._published == [] + + def test_unrecognised_entity_no_command(self): + """Entities that don't match any known pattern are silently ignored.""" + gw = self._make_gateway() + self._run(gw.select_event("select.predbat_some_other_select", "01:00:00")) + assert gw._published == [] + + +class TestNumberEvent: + """Tests for GatewayMQTT.number_event() — numeric entity → command routing.""" + + def _make_gateway(self): + from gateway import GatewayMQTT + from unittest.mock import MagicMock + + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.log = MagicMock() + gw.prefix = "predbat" + gw._mqtt_connected = True + gw._mqtt_client = MagicMock() + gw.topic_command = "predbat/devices/pbgw_test/command" + gw._published = [] + + async def fake_publish_command(command, **kwargs): + gw._published.append((command, kwargs)) + + gw.publish_command = fake_publish_command + return gw + + def _run(self, coro): + import asyncio + + return asyncio.run(coro) + + # ------------------------------------------------------------------ + # Routing to correct commands + # ------------------------------------------------------------------ + + def test_charge_rate_routes_correctly(self): + """charge_rate entity → set_charge_rate with power_w.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", "3000")) + assert gw._published == [("set_charge_rate", {"power_w": 3000})] + + def test_discharge_rate_routes_correctly(self): + """discharge_rate entity → set_discharge_rate with power_w (not charge_rate).""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_discharge_rate", "2500")) + assert gw._published == [("set_discharge_rate", {"power_w": 2500})] + + def test_reserve_soc_routes_correctly(self): + """reserve_soc entity → set_reserve with target_soc.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_reserve_soc", "10")) + assert gw._published == [("set_reserve", {"target_soc": 10})] + + def test_target_soc_routes_correctly(self): + """target_soc entity → set_target_soc with target_soc.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_target_soc", "100")) + assert gw._published == [("set_target_soc", {"target_soc": 100})] + + # ------------------------------------------------------------------ + # Value coercion + # ------------------------------------------------------------------ + + def test_float_string_truncated_to_int(self): + """Float string values are truncated to int before publishing.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", "3000.9")) + assert gw._published == [("set_charge_rate", {"power_w": 3000})] + + def test_integer_value_accepted(self): + """Plain integer values are accepted directly.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_reserve_soc", 4)) + assert gw._published == [("set_reserve", {"target_soc": 4})] + + def test_zero_value_sent(self): + """Zero is a valid value and is sent as-is.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", 0)) + assert gw._published == [("set_charge_rate", {"power_w": 0})] + + # ------------------------------------------------------------------ + # Invalid input + # ------------------------------------------------------------------ + + def test_non_numeric_string_no_command(self): + """Non-numeric value logs a warning and sends no command.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", "bad_value")) + assert gw._published == [] + gw.log.assert_called() + + def test_none_value_no_command(self): + """None value logs a warning and sends no command.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", None)) + assert gw._published == [] + gw.log.assert_called() + + def test_unrecognised_entity_no_command(self): + """Unrecognised entity ID sends no command.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_some_other_number", "50")) + assert gw._published == [] + + +class TestSwitchEvent: + """Tests for GatewayMQTT.switch_event() — charge/discharge enable → mode commands.""" + + def _make_gateway(self): + from gateway import GatewayMQTT + from unittest.mock import MagicMock + + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.log = MagicMock() + gw.prefix = "predbat" + gw._mqtt_connected = True + gw._mqtt_client = MagicMock() + gw.topic_command = "predbat/devices/pbgw_test/command" + gw._published = [] + gw._state = {} + + def fake_get_state_wrapper(entity_id, **kwargs): + return gw._state.get(entity_id, False) + + gw.get_state_wrapper = fake_get_state_wrapper + + async def fake_publish_command(command, **kwargs): + gw._published.append((command, kwargs)) + + gw.publish_command = fake_publish_command + return gw + + def _run(self, coro): + import asyncio + + return asyncio.run(coro) + + # ------------------------------------------------------------------ + # charge_enabled switch + # ------------------------------------------------------------------ + + def test_charge_enabled_turn_on(self): + """Turning charge_enabled on sends set_charge_enable enable=True.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "turn_on")) + assert gw._published == [("set_charge_enable", {"enable": True})] + + def test_charge_enabled_turn_off(self): + """Turning charge_enabled off sends set_charge_enable enable=False.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "turn_off")) + assert gw._published == [("set_charge_enable", {"enable": False})] + + def test_charge_enabled_toggle(self): + """Toggling charge_enabled flips based on current state from get_state_wrapper.""" + gw = self._make_gateway() + # currently on → toggle → off + gw._state["switch.predbat_gateway_456789_charge_enabled"] = True + self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "toggle")) + assert gw._published == [("set_charge_enable", {"enable": False})] + + # ------------------------------------------------------------------ + # discharge_enabled switch + # ------------------------------------------------------------------ + + def test_discharge_enabled_turn_on(self): + """Turning discharge_enabled on sends set_discharge_enable enable=True.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_discharge_enabled", "turn_on")) + assert gw._published == [("set_discharge_enable", {"enable": True})] + + def test_discharge_enabled_turn_off(self): + """Turning discharge_enabled off sends set_discharge_enable enable=False.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_discharge_enabled", "turn_off")) + assert gw._published == [("set_discharge_enable", {"enable": False})] + + def test_discharge_enabled_toggle(self): + """Toggling discharge_enabled flips based on current state from get_state_wrapper.""" + gw = self._make_gateway() + # currently off → toggle → on (get_state_wrapper returns False by default) + self._run(gw.switch_event("switch.predbat_gateway_456789_discharge_enabled", "toggle")) + assert gw._published == [("set_discharge_enable", {"enable": True})] + + # ------------------------------------------------------------------ + # Substring safety: discharge_enabled must not match _charge_enabled branch + # ------------------------------------------------------------------ + + def test_discharge_enabled_not_misrouted_as_charge(self): + """discharge_enabled sends set_discharge_enable, not set_charge_enable.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_discharge_enabled", "turn_off")) + assert gw._published[0][0] == "set_discharge_enable" + + # ------------------------------------------------------------------ + # Edge cases + # ------------------------------------------------------------------ + + def test_unrecognised_entity_no_command(self): + """An entity that doesn't match charge_enabled or discharge_enabled sends nothing.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_some_other_switch", "turn_on")) + assert gw._published == [] + + def test_only_one_command_per_call(self): + """Each switch_event call produces exactly one command.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "turn_on")) + assert len(gw._published) == 1 + + def run_gateway_tests(my_predbat=None): """Run all GatewayMQTT tests. Returns True on failure, False on success.""" test_classes = [ @@ -519,7 +1234,11 @@ def run_gateway_tests(my_predbat=None): TestPlanSerialization, TestCommandFormat, TestScheduleSlotCommand, - TestEMSEntities, + TestInjectEntities, + TestAutomaticConfig, + TestSelectEvent, + TestNumberEvent, + TestSwitchEvent, TestTokenRefresh, TestPlanHookConversion, TestMQTTIntegration,