Files
homeassistant-jackery/custom_components/jackery/sensor.py
Timo b7e13b73a5
Some checks failed
Validate / Validate (push) Has been cancelled
add polling for endpoint 23
2026-05-31 12:39:23 +02:00

948 lines
38 KiB
Python

"""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")
keys = list(body.keys()) if isinstance(body, dict) else body
_LOGGER.info("Jackery %s: received type=%s keys=%s", self.device_sn, msg_type, keys)
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,
)
# Request energy statistics
await ha_mqtt.async_publish(
self.hass, self._action_topic,
json.dumps({
"type": 23, "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"),
}