diff --git a/custom_components/jackery/__init__.py b/custom_components/jackery/__init__.py index c91011c..4b5f33c 100644 --- a/custom_components/jackery/__init__.py +++ b/custom_components/jackery/__init__.py @@ -1,9 +1,9 @@ -"""Energy Monitor MQTT Integration for Home Assistant.""" +"""Jackery Home Assistant Integration.""" import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.components import mqtt _LOGGER = logging.getLogger(__name__) @@ -13,48 +13,42 @@ PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Jackery from a config entry.""" - _LOGGER.info("Setting up Jackery integration") - - # 检查 MQTT 集成是否已配置和可用 + """Set up one Jackery device from a config entry.""" if not await mqtt.async_wait_for_mqtt_client(hass): - _LOGGER.error( - "MQTT integration is not available or not configured. " - "Please set up the MQTT integration first: " - "Settings -> Devices & Services -> Add Integration -> MQTT" - ) + _LOGGER.error("MQTT integration is not available") return False - - _LOGGER.info("MQTT integration is available and ready") - - # 初始化存储结构 + + from .sensor import JackeryCoordinator + + config = entry.data + coordinator = JackeryCoordinator( + hass=hass, + entry_id=entry.entry_id, + device_sn=config["device_sn"], + token=config.get("token", ""), + topic_prefix=config.get("topic_prefix", "hb"), + ) + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - "config": entry.data, - "coordinator": None, # 将在 sensor.py 中设置 - } - - # 加载传感器平台 + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Start MQTT subscriptions now; the poll loop waits 2 s so platform + # async_setup_entry callbacks are registered before the first poll fires. + await coordinator.async_start() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - _LOGGER.info("Unloading Jackery integration") - - # 停止协调器 - entry_data = hass.data[DOMAIN].get(entry.entry_id, {}) - coordinator = entry_data.get("coordinator") + coordinator = hass.data[DOMAIN].get(entry.entry_id) if coordinator: await coordinator.async_stop() - _LOGGER.info("Coordinator stopped") - - # 卸载传感器平台 + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - + if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - + hass.data[DOMAIN].pop(entry.entry_id, None) + return unload_ok diff --git a/custom_components/jackery/number.py b/custom_components/jackery/number.py index 0efead7..b9b6dd9 100644 --- a/custom_components/jackery/number.py +++ b/custom_components/jackery/number.py @@ -1,6 +1,6 @@ """Jackery Number Platform.""" import logging -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry @@ -10,16 +10,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN if TYPE_CHECKING: - from .sensor import JackeryDataCoordinator + from .sensor import JackeryCoordinator _LOGGER = logging.getLogger(__name__) - NUMBERS = { - "socChgLimit": {"name": "SOC Charge Limit", "min": 0, "max": 100, "step": 1}, - "socDischgLimit": {"name": "SOC Discharge Limit", "min": 0, "max": 100, "step": 1}, - "maxOutPw": {"name": "Max Output Power (OnGrid)", "min": 0, "max": 10000, "step": 10}, - "autoStandby": {"name": "Auto Standby Mode", "min": 0, "max": 2, "step": 1}, + "socChgLimit": {"name": "SOC Charge Limit", "min": 0, "max": 100, "step": 1}, + "socDischgLimit": {"name": "SOC Discharge Limit", "min": 0, "max": 100, "step": 1}, + "maxOutPw": {"name": "Max Output Power (OnGrid)", "min": 0, "max": 10000, "step": 10}, + "autoStandby": {"name": "Auto Standby Mode", "min": 0, "max": 2, "step": 1}, } @@ -28,75 +27,53 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Jackery number entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - if coordinator is None: - _LOGGER.warning("Coordinator not ready for numbers") - return + coordinator: "JackeryCoordinator" = hass.data[DOMAIN][config_entry.entry_id] - entities = [] - for key, cfg in NUMBERS.items(): - entities.append( - JackeryMainNumber( - key=key, - name=cfg["name"], - min_value=cfg["min"], - max_value=cfg["max"], - step=cfg["step"], - coordinator=coordinator, - config_entry_id=config_entry.entry_id, - ) - ) - - if entities: - async_add_entities(entities) + async_add_entities([ + JackeryMainNumber(key=key, coordinator=coordinator, **cfg) + for key, cfg in NUMBERS.items() + ]) class JackeryMainNumber(NumberEntity): - """Main device number (cmd=5).""" + """Numeric setting on the main Jackery device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_mode = NumberMode.SLIDER def __init__( self, key: str, name: str, - min_value: float, - max_value: float, + min: float, + max: float, step: float, - coordinator: "JackeryDataCoordinator", - config_entry_id: str, + coordinator: "JackeryCoordinator", ) -> None: self._key = key self._coordinator = coordinator self._attr_name = name - self._attr_unique_id = f"jackery_{config_entry_id}_main_{key}" - self._attr_has_entity_name = True - self._attr_mode = NumberMode.SLIDER - self._attr_native_min_value = min_value - self._attr_native_max_value = max_value + self._attr_unique_id = f"jackery_{coordinator.entry_id}_main_{key}" + self._attr_native_min_value = min + self._attr_native_max_value = max self._attr_native_step = step - device_sn = coordinator._device_sn or "Unknown" self._attr_device_info = { - "identifiers": {(DOMAIN, config_entry_id)}, - "name": f"Jackery {device_sn}", + "identifiers": {(DOMAIN, coordinator.entry_id)}, + "name": f"Jackery {coordinator.device_sn}", "manufacturer": "Jackery", "model": "Energy Monitor", } - @property - def should_poll(self) -> bool: - return False - async def async_added_to_hass(self) -> None: await super().async_added_to_hass() - self._coordinator.register_sensor(f"main_number_{self._key}", self) + self._coordinator.register_entity(f"main_number_{self._key}", self) async def async_will_remove_from_hass(self) -> None: - self._coordinator.unregister_sensor(f"main_number_{self._key}") + self._coordinator.unregister_entity(f"main_number_{self._key}") await super().async_will_remove_from_hass() def _update_from_coordinator(self, data: dict) -> None: - if self._key not in data: - return val = data.get(self._key) if val is None: return @@ -108,4 +85,4 @@ class JackeryMainNumber(NumberEntity): pass async def async_set_native_value(self, value: float) -> None: - await self._coordinator.async_control_main_device({self._key: int(value)}) + await self._coordinator.control_main({self._key: int(value)}) diff --git a/custom_components/jackery/sensor.py b/custom_components/jackery/sensor.py index fffe074..1d1f5fa 100644 --- a/custom_components/jackery/sensor.py +++ b/custom_components/jackery/sensor.py @@ -3,9 +3,8 @@ import asyncio import json import logging import random -import re import time -from typing import Any, Callable +from typing import Any from homeassistant.components import mqtt as ha_mqtt from homeassistant.components.sensor import ( @@ -22,1172 +21,777 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -# 常量定义 -REQUEST_INTERVAL = 10 # 数据请求间隔(秒) +POLL_INTERVAL = 10 # seconds between poll requests -# 传感器配置 -SENSORS = { - # 电池相关 +# --------------------------------------------------------------------------- +# Sensor descriptors +# --------------------------------------------------------------------------- + +SENSORS: dict[str, dict] = { "battery_soc": { - "json_key": "batSoc", - "name": "Battery SOC", - "unit": PERCENTAGE, - "icon": "mdi:battery-50", + "json_key": "batSoc", "name": "Battery SOC", + "unit": PERCENTAGE, "icon": "mdi:battery-50", "device_class": SensorDeviceClass.BATTERY, "state_class": SensorStateClass.MEASUREMENT, }, "battery_charge_power": { - "json_key": "batInPw", - "name": "Battery Charge Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:battery-charging", + "json_key": "batInPw", "name": "Battery Charge Power", + "unit": UnitOfPower.WATT, "icon": "mdi:battery-charging", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "battery_discharge_power": { - "json_key": "batOutPw", - "name": "Battery Discharge Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:battery-minus", + "json_key": "batOutPw", "name": "Battery Discharge Power", + "unit": UnitOfPower.WATT, "icon": "mdi:battery-minus", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "battery_temperature": { - "json_key": "cellTemp", - "name": "Battery Temperature", - "unit": UnitOfTemperature.CELSIUS, - "icon": "mdi:thermometer", + "json_key": "cellTemp", "name": "Battery Temperature", + "unit": UnitOfTemperature.CELSIUS, "icon": "mdi:thermometer", "device_class": SensorDeviceClass.TEMPERATURE, "state_class": SensorStateClass.MEASUREMENT, }, "battery_count": { - "json_key": "batNum", - "name": "Battery Count", - "unit": None, - "icon": "mdi:battery-multiple", - "device_class": None, - "state_class": SensorStateClass.MEASUREMENT, + "json_key": "batNum", "name": "Battery Count", + "unit": None, "icon": "mdi:battery-multiple", + "device_class": None, "state_class": SensorStateClass.MEASUREMENT, }, - # 电池能量统计 "battery_charge_energy": { - "json_key": "batChgEgy", - "name": "Battery Charge Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:battery-plus", + "json_key": "batChgEgy", "name": "Battery Charge Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:battery-plus", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "battery_discharge_energy": { - "json_key": "batDisChgEgy", - "name": "Battery Discharge Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:battery-minus", + "json_key": "batDisChgEgy", "name": "Battery Discharge Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:battery-minus", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, - - # 太阳能 "solar_power": { - "json_key": "pvPw", - "name": "Solar Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:solar-power", + "json_key": "pvPw", "name": "Solar Power", + "unit": UnitOfPower.WATT, "icon": "mdi:solar-power", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "solar_energy": { - "json_key": "pvEgy", - "name": "Solar Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-power", + "json_key": "pvEgy", "name": "Solar Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-power", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "solar_power_pv1": { - "json_key": "pv1", - "name": "Solar Power PV1", - "unit": UnitOfPower.WATT, - "icon": "mdi:solar-panel", + "json_key": "pv1", "name": "Solar Power PV1", + "unit": UnitOfPower.WATT, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "solar_energy_pv1": { - "json_key": "pv1Egy", - "name": "Solar Energy PV1", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-panel", + "json_key": "pv1Egy", "name": "Solar Energy PV1", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "solar_power_pv2": { - "json_key": "pv2", - "name": "Solar Power PV2", - "unit": UnitOfPower.WATT, - "icon": "mdi:solar-panel", + "json_key": "pv2", "name": "Solar Power PV2", + "unit": UnitOfPower.WATT, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "solar_energy_pv2": { - "json_key": "pv2Egy", - "name": "Solar Energy PV2", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-panel", + "json_key": "pv2Egy", "name": "Solar Energy PV2", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "solar_power_pv3": { - "json_key": "pv3", - "name": "Solar Power PV3", - "unit": UnitOfPower.WATT, - "icon": "mdi:solar-panel", + "json_key": "pv3", "name": "Solar Power PV3", + "unit": UnitOfPower.WATT, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "solar_energy_pv3": { - "json_key": "pv3Egy", - "name": "Solar Energy PV3", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-panel", + "json_key": "pv3Egy", "name": "Solar Energy PV3", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "solar_power_pv4": { - "json_key": "pv4", - "name": "Solar Power PV4", - "unit": UnitOfPower.WATT, - "icon": "mdi:solar-panel", + "json_key": "pv4", "name": "Solar Power PV4", + "unit": UnitOfPower.WATT, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "solar_energy_pv4": { - "json_key": "pv4Egy", - "name": "Solar Energy PV4", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-panel", + "json_key": "pv4Egy", "name": "Solar Energy PV4", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, - - # 电网相关 - "grid_import_power": { # Grid -> System (outOngridPw) - "json_key": "inOngridPw", - "name": "Grid Import Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:transmission-tower-import", + "grid_import_power": { + "json_key": "inOngridPw", "name": "Grid Import Power", + "unit": UnitOfPower.WATT, "icon": "mdi:transmission-tower-import", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "grid_import_energy": { - "json_key": "inOngridEgy", - "name": "Grid Import Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:transmission-tower-import", + "json_key": "inOngridEgy", "name": "Grid Import Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:transmission-tower-import", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, - "grid_export_power": { # System -> Grid/Home (inOngirdPw) - "json_key": "outOngridPw", - "name": "Grid Export Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:transmission-tower-export", + "grid_export_power": { + "json_key": "outOngridPw", "name": "Grid Export Power", + "unit": UnitOfPower.WATT, "icon": "mdi:transmission-tower-export", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "grid_export_energy": { - "json_key": "outOngridEgy", - "name": "Grid Export Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:transmission-tower-export", + "json_key": "outOngridEgy", "name": "Grid Export Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:transmission-tower-export", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "max_output_power": { - "json_key": "maxOutPw", - "name": "Max Output Power (OnGrid)", - "unit": UnitOfPower.WATT, - "icon": "mdi:speedometer", + "json_key": "maxOutPw", "name": "Max Output Power (OnGrid)", + "unit": UnitOfPower.WATT, "icon": "mdi:speedometer", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, - - # EPS (离网输出) "eps_output_power": { - "json_key": "swEpsOutPw", - "name": "EPS Output Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:power-plug", + "json_key": "swEpsOutPw", "name": "EPS Output Power", + "unit": UnitOfPower.WATT, "icon": "mdi:power-plug", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "eps_output_energy": { - "json_key": "outEpsEgy", - "name": "EPS Output Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:power-plug", + "json_key": "outEpsEgy", "name": "EPS Output Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:power-plug", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "eps_input_power": { - "json_key": "swEpsInPw", - "name": "EPS Input Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:power-plug", + "json_key": "swEpsInPw", "name": "EPS Input Power", + "unit": UnitOfPower.WATT, "icon": "mdi:power-plug", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "eps_input_energy": { - "json_key": "inEpsEgy", - "name": "EPS Input Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:power-plug", + "json_key": "inEpsEgy", "name": "EPS Input Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:power-plug", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "eps_state": { - "json_key": "swEpsState", - "name": "EPS State", - "unit": None, - "icon": "mdi:power-settings", - "device_class": None, - "state_class": None, # 1-Normal, 0-Abnormal + "json_key": "swEpsState", "name": "EPS State", + "unit": None, "icon": "mdi:power-settings", + "device_class": None, "state_class": None, }, "eps_switch": { - "json_key": "swEps", - "name": "EPS Switch Status", - "unit": None, - "icon": "mdi:toggle-switch", - "device_class": None, - "state_class": None, # 1-On, 0-Off + "json_key": "swEps", "name": "EPS Switch Status", + "unit": None, "icon": "mdi:toggle-switch", + "device_class": None, "state_class": None, }, - - # Limits & Settings & Status "soc_charge_limit": { - "json_key": "socChgLimit", - "name": "SOC Charge Limit", - "unit": PERCENTAGE, - "icon": "mdi:battery-arrow-up", - "device_class": None, - "state_class": SensorStateClass.MEASUREMENT, + "json_key": "socChgLimit", "name": "SOC Charge Limit", + "unit": PERCENTAGE, "icon": "mdi:battery-arrow-up", + "device_class": None, "state_class": SensorStateClass.MEASUREMENT, }, "soc_discharge_limit": { - "json_key": "socDischgLimit", - "name": "SOC Discharge Limit", - "unit": PERCENTAGE, - "icon": "mdi:battery-arrow-down", - "device_class": None, - "state_class": SensorStateClass.MEASUREMENT, + "json_key": "socDischgLimit", "name": "SOC Discharge Limit", + "unit": PERCENTAGE, "icon": "mdi:battery-arrow-down", + "device_class": None, "state_class": SensorStateClass.MEASUREMENT, }, - # "is_auto_standby": { - # "json_key": "isAutoStandby", - # "name": "Auto Standby Allowed", - # "unit": None, - # "icon": "mdi:power-sleep", - # "device_class": None, - # "state_class": None, # 1-Allowed, 0-Not Allowed - # }, - # "auto_standby_status": { - # "json_key": "autoStandby", - # "name": "Auto Standby Mode", - # "unit": None, - # "icon": "mdi:power-sleep", - # "device_class": None, - # "state_class": None, # 0-Invalid, 1-Sleep/Off, 2-On - # }, - - # Calculated Sensors "home_power": { - "json_key": "calc_home_power", - "name": "Home Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:home-lightning-bolt", + "json_key": "calc_home_power", "name": "Home Power", + "unit": UnitOfPower.WATT, "icon": "mdi:home-lightning-bolt", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "battery_net_power": { - "json_key": "calc_batt_net_power", - "name": "Battery Net Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:battery-sync", + "json_key": "calc_batt_net_power", "name": "Battery Net Power", + "unit": UnitOfPower.WATT, "icon": "mdi:battery-sync", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "calc_battery_charge_power": { - "json_key": "calc_battery_charge_power", - "name": "Battery Charge Power (Calc)", - "unit": UnitOfPower.WATT, - "icon": "mdi:battery-charging", + "json_key": "calc_battery_charge_power", "name": "Battery Charge Power (Calc)", + "unit": UnitOfPower.WATT, "icon": "mdi:battery-charging", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "calc_battery_discharge_power": { - "json_key": "calc_battery_discharge_power", - "name": "Battery Discharge Power (Calc)", - "unit": UnitOfPower.WATT, - "icon": "mdi:battery-minus", + "json_key": "calc_battery_discharge_power", "name": "Battery Discharge Power (Calc)", + "unit": UnitOfPower.WATT, "icon": "mdi:battery-minus", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "grid_net_power": { - "json_key": "calc_grid_net_power", - "name": "Grid Net Power", - "unit": UnitOfPower.WATT, - "icon": "mdi:transmission-tower", + "json_key": "calc_grid_net_power", "name": "Grid Net Power", + "unit": UnitOfPower.WATT, "icon": "mdi:transmission-tower", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, - # 更多能量流向统计 "ac_to_battery_energy": { - "json_key": "acOtBatEgy", - "name": "AC to Battery Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:battery-arrow-up", + "json_key": "acOtBatEgy", "name": "AC to Battery Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:battery-arrow-up", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "pv_to_battery_energy": { - "json_key": "pvOtBatEgy", - "name": "PV to Battery Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-power-variant", + "json_key": "pvOtBatEgy", "name": "PV to Battery Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-power-variant", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "pv_to_ac_energy": { - "json_key": "pvOtAcEgy", - "name": "PV to AC Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:solar-panel", + "json_key": "pvOtAcEgy", "name": "PV to AC Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:solar-panel", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "pv_to_grid_energy": { - "json_key": "pvOtOngridEgy", - "name": "PV to Grid Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:transmission-tower-export", + "json_key": "pvOtOngridEgy", "name": "PV to Grid Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:transmission-tower-export", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "grid_to_ac_load_energy": { - "json_key": "ongridOtAcLoadEgy", - "name": "Grid to AC Load Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:home-import-outline", + "json_key": "ongridOtAcLoadEgy", "name": "Grid to AC Load Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:home-import-outline", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "battery_to_ac_energy": { - "json_key": "batOtAcEgy", - "name": "Battery to AC Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:battery-arrow-down", + "json_key": "batOtAcEgy", "name": "Battery to AC Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:battery-arrow-down", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "battery_to_grid_energy": { - "json_key": "batOtGridEgy", - "name": "Battery to Grid Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:transmission-tower-export", + "json_key": "batOtGridEgy", "name": "Battery to Grid Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:transmission-tower-export", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "grid_to_battery_energy": { - "json_key": "ongridOtBatEgy", - "name": "Grid to Battery Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "icon": "mdi:battery-arrow-up", + "json_key": "ongridOtBatEgy", "name": "Grid to Battery Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "icon": "mdi:battery-arrow-up", "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.TOTAL_INCREASING, - "scale": 0.01, + "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, } -# 子设备传感器配置 -SUBDEVICE_SENSORS = { - # 智能插座 (devType=6 or 1) +SUBDEVICE_SENSORS: dict[str, dict] = { "plug": { "power": { - "key": "outPw", # Fallback to 'power' - "name": "Power", - "unit": UnitOfPower.WATT, - "device_class": SensorDeviceClass.POWER, - "state_class": SensorStateClass.MEASUREMENT, - "icon": "mdi:power-socket-eu", + "key": "outPw", "name": "Power", + "unit": UnitOfPower.WATT, "device_class": SensorDeviceClass.POWER, + "state_class": SensorStateClass.MEASUREMENT, "icon": "mdi:power-socket-eu", }, "energy": { - "key": "totalEgy", - "name": "Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "device_class": SensorDeviceClass.ENERGY, + "key": "totalEgy", "name": "Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, - "icon": "mdi:lightning-bolt", - "scale": 0.01, + "icon": "mdi:lightning-bolt", "scale": 0.01, }, }, - # CT / Smart Meter (devType=2) "ct": { "power": { - "key": "phasePw", # Resolve by subType to A/B/C/Total - "name": "Power", - "unit": UnitOfPower.WATT, - "device_class": SensorDeviceClass.POWER, - "state_class": SensorStateClass.MEASUREMENT, - "icon": "mdi:current-ac", + "key": "phasePw", "name": "Power", + "unit": UnitOfPower.WATT, "device_class": SensorDeviceClass.POWER, + "state_class": SensorStateClass.MEASUREMENT, "icon": "mdi:current-ac", }, "energy": { - "key": "phaseEgy", # Resolve by subType to A/B/C/Total - "name": "Energy", - "unit": UnitOfEnergy.KILO_WATT_HOUR, - "device_class": SensorDeviceClass.ENERGY, + "key": "phaseEgy", "name": "Energy", + "unit": UnitOfEnergy.KILO_WATT_HOUR, "device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, - "icon": "mdi:lightning-bolt", - "scale": 0.01, # Assumption + "icon": "mdi:lightning-bolt", "scale": 0.01, }, }, } -class JackeryDataCoordinator: - """协调器:管理MQTT订阅和数据获取,供所有传感器实体共享使用.""" +# --------------------------------------------------------------------------- +# Coordinator +# --------------------------------------------------------------------------- - def __init__(self, hass: HomeAssistant, topic_prefix: str, token: str, mqtt_host: str, device_sn: str) -> None: - """初始化协调器.""" +class JackeryCoordinator: + """Manages MQTT for one Jackery device and distributes data to entities.""" + + def __init__( + self, + hass: HomeAssistant, + entry_id: str, + device_sn: str, + token: str, + topic_prefix: str, + ) -> None: self.hass = hass - self._topic_prefix = topic_prefix + self.entry_id = entry_id + self.device_sn = device_sn self._token = token - self._mqtt_host = mqtt_host - self._device_sn = device_sn - self._topic_root = topic_prefix - self._sensors = {} # {sensor_id: entity} - self._data_task = None - self._subscribed = False - self._last_update_time = time.time() + self._topic_status = f"{topic_prefix}/device/{device_sn}/status" + self._topic_event = f"{topic_prefix}/device/{device_sn}/event" + self._action_topic = f"{topic_prefix}/device/{device_sn}/action" - self._known_plugs = set() # Set of known plug SNs - self._subdevice_missing_since = {} # {sn: timestamp} for deletion delay - self.add_entities_callback = None # Callback to add new entities - self.add_switch_entities_callback = None # Callback to add new switch entities - self._data_cache = {} # Cache for merged data from status and events + self._cache: dict = {} + self._entities: dict[str, Any] = {} # key -> entity + self._known_subdevices: set[str] = set() + self._poll_task: asyncio.Task | None = None + self._last_msg_time: float = time.monotonic() - # Topic patterns — device-specific so multiple instances don't cross-receive - self._topic_status = f"{self._topic_root}/device/{self._device_sn}/status" - self._topic_event = f"{self._topic_root}/device/{self._device_sn}/event" + # Registered by platform async_setup_entry calls + self.add_sensor_entities: Any = None + self.add_switch_entities: Any = None - def register_sensor(self, sensor_id: str, entity: "JackerySensor") -> None: - """注册传感器实体.""" - self._sensors[sensor_id] = entity - - def unregister_sensor(self, sensor_id: str) -> None: - """注销传感器实体.""" - if sensor_id in self._sensors: - del self._sensors[sensor_id] + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ async def async_start(self) -> None: - """启动协调器.""" - if self._subscribed: - return + @callback + def on_message(msg: Any) -> None: + self._on_message(msg) - try: - # 订阅状态主题 (Wildcard) 以发现设备和接收数据 - @callback - def message_received(msg): - self._handle_message(msg) + await ha_mqtt.async_subscribe(self.hass, self._topic_status, on_message, 1) + await ha_mqtt.async_subscribe(self.hass, self._topic_event, on_message, 1) + _LOGGER.info("Jackery %s: subscribed to %s and %s", + self.device_sn, self._topic_status, self._topic_event) - await ha_mqtt.async_subscribe( - self.hass, - self._topic_status, - message_received, - 1 - ) - _LOGGER.info(f"Coordinator subscribed to: {self._topic_status}") - - # Subscribe to event topic for sub-device data (Type 101) - await ha_mqtt.async_subscribe( - self.hass, - self._topic_event, - message_received, - 1 - ) - _LOGGER.info(f"Coordinator subscribed to: {self._topic_event}") - - self._subscribed = True - - # 启动定时轮询 - self._data_task = asyncio.create_task(self._periodic_data_request()) - - except Exception as e: - _LOGGER.error(f"Failed to start coordinator: {e}") + self._poll_task = asyncio.create_task(self._poll_loop()) async def async_stop(self) -> None: - """停止协调器.""" - if self._data_task and not self._data_task.done(): - self._data_task.cancel() + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() try: - await self._data_task + await self._poll_task except asyncio.CancelledError: pass - _LOGGER.info("Coordinator stopped") + _LOGGER.info("Jackery %s: coordinator stopped", self.device_sn) - def _handle_message(self, msg) -> None: - """处理接收到的 MQTT 消息.""" - self._last_update_time = time.time() + # ------------------------------------------------------------------ + # Entity registry + # ------------------------------------------------------------------ + + def register_entity(self, key: str, entity: Any) -> None: + self._entities[key] = entity + + def unregister_entity(self, key: str) -> None: + self._entities.pop(key, None) + + # ------------------------------------------------------------------ + # MQTT message handling + # ------------------------------------------------------------------ + + def _on_message(self, msg: Any) -> None: + self._last_msg_time = time.monotonic() try: - topic = msg.topic payload = msg.payload if isinstance(payload, bytes): payload = payload.decode("utf-8") - - - # Parse Payload - try: - raw_data = json.loads(payload) - msg_code = raw_data.get("type") - body = raw_data.get("body") - - # If body is missing or None, use empty dict or the raw_data itself if it looks like data - # But protocol says data is in body. - if body is None: - # Some status messages might be flat? Assuming body per protocol. - # If Type 101 and body is None, ignore. - if msg_code == 101: - return - body = {} - - # Merge logic - # Type 23: Statistical/Energy Data - if msg_code == 23 and isinstance(body, dict): - device_sn_in_body = body.get("deviceSn") - if device_sn_in_body == "system": - # Merge into main device cache - self._data_cache.update(body) - else: - # Find and update sub-device in cache - # Search in plugs and cts - for key in ["plugs", "plug", "cts"]: - items = self._data_cache.get(key) - if isinstance(items, list): - for item in items: - if item.get("sn") == device_sn_in_body or item.get("deviceSn") == device_sn_in_body: - item.update(body) - break - - # Type 101: Sub-device full data - elif msg_code == 101 and isinstance(body, dict): - # Normalize sub-device payloads for plugs/sockets/CTs - raw_plugs = body.get("plug") or body.get("plugs") or body.get("socket") or body.get("sockets") or [] - raw_cts = body.get("ct") or body.get("cts") or [] - - current_cts = [] - current_plugs = [] - - # Combine all sub-devices into a single list for discovery - combined = [] - if isinstance(raw_plugs, list): - for item in raw_plugs: - if isinstance(item, dict) and item.get("devType") is None: - item = {**item, "devType": 6} - combined.append(item) - if isinstance(raw_cts, list): - for item in raw_cts: - if isinstance(item, dict): - # Some CT payloads report devType=3, subType=2; normalize to devType=2 - sub_type = item.get("subType") - if sub_type == 2: - item = {**item, "devType": 2} - elif item.get("devType") is None: - item = {**item, "devType": 2} - combined.append(item) - - for item in combined: - if not isinstance(item, dict): - continue - dt = item.get("devType") - if dt == 2: - current_cts.append(item) - else: - current_plugs.append(item) - - self._data_cache["cts"] = current_cts - # Store all in "plugs" for JackeryPlugSensor to find itself by SN - self._data_cache["plugs"] = combined - self._data_cache["plug"] = combined # Keep original key too - - # Type 25 or Status: Main device data - elif isinstance(body, dict): - # Merge top-level keys into cache to preserve fields not present in current message - self._data_cache.update(body) - - # Special handling for isAutoStandby/autoStandby to ensure they are always available if ever seen - # (Optional: can add more critical fields here if needed) - - except json.JSONDecodeError: - _LOGGER.warning(f"Invalid JSON payload on {topic}") - return - - # Enrich data with calculations using merged cache - # operate on copy or direct? Direct is fine. - self._data_cache = self._calculate_energy_flow(self._data_cache) - - # Check for new plugs - self._check_for_new_plugs(self._data_cache) - - self._distribute_data(self._data_cache) - - except Exception as e: - _LOGGER.error(f"Error handling message: {e}") - - def _check_for_new_plugs(self, data: dict) -> None: - """检查并同步插座/CT(添加新设备,移除旧设备).""" - # Check both keys - plugs = data.get("plugs") or data.get("plug") - if not plugs or not isinstance(plugs, list): - plugs = data.get("cts") if isinstance(data.get("cts"), list) else None - - # 如果数据中根本没有 plugs/cts 字段,不做处理 - if plugs is None: + raw = json.loads(payload) + except Exception: + _LOGGER.warning("Jackery %s: invalid MQTT payload on %s", + self.device_sn, msg.topic) return - current_sns = set() - for plug in plugs: - sn = plug.get("deviceSn") or plug.get("sn") - if sn: - current_sns.add(sn) - - now = time.time() + msg_type = raw.get("type") + body = raw.get("body") - # 1. 更新 missing 状态 - for sn in current_sns: - if sn in self._subdevice_missing_since: - _LOGGER.info(f"Sub-device {sn} reappeared, cancelling deletion.") - del self._subdevice_missing_since[sn] - - for sn in self._known_plugs: - if sn not in current_sns: - if sn not in self._subdevice_missing_since: - self._subdevice_missing_since[sn] = now - _LOGGER.info(f"Sub-device {sn} missing, starting 60s deletion timer...") - - # 2. 执行真正的移除 - for sn in list(self._subdevice_missing_since.keys()): - if sn not in self._known_plugs: - del self._subdevice_missing_since[sn] - continue - - if sn in current_sns: - del self._subdevice_missing_since[sn] - continue - - missing_time = self._subdevice_missing_since[sn] - if now - missing_time > 60: - _LOGGER.info(f"Sub-device {sn} missing for >60s. Removing.") - self._known_plugs.remove(sn) - del self._subdevice_missing_since[sn] - - # Remove entities - for sensor_id, entity in list(self._sensors.items()): - # Match unique IDs containing the SN for sub-devices - # Format: jackery_plug_{sn}_xxx or jackery_ct_{sn}_xxx or jackery_plug_{sn}_switch - if f"_{sn}_" in sensor_id or sensor_id.endswith(f"_{sn}"): - self.hass.async_create_task(entity.async_remove(force_remove=True)) - - # 3. 处理新增 - new_entities = [] - new_switch_entities = [] - for plug in plugs: - sn = plug.get("deviceSn") or plug.get("sn") - dev_type = plug.get("devType") - if dev_type is None and plug.get("subType") == 2: - dev_type = 2 - - if sn and sn not in self._known_plugs: - _LOGGER.info(f"Discovered new sub-device: {sn} (Type: {dev_type})") - self._known_plugs.add(sn) - - if hasattr(self, "config_entry_id"): - # Create Sensors defined in SUBDEVICE_SENSORS - sensor_group = "ct" if dev_type == 2 else "plug" - group_config = SUBDEVICE_SENSORS.get(sensor_group, {}) - - for sensor_key, sensor_cfg in group_config.items(): - entity = JackerySubDeviceSensor( - plug_sn=sn, - dev_type=dev_type, - sensor_key=sensor_key, - sensor_config=sensor_cfg, - coordinator=self, - config_entry_id=self.config_entry_id - ) - new_entities.append(entity) - - if dev_type != 2: - from .switch import JackeryPlugSwitch - switch_entity = JackeryPlugSwitch( - plug_sn=sn, - dev_type=dev_type, - coordinator=self, - config_entry_id=self.config_entry_id - ) - new_switch_entities.append(switch_entity) - - if new_entities and self.add_entities_callback: - self.add_entities_callback(new_entities) - if new_switch_entities and self.add_switch_entities_callback: - self.add_switch_entities_callback(new_switch_entities) - - def get_subdevices(self) -> list[dict[str, Any]]: - """Return latest sub-device list from cache.""" - plugs = self._data_cache.get("plugs") or self._data_cache.get("plug") - if isinstance(plugs, list): - return [p for p in plugs if isinstance(p, dict)] - cts = self._data_cache.get("cts") - if isinstance(cts, list): - return [p for p in cts if isinstance(p, dict)] - return [] - - async def async_control_subdevice_switch(self, plug_sn: str, dev_type: int, is_on: bool) -> None: - """Control sub-device switch via type 103.""" - if not self._device_sn: - _LOGGER.warning("Cannot control sub-device: device SN not discovered") - return - - action_topic = f"{self._topic_root}/device/{self._device_sn}/action" - ts = int(time.time()) - payload = { - "type": 103, - "eventId": 0, - "messageId": random.randint(1000, 9999), - "ts": ts, - "body": { - "deviceSn": plug_sn, - "devType": dev_type, - "sysSwitch": 1 if is_on else 0, - }, - } - if self._token: - payload["token"] = self._token - - await ha_mqtt.async_publish( - self.hass, - action_topic, - json.dumps(payload), - 0, - False - ) - - async def async_control_main_device(self, params: dict[str, Any]) -> None: - """Control main device via type 1, cmd 5.""" - if not self._device_sn: - _LOGGER.warning("Cannot control main device: device SN not discovered") - return - - action_topic = f"{self._topic_root}/device/{self._device_sn}/action" - ts = int(time.time()) - body = {"cmd": 5, "rc": 1} - body.update(params) - payload = { - "type": 1, - "eventId": 3, - "messageId": random.randint(1000, 9999), - "ts": ts, - "body": body, - } - if self._token: - payload["token"] = self._token - - await ha_mqtt.async_publish( - self.hass, - action_topic, - json.dumps(payload), - 0, - False - ) - - def _calculate_energy_flow(self, data: dict) -> dict: - """ - 根据用户需求计算能量流数据. - - Variables Mapping: - - PV: pvPw - - OngridCharge: inOngridPw - - OngridSupply: outOngridPw - - ACIn: swEpsInPw - - ACOut: swEpsOutPw - - GridBuy: (Need Key, assuming 'gridBuyPw' or similar, else None) - - GridSell: (Need Key, assuming 'gridSellPw', else None) - """ try: - # 1. PV - # Handle dict for PV if necessary (copied from sensor logic) - pv_val = data.get("pvPw", 0) - if isinstance(pv_val, dict): - pv = float(pv_val.get("pvPw", 0) or pv_val.get("w", 0) or pv_val.get("power", 0)) + if msg_type == 101: + # Sub-device list event — replaces plug/ct cache entirely + if isinstance(body, dict): + self._handle_subdevice_event(body) + elif msg_type == 23: + # Statistical / energy data + if isinstance(body, dict): + sn = body.get("deviceSn") + if sn == "system": + self._cache.update(body) + else: + self._patch_subdevice(sn, body) else: - pv = float(pv_val) - - # 2. Ongrid - ongrid_charge = float(data.get("inOngridPw", 0)) - ongrid_supply = float(data.get("outOngridPw", 0)) - p_ong = ongrid_charge - ongrid_supply # 流入主机为正 - - # 3. ACSocket (EPS) - ac_in = float(data.get("swEpsInPw", 0)) - ac_out = float(data.get("swEpsOutPw", 0)) - p_ac = ac_in - ac_out # 流入主机为正 - - # 4. Grid (Meter) - # 优先从 'cts' 数组中提取 CT 数据 (Smart CT Meter) - # cts item: { ..., "TphasePw": , "TnphasePw": , "commState": 1/0, ... } - grid_available = False - grid_buy = 0.0 - grid_sell = 0.0 - - cts = data.get("cts") - if cts and isinstance(cts, list) and len(cts) > 0: - # 尝试获取第一个 CT 数据 - ct_data = cts[0] - # 检查通讯状态 (如果 commState 存在且为 0 可能表示离线,视具体协议而定,这里暂定只要有数据即可) - # TphasePw: 总正向有功 (Grid Buy) - # TnphasePw: 总负向有功 (Grid Sell) - t_phase_pw = ct_data.get("TphasePw") or ct_data.get("tPhasePw") - tn_phase_pw = ct_data.get("TnphasePw") or ct_data.get("tnPhasePw") - - # Fallback: if total phase missing, sum A/B/C - if t_phase_pw is None: - a_pw = ct_data.get("AphasePw") or ct_data.get("aPhasePw") or 0 - b_pw = ct_data.get("BphasePw") or ct_data.get("bPhasePw") or 0 - c_pw = ct_data.get("CphasePw") or ct_data.get("cPhasePw") or 0 - if any(v is not None for v in [a_pw, b_pw, c_pw]): - t_phase_pw = float(a_pw) + float(b_pw) + float(c_pw) - - if tn_phase_pw is None: - an_pw = ct_data.get("AnphasePw") or ct_data.get("anPhasePw") or 0 - bn_pw = ct_data.get("BnphasePw") or ct_data.get("bnPhasePw") or 0 - cn_pw = ct_data.get("CnphasePw") or ct_data.get("cnPhasePw") or 0 - if any(v is not None for v in [an_pw, bn_pw, cn_pw]): - tn_phase_pw = float(an_pw) + float(bn_pw) + float(cn_pw) - - if t_phase_pw is not None or tn_phase_pw is not None: - grid_buy = float(t_phase_pw or 0) - grid_sell = float(tn_phase_pw or 0) - grid_available = True - - # 兼容旧逻辑或直接字段 (如果 cts 不存在) - if not grid_available: - grid_buy_raw = data.get("gridBuyPw") or data.get("gridInPw") - grid_sell_raw = data.get("gridSellPw") or data.get("gridOutPw") - if grid_buy_raw is not None and grid_sell_raw is not None: - grid_available = True - grid_buy = float(grid_buy_raw) - grid_sell = float(grid_sell_raw) - - # Calculate P_grid - p_grid = None - if grid_available: - p_grid = grid_buy - grid_sell - - # 🔴异常流程(仅当电表可用且并网口处于充电态时生效) - # GridAvailable=true 且 GridBuy < OngridCharge 且 (OngridCharge - GridBuy) <= 50W - if grid_buy < ongrid_charge and (ongrid_charge - grid_buy) <= 50: - p_grid = p_ong - - # 5. Battery (Calculated) - # P_batt = P_pv + P_ac + P_ong - p_batt = pv + p_ac + p_ong - - # 6. Home (Calculated) - p_home = 0.0 - - if p_grid is not None: - # 电表可用 - p_home = p_grid - p_ong - - # 🔴 异常分支 1 - if grid_buy > 0 and ongrid_charge > 0 and grid_buy < ongrid_charge and (ongrid_charge - grid_buy) <= 50: - # p_grid = p_ong # Already handled in p_grid calc above? - # Note: User spec says "P_grid = P_ong (按异常流程先修正); P_home = 0" - # My P_grid calc above handled P_grid. Now P_home: - p_home = 0.0 - - # 🔴 异常分支 2 - elif grid_buy > 0 and ongrid_charge > 0 and grid_buy < ongrid_charge and (ongrid_charge - grid_buy) > 50: - p_home = ongrid_charge - grid_buy - - # 🔴 馈网场景分支 A - elif grid_sell > 0 and ongrid_supply > 0: - p_home = grid_sell - ongrid_supply - - # 🔴 馈网场景分支 B - elif grid_sell > 0 and ongrid_charge > 0: - p_home = grid_sell + ongrid_charge - - else: - # 电表不可用 (No CT) - if ongrid_supply > 0: - p_home = ongrid_supply - else: - p_home = 0.0 - - # Store calculated values - data["calc_home_power"] = p_home - data["calc_batt_net_power"] = p_batt - data["calc_battery_charge_power"] = max(0.0, p_batt) - data["calc_battery_discharge_power"] = max(0.0, -p_batt) - data["grid_available"] = grid_available - data["calc_grid_net_power"] = p_grid if grid_available else None + # Type 25 status response and any other type: + # merge into main cache but never touch sub-device lists + if isinstance(body, dict): + for k, v in body.items(): + if k not in ("plugs", "plug", "cts"): + self._cache[k] = v + self._cache = self._calc_energy_flow(self._cache) + self._distribute() except Exception as e: - _LOGGER.error(f"Error calculating energy flow: {e}") - - return data + _LOGGER.error("Jackery %s: error processing message: %s", self.device_sn, e) - def _distribute_data(self, data: dict) -> None: - """分发数据给传感器.""" - for sensor_id, entity in self._sensors.items(): - entity._update_from_coordinator(data) + def _handle_subdevice_event(self, body: dict) -> None: + raw_plugs = ( + body.get("plug") or body.get("plugs") + or body.get("socket") or body.get("sockets") or [] + ) + raw_cts = body.get("ct") or body.get("cts") or [] - def _mark_all_offline(self) -> None: - """Mark all entities as unavailable.""" - for entity in self._sensors.values(): + combined: list[dict] = [] + + if isinstance(raw_plugs, list): + for item in raw_plugs: + if isinstance(item, dict): + combined.append({**item, "devType": item.get("devType") or 6}) + + if isinstance(raw_cts, list): + for item in raw_cts: + if isinstance(item, dict): + sub = item.get("subType") + dt = item.get("devType") + if dt is None or sub == 2: + dt = 2 + combined.append({**item, "devType": dt}) + + self._cache["plugs"] = combined + self._cache["plug"] = combined + self._cache["cts"] = [i for i in combined if i.get("devType") == 2] + + self._discover_subdevices(combined) + + def _patch_subdevice(self, sn: str, body: dict) -> None: + for key in ("plugs", "plug", "cts"): + items = self._cache.get(key) + if isinstance(items, list): + for item in items: + if item.get("sn") == sn or item.get("deviceSn") == sn: + item.update(body) + + def _discover_subdevices(self, combined: list[dict]) -> None: + new_sensors: list = [] + new_switches: list = [] + + for item in combined: + sn = item.get("sn") or item.get("deviceSn") + dev_type = item.get("devType", 6) + if not sn or sn in self._known_subdevices: + continue + + self._known_subdevices.add(sn) + _LOGGER.info("Jackery %s: discovered sub-device %s (type %s)", + self.device_sn, sn, dev_type) + + group = "ct" if dev_type == 2 else "plug" + for sensor_key, sensor_cfg in SUBDEVICE_SENSORS.get(group, {}).items(): + new_sensors.append(JackerySubDeviceSensor( + plug_sn=sn, + dev_type=dev_type, + sensor_key=sensor_key, + sensor_config=sensor_cfg, + coordinator=self, + entry_id=self.entry_id, + )) + + if dev_type != 2: + from .switch import JackeryPlugSwitch + new_switches.append(JackeryPlugSwitch( + plug_sn=sn, + dev_type=dev_type, + coordinator=self, + entry_id=self.entry_id, + )) + + if new_sensors and self.add_sensor_entities: + self.add_sensor_entities(new_sensors) + if new_switches and self.add_switch_entities: + self.add_switch_entities(new_switches) + + # ------------------------------------------------------------------ + # Data distribution + # ------------------------------------------------------------------ + + def _distribute(self) -> None: + for entity in self._entities.values(): + entity._update_from_coordinator(self._cache) + + def _mark_all_unavailable(self) -> None: + for entity in self._entities.values(): if entity.available: entity._attr_available = False entity.async_write_ha_state() - async def _periodic_data_request(self) -> None: - """定期发送 'type: 25' 和 'type: 100' 指令.""" - _LOGGER.info(f"Starting periodic data polling for {self._device_sn} via {self._mqtt_host}...") + # ------------------------------------------------------------------ + # Poll loop + # ------------------------------------------------------------------ + + async def _poll_loop(self) -> None: + # Brief delay so platform setups can register their entity callbacks await asyncio.sleep(2) + _LOGGER.info("Jackery %s: poll loop started", self.device_sn) while True: try: - if time.time() - self._last_update_time > 60: - self._mark_all_offline() + if time.monotonic() - self._last_msg_time > 60: + self._mark_all_unavailable() - if not self._device_sn: - _LOGGER.debug("Waiting for device SN discovery...") - await asyncio.sleep(5) - continue - - # Construct Action Topic - action_topic = f"{self._topic_root}/device/{self._device_sn}/action" ts = int(time.time()) - - # 1. Poll Device Status (Type 25) - try: - payload_25 = { - "type": 25, - "eventId": 0, + + await ha_mqtt.async_publish( + self.hass, self._action_topic, + json.dumps({ + "type": 25, "eventId": 0, "messageId": random.randint(1000, 9999), - "ts": ts, - "token": self._token, - "body": None - } + "ts": ts, "token": self._token, "body": None, + }), 0, False, + ) + for dev_type in (2, 6): await ha_mqtt.async_publish( - self.hass, - action_topic, - json.dumps(payload_25), - 0, - False - ) - except Exception as e: - _LOGGER.warning(f"Error polling device status (Type 25): {e}") - - # 2. Poll Sub-devices (Type 100) - CTs (2) and Plugs (6) - try: - for dev_type in [2, 6]: - payload_100 = { - "type": 100, - "eventId": 0, + self.hass, self._action_topic, + json.dumps({ + "type": 100, "eventId": 0, "messageId": random.randint(1000, 9999), - "ts": ts, - "token": self._token, - "body": { - "devType": dev_type - } - } + "ts": ts, "token": self._token, + "body": {"devType": dev_type}, + }), 0, False, + ) + await asyncio.sleep(0.5) - await ha_mqtt.async_publish( - self.hass, - action_topic, - json.dumps(payload_100), - 0, - False - ) - await asyncio.sleep(0.5) # Avoid spamming too fast - except Exception as e: - _LOGGER.warning(f"Error polling sub-devices (Type 100): {e}") - - _LOGGER.debug(f"Sent poll requests (25 & 100 [2,6]) to {action_topic}") - - await asyncio.sleep(REQUEST_INTERVAL) + _LOGGER.debug("Jackery %s: poll sent", self.device_sn) + await asyncio.sleep(POLL_INTERVAL) except asyncio.CancelledError: break except Exception as e: - _LOGGER.error(f"Error in polling task: {e}") - await asyncio.sleep(REQUEST_INTERVAL) + _LOGGER.error("Jackery %s: poll error: %s", self.device_sn, e) + await asyncio.sleep(POLL_INTERVAL) + # ------------------------------------------------------------------ + # Device control + # ------------------------------------------------------------------ + + async def control_main(self, params: dict) -> None: + body = {"cmd": 5, "rc": 1, **params} + await ha_mqtt.async_publish( + self.hass, self._action_topic, + json.dumps({ + "type": 1, "eventId": 3, + "messageId": random.randint(1000, 9999), + "ts": int(time.time()), "token": self._token, + "body": body, + }), 0, False, + ) + + async def control_subdevice(self, plug_sn: str, dev_type: int, is_on: bool) -> None: + await ha_mqtt.async_publish( + self.hass, self._action_topic, + json.dumps({ + "type": 103, "eventId": 0, + "messageId": random.randint(1000, 9999), + "ts": int(time.time()), "token": self._token, + "body": { + "deviceSn": plug_sn, + "devType": dev_type, + "sysSwitch": 1 if is_on else 0, + }, + }), 0, False, + ) + + # ------------------------------------------------------------------ + # Energy flow calculations + # ------------------------------------------------------------------ + + def _calc_energy_flow(self, data: dict) -> dict: + try: + pv_val = data.get("pvPw", 0) + if isinstance(pv_val, dict): + pv = float( + pv_val.get("pvPw") or pv_val.get("w") or pv_val.get("power") or 0 + ) + else: + pv = float(pv_val or 0) + + ongrid_charge = float(data.get("inOngridPw") or 0) + ongrid_supply = float(data.get("outOngridPw") or 0) + p_ong = ongrid_charge - ongrid_supply + + ac_in = float(data.get("swEpsInPw") or 0) + ac_out = float(data.get("swEpsOutPw") or 0) + p_ac = ac_in - ac_out + + grid_available = False + grid_buy = 0.0 + grid_sell = 0.0 + + cts = data.get("cts") + if cts and isinstance(cts, list): + ct = cts[0] + t_pw = ct.get("TphasePw") or ct.get("tPhasePw") + tn_pw = ct.get("TnphasePw") or ct.get("tnPhasePw") + if t_pw is None: + a = float(ct.get("AphasePw") or ct.get("aPhasePw") or 0) + b = float(ct.get("BphasePw") or ct.get("bPhasePw") or 0) + c = float(ct.get("CphasePw") or ct.get("cPhasePw") or 0) + if any(v != 0 for v in (a, b, c)): + t_pw = a + b + c + if tn_pw is None: + a = float(ct.get("AnphasePw") or ct.get("anPhasePw") or 0) + b = float(ct.get("BnphasePw") or ct.get("bnPhasePw") or 0) + c = float(ct.get("CnphasePw") or ct.get("cnPhasePw") or 0) + if any(v != 0 for v in (a, b, c)): + tn_pw = a + b + c + if t_pw is not None or tn_pw is not None: + grid_buy = float(t_pw or 0) + grid_sell = float(tn_pw or 0) + grid_available = True + + if not grid_available: + gb = data.get("gridBuyPw") or data.get("gridInPw") + gs = data.get("gridSellPw") or data.get("gridOutPw") + if gb is not None and gs is not None: + grid_buy, grid_sell = float(gb), float(gs) + grid_available = True + + p_grid = None + p_home = 0.0 + + if grid_available: + p_grid = grid_buy - grid_sell + if grid_buy < ongrid_charge and (ongrid_charge - grid_buy) <= 50: + p_grid = p_ong + p_home = 0.0 + elif grid_buy > 0 and ongrid_charge > 0 and (ongrid_charge - grid_buy) > 50: + p_home = ongrid_charge - grid_buy + elif grid_sell > 0 and ongrid_supply > 0: + p_home = grid_sell - ongrid_supply + elif grid_sell > 0 and ongrid_charge > 0: + p_home = grid_sell + ongrid_charge + else: + p_home = p_grid - p_ong + else: + p_home = ongrid_supply if ongrid_supply > 0 else 0.0 + + p_batt = pv + p_ac + p_ong + + data["calc_home_power"] = p_home + data["calc_batt_net_power"] = p_batt + data["calc_battery_charge_power"] = max(0.0, p_batt) + data["calc_battery_discharge_power"] = max(0.0, -p_batt) + data["calc_grid_net_power"] = p_grid if grid_available else None + + except Exception as e: + _LOGGER.error("Jackery %s: energy flow calc error: %s", self.device_sn, e) + + return data + + +# --------------------------------------------------------------------------- +# Platform setup +# --------------------------------------------------------------------------- async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Jackery sensors.""" - config = config_entry.data - topic_prefix = config.get("topic_prefix", "hb") - token = config.get("token") - mqtt_host = config.get("mqtt_host") - device_sn = config.get("device_sn") + coordinator: JackeryCoordinator = hass.data[DOMAIN][config_entry.entry_id] - coordinator = JackeryDataCoordinator(hass, topic_prefix, token, mqtt_host, device_sn) - coordinator.config_entry_id = config_entry.entry_id # Assign entry_id - - # Register callback for dynamic entities - def add_entities_callback(new_entities): - async_add_entities(new_entities) - coordinator.add_entities_callback = add_entities_callback - - hass.data[DOMAIN][config_entry.entry_id]["coordinator"] = coordinator - - entities = [] - for sensor_id, sensor_config in SENSORS.items(): - if sensor_config.get("json_key") is None: - continue - - entity = JackerySensor( - sensor_id=sensor_id, - coordinator=coordinator, - config_entry_id=config_entry.entry_id, - ) - entities.append(entity) + coordinator.add_sensor_entities = async_add_entities + entities = [ + JackerySensor(sensor_id=sid, coordinator=coordinator) + for sid, cfg in SENSORS.items() + if cfg.get("json_key") + ] async_add_entities(entities) - await coordinator.async_start() +# --------------------------------------------------------------------------- +# Main device sensor entity +# --------------------------------------------------------------------------- + class JackerySensor(SensorEntity): - """Jackery Sensor.""" - # ... (Existing JackerySensor Code) ... - def __init__( - self, - sensor_id: str, - coordinator: JackeryDataCoordinator, - config_entry_id: str, - ) -> None: - """Initialize.""" + """Sensor for one data point on a Jackery main device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, sensor_id: str, coordinator: JackeryCoordinator) -> None: self._sensor_id = sensor_id self._coordinator = coordinator - self._config = SENSORS[sensor_id] + cfg = SENSORS[sensor_id] - self._attr_name = self._config["name"] - self._attr_native_unit_of_measurement = self._config["unit"] - self._attr_icon = self._config["icon"] - self._attr_device_class = self._config["device_class"] - self._attr_state_class = self._config["state_class"] - self._attr_unique_id = f"jackery_{config_entry_id}_{sensor_id}" - self._attr_has_entity_name = True - - device_sn = coordinator._device_sn or "Unknown" + self._attr_name = cfg["name"] + self._attr_native_unit_of_measurement = cfg["unit"] + self._attr_icon = cfg["icon"] + self._attr_device_class = cfg["device_class"] + self._attr_state_class = cfg["state_class"] + self._attr_unique_id = f"jackery_{coordinator.entry_id}_{sensor_id}" self._attr_device_info = { - "identifiers": {(DOMAIN, config_entry_id)}, - "name": f"Jackery {device_sn}", + "identifiers": {(DOMAIN, coordinator.entry_id)}, + "name": f"Jackery {coordinator.device_sn}", "manufacturer": "Jackery", "model": "Energy Monitor", } - @property - def should_poll(self) -> bool: - return False - async def async_added_to_hass(self) -> None: await super().async_added_to_hass() - self._coordinator.register_sensor(self._sensor_id, self) + self._coordinator.register_entity(self._sensor_id, self) async def async_will_remove_from_hass(self) -> None: - self._coordinator.unregister_sensor(self._sensor_id) + self._coordinator.unregister_entity(self._sensor_id) await super().async_will_remove_from_hass() def _update_from_coordinator(self, data: dict) -> None: - """Receive data from coordinator.""" - # Special handling for EPS Output Power (Bidirectional) + cfg = SENSORS[self._sensor_id] + json_key = cfg.get("json_key") + if self._sensor_id == "eps_output_power": - out_p = float(data.get("swEpsOutPw", 0)) - in_p = float(data.get("swEpsInPw", 0)) - self._attr_native_value = out_p - in_p + self._attr_native_value = ( + float(data.get("swEpsOutPw") or 0) - float(data.get("swEpsInPw") or 0) + ) self._attr_available = True self.async_write_ha_state() return - json_key = self._config.get("json_key") if not json_key or json_key not in data: return value = data[json_key] if self._sensor_id == "grid_net_power" and value is None: - # Keep last value when CT data is temporarily missing - return + return # keep last value - # Process specific conversions - if self._sensor_id == "battery_temperature": - # cellTemp is 0.1 C - try: + try: + if self._sensor_id == "battery_temperature": self._attr_native_value = float(value) * 0.1 - except (TypeError, ValueError): - pass - elif self._sensor_id == "battery_soc": - self._attr_native_value = value - elif self._sensor_id.startswith("solar_power_pv") and isinstance(value, dict): - # Handle dictionary for PV if it occurs - if "pvPw" in value: - self._attr_native_value = value["pvPw"] - elif "w" in value: - self._attr_native_value = value["w"] - elif "power" in value: - self._attr_native_value = value["power"] + elif self._sensor_id.startswith("solar_power_pv") and isinstance(value, dict): + self._attr_native_value = float( + value.get("pvPw") or value.get("w") or value.get("power") or 0 + ) else: - self._attr_native_value = str(value) - else: - scale = self._config.get("scale", 1) - try: + scale = cfg.get("scale", 1) self._attr_native_value = float(value) * scale - except (TypeError, ValueError): - self._attr_native_value = value + except (TypeError, ValueError): + self._attr_native_value = value self._attr_available = True self.async_write_ha_state() @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict: return { - "device_sn": self._coordinator._device_sn, - "raw_key": self._config.get("json_key") + "device_sn": self._coordinator.device_sn, + "raw_key": SENSORS[self._sensor_id].get("json_key"), } +# --------------------------------------------------------------------------- +# Sub-device sensor entity +# --------------------------------------------------------------------------- + class JackerySubDeviceSensor(SensorEntity): - """Jackery Smart Plug / CT Sub-device Sensor.""" + """Sensor for one data point on a Jackery sub-device (plug / CT).""" + + _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, @@ -1195,184 +799,137 @@ class JackerySubDeviceSensor(SensorEntity): dev_type: int, sensor_key: str, sensor_config: dict, - coordinator: JackeryDataCoordinator, - config_entry_id: str, + coordinator: JackeryCoordinator, + entry_id: str, ) -> None: - """Initialize.""" self._plug_sn = plug_sn self._dev_type = dev_type self._sensor_key = sensor_key self._sensor_config = sensor_config self._coordinator = coordinator - - # Determine Device Name based on Type - if self._dev_type == 2: - device_name = "CT" - else: - device_name = "Plug" + self._raw_data: dict = {} - # Entity Name: "Power", "Energy", etc. - self._attr_name = self._sensor_config["name"] - - self._attr_native_unit_of_measurement = self._sensor_config.get("unit") - self._attr_icon = self._sensor_config.get("icon") - self._attr_device_class = self._sensor_config.get("device_class") - self._attr_state_class = self._sensor_config.get("state_class") - - # Unique ID: jackery_ct_{sn}_power, jackery_plug_{sn}_energy, etc. - safe_key = self._sensor_key.replace("_", "") # e.g. energy_import -> energyimport - self._attr_unique_id = f"jackery_{device_name.lower()}_{plug_sn}_{safe_key}" - self._attr_has_entity_name = True + kind = "CT" if dev_type == 2 else "Plug" + safe_key = sensor_key.replace("_", "") + self._attr_name = sensor_config["name"] + self._attr_native_unit_of_measurement = sensor_config.get("unit") + self._attr_icon = sensor_config.get("icon") + self._attr_device_class = sensor_config.get("device_class") + self._attr_state_class = sensor_config.get("state_class") + self._attr_unique_id = f"jackery_{kind.lower()}_{plug_sn}_{safe_key}" self._attr_device_info = { - "identifiers": {(DOMAIN, f"sub_{plug_sn}")}, - "via_device": (DOMAIN, config_entry_id), - "name": f"Jackery {device_name} {plug_sn}", + "identifiers": {(DOMAIN, f"sub_{plug_sn}")}, + "via_device": (DOMAIN, entry_id), + "name": f"Jackery {kind} {plug_sn}", "manufacturer": "Jackery", "model": f"Sub-device Type {dev_type}", } - @property - def should_poll(self) -> bool: - return False - async def async_added_to_hass(self) -> None: await super().async_added_to_hass() - # Register with coordinator using a unique ID format - self._coordinator.register_sensor(self._attr_unique_id, self) + self._coordinator.register_entity(self._attr_unique_id, self) async def async_will_remove_from_hass(self) -> None: - self._coordinator.unregister_sensor(self._attr_unique_id) + self._coordinator.unregister_entity(self._attr_unique_id) await super().async_will_remove_from_hass() def _update_from_coordinator(self, data: dict) -> None: - """Receive data from coordinator.""" - if self._dev_type == 2: - plugs = data.get("cts") - else: - plugs = data.get("plugs") or data.get("plug") - if not plugs or not isinstance(plugs, list): + pool = data.get("cts") if self._dev_type == 2 else ( + data.get("plugs") or data.get("plug") + ) + if not isinstance(pool, list): return - # Find my plug data - my_plug = next((p for p in plugs if (p.get("sn") == self._plug_sn or p.get("deviceSn") == self._plug_sn)), None) - if not my_plug: + my = next( + (p for p in pool + if p.get("sn") == self._plug_sn or p.get("deviceSn") == self._plug_sn), + None, + ) + if not my: return - # Store full raw data for attributes - self._raw_data = dict(my_plug) - - target_key = self._sensor_config.get("key") - val = my_plug.get(target_key) + self._raw_data = dict(my) + val = self._resolve_value(my) - # CT phase mapping by subType (1=A, 2=B, 3=C, 4=Total) - if self._dev_type == 2 and target_key in {"phasePw", "phaseEgy"}: - sub_type = my_plug.get("subType") - if target_key == "phasePw": - if sub_type == 1: - val = my_plug.get("AphasePw") or my_plug.get("aPhasePw") - elif sub_type == 2: - val = my_plug.get("BphasePw") or my_plug.get("bPhasePw") - elif sub_type == 3: - # C 相(单相:A+B 路) - val = my_plug.get("CphasePw") or my_plug.get("cPhasePw") - if not val: - a_pw = my_plug.get("AphasePw") or my_plug.get("aPhasePw") or 0 - b_pw = my_plug.get("BphasePw") or my_plug.get("bPhasePw") or 0 - if any(v is not None for v in [a_pw, b_pw]): - val = float(a_pw) + float(b_pw) - else: - val = my_plug.get("TphasePw") or my_plug.get("tPhasePw") - else: - if sub_type == 1: - val = my_plug.get("AphaseEgy") or my_plug.get("aPhaseEgy") - elif sub_type == 2: - val = my_plug.get("BphaseEgy") or my_plug.get("bPhaseEgy") - elif sub_type == 3: - # C 相(单相:A+B 路) - val = my_plug.get("CphaseEgy") or my_plug.get("cPhaseEgy") - if not val: - a_egy = my_plug.get("AphaseEgy") or my_plug.get("aPhaseEgy") or 0 - b_egy = my_plug.get("BphaseEgy") or my_plug.get("bPhaseEgy") or 0 - if any(v is not None for v in [a_egy, b_egy]): - val = float(a_egy) + float(b_egy) - else: - val = my_plug.get("TphaseEgy") or my_plug.get("tPhaseEgy") - # If subtype energy is zero/None but total is non-zero, fall back to the single non-zero phase - if not val: - total_egy = my_plug.get("TphaseEgy") or my_plug.get("tPhaseEgy") - if total_egy: - a_egy = my_plug.get("AphaseEgy") or my_plug.get("aPhaseEgy") or 0 - b_egy = my_plug.get("BphaseEgy") or my_plug.get("bPhaseEgy") or 0 - c_egy = my_plug.get("CphaseEgy") or my_plug.get("cPhaseEgy") or 0 - non_zero = [v for v in [a_egy, b_egy, c_egy] if v] - if len(non_zero) == 1: - val = non_zero[0] - - # Fallback logic for specific keys if needed (like Power) - if val is None: - if target_key == "outPw": - val = my_plug.get("power") - elif target_key == "TphasePw": - # Accept alternate key casing and sum phase powers if needed - val = my_plug.get("tPhasePw") - if val is None: - a_pw = my_plug.get("AphasePw") or my_plug.get("aPhasePw") or 0 - b_pw = my_plug.get("BphasePw") or my_plug.get("bPhasePw") or 0 - c_pw = my_plug.get("CphasePw") or my_plug.get("cPhasePw") or 0 - if any(v is not None for v in [a_pw, b_pw, c_pw]): - val = float(a_pw) + float(b_pw) + float(c_pw) - elif target_key == "TphaseEgy": - # Total forward active energy - val = my_plug.get("tPhaseEgy") - if val is None: - a_egy = my_plug.get("AphaseEgy") or my_plug.get("aPhaseEgy") or 0 - b_egy = my_plug.get("BphaseEgy") or my_plug.get("bPhaseEgy") or 0 - c_egy = my_plug.get("CphaseEgy") or my_plug.get("cPhaseEgy") or 0 - if any(v is not None for v in [a_egy, b_egy, c_egy]): - val = float(a_egy) + float(b_egy) + float(c_egy) - elif target_key == "TnphaseEgy": - # Total reverse active energy - val = my_plug.get("tnPhaseEgy") - if val is None: - an_egy = my_plug.get("AnphaseEgy") or my_plug.get("anPhaseEgy") or 0 - bn_egy = my_plug.get("BnphaseEgy") or my_plug.get("bnPhaseEgy") or 0 - cn_egy = my_plug.get("CnphaseEgy") or my_plug.get("cnPhaseEgy") or 0 - if any(v is not None for v in [an_egy, bn_egy, cn_egy]): - val = float(an_egy) + float(bn_egy) + float(cn_egy) - if val is not None: try: - native_val = float(val) scale = self._sensor_config.get("scale", 1) - self._attr_native_value = native_val * scale + self._attr_native_value = float(val) * scale self._attr_available = True self.async_write_ha_state() except (TypeError, ValueError): pass + def _resolve_value(self, plug: dict) -> Any: + target = self._sensor_config.get("key") + val = plug.get(target) + + # CT phase mapping + if self._dev_type == 2 and target in ("phasePw", "phaseEgy"): + sub = plug.get("subType") + is_pw = target == "phasePw" + phase_map = { + 1: ("AphasePw", "aPhasePw") if is_pw else ("AphaseEgy", "aPhaseEgy"), + 2: ("BphasePw", "bPhasePw") if is_pw else ("BphaseEgy", "bPhaseEgy"), + 3: ("CphasePw", "cPhasePw") if is_pw else ("CphaseEgy", "cPhaseEgy"), + } + total_keys = ("TphasePw", "tPhasePw") if is_pw else ("TphaseEgy", "tPhaseEgy") + + if sub in phase_map: + k1, k2 = phase_map[sub] + val = plug.get(k1) or plug.get(k2) + if val is None and sub == 3: + # C-phase fallback: sum A+B + ak1, ak2 = phase_map[1] + bk1, bk2 = phase_map[2] + a = float(plug.get(ak1) or plug.get(ak2) or 0) + b = float(plug.get(bk1) or plug.get(bk2) or 0) + if a or b: + val = a + b + else: + val = plug.get(total_keys[0]) or plug.get(total_keys[1]) + + # Plug fallbacks + if val is None: + if target == "outPw": + val = plug.get("power") + elif target in ("TphasePw", "TphaseEgy", "TnphaseEgy"): + alt = { + "TphasePw": ("tPhasePw", "AphasePw", "BphasePw", "CphasePw", + "aPhasePw", "bPhasePw", "cPhasePw"), + "TphaseEgy": ("tPhaseEgy", "AphaseEgy", "BphaseEgy", "CphaseEgy", + "aPhaseEgy", "bPhaseEgy", "cPhaseEgy"), + "TnphaseEgy": ("tnPhaseEgy", "AnphaseEgy", "BnphaseEgy", "CnphaseEgy", + "anPhaseEgy", "bnPhaseEgy", "cnPhaseEgy"), + } + keys = alt[target] + val = plug.get(keys[0]) + if val is None: + a = float(plug.get(keys[1]) or plug.get(keys[4]) or 0) + b = float(plug.get(keys[2]) or plug.get(keys[5]) or 0) + c = float(plug.get(keys[3]) or plug.get(keys[6]) or 0) + if any((a, b, c)): + val = a + b + c + + return val + @property - def extra_state_attributes(self) -> dict[str, Any]: - raw = getattr(self, "_raw_data", None) or {} + def extra_state_attributes(self) -> dict: + raw = self._raw_data return { "plug_sn": self._plug_sn, "dev_type": self._dev_type, "sensor_type": self._sensor_key, "subType": raw.get("subType"), - # Normalized CT/plug fields (if present) "sn": raw.get("sn") or raw.get("deviceSn"), "name": raw.get("name") or raw.get("scanName"), "commState": raw.get("commState"), - # Plug fields "inPw": raw.get("inPw"), "outPw": raw.get("outPw"), "sysSwitch": raw.get("sysSwitch") if raw.get("sysSwitch") is not None else raw.get("switchSta"), "totalEgy": raw.get("totalEgy"), - # CT Fields - "TphasePw": raw.get("TphasePw"), - "TphaseEgy": raw.get("TphaseEgy"), - "TnphaseEgy": raw.get("TnphaseEgy"), - "tPhasePw": raw.get("tPhasePw"), - "tPhaseEgy": raw.get("tPhaseEgy"), - "tnPhaseEgy": raw.get("tnPhaseEgy"), + "TphasePw": raw.get("TphasePw") or raw.get("tPhasePw"), + "TphaseEgy": raw.get("TphaseEgy") or raw.get("tPhaseEgy"), + "TnphaseEgy": raw.get("TnphaseEgy") or raw.get("tnPhaseEgy"), } diff --git a/custom_components/jackery/switch.py b/custom_components/jackery/switch.py index afbb4b3..451b7ad 100644 --- a/custom_components/jackery/switch.py +++ b/custom_components/jackery/switch.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN if TYPE_CHECKING: - from .sensor import JackeryDataCoordinator + from .sensor import JackeryCoordinator _LOGGER = logging.getLogger(__name__) @@ -20,178 +20,43 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Jackery switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - if coordinator is None: - _LOGGER.warning("Coordinator not ready for switches") - return + coordinator: "JackeryCoordinator" = hass.data[DOMAIN][config_entry.entry_id] - # Register callback for dynamic switch entities - def add_switch_entities_callback(new_entities): - async_add_entities(new_entities) - coordinator.add_switch_entities_callback = add_switch_entities_callback + coordinator.add_switch_entities = async_add_entities - entities = [] - - # Main device switches - entities.extend( - [ - JackeryMainSwitch( - key="isAutoStandby", - name="Auto Standby Allowed", - coordinator=coordinator, - config_entry_id=config_entry.entry_id, - ), - JackeryMainSwitch( - key="swEps", - name="EPS Switch", - coordinator=coordinator, - config_entry_id=config_entry.entry_id, - ), - ] - ) - - # Add any existing sub-devices as switches (non-CT) - for item in coordinator.get_subdevices(): - sn = item.get("deviceSn") or item.get("sn") - dev_type = item.get("devType") - if dev_type is None and item.get("subType") == 2: - dev_type = 2 - if sn and dev_type != 2: - entities.append( - JackeryPlugSwitch( - plug_sn=sn, - dev_type=dev_type, - coordinator=coordinator, - config_entry_id=config_entry.entry_id, - ) - ) - - if entities: - async_add_entities(entities) - - -class JackeryPlugSwitch(SwitchEntity): - """Jackery Smart Plug Switch.""" - - def __init__( - self, - plug_sn: str, - dev_type: int, - coordinator: "JackeryDataCoordinator", - config_entry_id: str, - ) -> None: - """Initialize.""" - self._plug_sn = plug_sn - self._dev_type = dev_type - self._coordinator = coordinator - self._raw_data = {} - - self._attr_name = "Switch" - self._attr_unique_id = f"jackery_plug_{plug_sn}_switch" - self._attr_has_entity_name = True - - self._attr_device_info = { - "identifiers": {(DOMAIN, f"sub_{plug_sn}")}, - "via_device": (DOMAIN, config_entry_id), - "name": f"Jackery Plug {plug_sn}", - "manufacturer": "Jackery", - "model": f"Sub-device Type {dev_type}", - } - - @property - def should_poll(self) -> bool: - return False - - async def async_added_to_hass(self) -> None: - await super().async_added_to_hass() - self._coordinator.register_sensor(f"plug_switch_{self._plug_sn}", self) - - async def async_will_remove_from_hass(self) -> None: - self._coordinator.unregister_sensor(f"plug_switch_{self._plug_sn}") - await super().async_will_remove_from_hass() - - def _update_from_coordinator(self, data: dict) -> None: - plugs = data.get("plugs") or data.get("plug") - if not plugs or not isinstance(plugs, list): - return - - my_plug = next((p for p in plugs if (p.get("sn") == self._plug_sn or p.get("deviceSn") == self._plug_sn)), None) - if not my_plug: - return - - self._raw_data = dict(my_plug) - val = my_plug.get("sysSwitch") - if val is None: - val = my_plug.get("switchSta") - if val is None: - return - - self._attr_is_on = bool(int(val)) - self._attr_available = True - self.async_write_ha_state() - - async def async_turn_on(self, **kwargs: Any) -> None: - await self._coordinator.async_control_subdevice_switch( - plug_sn=self._plug_sn, - dev_type=self._dev_type, - is_on=True, - ) - - async def async_turn_off(self, **kwargs: Any) -> None: - await self._coordinator.async_control_subdevice_switch( - plug_sn=self._plug_sn, - dev_type=self._dev_type, - is_on=False, - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - return { - "plug_sn": self._plug_sn, - "dev_type": self._dev_type, - "raw_data": self._raw_data, - } + async_add_entities([ + JackeryMainSwitch("isAutoStandby", "Auto Standby Allowed", coordinator), + JackeryMainSwitch("swEps", "EPS Switch", coordinator), + ]) class JackeryMainSwitch(SwitchEntity): - """Main device switch (cmd=5).""" + """On/off switch for a main-device boolean setting.""" - def __init__( - self, - key: str, - name: str, - coordinator: "JackeryDataCoordinator", - config_entry_id: str, - ) -> None: + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, key: str, name: str, coordinator: "JackeryCoordinator") -> None: self._key = key self._coordinator = coordinator self._attr_name = name - self._attr_unique_id = f"jackery_{config_entry_id}_main_{key}" - self._attr_has_entity_name = True - device_sn = coordinator._device_sn or "Unknown" + self._attr_unique_id = f"jackery_{coordinator.entry_id}_main_{key}" self._attr_device_info = { - "identifiers": {(DOMAIN, config_entry_id)}, - "name": f"Jackery {device_sn}", + "identifiers": {(DOMAIN, coordinator.entry_id)}, + "name": f"Jackery {coordinator.device_sn}", "manufacturer": "Jackery", "model": "Energy Monitor", } - @property - def should_poll(self) -> bool: - return False - async def async_added_to_hass(self) -> None: await super().async_added_to_hass() - self._coordinator.register_sensor(f"main_switch_{self._key}", self) + self._coordinator.register_entity(f"main_switch_{self._key}", self) async def async_will_remove_from_hass(self) -> None: - self._coordinator.unregister_sensor(f"main_switch_{self._key}") + self._coordinator.unregister_entity(f"main_switch_{self._key}") await super().async_will_remove_from_hass() def _update_from_coordinator(self, data: dict) -> None: - if self._key not in data: - return val = data.get(self._key) if val is None: return @@ -200,7 +65,77 @@ class JackeryMainSwitch(SwitchEntity): self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: - await self._coordinator.async_control_main_device({self._key: 1}) + await self._coordinator.control_main({self._key: 1}) async def async_turn_off(self, **kwargs: Any) -> None: - await self._coordinator.async_control_main_device({self._key: 0}) + await self._coordinator.control_main({self._key: 0}) + + +class JackeryPlugSwitch(SwitchEntity): + """On/off switch for a smart plug sub-device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + plug_sn: str, + dev_type: int, + coordinator: "JackeryCoordinator", + entry_id: str, + ) -> None: + self._plug_sn = plug_sn + self._dev_type = dev_type + self._coordinator = coordinator + self._raw_data: dict = {} + + self._attr_name = "Switch" + self._attr_unique_id = f"jackery_plug_{plug_sn}_switch" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"sub_{plug_sn}")}, + "via_device": (DOMAIN, entry_id), + "name": f"Jackery Plug {plug_sn}", + "manufacturer": "Jackery", + "model": f"Sub-device Type {dev_type}", + } + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self._coordinator.register_entity(f"plug_switch_{self._plug_sn}", self) + + async def async_will_remove_from_hass(self) -> None: + self._coordinator.unregister_entity(f"plug_switch_{self._plug_sn}") + await super().async_will_remove_from_hass() + + def _update_from_coordinator(self, data: dict) -> None: + pool = data.get("plugs") or data.get("plug") + if not isinstance(pool, list): + return + my = next( + (p for p in pool + if p.get("sn") == self._plug_sn or p.get("deviceSn") == self._plug_sn), + None, + ) + if not my: + return + self._raw_data = dict(my) + val = my.get("sysSwitch") if my.get("sysSwitch") is not None else my.get("switchSta") + if val is None: + return + self._attr_is_on = bool(int(val)) + self._attr_available = True + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + await self._coordinator.control_subdevice(self._plug_sn, self._dev_type, True) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self._coordinator.control_subdevice(self._plug_sn, self._dev_type, False) + + @property + def extra_state_attributes(self) -> dict: + return { + "plug_sn": self._plug_sn, + "dev_type": self._dev_type, + "raw_data": self._raw_data, + }