"""Jackery Sensor Platform.""" import asyncio import json import logging import random import time from typing import Any from homeassistant.components import mqtt as ha_mqtt from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN _LOGGER = logging.getLogger(__name__) POLL_INTERVAL = 10 # seconds between poll requests # --------------------------------------------------------------------------- # Sensor descriptors # --------------------------------------------------------------------------- SENSORS: dict[str, dict] = { "battery_soc": { "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", "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", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "battery_temperature": { "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, }, "battery_charge_energy": { "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, }, "battery_discharge_energy": { "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, }, "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", "device_class": SensorDeviceClass.ENERGY, "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", "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", "device_class": SensorDeviceClass.ENERGY, "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", "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", "device_class": SensorDeviceClass.ENERGY, "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", "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", "device_class": SensorDeviceClass.ENERGY, "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", "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", "device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "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", "device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, }, "eps_output_power": { "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", "device_class": SensorDeviceClass.ENERGY, "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", "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", "device_class": SensorDeviceClass.ENERGY, "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, }, "eps_switch": { "json_key": "swEps", "name": "EPS Switch Status", "unit": None, "icon": "mdi:toggle-switch", "device_class": None, "state_class": None, }, "soc_charge_limit": { "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, }, "home_power": { "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", "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", "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", "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", "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "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", "device_class": SensorDeviceClass.ENERGY, "state_class": SensorStateClass.TOTAL_INCREASING, "scale": 0.01, }, } SUBDEVICE_SENSORS: dict[str, dict] = { "plug": { "power": { "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, "state_class": SensorStateClass.TOTAL_INCREASING, "icon": "mdi:lightning-bolt", "scale": 0.01, }, }, "ct": { "power": { "key": "phasePw", "name": "Power", "unit": UnitOfPower.WATT, "device_class": SensorDeviceClass.POWER, "state_class": SensorStateClass.MEASUREMENT, "icon": "mdi:current-ac", }, "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, }, }, } # --------------------------------------------------------------------------- # Coordinator # --------------------------------------------------------------------------- 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.entry_id = entry_id self.device_sn = device_sn self._token = token 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._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() # Registered by platform async_setup_entry calls self.add_sensor_entities: Any = None self.add_switch_entities: Any = None # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ async def async_start(self) -> None: @callback def on_message(msg: Any) -> None: self._on_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) self._poll_task = asyncio.create_task(self._poll_loop()) async def async_stop(self) -> None: if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: await self._poll_task except asyncio.CancelledError: pass _LOGGER.info("Jackery %s: coordinator stopped", self.device_sn) # ------------------------------------------------------------------ # 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: payload = msg.payload if isinstance(payload, bytes): payload = payload.decode("utf-8") raw = json.loads(payload) except Exception: _LOGGER.warning("Jackery %s: invalid MQTT payload on %s", self.device_sn, msg.topic) return msg_type = raw.get("type") body = raw.get("body") _LOGGER.info("Jackery %s: received type=%s on %s", self.device_sn, msg_type, msg.topic) try: 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: # 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("Jackery %s: error processing message: %s", self.device_sn, e) 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 [] 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() # ------------------------------------------------------------------ # 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.monotonic() - self._last_msg_time > 60: self._mark_all_unavailable() ts = int(time.time()) 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, }), 0, False, ) for dev_type in (2, 6): await ha_mqtt.async_publish( 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}, }), 0, False, ) await asyncio.sleep(0.5) _LOGGER.debug("Jackery %s: poll sent", self.device_sn) await asyncio.sleep(POLL_INTERVAL) except asyncio.CancelledError: break except Exception as e: _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: coordinator: JackeryCoordinator = hass.data[DOMAIN][config_entry.entry_id] 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) # --------------------------------------------------------------------------- # Main device sensor entity # --------------------------------------------------------------------------- class JackerySensor(SensorEntity): """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 cfg = SENSORS[sensor_id] 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, coordinator.entry_id)}, "name": f"Jackery {coordinator.device_sn}", "manufacturer": "Jackery", "model": "Energy Monitor", } async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self._coordinator.register_entity(self._sensor_id, self) async def async_will_remove_from_hass(self) -> None: self._coordinator.unregister_entity(self._sensor_id) await super().async_will_remove_from_hass() def _update_from_coordinator(self, data: dict) -> None: cfg = SENSORS[self._sensor_id] json_key = cfg.get("json_key") if self._sensor_id == "eps_output_power": 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 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: return # keep last value try: if self._sensor_id == "battery_temperature": self._attr_native_value = float(value) * 0.1 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: scale = cfg.get("scale", 1) self._attr_native_value = float(value) * scale except (TypeError, ValueError): self._attr_native_value = value self._attr_available = True self.async_write_ha_state() @property def extra_state_attributes(self) -> dict: return { "device_sn": self._coordinator.device_sn, "raw_key": SENSORS[self._sensor_id].get("json_key"), } # --------------------------------------------------------------------------- # Sub-device sensor entity # --------------------------------------------------------------------------- class JackerySubDeviceSensor(SensorEntity): """Sensor for one data point on a Jackery sub-device (plug / CT).""" _attr_should_poll = False _attr_has_entity_name = True def __init__( self, plug_sn: str, dev_type: int, sensor_key: str, sensor_config: dict, coordinator: JackeryCoordinator, entry_id: str, ) -> None: self._plug_sn = plug_sn self._dev_type = dev_type self._sensor_key = sensor_key self._sensor_config = sensor_config self._coordinator = coordinator self._raw_data: dict = {} 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, entry_id), "name": f"Jackery {kind} {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(self._attr_unique_id, self) async def async_will_remove_from_hass(self) -> None: self._coordinator.unregister_entity(self._attr_unique_id) await super().async_will_remove_from_hass() def _update_from_coordinator(self, data: dict) -> None: pool = data.get("cts") if self._dev_type == 2 else ( 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 = self._resolve_value(my) if val is not None: try: scale = self._sensor_config.get("scale", 1) 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: raw = self._raw_data return { "plug_sn": self._plug_sn, "dev_type": self._dev_type, "sensor_type": self._sensor_key, "subType": raw.get("subType"), "sn": raw.get("sn") or raw.get("deviceSn"), "name": raw.get("name") or raw.get("scanName"), "commState": raw.get("commState"), "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"), "TphasePw": raw.get("TphasePw") or raw.get("tPhasePw"), "TphaseEgy": raw.get("TphaseEgy") or raw.get("tPhaseEgy"), "TnphaseEgy": raw.get("TnphaseEgy") or raw.get("tnPhaseEgy"), }