948 lines
38 KiB
Python
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 = 3 # 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"),
|
|
}
|