From 66eb2af2d6cf9046e479fa6f7a2eee86630dcc85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E6=B1=82=E5=9C=A3=E5=89=91?= Date: Mon, 2 Feb 2026 15:42:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A7=E5=88=B6=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/jackery/__init__.py | 3 +- custom_components/jackery/number.py | 110 +++++++++++ custom_components/jackery/sensor.py | 87 +++++++++ custom_components/jackery/switch.py | 256 ++++++++++++++++++++++++++ 4 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 custom_components/jackery/number.py create mode 100644 custom_components/jackery/switch.py diff --git a/custom_components/jackery/__init__.py b/custom_components/jackery/__init__.py index adfaeb4..c91011c 100644 --- a/custom_components/jackery/__init__.py +++ b/custom_components/jackery/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import mqtt _LOGGER = logging.getLogger(__name__) DOMAIN = "jackery" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -58,4 +58,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - diff --git a/custom_components/jackery/number.py b/custom_components/jackery/number.py new file mode 100644 index 0000000..07510a3 --- /dev/null +++ b/custom_components/jackery/number.py @@ -0,0 +1,110 @@ +"""Jackery Number Platform.""" +import logging +from typing import Any, TYPE_CHECKING + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + +if TYPE_CHECKING: + from .sensor import JackeryDataCoordinator + +_LOGGER = logging.getLogger(__name__) + + +NUMBERS = { + "socChgLimit": {"name": "SOC Charge Limit", "min": 0, "max": 100, "step": 1}, + "socDischgLimit": {"name": "SOC Discharge Limit", "min": 0, "max": 100, "step": 1}, + "maxOutPw": {"name": "Max Output Power (OnGrid)", "min": 0, "max": 10000, "step": 10}, + "autoStandby": {"name": "Auto Standby Mode", "min": 0, "max": 2, "step": 1}, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Jackery number entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + if coordinator is None: + _LOGGER.warning("Coordinator not ready for numbers") + return + + entities = [] + for key, cfg in NUMBERS.items(): + entities.append( + JackeryMainNumber( + key=key, + name=cfg["name"], + min_value=cfg["min"], + max_value=cfg["max"], + step=cfg["step"], + coordinator=coordinator, + config_entry_id=config_entry.entry_id, + ) + ) + + if entities: + async_add_entities(entities) + + +class JackeryMainNumber(NumberEntity): + """Main device number (cmd=5).""" + + def __init__( + self, + key: str, + name: str, + min_value: float, + max_value: float, + step: float, + coordinator: "JackeryDataCoordinator", + config_entry_id: str, + ) -> None: + self._key = key + self._coordinator = coordinator + self._attr_name = name + self._attr_unique_id = f"jackery_main_{key}" + self._attr_has_entity_name = True + self._attr_mode = NumberMode.SLIDER + self._attr_native_min_value = min_value + self._attr_native_max_value = max_value + self._attr_native_step = step + self._attr_device_info = { + "identifiers": {(DOMAIN, config_entry_id)}, + "name": "Jackery", + "manufacturer": "Jackery", + "model": "Energy Monitor", + } + + @property + def should_poll(self) -> bool: + return False + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self._coordinator.register_sensor(f"main_number_{self._key}", self) + + async def async_will_remove_from_hass(self) -> None: + self._coordinator.unregister_sensor(f"main_number_{self._key}") + await super().async_will_remove_from_hass() + + def _update_from_coordinator(self, data: dict) -> None: + if self._key not in data: + return + val = data.get(self._key) + if val is None: + return + try: + self._attr_native_value = float(val) + self._attr_available = True + self.async_write_ha_state() + except (TypeError, ValueError): + pass + + async def async_set_native_value(self, value: float) -> None: + await self._coordinator.async_control_main_device({self._key: int(value)}) diff --git a/custom_components/jackery/sensor.py b/custom_components/jackery/sensor.py index 99b7d85..8aa7948 100644 --- a/custom_components/jackery/sensor.py +++ b/custom_components/jackery/sensor.py @@ -267,6 +267,7 @@ class JackeryDataCoordinator: self._known_plugs = set() # Set of known plug SNs self.add_entities_callback = None # Callback to add new entities + self.add_switch_entities_callback = None # Callback to add new switch entities self._data_cache = {} # Cache for merged data from status and events # Topic patterns @@ -436,6 +437,7 @@ class JackeryDataCoordinator: return new_entities = [] + new_switch_entities = [] for plug in plugs: # Check SN key (could be 'sn' or 'deviceSn') sn = plug.get("deviceSn") or plug.get("sn") @@ -457,9 +459,88 @@ class JackeryDataCoordinator: config_entry_id=self.config_entry_id ) new_entities.append(entity) + if dev_type != 2: + from .switch import JackeryPlugSwitch + switch_entity = JackeryPlugSwitch( + plug_sn=sn, + dev_type=dev_type, + coordinator=self, + config_entry_id=self.config_entry_id + ) + new_switch_entities.append(switch_entity) if new_entities and self.add_entities_callback: self.add_entities_callback(new_entities) + if new_switch_entities and self.add_switch_entities_callback: + self.add_switch_entities_callback(new_switch_entities) + + def get_subdevices(self) -> list[dict[str, Any]]: + """Return latest sub-device list from cache.""" + plugs = self._data_cache.get("plugs") or self._data_cache.get("plug") + if isinstance(plugs, list): + return [p for p in plugs if isinstance(p, dict)] + cts = self._data_cache.get("cts") + if isinstance(cts, list): + return [p for p in cts if isinstance(p, dict)] + return [] + + async def async_control_subdevice_switch(self, plug_sn: str, dev_type: int, is_on: bool) -> None: + """Control sub-device switch via type 103.""" + if not self._device_sn: + _LOGGER.warning("Cannot control sub-device: device SN not discovered") + return + + action_topic = f"{self._topic_root}/device/{self._device_sn}/action" + ts = int(time.time()) + payload = { + "type": 103, + "eventId": 0, + "messageId": random.randint(1000, 9999), + "ts": ts, + "body": { + "deviceSn": plug_sn, + "devType": dev_type, + "sysSwitch": 1 if is_on else 0, + }, + } + if self._token: + payload["token"] = self._token + + await ha_mqtt.async_publish( + self.hass, + action_topic, + json.dumps(payload), + 0, + False + ) + + async def async_control_main_device(self, params: dict[str, Any]) -> None: + """Control main device via type 1, cmd 5.""" + if not self._device_sn: + _LOGGER.warning("Cannot control main device: device SN not discovered") + return + + action_topic = f"{self._topic_root}/device/{self._device_sn}/action" + ts = int(time.time()) + body = {"cmd": 5, "rc": 1} + body.update(params) + payload = { + "type": 1, + "eventId": 3, + "messageId": random.randint(1000, 9999), + "ts": ts, + "body": body, + } + if self._token: + payload["token"] = self._token + + await ha_mqtt.async_publish( + self.hass, + action_topic, + json.dumps(payload), + 0, + False + ) def _calculate_energy_flow(self, data: dict) -> dict: """ @@ -895,6 +976,12 @@ class JackeryPlugSensor(SensorEntity): "commState": raw.get("commState"), "funForm": raw.get("funForm"), "schePhase": raw.get("schePhase"), + # Plug fields + "inPw": raw.get("inPw"), + "outPw": raw.get("outPw"), + "sysSwitch": raw.get("sysSwitch") if raw.get("sysSwitch") is not None else raw.get("switchSta"), + "socketPri": raw.get("socketPri"), + "totalEgy": raw.get("totalEgy"), "AphasePw": raw.get("AphasePw") or raw.get("aPhasePw"), "BphasePw": raw.get("BphasePw") or raw.get("bPhasePw"), "CphasePw": raw.get("CphasePw") or raw.get("cPhasePw"), diff --git a/custom_components/jackery/switch.py b/custom_components/jackery/switch.py new file mode 100644 index 0000000..99abbed --- /dev/null +++ b/custom_components/jackery/switch.py @@ -0,0 +1,256 @@ +"""Jackery Switch Platform.""" +import logging +from typing import Any, TYPE_CHECKING + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + +if TYPE_CHECKING: + from .sensor import JackeryDataCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Jackery switches.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + if coordinator is None: + _LOGGER.warning("Coordinator not ready for switches") + return + + # Register callback for dynamic switch entities + def add_switch_entities_callback(new_entities): + async_add_entities(new_entities) + coordinator.add_switch_entities_callback = add_switch_entities_callback + + entities = [] + + # Main device switches + entities.extend( + [ + JackeryMainSwitch( + key="isAutoStandby", + name="Auto Standby Allowed", + coordinator=coordinator, + config_entry_id=config_entry.entry_id, + ), + JackeryMainSwitch( + key="swEps", + name="EPS Switch", + coordinator=coordinator, + config_entry_id=config_entry.entry_id, + ), + JackeryRebootSwitch( + coordinator=coordinator, + config_entry_id=config_entry.entry_id, + ), + ] + ) + + # Add any existing sub-devices as switches (non-CT) + for item in coordinator.get_subdevices(): + sn = item.get("deviceSn") or item.get("sn") + dev_type = item.get("devType") + if dev_type is None and item.get("subType") == 2: + dev_type = 2 + if sn and dev_type != 2: + entities.append( + JackeryPlugSwitch( + plug_sn=sn, + dev_type=dev_type, + coordinator=coordinator, + config_entry_id=config_entry.entry_id, + ) + ) + + if entities: + async_add_entities(entities) + + +class JackeryPlugSwitch(SwitchEntity): + """Jackery Smart Plug Switch.""" + + def __init__( + self, + plug_sn: str, + dev_type: int, + coordinator: "JackeryDataCoordinator", + config_entry_id: str, + ) -> None: + """Initialize.""" + self._plug_sn = plug_sn + self._dev_type = dev_type + self._coordinator = coordinator + self._raw_data = {} + + self._attr_name = f"Plug {plug_sn} Switch" + self._attr_unique_id = f"jackery_plug_{plug_sn}_switch" + self._attr_has_entity_name = True + + self._attr_device_info = { + "identifiers": {(DOMAIN, f"sub_{plug_sn}")}, + "via_device": (DOMAIN, config_entry_id), + "name": f"Jackery Plug {plug_sn}", + "manufacturer": "Jackery", + "model": f"Sub-device Type {dev_type}", + } + + @property + def should_poll(self) -> bool: + return False + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self._coordinator.register_sensor(f"plug_switch_{self._plug_sn}", self) + + async def async_will_remove_from_hass(self) -> None: + self._coordinator.unregister_sensor(f"plug_switch_{self._plug_sn}") + await super().async_will_remove_from_hass() + + def _update_from_coordinator(self, data: dict) -> None: + plugs = data.get("plugs") or data.get("plug") + if not plugs or not isinstance(plugs, list): + return + + my_plug = next((p for p in plugs if (p.get("sn") == self._plug_sn or p.get("deviceSn") == self._plug_sn)), None) + if not my_plug: + return + + self._raw_data = dict(my_plug) + val = my_plug.get("sysSwitch") + if val is None: + val = my_plug.get("switchSta") + if val is None: + return + + self._attr_is_on = bool(int(val)) + self._attr_available = True + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + await self._coordinator.async_control_subdevice_switch( + plug_sn=self._plug_sn, + dev_type=self._dev_type, + is_on=True, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self._coordinator.async_control_subdevice_switch( + plug_sn=self._plug_sn, + dev_type=self._dev_type, + is_on=False, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + return { + "plug_sn": self._plug_sn, + "dev_type": self._dev_type, + "raw_data": self._raw_data, + } + + +class JackeryMainSwitch(SwitchEntity): + """Main device switch (cmd=5).""" + + def __init__( + self, + key: str, + name: str, + coordinator: "JackeryDataCoordinator", + config_entry_id: str, + ) -> None: + self._key = key + self._coordinator = coordinator + self._attr_name = name + self._attr_unique_id = f"jackery_main_{key}" + self._attr_has_entity_name = True + self._attr_device_info = { + "identifiers": {(DOMAIN, config_entry_id)}, + "name": "Jackery", + "manufacturer": "Jackery", + "model": "Energy Monitor", + } + + @property + def should_poll(self) -> bool: + return False + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self._coordinator.register_sensor(f"main_switch_{self._key}", self) + + async def async_will_remove_from_hass(self) -> None: + self._coordinator.unregister_sensor(f"main_switch_{self._key}") + await super().async_will_remove_from_hass() + + def _update_from_coordinator(self, data: dict) -> None: + if self._key not in data: + return + val = data.get(self._key) + if val is None: + return + self._attr_is_on = bool(int(val)) + self._attr_available = True + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + await self._coordinator.async_control_main_device({self._key: 1}) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self._coordinator.async_control_main_device({self._key: 0}) + + +class JackeryRebootSwitch(SwitchEntity): + """Main device reboot switch (momentary).""" + + def __init__( + self, + coordinator: "JackeryDataCoordinator", + config_entry_id: str, + ) -> None: + self._coordinator = coordinator + self._attr_name = "Reboot" + self._attr_unique_id = "jackery_main_reboot" + self._attr_has_entity_name = True + self._attr_device_info = { + "identifiers": {(DOMAIN, config_entry_id)}, + "name": "Jackery", + "manufacturer": "Jackery", + "model": "Energy Monitor", + } + + @property + def should_poll(self) -> bool: + return False + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self._coordinator.register_sensor("main_switch_reboot", self) + + async def async_will_remove_from_hass(self) -> None: + self._coordinator.unregister_sensor("main_switch_reboot") + await super().async_will_remove_from_hass() + + def _update_from_coordinator(self, data: dict) -> None: + # Reboot doesn't have stable state; keep off unless explicitly set + self._attr_is_on = False + self._attr_available = True + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + await self._coordinator.async_control_main_device({"reboot": 1}) + self._attr_is_on = False + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + self._attr_is_on = False + self.async_write_ha_state()