├── .devcontainer.json ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── hacs_action.yaml │ ├── hassfest.yaml │ └── validate.yaml ├── .gitignore ├── .ruff.toml ├── LICENSE ├── README.md ├── config └── configuration.yaml ├── custom_components └── zendure_ha │ ├── __init__.py │ ├── api.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── devices │ ├── ace1500.py │ ├── aio2400.py │ ├── hub1200.py │ ├── hub2000.py │ ├── hyper2000.py │ ├── solarflow2400ac.py │ ├── solarflow800.py │ └── solarflow800Pro.py │ ├── manifest.json │ ├── number.py │ ├── select.py │ ├── sensor.py │ ├── switch.py │ ├── translations │ ├── de.json │ ├── en.json │ ├── fr.json │ └── nl.json │ ├── zendurebase.py │ ├── zendurebattery.py │ ├── zenduredevice.py │ └── zendurermanager.py ├── hacs.json ├── requirements.txt └── scripts ├── develop ├── lint └── setup /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireson/zendure_ha", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "charliermarsh.ruff", 18 | "github.vscode-pull-request-github", 19 | "ms-python.mypy-type-checker", 20 | "ms-python.python", 21 | "ms-python.vscode-pylance", 22 | "ryanluker.vscode-coverage-gutters" 23 | ], 24 | "settings": { 25 | "files.eol": "\n", 26 | "editor.tabSize": 4, 27 | "editor.formatOnPaste": false, 28 | "editor.formatOnSave": true, 29 | "editor.formatOnType": true, 30 | "files.trimTrailingWhitespace": true, 31 | "python.analysis.typeCheckingMode": "basic", 32 | "python.analysis.autoImportCompletions": true, 33 | "python.defaultInterpreterPath": "/usr/local/bin/python", 34 | "[python]": { 35 | "editor.defaultFormatter": "charliermarsh.ruff" 36 | } 37 | } 38 | } 39 | }, 40 | "mounts": [ 41 | "type=bind,source=/run/dbus,target=/run/dbus,readonly" 42 | ], 43 | "privileged": true, 44 | "remoteUser": "vscode", 45 | "runArgs": [ 46 | "--privileged", 47 | "-v", 48 | "/dev/bus/usb:/dev/bus/usb", 49 | "--group-add", 50 | "bluetooth" 51 | ], 52 | "features": { 53 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 54 | "packages": [ 55 | "bluez", 56 | "ffmpeg", 57 | "libturbojpeg0", 58 | "libpcap-dev", 59 | "libudev-dev" 60 | ] 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: FireSon 4 | buy_me_a_coffee: Fireson 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" 16 | -------------------------------------------------------------------------------- /.github/workflows/hacs_action.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: HACS Action 15 | uses: hacs/action@main 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | 2 | name: Validate 3 | 4 | on: 5 | push: 6 | pull_request: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | validate-hacs: 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | coverage.xml 13 | .ruff_cache 14 | 15 | 16 | # Home Assistant configuration 17 | config/* 18 | !config/configuration.yaml 19 | /.vs/Fireson 20 | /.vs 21 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py312" 4 | 5 | [lint] 6 | select = [ 7 | "ALL", 8 | ] 9 | 10 | ignore = [ 11 | "ANN101", # Missing type annotation for `self` in method 12 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 13 | "D203", # no-blank-line-before-class (incompatible with formatter) 14 | "D212", # multi-line-summary-first-line (incompatible with formatter) 15 | "COM812", # incompatible with formatter 16 | "ISC001", # incompatible with formatter 17 | "N806", # non-lowercase-variable-in-function 18 | "G004", # logging-f-string 19 | "ERA001", # commented-out-code 20 | "D101", # undocumented-public-class 21 | "D102", # undocumented-public-method 22 | "BLE001", # blind-except 23 | "DTZ005", #call-datetime-now-without-tzinfo 24 | "PLR0912", #too-many-branches 25 | "N802", #invalid-function-name 26 | "N803", #argumentname must be lowercase 27 | "N815", #argumentname must be lowercase 28 | "PLR0913", #too-many-arguments 29 | "FBT001", # boolean-type-hint-positional-argument 30 | "FBT002", # boolean-default-value-positional-argument 31 | "FBT003", # boolean-type-hint-positional-argument 32 | "TRY400", # error-instead-of-exception 33 | "TC001", # typing-only-third-party-import 34 | "TC002", # typing-only-third-party-import 35 | "RUF012", # mutable-class-default 36 | "TC003", #typing-only-standard-library-import 37 | "UP035", #deprecated-import 38 | "DTZ901", #datetime-min-max 39 | ] 40 | 41 | [lint.flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [lint.pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [lint.mccabe] 48 | max-complexity = 25 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 peteS-UK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zendure Integration 2 | ![image](https://github.com/user-attachments/assets/393fec2b-af03-4876-a2d3-3bb3111de1d0) 3 | 4 | ## Compatible Devices 5 | 6 | | Device | 7 | |--------| 8 | | Hyper 2000 | 9 | | SolarFlow 800 | 10 | | SolarFlow 2400 AC | 11 | | ACE 1500 | 12 | | AIO 2400 | 13 | | Hub 1200 | 14 | | Hub 2000 | 15 | 16 | ## What This Integration Does 17 | 18 | This Home Assistant integration connects your Zendure power stations and energy storage devices to your smart home system. Once configured, it allows you to monitor and control your Zendure devices directly from Home Assistant. You can track battery levels, power input/output, manage charging settings, and integrate your Zendure devices into your home automation routines. The integration also provides a power manager feature that can help balance energy usage across multiple devices without requiring a seperate Shelly or P1 meter. 19 | 20 | ### How It Works 21 | 22 | The integration works by connecting to the Zendure cloud API using your Zendure account credentials. After authentication, it automatically discovers all Zendure devices linked to your account and makes them available in Home Assistant. The integration uses MQTT to then get updates from Zendure cloud to update the relevant entities in Home assistant. 23 | 24 | ### Installation using HACS 25 | 26 | You can also find a tutorial here: [Domotica & IoT](https://iotdomotica.nl/tutorial/install-zendure-home-assistant-integration-tutorial) 27 | 28 | Preferable way to install this custom integration is to use [HACS](https://www.hacs.xyz/). Learn how to install HACS [here](https://www.hacs.xyz/docs/use/download/download). 29 | After you have successfully installed and configured HACS you can simply press this button to add this repository to HACS and proceed to `Zendure Home Assistant Integration` installation. 30 | 31 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=FireSon&repository=Zendure-HA&category=integration) 32 | 33 | ## Configuration options 34 | 35 | ![image](https://github.com/user-attachments/assets/a92daa42-99aa-41fa-880a-d7acd19185da) 36 | 37 | It is strongly recommended to create a 2nd Zendure account for this integration to avoid being logged out of the app. To do this: 38 | - Signout of the zendure app (or use a 2nd device/spouse for this if available) 39 | - Register with a secondary e-mail (tip, for gmail you can use +zendure@gmail.com which will just end up in your own inbox) 40 | - After setting up and activating the secondary account logout of it and back into your primary account 41 | - Go to Profile > Device Sharing and setup a share for your 2nd account 42 | - Logout of primary, into secondary 43 | - Accept the request. 44 | 45 | Now that this is completed use the 2nd account for the setup of the integration. 46 | 47 | ### Smart Matching Sensor Configuration 48 | 49 | For the smart matching feature to work properly, you need to configure a power sensor that: 50 | 51 | - Reports values in Watts (W) 52 | - Reports negative values when there is excess energy (e.g., from solar production) 53 | - Reports positive values when the house is drawing power from the grid 54 | 55 | If your existing power meter sensor doesn't meet these requirements, you can create a template sensor to convert the values appropriately (see below). 56 | 57 | #### Example: Converting DSMR Integration Values 58 | 59 | If you're using the DSMR integration which reports values in kilowatts (kW) as separate "delivered" and "returned" sensors, you can create a template to combine and convert them to the required format: 60 | 61 | ```yaml 62 | {{ (states("sensor.dsmr_reading_electricity_currently_delivered") | float - states("sensor.dsmr_reading_electricity_currently_returned") | float) * 1000 }} 63 | ``` 64 | 65 | This template: 66 | 1. Takes the currently delivered electricity value (positive when consuming from grid) 67 | 2. Subtracts the currently returned electricity value (positive when sending to grid) 68 | 3. Multiplies by 1000 to convert from kW to W 69 | 70 | #### Setting Up a Template Sensor 71 | 72 | You can set this up as a Helper in Home Assistant: 73 | 74 | 1. Go to Settings → Devices & Services → Helpers 75 | 2. Click "Add Helper" and select "Template" 76 | 3. Choose "Sensor" as the template type 77 | 4. Enter the template code above 78 | 5. Configure the name, icon, and unit of measurement (W) 79 | 6. Save the helper 80 | 81 | For more information on template sensors, see the [Home Assistant Template documentation](https://www.home-assistant.io/integrations/template/). 82 | 83 | ## Telemetry 84 | All the properties which the devices are reporting, are automatically added to HA. 85 | 86 | ### Exposed Sensors 87 | 88 | Exposed sensors/controls can vary based on the device type. 89 | 90 | | Sensor | Description | Unit | Device Class | 91 | |--------|-------------|------|-------------| 92 | | Electric Level | Current battery level | % | battery | 93 | | Solar Input Power | Power input from solar panels | W | power | 94 | | Pack Input Power | Power input to the battery pack | W | power | 95 | | Output Pack Power | Power output from the battery pack | W | power | 96 | | Output Home Power | Power output to home/devices | W | power | 97 | | Grid Input Power | Power input from the grid | W | power | 98 | | Remain Out Time | Estimated time remaining for discharge | h/min | duration | 99 | | Remain Input Time | Estimated time remaining for full charge | h/min | duration | 100 | | Pack Num | Number of battery packs connected | - | - | 101 | | Pack State | Current state of the battery pack (Sleeping/Charging/Discharging) | - | - | 102 | | Auto Model | Current operation mode | - | - | 103 | | AC Mode | Current AC mode (input/output) | - | - | 104 | | Hyper Temperature | Device temperature | °C | temperature | 105 | | WiFi strength | WiFi signal strength | - | - | 106 | 107 | ### Controls 108 | 109 | | Control | Type | Description | 110 | |---------|------|-------------| 111 | | Master Switch | Switch | Main power switch for the device | 112 | | Buzzer Switch | Switch | Toggle device sound on/off | 113 | | Lamp Switch | Switch | Toggle device light on/off | 114 | | Limit Input | Number | Set maximum input power limit | 115 | | Limit Output | Number | Set maximum output power limit | 116 | | Soc maximum | Number | Set maximum state of charge level | 117 | | Soc minimum | Number | Set minimum state of charge level | 118 | | AC Mode | Select | Choose between AC input or output mode | 119 | 120 | ## ZendureManager 121 | The ZendureManager, can be used to manage all Zendure devices. 122 | - There are three mode of operation available for the Zendure Manger in order to mange how it operates: 123 | 1) Off; the Zendure Manger does nothing. 124 | 2) Manual power; the 'Zendure Manual Power' number is used to set discharging (if negative) and charging if positive. 125 | 3) Smart matching; The 'P1 Sensor for smart matching' sensor is used to keep zero on the meter. 126 | 127 | In all of these modes, the current is always distributed dynamicly, based on the 'actual soc' for charging and discharging. 128 | The actual soc is calculated like this: 129 | - chargecapacity = kwh * max(0, socSet - electricLevel) 130 | - dischargecapacity = kwh * max(0, electricLevel - minSoc) 131 | 132 | In this way the maximal availability for charging/discharging is achieved. This is also the reason why the AC mode can not be manipulated because it would break this feature. 133 | 134 | ## Clusters 135 | At this moment the integration cannot handle the Zenlink cluster (will be added in the future). 136 | However it is possible to create clusters of your own in the integration. For which you can use the information about clusters from the Zendure App for that as well. This option is only available if you have multiple devices. 137 | ![image](https://github.com/user-attachments/assets/dba74b54-e75f-481d-b35b-98a37f079fad) 138 | In this example the Zen 05 behaves like a cluster with a maximum output of 800watt. At this moment there are three options available 800/1200 and 2400 watt. The Zen66 device is part of this cluster. The output per device of this cluster is dependant on the actual capacity of the devices. If the device is not in a cluster the ZendureManager will use it maximum input or output. Wherever the device cluster is not defined, the ZendureManager will not use the device! The configured values are persisted, and also after a reboot of HA they should stay the same. 139 | 140 | ## Home assistant Energy Dashboard 141 | 142 | The Zendure integration reports power values in watts (W), which represent instantaneous power flow. However, the Home Assistant Energy Dashboard requires energy values in watt-hours (Wh) or kilowatt-hours (kWh), which represent accumulated energy over time. 143 | 144 | To integrate your Zendure devices with the Energy Dashboard, you'll need to create additional sensors that convert the power readings into energy measurements. You can use the (Riemann sum) Integral sensor to accumulate power readings into energy values. 145 | 146 | To do this go to Devices & Services > Helpers and add an Integral sensor for both the power flowing into the battery (eg: `sensor.hyper_2000_grid_input_power`) as well as the power feeding back to the grid/house (eg: `sensor.hyper_2000_energy_power`) 147 | 148 | Once you have the integral sensors set up: 149 | 150 | 1. Go to Settings → Dashboards → Energy 151 | 2. In the "Grid" section, add your grid consumption/return sensors 152 | 3. In the "Battery" section: 153 | - Add your Zendure battery 154 | - Select the integral sensor for energy going into the battery 155 | - Select the integral sensor for energy coming from the battery 156 | 4. Save your configuration & wait to the next hour before the summarized data starts to show up. 157 | 158 | For more information, see the [Home Assistant Energy documentation](https://www.home-assistant.io/docs/energy/). 159 | 160 | ## License 161 | 162 | MIT License 163 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/homeassistant/ 5 | homeassistant: 6 | debug: true 7 | 8 | # https://www.home-assistant.io/integrations/logger/ 9 | logger: 10 | default: info 11 | logs: 12 | custom_components.zendure_ha: debug 13 | 14 | sensor: 15 | - platform: rest 16 | resource: http://192.168.2.97:8123/api/states/sensor.power_actual 17 | name: power_actual 18 | unique_id: power_actual 19 | scan_interval: 2 20 | device_class: power 21 | value_template: "{{ value_json.state }}" 22 | headers: 23 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkMmNlNzYyZGFjZjY0MGM4YWEwNmJjN2YxMjIxM2UxZSIsImlhdCI6MTc0MzI5ODAxNywiZXhwIjoyMDU4NjU4MDE3fQ.juJNrxIo5tSmSiCeA8shKpPPEg4AAlA8zMHa--VJw1Q -------------------------------------------------------------------------------- /custom_components/zendure_ha/__init__.py: -------------------------------------------------------------------------------- 1 | """The Zendure Integration integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import Platform 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.exceptions import ConfigEntryNotReady 12 | from homeassistant.helpers.device_registry import DeviceEntry 13 | 14 | from .zendurermanager import ZendureManager 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] 19 | 20 | type MyConfigEntry = ConfigEntry[RuntimeData] 21 | 22 | 23 | @dataclass 24 | class RuntimeData: 25 | """Class to hold your data.""" 26 | 27 | manager: ZendureManager 28 | 29 | 30 | async def async_setup_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 31 | """Set up Zendure Integration from a config entry.""" 32 | manager = ZendureManager(hass, config_entry) 33 | config_entry.runtime_data = RuntimeData(manager) 34 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 35 | 36 | _LOGGER.debug("Open API connection") 37 | if not await manager.load(): 38 | raise ConfigEntryNotReady 39 | 40 | await manager.async_config_entry_first_refresh() 41 | 42 | config_entry.async_on_unload(config_entry.add_update_listener(_async_update_listener)) 43 | 44 | # Return true to denote a successful setup. 45 | return True 46 | 47 | 48 | async def _async_update_listener(hass: HomeAssistant, config_entry: MyConfigEntry) -> None: 49 | """Handle config options update.""" 50 | # Reload the integration when the options change. 51 | await hass.config_entries.async_reload(config_entry.entry_id) 52 | 53 | 54 | async def async_remove_config_entry_device(_hass: HomeAssistant, _config_entry: ConfigEntry, _device_entry: DeviceEntry) -> bool: 55 | """Handle removal of a device entry.""" 56 | return False 57 | 58 | 59 | async def async_unload_entry(hass: HomeAssistant, config_entry: MyConfigEntry) -> bool: 60 | """Unload a config entry.""" 61 | unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 62 | if unload_ok: 63 | # Unload platforms and return result 64 | data = config_entry.runtime_data 65 | manager = data.manager 66 | if manager: 67 | await manager.unload() 68 | return True 69 | 70 | # If unloading failed, return false 71 | _LOGGER.error("async_unload_entry call to hass.config_entries.async_unload_platforms returned False") 72 | return False 73 | 74 | 75 | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 76 | """Migrate an old entry.""" 77 | if entry.version == 1 and entry.minor_version < 1: 78 | new_data = entry.data.copy() 79 | hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) 80 | _LOGGER.info(f"Migration to configuration version %s.%s successful {entry.version}, {entry.minor_version}") 81 | return True 82 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/api.py: -------------------------------------------------------------------------------- 1 | """Module for Zendure API integration with Home Assistant.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from aiohttp import ClientSession 9 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | SF_AUTH_PATH = "/auth/app/token" 16 | SF_DEVICELIST_PATH = "/productModule/device/queryDeviceListByConsumerId" 17 | 18 | 19 | class Api: 20 | """Class for Zendure API.""" 21 | 22 | def __init__(self, hass: HomeAssistant, data: dict[str, Any]) -> None: 23 | """Initialize the API.""" 24 | self.hass = hass 25 | self.username = data[CONF_USERNAME] 26 | self.password = data[CONF_PASSWORD] 27 | self.session: ClientSession | None 28 | self.token: str = "" 29 | self.mqttUrl = "" 30 | self.zen_api = "" 31 | self.mqttinfo = "" 32 | 33 | async def connect(self) -> bool: 34 | _LOGGER.info("Connecting to Zendure") 35 | self.session = async_get_clientsession(self.hass) 36 | self.headers = { 37 | "Content-Type": "application/json", 38 | "Accept-Language": "en-EN", 39 | "appVersion": "4.3.1", 40 | "User-Agent": "Zendure/4.3.1 (iPhone; iOS 14.4.2; Scale/3.00)", 41 | "Accept": "*/*", 42 | "Blade-Auth": "bearer (null)", 43 | } 44 | 45 | authBody = { 46 | "password": self.password, 47 | "account": self.username, 48 | "appId": "121c83f761305d6cf7e", 49 | "appType": "iOS", 50 | "grantType": "password", 51 | "tenantId": "", 52 | } 53 | 54 | try: 55 | url = f"https://app.zendure.tech/v2{SF_AUTH_PATH}" 56 | response = await self.session.post(url=url, json=authBody, headers=self.headers) 57 | 58 | if response.ok: 59 | respJson = await response.json() 60 | json = respJson["data"] 61 | self.zen_api = json["serverNodeUrl"] 62 | self.mqttUrl = json["iotUrl"] 63 | if self.zen_api.endswith("eu"): 64 | self.mqttinfo = "SDZzJGo5Q3ROYTBO" 65 | else: 66 | self.zen_api = "https://app.zendure.tech/v2" 67 | self.mqttinfo = "b0sjUENneTZPWnhk" 68 | 69 | self.token = json["accessToken"] 70 | self.headers["Blade-Auth"] = f"bearer {self.token}" 71 | _LOGGER.info(f"Connected to {self.zen_api} => Mqtt: {self.mqttUrl}") 72 | return True 73 | 74 | except Exception as e: 75 | _LOGGER.error(f"Unable to connect to Zendure {self.zen_api} {e}!") 76 | return False 77 | 78 | _LOGGER.error(f"Unable to connect to Zendure {self.zen_api}!") 79 | return False 80 | 81 | async def getDevices(self) -> Any: 82 | if not self.session: 83 | raise SessionNotInitializedError 84 | 85 | try: 86 | url = f"{self.zen_api}{SF_DEVICELIST_PATH}" 87 | _LOGGER.info("Getting device list ...") 88 | 89 | response = await self.session.post(url=url, headers=self.headers) 90 | if response.ok: 91 | respJson = await response.json() 92 | return respJson["data"] 93 | 94 | _LOGGER.error(f"Fetching device list failed: {response.text}") 95 | except Exception as e: 96 | _LOGGER.error(e) 97 | 98 | return None 99 | 100 | 101 | class SessionNotInitializedError(Exception): 102 | """Exception raised when the session is not initialized.""" 103 | 104 | def __init__(self) -> None: 105 | """Initialize the exception.""" 106 | super().__init__("Session is not initialized!") 107 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Zendure Integration binairy sensors.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.binary_sensor import ( 7 | BinarySensorEntity, BinarySensorEntityDescription) 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.device_registry import DeviceInfo 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.template import Template 13 | from stringcase import snakecase 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 19 | """Set up the Zendure binary_sensor.""" 20 | ZendureBinarySensor.add = async_add_entities 21 | 22 | 23 | class ZendureBinarySensor(BinarySensorEntity): 24 | add: AddEntitiesCallback 25 | 26 | def __init__( 27 | self, 28 | deviceinfo: DeviceInfo, 29 | uniqueid: str, 30 | template: Template | None = None, 31 | deviceclass: Any | None = None, 32 | ) -> None: 33 | """Initialize a binary sensor entity.""" 34 | self._attr_has_entity_name = True 35 | self._attr_should_poll = False 36 | self._attr_available = True 37 | self.entity_description = BinarySensorEntityDescription(key=uniqueid, name=uniqueid, device_class=deviceclass) 38 | self._attr_device_info = deviceinfo 39 | self._attr_unique_id = f"{deviceinfo.get('name', None)}-{uniqueid}" 40 | self.entity_id = f"binary_sensor.{deviceinfo.get('name', None)}-{snakecase(uniqueid)}" 41 | self._attr_translation_key = snakecase(uniqueid) 42 | self._attr_is_on = False 43 | self._value_template: Template | None = template 44 | 45 | def update_value(self, value: Any) -> None: 46 | try: 47 | is_on = bool( 48 | int(self._value_template.async_render_with_possible_json_value(value, None)) != 0 if self._value_template is not None else int(value) != 0 49 | ) 50 | 51 | if self._attr_is_on == is_on: 52 | return 53 | 54 | _LOGGER.info(f"Update binary_sensor: {self._attr_unique_id} => {is_on}") 55 | 56 | self._attr_is_on = is_on 57 | if self.hass and self.hass.loop.is_running(): 58 | self.schedule_update_ha_state() 59 | except Exception as err: 60 | _LOGGER.error(f"Error {err} setting state: {self._attr_unique_id} => {value}") 61 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Zendure Integration integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | from homeassistant.components import mqtt 10 | from homeassistant.config_entries import (ConfigEntry, ConfigFlow, 11 | ConfigFlowResult, OptionsFlow) 12 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 13 | from homeassistant.core import callback 14 | from homeassistant.exceptions import HomeAssistantError 15 | from homeassistant.helpers import selector 16 | 17 | from .api import Api 18 | from .const import (CONF_MQTTLOCAL, CONF_MQTTLOG, CONF_P1METER, CONF_WIFIPSW, 19 | CONF_WIFISSID, DOMAIN) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class ZendureConfigFlow(ConfigFlow, domain=DOMAIN): 25 | """Handle a config flow for Zendure Integration.""" 26 | 27 | VERSION = 1 28 | MINOR_VERSION = 1 29 | 30 | _input_data: dict[str, Any] 31 | data_schema = vol.Schema({ 32 | vol.Required(CONF_USERNAME): str, 33 | vol.Required(CONF_PASSWORD): selector.TextSelector( 34 | selector.TextSelectorConfig( 35 | type=selector.TextSelectorType.PASSWORD, 36 | ), 37 | ), 38 | vol.Required(CONF_P1METER, description={"suggested_value": "sensor.power_actual"}): str, 39 | vol.Required(CONF_MQTTLOCAL): bool, 40 | vol.Required(CONF_MQTTLOG): bool, 41 | vol.Required(CONF_WIFISSID): str, 42 | vol.Required(CONF_WIFIPSW): selector.TextSelector( 43 | selector.TextSelectorConfig( 44 | type=selector.TextSelectorType.PASSWORD, 45 | ), 46 | ), 47 | }) 48 | 49 | def __init__(self) -> None: 50 | """Initialize.""" 51 | self._user_input: dict[str, Any] | None = None 52 | 53 | @staticmethod 54 | @callback 55 | def async_get_options_flow(_config_entry: ConfigEntry) -> ZendureOptionsFlowHandler: 56 | """Get the options flow for this handler.""" 57 | return ZendureOptionsFlowHandler() 58 | 59 | async def validate_input(self) -> None: 60 | """Create the manager.""" 61 | _LOGGER.debug("Create manager") 62 | user_input = self._user_input 63 | if user_input is None: 64 | raise Exception("User input is empty") 65 | 66 | # Check if we can connect to the Zendure API 67 | api = Api(self.hass, user_input) 68 | if not await api.connect(): 69 | raise ZendureConnectionError 70 | 71 | mqttlocal = user_input.get(CONF_MQTTLOCAL, False) 72 | if mqttlocal and not await mqtt.async_wait_for_mqtt_client(self.hass): 73 | raise Exception("MQTT addon is not found") 74 | 75 | async def create_manager(self) -> ConfigFlowResult: 76 | if self._user_input is None: 77 | self._user_input = {} 78 | await self.validate_input() 79 | await self.async_set_unique_id("Zendure") 80 | self._abort_if_unique_id_configured() 81 | return self.async_create_entry(title="Zendure", data=self._user_input) 82 | 83 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 84 | """Step when user initializes a integration.""" 85 | errors: dict[str, str] = {} 86 | if user_input is not None: 87 | self._user_input = self._user_input | user_input if self._user_input else user_input 88 | try: 89 | return await self.create_manager() 90 | except ZendureConnectionError: 91 | errors["base"] = "Error connecting to Zendure API" 92 | except Exception as err: # pylint: disable=broad-except 93 | _LOGGER.error(f"Unexpected exception: {err}") 94 | errors["base"] = f"invalid input {err}" 95 | 96 | return self.async_show_form(step_id="user", data_schema=self.data_schema, errors=errors) 97 | 98 | async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 99 | """Add reconfigure step to allow to reconfigure a config entry.""" 100 | errors: dict[str, str] = {} 101 | config_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) 102 | 103 | schema = self.data_schema 104 | if user_input is not None: 105 | self._user_input = self._user_input | user_input if self._user_input else user_input 106 | try: 107 | await self.validate_input() 108 | except ZendureConnectionError: 109 | errors["base"] = "Error connecting to Zendure API" 110 | except Exception as err: # pylint: disable=broad-except 111 | _LOGGER.error(f"Unexpected exception: {err}") 112 | errors["base"] = f"invalid input {err}" 113 | else: 114 | return self.async_update_reload_and_abort( 115 | config_entry, 116 | unique_id=config_entry.unique_id, 117 | data={**config_entry.data, **self._user_input}, 118 | reason="reconfigure_successful", 119 | ) 120 | 121 | return self.async_show_form( 122 | step_id="reconfigure", 123 | data_schema=self.add_suggested_values_to_schema( 124 | data_schema=schema, 125 | suggested_values=config_entry.data | (user_input or {}), 126 | ), 127 | errors=errors, 128 | ) 129 | 130 | 131 | class ZendureOptionsFlowHandler(OptionsFlow): 132 | """Handles the options flow.""" 133 | 134 | async def async_step_init(self, user_input: Any = None) -> ConfigFlowResult: 135 | """Handle options flow.""" 136 | if user_input is not None: 137 | options = self.config_entry.options | user_input 138 | return self.async_create_entry(title="", data=options) 139 | 140 | return self.async_show_form( 141 | step_id="init", 142 | data_schema=vol.Schema({ 143 | vol.Required(CONF_P1METER, description={"suggested_value": "sensor.power_actual"}): str, 144 | vol.Required(CONF_MQTTLOG): bool, 145 | }), 146 | ) 147 | 148 | 149 | class ZendureConnectionError(HomeAssistantError): 150 | """Error to indicate there is a connection issue with Zendure Integration.""" 151 | 152 | def __init__(self) -> None: 153 | """Initialize the connection error.""" 154 | super().__init__("Zendure Integration") 155 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Zendure Integration integration.""" 2 | 3 | from enum import NAMED_FLAGS, Enum, Flag, auto, verify 4 | 5 | DOMAIN = "zendure_ha" 6 | 7 | CONF_P1METER = "p1meter" 8 | CONF_MQTTLOG = "mqttlog" 9 | CONF_MQTTLOCAL = "mqttlocal" 10 | CONF_MQTTSERVER = "mqttserver" 11 | CONF_MQTTPORT = "mqttport" 12 | CONF_MQTTUSER = "mqttuser" 13 | CONF_MQTTPSW = "mqttpsw" 14 | CONF_WIFISSID = "wifissid" 15 | CONF_WIFIPSW = "wifipsw" 16 | 17 | 18 | class ManagerState(Enum): 19 | IDLE = 0 20 | CHARGING = 1 21 | DISCHARGING = 2 22 | 23 | 24 | class AcMode: 25 | INPUT = 1 26 | OUTPUT = 2 27 | 28 | 29 | @verify(NAMED_FLAGS) 30 | class MqttState(Flag): 31 | UNKNOWN = 0 32 | BLE = 1 33 | LOCAL = 2 34 | CLOUD = 4 35 | APP = 8 36 | BLE_ERR = 16 37 | 38 | 39 | class SmartMode: 40 | NONE = 0 41 | MANUAL = 1 42 | MATCHING = 2 43 | FAST_UPDATE = 100 44 | MIN_POWER = 50 45 | START_POWER = 100 46 | TIMEFAST = 3 47 | TIMEZERO = 5 48 | TIMEIDLE = 10 49 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/ace1500.py: -------------------------------------------------------------------------------- 1 | """Module for the Hyper2000 device integration in Home Assistant.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class ACE1500(ZendureDevice): 19 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any, parent: str | None = None) -> None: 20 | """Initialise Ace1500.""" 21 | super().__init__(hass, deviceId, prodName, definition, parent) 22 | self.powerMin = -900 23 | self.powerMax = 800 24 | self.numbers: list[ZendureNumber] = [] 25 | 26 | def entitiesCreate(self) -> None: 27 | super().entitiesCreate() 28 | 29 | binaries = [ 30 | self.binary("masterSwitch"), 31 | self.binary("buzzerSwitch"), 32 | self.binary("wifiState"), 33 | self.binary("heatState"), 34 | self.binary("reverseState"), 35 | ] 36 | ZendureBinarySensor.add(binaries) 37 | 38 | self.numbers = [ 39 | self.number("inputLimit", None, "W", "power", 0, 900, NumberMode.SLIDER), 40 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 41 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 42 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 43 | ] 44 | ZendureNumber.add(self.numbers) 45 | 46 | sensors = [ 47 | self.sensor("hubState"), 48 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 49 | self.sensor("BatVolt", None, "V", "voltage", "measurement"), 50 | self.sensor("packInputPower", None, "W", "power", "measurement"), 51 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 52 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 53 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 54 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 55 | self.sensor("packNum", None), 56 | self.sensor("electricLevel", None, "%", "battery"), 57 | self.sensor("energyPower", None, "W"), 58 | self.sensor("inverseMaxPower", None, "W"), 59 | self.sensor("solarPower1", None, "W", "power", "measurement"), 60 | self.sensor("solarPower2", None, "W", "power", "measurement"), 61 | self.sensor("gridInputPower", None, "W", "power", "measurement"), 62 | self.sensor("pass", None), 63 | self.sensor("strength", None), 64 | ] 65 | ZendureSensor.add(sensors) 66 | 67 | selects = [self.select("acMode", {2: "input", 1: "output"}, self.update_ac_mode)] 68 | ZendureSelect.add(selects) 69 | 70 | def entityUpdate(self, key: Any, value: Any) -> bool: 71 | # Call the base class entityUpdate method 72 | if not super().entityUpdate(key, value): 73 | return False 74 | match key: 75 | case "inverseMaxPower": 76 | self.powerMax = value 77 | self.numbers[1].update_range(0, value) 78 | return True 79 | 80 | def writePower(self, power: int, inprogram: bool) -> None: 81 | delta = abs(power - self.powerAct) 82 | if delta <= 1 and inprogram: 83 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 84 | return 85 | 86 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 87 | self.mqttInvoke({ 88 | "arguments": [ 89 | { 90 | "autoModelProgram": 2 if inprogram else 0, 91 | "autoModelValue": { 92 | "chargingType": 0 if power >= 0 else 1, 93 | "chargingPower": 0 if power >= 0 else -power, 94 | "freq": 2 if delta < 100 else 1 if delta < 200 else 0, 95 | "outPower": max(0, power), 96 | }, 97 | "msgType": 1, 98 | "autoModel": 8 if inprogram else 0, 99 | } 100 | ], 101 | "function": "deviceAutomation", 102 | }) 103 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/aio2400.py: -------------------------------------------------------------------------------- 1 | """Module for the Hyper2000 device integration in Home Assistant.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.switch import ZendureSwitch 14 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class AIO2400(ZendureDevice): 20 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 21 | """Initialise AIO2400.""" 22 | super().__init__(hass, deviceId, prodName, definition) 23 | self.powerMin = -1200 24 | self.powerMax = 1200 25 | 26 | def entitiesCreate(self) -> None: 27 | super().entitiesCreate() 28 | 29 | binaries = [ 30 | self.binary("masterSwitch"), 31 | self.binary("buzzerSwitch"), 32 | self.binary("wifiState"), 33 | self.binary("heatState"), 34 | self.binary("reverseState"), 35 | ] 36 | ZendureBinarySensor.add(binaries) 37 | 38 | self.numbers = [ 39 | self.number("inputLimit", None, "W", "power", 0, 1200, NumberMode.SLIDER), 40 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 41 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 42 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 43 | ] 44 | ZendureNumber.add(self.numbers) 45 | 46 | switches = [ 47 | self.switch("lampSwitch"), 48 | ] 49 | ZendureSwitch.add(switches) 50 | 51 | sensors = [ 52 | self.sensor("hubState"), 53 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 54 | self.sensor("BatVolt", None, "V", "voltage", "measurement"), 55 | self.sensor("packInputPower", None, "W", "power", "measurement"), 56 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 57 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 58 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 59 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 60 | self.sensor("packNum", None), 61 | self.sensor("electricLevel", None, "%", "battery"), 62 | self.sensor("energyPower", None, "W"), 63 | self.sensor("inverseMaxPower", None, "W"), 64 | self.sensor("solarPower1", None, "W", "power", "measurement"), 65 | self.sensor("solarPower2", None, "W", "power", "measurement"), 66 | self.sensor("gridInputPower", None, "W", "power", "measurement"), 67 | self.sensor("pass", None), 68 | self.sensor("strength", None), 69 | ] 70 | ZendureSensor.add(sensors) 71 | 72 | selects = [ 73 | self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode), 74 | self.select("passMode", {0: "auto", 2: "on", 1: "off"}), 75 | self.select("autoRecover", {0: "off", 1: "on"}), 76 | ] 77 | ZendureSelect.add(selects) 78 | 79 | def entityUpdate(self, key: Any, value: Any) -> bool: 80 | # Call the base class entityUpdate method 81 | if not super().entityUpdate(key, value): 82 | return False 83 | match key: 84 | case "inverseMaxPower": 85 | self.powerMax = value 86 | self.numbers[1].update_range(0, value) 87 | return True 88 | 89 | def writePower(self, power: int, inprogram: bool) -> None: 90 | delta = abs(power - self.powerAct) 91 | if delta <= 1 and inprogram: 92 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 93 | return 94 | 95 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 96 | self.mqttInvoke({ 97 | "arguments": [ 98 | { 99 | "autoModelProgram": 2 if inprogram else 0, 100 | "autoModelValue": { 101 | "chargingType": 0 if power >= 0 else 1, 102 | "chargingPower": 0 if power >= 0 else -power, 103 | "freq": 2 if delta < 100 else 1 if delta < 200 else 0, 104 | "outPower": max(0, power), 105 | }, 106 | "msgType": 1, 107 | "autoModel": 8 if inprogram else 0, 108 | } 109 | ], 110 | "function": "deviceAutomation", 111 | }) 112 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/hub1200.py: -------------------------------------------------------------------------------- 1 | """Module for the Hyper2000 device integration in Home Assistant.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.zendurebase import ZendureBase 14 | from custom_components.zendure_ha.zendurebattery import ZendureBattery 15 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class Hub1200(ZendureDevice): 21 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 22 | """Initialise Hub1200.""" 23 | super().__init__(hass, deviceId, prodName, definition) 24 | self.powerMin = -800 25 | self.powerMax = 800 26 | self.numbers: list[ZendureNumber] = [] 27 | 28 | def entitiesCreate(self) -> None: 29 | super().entitiesCreate() 30 | 31 | binaries = [ 32 | self.binary("masterSwitch"), 33 | self.binary("buzzerSwitch"), 34 | self.binary("wifiState"), 35 | self.binary("heatState"), 36 | self.binary("reverseState"), 37 | self.binary("pass"), 38 | ] 39 | ZendureBinarySensor.add(binaries) 40 | 41 | self.numbers = [ 42 | self.number("inputLimit", None, "W", "power", 0, 800, NumberMode.SLIDER), 43 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 44 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 45 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 46 | ] 47 | ZendureNumber.add(self.numbers) 48 | 49 | sensors = [ 50 | self.sensor("hubState"), 51 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 52 | self.sensor("packInputPower", None, "W", "power", "measurement"), 53 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 54 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 55 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 56 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 57 | self.sensor("packNum", None), 58 | self.sensor("electricLevel", None, "%", "battery"), 59 | self.sensor("energyPower", None, "W"), 60 | self.sensor("gridPower", None, "W", "power", "measurement"), 61 | self.sensor("inverseMaxPower", None, "W"), 62 | self.sensor("solarPower1", None, "W", "power", "measurement"), 63 | self.sensor("solarPower2", None, "W", "power", "measurement"), 64 | ] 65 | ZendureSensor.add(sensors) 66 | 67 | selects = [ 68 | self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode), 69 | self.select("passMode", {0: "auto", 2: "on", 1: "off"}), 70 | self.select("autoRecover", {0: "off", 1: "on"}), 71 | ] 72 | ZendureSelect.add(selects) 73 | 74 | def entitiesBattery(self, battery: ZendureBattery, sensors: list[ZendureSensor]) -> None: 75 | sensors.append(battery.sensor("soh", "{{ (value / 10) }}", "%", None)) 76 | if battery.kwh > 1: 77 | self.powerMin = -1200 78 | self.numbers[0].update_range(0, abs(self.powerMin)) 79 | 80 | def entityUpdate(self, key: Any, value: Any) -> bool: 81 | # Call the base class entityUpdate method 82 | if not super().entityUpdate(key, value): 83 | return False 84 | match key: 85 | case "inverseMaxPower": 86 | self.powerMax = value 87 | self.numbers[1].update_range(0, value) 88 | return True 89 | 90 | def writePower(self, power: int, inprogram: bool) -> None: 91 | delta = abs(power - self.powerAct) 92 | if delta <= 1 and inprogram: 93 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 94 | return 95 | 96 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 97 | self.mqttInvoke({ 98 | "arguments": [ 99 | { 100 | "autoModelProgram": 2 if inprogram else 0, 101 | "autoModelValue": power, 102 | "msgType": 1, 103 | "autoModel": 8 if inprogram else 0, 104 | } 105 | ], 106 | "function": "deviceAutomation", 107 | }) 108 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/hub2000.py: -------------------------------------------------------------------------------- 1 | """Module for the Hyper2000 device integration in Home Assistant.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.zendurebattery import ZendureBattery 14 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class Hub2000(ZendureDevice): 20 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 21 | """Initialise Hub2000.""" 22 | super().__init__(hass, deviceId, prodName, definition) 23 | self.powerMin = -800 24 | self.powerMax = 800 25 | self.numbers: list[ZendureNumber] = [] 26 | self.batCount = 0 27 | 28 | def entitiesCreate(self) -> None: 29 | super().entitiesCreate() 30 | 31 | binaries = [ 32 | self.binary("masterSwitch"), 33 | self.binary("buzzerSwitch"), 34 | self.binary("wifiState"), 35 | self.binary("heatState"), 36 | self.binary("reverseState"), 37 | self.binary("pass"), 38 | ] 39 | ZendureBinarySensor.add(binaries) 40 | 41 | self.numbers = [ 42 | self.number("inputLimit", None, "W", "power", 0, 800, NumberMode.SLIDER), 43 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 44 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 45 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 46 | ] 47 | ZendureNumber.add(self.numbers) 48 | 49 | sensors = [ 50 | self.sensor("hubState"), 51 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 52 | self.sensor("packInputPower", None, "W", "power", "measurement"), 53 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 54 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 55 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 56 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 57 | self.sensor("packNum", None), 58 | self.sensor("electricLevel", None, "%", "battery"), 59 | self.sensor("gridPower", None, "W", "power", "measurement"), 60 | self.sensor("energyPower", None, "W"), 61 | self.sensor("inverseMaxPower", None, "W"), 62 | self.sensor("solarPower1", None, "W", "power", "measurement"), 63 | self.sensor("solarPower2", None, "W", "power", "measurement"), 64 | ] 65 | ZendureSensor.add(sensors) 66 | 67 | selects = [ 68 | self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode), 69 | self.select("passMode", {0: "auto", 2: "on", 1: "off"}), 70 | self.select("autoRecover", {0: "off", 1: "on"}), 71 | ] 72 | ZendureSelect.add(selects) 73 | 74 | def entitiesBattery(self, battery: ZendureBattery, _sensors: list[ZendureSensor]) -> None: 75 | self.batCount += 1 76 | self.powerMin = (-1200 if battery.kwh > 1 else -800) if self.batCount == 1 else -1800 77 | self.numbers[0].update_range(0, abs(self.powerMin)) 78 | 79 | def entityUpdate(self, key: Any, value: Any) -> bool: 80 | # Call the base class entityUpdate method 81 | if not super().entityUpdate(key, value): 82 | return False 83 | match key: 84 | case "inverseMaxPower": 85 | self.powerMax = value 86 | self.numbers[1].update_range(0, value) 87 | return True 88 | 89 | def writePower(self, power: int, inprogram: bool) -> None: 90 | delta = abs(power - self.powerAct) 91 | if delta <= 1 and inprogram: 92 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 93 | return 94 | 95 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 96 | self.mqttInvoke({ 97 | "arguments": [ 98 | { 99 | "autoModelProgram": 2 if inprogram else 0, 100 | "autoModelValue": power, 101 | "msgType": 1, 102 | "autoModel": 8 if inprogram else 0, 103 | } 104 | ], 105 | "function": "deviceAutomation", 106 | }) 107 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/hyper2000.py: -------------------------------------------------------------------------------- 1 | """Module for the Hyper2000 device integration in Home Assistant.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components.number import NumberMode 9 | from homeassistant.core import HomeAssistant 10 | 11 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 12 | from custom_components.zendure_ha.number import ZendureNumber 13 | from custom_components.zendure_ha.select import ZendureSelect 14 | from custom_components.zendure_ha.sensor import ZendureSensor 15 | from custom_components.zendure_ha.switch import ZendureSwitch 16 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class Hyper2000(ZendureDevice): 22 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 23 | """Initialise Hyper2000.""" 24 | super().__init__(hass, deviceId, prodName, definition) 25 | self.powerMin = -1200 26 | self.powerMax = 800 27 | self.numbers: list[ZendureNumber] = [] 28 | 29 | def entitiesCreate(self) -> None: 30 | super().entitiesCreate() 31 | 32 | binaries = [ 33 | self.binary("masterSwitch"), 34 | self.binary("buzzerSwitch"), 35 | self.binary("wifiState"), 36 | self.binary("heatState"), 37 | self.binary("reverseState"), 38 | self.binary("pass"), 39 | self.binary("lowTemperature"), 40 | self.binary("autoHeat"), 41 | self.binary("localState"), 42 | self.binary("ctOff"), 43 | ] 44 | ZendureBinarySensor.add(binaries) 45 | 46 | self.numbers = [ 47 | self.number("inputLimit", None, "W", "power", 0, 1200, NumberMode.SLIDER), 48 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 49 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 50 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 51 | ] 52 | ZendureNumber.add(self.numbers) 53 | 54 | switches = [ 55 | self.switch("lampSwitch"), 56 | ] 57 | ZendureSwitch.add(switches) 58 | 59 | sensors = [ 60 | # self.sensor("chargingMode"), 61 | self.sensor("autoModel"), 62 | self.sensor("hubState"), 63 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 64 | self.sensor("BatVolt", None, "V", "voltage", "measurement"), 65 | self.sensor("packInputPower", None, "W", "power", "measurement"), 66 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 67 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 68 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 69 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 70 | self.sensor("packNum", None), 71 | self.sensor("electricLevel", None, "%", "battery", "measurement"), 72 | self.sensor("energyPower", None, "W"), 73 | self.sensor("inverseMaxPower", None, "W"), 74 | self.sensor("solarPower1", None, "W", "power", "measurement"), 75 | self.sensor("solarPower2", None, "W", "power", "measurement"), 76 | self.sensor("gridInputPower", None, "W", "power", "measurement"), 77 | self.sensor("socStatus", None), 78 | self.sensor("strength", None), 79 | self.sensor("hyperTmp", "{{ (value | float - 2731) / 10 | round(1) }}", "°C", "temperature", "measurement"), 80 | self.sensor("packState"), 81 | self.version("masterSoftVersion"), 82 | self.version("masterhaerVersion"), 83 | self.sensor("inputMode"), 84 | self.sensor("blueOta"), 85 | self.sensor("plugState"), 86 | self.sensor("pvBrand"), 87 | self.sensor("VoltWakeup", None, "V", "voltage", "measurement"), 88 | self.sensor("OldMode"), 89 | self.sensor("circuitCheckMode"), 90 | self.version("dspversion"), 91 | self.sensor("gridOffMode"), 92 | ] 93 | ZendureSensor.add(sensors) 94 | 95 | self.nosensor(["invOutputPower"]) 96 | self.nosensor(["ambientLightNess"]) 97 | self.nosensor(["ambientLightColor"]) 98 | self.nosensor(["ambientLightMode"]) 99 | self.nosensor(["ambientSwitch"]) 100 | 101 | selects = [ 102 | self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode), 103 | self.select("gridReverse", {0: "auto", 1: "on", 2: "off"}), 104 | ] 105 | ZendureSelect.add(selects) 106 | 107 | def entityUpdate(self, key: Any, value: Any) -> bool: 108 | # Call the base class entityUpdate method 109 | if not super().entityUpdate(key, value): 110 | return False 111 | match key: 112 | case "inverseMaxPower": 113 | self.powerMax = value 114 | self.numbers[1].update_range(0, value) 115 | return True 116 | 117 | def writePower(self, power: int, inprogram: bool) -> None: 118 | delta = abs(power - self.powerAct) 119 | if delta <= 1 and inprogram: 120 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 121 | return 122 | 123 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity} program: {inprogram}") 124 | self.mqttInvoke({ 125 | "arguments": [ 126 | { 127 | "autoModelProgram": 2 if inprogram else 0, 128 | "autoModelValue": { 129 | "chargingType": 0 if power >= 0 else 1, 130 | "chargingPower": 0 if power >= 0 else -power, 131 | "freq": 2 if delta < 100 else 1 if delta < 200 else 0, 132 | "outPower": max(0, power), 133 | }, 134 | "msgType": 1, 135 | "autoModel": 8 if inprogram else 0, 136 | } 137 | ], 138 | "function": "deviceAutomation", 139 | }) 140 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/solarflow2400ac.py: -------------------------------------------------------------------------------- 1 | """Module for the Solarflow2400AC device integration in Home Assistant.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.switch import ZendureSwitch 14 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class SolarFlow2400AC(ZendureDevice): 20 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 21 | """Initialise SolarFlow2400AC.""" 22 | super().__init__(hass, deviceId, prodName, definition) 23 | self.powerMin = -2400 24 | self.powerMax = 2400 25 | self.numbers: list[ZendureNumber] = [] 26 | 27 | def entitiesCreate(self) -> None: 28 | super().entitiesCreate() 29 | 30 | binaries = [ 31 | self.binary("masterSwitch"), 32 | self.binary("buzzerSwitch"), 33 | self.binary("wifiState"), 34 | self.binary("heatState"), 35 | self.binary("reverseState"), 36 | ] 37 | ZendureBinarySensor.add(binaries) 38 | 39 | self.numbers = [ 40 | self.number("inputLimit", None, "W", "power", 0, 2400, NumberMode.SLIDER), 41 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 42 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 43 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 44 | ] 45 | ZendureNumber.add(self.numbers) 46 | 47 | switches = [ 48 | self.switch("lampSwitch"), 49 | ] 50 | ZendureSwitch.add(switches) 51 | 52 | sensors = [ 53 | self.sensor("hubState"), 54 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 55 | self.sensor("BatVolt", None, "V", "voltage", "measurement"), 56 | self.sensor("packInputPower", None, "W", "power", "measurement"), 57 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 58 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 59 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 60 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 61 | self.sensor("packNum", None), 62 | self.sensor("electricLevel", None, "%", "battery"), 63 | self.sensor("energyPower", None, "W"), 64 | self.sensor("inverseMaxPower", None, "W"), 65 | self.sensor("solarPower1", None, "W", "power", "measurement"), 66 | self.sensor("solarPower2", None, "W", "power", "measurement"), 67 | self.sensor("gridInputPower", None, "W", "power", "measurement"), 68 | self.sensor("pass", None), 69 | self.sensor("strength", None), 70 | self.sensor("hyperTmp", "{{ (value | float - 2731) / 10 | round(1) }}", "°C", "temperature", "measurement"), 71 | ] 72 | ZendureSensor.add(sensors) 73 | 74 | selects = [self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode)] 75 | ZendureSelect.add(selects) 76 | 77 | def entityUpdate(self, key: Any, value: Any) -> bool: 78 | # Call the base class entityUpdate method 79 | if not super().entityUpdate(key, value): 80 | return False 81 | match key: 82 | case "inverseMaxPower": 83 | self.powerMax = value 84 | self.numbers[1].update_range(0, value) 85 | return True 86 | 87 | def writePower(self, power: int, inprogram: bool) -> None: 88 | delta = abs(power - self.powerAct) 89 | if delta <= 1 and inprogram: 90 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 91 | return 92 | 93 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 94 | self.mqttInvoke({ 95 | "function": "hemsEP", 96 | "arguments": { 97 | "outputPower": max(0, power), 98 | "chargeState": 0 if power >= 0 else 1, 99 | "chargePower": 0 if power >= 0 else -power, 100 | "mode": 9 if inprogram else 0, 101 | }, 102 | }) 103 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/solarflow800.py: -------------------------------------------------------------------------------- 1 | """Module for SolarFlow800 integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.switch import ZendureSwitch 14 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class SolarFlow800(ZendureDevice): 20 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 21 | """Initialise SolarFlow800.""" 22 | super().__init__(hass, deviceId, prodName, definition) 23 | self.powerMin = -1200 24 | self.powerMax = 800 25 | self.numbers: list[ZendureNumber] = [] 26 | 27 | def entitiesCreate(self) -> None: 28 | super().entitiesCreate() 29 | 30 | binaries = [ 31 | self.binary("heatState"), 32 | self.binary("reverseState"), 33 | ] 34 | ZendureBinarySensor.add(binaries) 35 | 36 | self.numbers = [ 37 | self.number("outputLimit", None, "W", "power", 0, 800, NumberMode.SLIDER), 38 | self.number("inputLimit", None, "W", "power", 0, 1200, NumberMode.SLIDER), 39 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 40 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 41 | ] 42 | ZendureNumber.add(self.numbers) 43 | 44 | switches = [ 45 | self.switch("lampSwitch"), 46 | ] 47 | ZendureSwitch.add(switches) 48 | 49 | sensors = [ 50 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 51 | self.sensor("packInputPower", None, "W", "power", "measurement"), 52 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 53 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 54 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 55 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 56 | self.sensor("packNum", None), 57 | self.sensor("electricLevel", None, "%", "battery"), 58 | self.sensor("inverseMaxPower", None, "W"), 59 | self.sensor("solarPower1", None, "W", "power", "measurement"), 60 | self.sensor("solarPower2", None, "W", "power", "measurement"), 61 | self.sensor("gridInputPower", None, "W", "power", "measurement"), 62 | self.sensor("pass"), 63 | ] 64 | ZendureSensor.add(sensors) 65 | 66 | selects = [self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode)] 67 | ZendureSelect.add(selects) 68 | 69 | def entityUpdate(self, key: Any, value: Any) -> bool: 70 | # Call the base class entityUpdate method 71 | if not super().entityUpdate(key, value): 72 | return False 73 | match key: 74 | case "inverseMaxPower": 75 | self.powerMax = value 76 | self.numbers[1].update_range(0, value) 77 | return True 78 | 79 | def writePower(self, power: int, inprogram: bool) -> None: 80 | delta = abs(power - self.powerAct) 81 | if delta <= 1 and inprogram: 82 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 83 | return 84 | 85 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 86 | self.mqttInvoke({ 87 | "function": "hemsEP", 88 | "arguments": { 89 | "outputPower": max(0, power), 90 | "chargeState": 0 if power >= 0 else 1, 91 | "chargePower": 0 if power >= 0 else -power, 92 | "mode": 9 if inprogram else 0, 93 | }, 94 | }) 95 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/devices/solarflow800Pro.py: -------------------------------------------------------------------------------- 1 | """Module for SolarFlow800 integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from homeassistant.components.number import NumberMode 7 | from homeassistant.core import HomeAssistant 8 | 9 | from custom_components.zendure_ha.binary_sensor import ZendureBinarySensor 10 | from custom_components.zendure_ha.number import ZendureNumber 11 | from custom_components.zendure_ha.select import ZendureSelect 12 | from custom_components.zendure_ha.sensor import ZendureSensor 13 | from custom_components.zendure_ha.switch import ZendureSwitch 14 | from custom_components.zendure_ha.zenduredevice import ZendureDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class SolarFlow800Pro(ZendureDevice): 20 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any) -> None: 21 | """Initialise SolarFlow800Pro.""" 22 | super().__init__(hass, deviceId, prodName, definition) 23 | self.powerMin = -2400 24 | self.powerMax = 2400 25 | self.numbers: list[ZendureNumber] = [] 26 | 27 | def entitiesCreate(self) -> None: 28 | super().entitiesCreate() 29 | 30 | binaries = [ 31 | self.binary("masterSwitch"), 32 | self.binary("buzzerSwitch"), 33 | self.binary("wifiState"), 34 | self.binary("heatState"), 35 | self.binary("reverseState"), 36 | ] 37 | ZendureBinarySensor.add(binaries) 38 | 39 | self.numbers = [ 40 | self.number("inputLimit", None, "W", "power", 0, 1200, NumberMode.SLIDER), 41 | self.number("outputLimit", None, "W", "power", 0, 200, NumberMode.SLIDER), 42 | self.number("socSet", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 43 | self.number("minSoc", "{{ value | int / 10 }}", "%", None, 5, 100, NumberMode.SLIDER), 44 | ] 45 | ZendureNumber.add(self.numbers) 46 | 47 | switches = [ 48 | self.switch("lampSwitch"), 49 | ] 50 | ZendureSwitch.add(switches) 51 | 52 | sensors = [ 53 | self.sensor("hubState"), 54 | self.sensor("solarInputPower", None, "W", "power", "measurement"), 55 | self.sensor("BatVolt", None, "V", "voltage", "measurement"), 56 | self.sensor("packInputPower", None, "W", "power", "measurement"), 57 | self.sensor("outputPackPower", None, "W", "power", "measurement"), 58 | self.sensor("outputHomePower", None, "W", "power", "measurement"), 59 | self.calculate("remainOutTime", self.remainingOutput, "h", "duration"), 60 | self.calculate("remainInputTime", self.remainingInput, "h", "duration"), 61 | self.sensor("packNum", None), 62 | self.sensor("electricLevel", None, "%", "battery"), 63 | self.sensor("energyPower", None, "W"), 64 | self.sensor("inverseMaxPower", None, "W"), 65 | self.sensor("solarPower1", None, "W", "power", "measurement"), 66 | self.sensor("solarPower2", None, "W", "power", "measurement"), 67 | self.sensor("gridInputPower", None, "W", "power", "measurement"), 68 | self.sensor("pass", None), 69 | self.sensor("strength", None), 70 | ] 71 | ZendureSensor.add(sensors) 72 | 73 | selects = [self.select("acMode", {1: "input", 2: "output"}, self.update_ac_mode)] 74 | ZendureSelect.add(selects) 75 | 76 | def entityUpdate(self, key: Any, value: Any) -> bool: 77 | # Call the base class entityUpdate method 78 | if not super().entityUpdate(key, value): 79 | return False 80 | match key: 81 | case "inverseMaxPower": 82 | self.powerMax = value 83 | self.numbers[1].update_range(0, value) 84 | return True 85 | 86 | def writePower(self, power: int, inprogram: bool) -> None: 87 | delta = abs(power - self.powerAct) 88 | if delta <= 1 and inprogram: 89 | _LOGGER.info(f"Update power {self.name} => no action [power {power} capacity {self.capacity}]") 90 | return 91 | 92 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity}") 93 | self.mqttInvoke({ 94 | "function": "hemsEP", 95 | "arguments": { 96 | "outputPower": max(0, power), 97 | "chargeState": 0 if power >= 0 else 1, 98 | "chargePower": 0 if power >= 0 else -power, 99 | "mode": 9 if inprogram else 0, 100 | }, 101 | }) 102 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "zendure_ha", 3 | "name": "Zendure Home Assistant Integration", 4 | "codeowners": [ 5 | "@fireson" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [ 9 | "bluetooth", 10 | "mqtt" 11 | ], 12 | "documentation": "https://github.com/fireson/zendure-ha", 13 | "iot_class": "local_polling", 14 | "issue_tracker": "https://github.com/FireSon/Zendure-HA/issues", 15 | "requirements": [ 16 | "paho-mqtt==2.1.0", 17 | "stringcase==1.2.0" 18 | ], 19 | "single_config_entry": true, 20 | "version": "1.0.47" 21 | } -------------------------------------------------------------------------------- /custom_components/zendure_ha/number.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Zendure Integration number.""" 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from typing import Any 6 | 7 | from homeassistant.components.number import NumberEntity, NumberEntityDescription, NumberMode 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.device_registry import DeviceInfo 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.restore_state import RestoreEntity 13 | from homeassistant.helpers.template import Template 14 | from stringcase import snakecase 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 20 | """Set up the Zendure number.""" 21 | ZendureNumber.add = async_add_entities 22 | 23 | 24 | class ZendureNumber(NumberEntity): 25 | add: AddEntitiesCallback 26 | 27 | def __init__( 28 | self, 29 | deviceinfo: DeviceInfo, 30 | uniqueid: str, 31 | onwrite: Callable, 32 | template: Template | None = None, 33 | uom: str | None = None, 34 | deviceclass: Any | None = None, 35 | maximum: int = 2000, 36 | minimum: int = 0, 37 | mode: NumberMode = NumberMode.AUTO, 38 | ) -> None: 39 | """Initialize a number entity.""" 40 | self._attr_has_entity_name = True 41 | self._attr_should_poll = False 42 | self._attr_available = True 43 | self.entity_description = NumberEntityDescription( 44 | key=uniqueid, 45 | name=uniqueid, 46 | native_unit_of_measurement=uom, 47 | device_class=deviceclass, 48 | ) 49 | self._attr_device_info = deviceinfo 50 | self._attr_unique_id = f"{deviceinfo.get('name', None)}-{uniqueid}" 51 | self.entity_id = f"number.{deviceinfo.get('name', None)}-{snakecase(uniqueid)}" 52 | self._attr_translation_key = snakecase(uniqueid) 53 | 54 | self._value_template: Template | None = template 55 | self._onwrite = onwrite 56 | self._attr_native_max_value = maximum 57 | self._attr_native_min_value = minimum 58 | self._attr_mode = mode 59 | 60 | def update_value(self, value: Any) -> None: 61 | try: 62 | new_value = int( 63 | float(self._value_template.async_render_with_possible_json_value(value, None)) if self._value_template is not None else float(value) 64 | ) 65 | 66 | if self._attr_native_value == new_value: 67 | return 68 | 69 | _LOGGER.info(f"Update number: {self._attr_unique_id} => {new_value}") 70 | 71 | self._attr_native_value = new_value 72 | if self.hass and self.hass.loop.is_running(): 73 | self.schedule_update_ha_state() 74 | except Exception as err: 75 | _LOGGER.error(f"Error {err} setting state: {self._attr_unique_id} => {value}") 76 | 77 | async def async_set_native_value(self, value: float) -> None: 78 | """Set the value.""" 79 | self._onwrite(self, value) 80 | self._attr_native_value = value 81 | if self.hass and self.hass.loop.is_running(): 82 | self.schedule_update_ha_state() 83 | 84 | def update_range(self, minimum: int, maximum: int) -> None: 85 | self._attr_native_min_value = minimum 86 | self._attr_native_max_value = maximum 87 | if self.hass and self.hass.loop.is_running(): 88 | self.schedule_update_ha_state() 89 | 90 | 91 | class ZendureRestoreNumber(ZendureNumber, RestoreEntity): 92 | """Representation of a Zendure number entity with restore.""" 93 | 94 | def __init__( 95 | self, 96 | deviceinfo: DeviceInfo, 97 | uniqueid: str, 98 | onwrite: Callable, 99 | template: Template | None = None, 100 | uom: str | None = None, 101 | deviceclass: Any | None = None, 102 | maximum: int = 2000, 103 | minimum: int = 0, 104 | mode: NumberMode = NumberMode.AUTO, 105 | ) -> None: 106 | """Initialize a number entity.""" 107 | super().__init__(deviceinfo, uniqueid, onwrite, template, uom, deviceclass, maximum, minimum, mode) 108 | 109 | async def async_added_to_hass(self) -> None: 110 | """Handle entity which will be added.""" 111 | await super().async_added_to_hass() 112 | if state := await self.async_get_last_state(): 113 | if state.state is None or state.state == "unknown": 114 | return 115 | self._attr_native_value = int(state.state) 116 | self._onwrite(self, int(state.state)) 117 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/select.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Zendure Integration.""" 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from typing import Any 6 | 7 | from homeassistant.components.select import (SelectEntity, 8 | SelectEntityDescription) 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.device_registry import DeviceInfo 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.helpers.restore_state import RestoreEntity 14 | from stringcase import snakecase 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 20 | """Set up the Zendure select.""" 21 | ZendureSelect.add = async_add_entities 22 | 23 | 24 | class ZendureSelect(SelectEntity): 25 | """Representation of a Zendure select entity.""" 26 | 27 | add: AddEntitiesCallback 28 | 29 | def __init__(self, deviceinfo: DeviceInfo, uniqueid: str, options: dict[Any, str], onchanged: Callable | None, current: int | None = None) -> None: 30 | """Initialize a select entity.""" 31 | self._attr_has_entity_name = True 32 | self._attr_should_poll = False 33 | self.entity_description = SelectEntityDescription(key=uniqueid, name=uniqueid) 34 | self._attr_device_info = deviceinfo 35 | self._attr_unique_id = f"{deviceinfo.get('name', None)}-{uniqueid}" 36 | self.entity_id = f"select.{deviceinfo.get('name', None)}-{snakecase(uniqueid)}" 37 | self._attr_translation_key = snakecase(uniqueid) 38 | 39 | self._options = options 40 | self._attr_options = list(options.values()) 41 | if current: 42 | self._attr_current_option = options[current] 43 | else: 44 | self._attr_current_option = self._attr_options[0] 45 | self._onchanged = onchanged 46 | 47 | def update_value(self, value: Any) -> None: 48 | try: 49 | if value not in self._options: 50 | return 51 | new_value = self._options[value] 52 | if new_value != self._attr_current_option: 53 | self._attr_current_option = new_value 54 | if self.hass and self.hass.loop.is_running(): 55 | _LOGGER.info(f"Update sensor state: {self._attr_unique_id} => {new_value}") 56 | self.schedule_update_ha_state() 57 | 58 | except Exception as err: 59 | _LOGGER.error(f"Error {err} setting state: {self._attr_unique_id} => {value}") 60 | 61 | async def async_select_option(self, option: str) -> None: 62 | """Update the current selected option.""" 63 | for key, value in self._options.items(): 64 | if value == option: 65 | self._attr_current_option = option 66 | self.async_write_ha_state() 67 | if self._onchanged: 68 | self._onchanged(self, key) 69 | break 70 | 71 | 72 | class ZendureRestoreSelect(ZendureSelect, RestoreEntity): 73 | """Representation of a Zendure select entity with restore.""" 74 | 75 | def __init__(self, deviceinfo: DeviceInfo, uniqueid: str, options: dict[int, str], onchanged: Callable | None, current: int | None = None) -> None: 76 | """Initialize a select entity.""" 77 | super().__init__(deviceinfo, uniqueid, options, onchanged, current) 78 | 79 | async def async_added_to_hass(self) -> None: 80 | """Handle entity which will be added.""" 81 | await super().async_added_to_hass() 82 | if state := await self.async_get_last_state(): 83 | self._attr_current_option = state.state 84 | else: 85 | self._attr_current_option = self._attr_options[0] 86 | 87 | # do the onchanged callback 88 | for key, value in self._options.items(): 89 | if value == self._attr_current_option: 90 | if self._onchanged: 91 | self._onchanged(self, key) 92 | return 93 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/sensor.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Zendure Integration api sensors.""" 2 | 3 | import logging 4 | import traceback 5 | from collections.abc import Callable 6 | from datetime import datetime 7 | from typing import Any 8 | 9 | from homeassistant.components.sensor import SensorEntity, SensorEntityDescription 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.device_registry import DeviceInfo 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.restore_state import RestoreEntity 15 | from homeassistant.helpers.template import Template 16 | from homeassistant.util import dt as dt_util 17 | from stringcase import snakecase 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 23 | """Set up the Zendure sensor.""" 24 | ZendureSensor.add = async_add_entities 25 | 26 | 27 | class ZendureSensor(SensorEntity): 28 | add: AddEntitiesCallback 29 | 30 | def __init__( 31 | self, 32 | deviceinfo: DeviceInfo, 33 | uniqueid: str, 34 | template: Template | None = None, 35 | uom: str | None = None, 36 | deviceclass: Any | None = None, 37 | stateclass: Any | None = None, 38 | precision: int | None = None, 39 | ) -> None: 40 | """Initialize a Zendure entity.""" 41 | self._attr_has_entity_name = True 42 | self._attr_should_poll = False 43 | self._attr_available = True 44 | self.entity_description = SensorEntityDescription( 45 | key=uniqueid, name=uniqueid, native_unit_of_measurement=uom, device_class=deviceclass, state_class=stateclass 46 | ) 47 | self._attr_device_info = deviceinfo 48 | self._attr_unique_id = f"{deviceinfo.get('name', None)}-{uniqueid}" 49 | self.entity_id = f"sensor.{deviceinfo.get('name', None)}-{snakecase(uniqueid)}" 50 | self._attr_translation_key = snakecase(uniqueid) 51 | self._value_template: Template | None = template 52 | if precision is not None: 53 | self._attr_suggested_display_precision = precision 54 | 55 | def update_value(self, value: Any) -> None: 56 | try: 57 | new_value = self._value_template.async_render_with_possible_json_value(value, None) if self._value_template is not None else value 58 | 59 | if self.hass and new_value != self._attr_native_value: 60 | self._attr_native_value = new_value 61 | if self.hass and self.hass.loop.is_running(): 62 | self.schedule_update_ha_state() 63 | 64 | except Exception as err: 65 | self._attr_native_value = value 66 | _LOGGER.error(f"Error {err} setting state: {self._attr_unique_id} => {value}") 67 | _LOGGER.error(traceback.format_exc()) 68 | 69 | 70 | class ZendureRestoreSensor(ZendureSensor, RestoreEntity): 71 | """Representation of a Zendure sensor entity with restore.""" 72 | 73 | def __init__( 74 | self, 75 | deviceinfo: DeviceInfo, 76 | uniqueid: str, 77 | template: Template | None = None, 78 | uom: str | None = None, 79 | deviceclass: Any | None = None, 80 | stateclass: Any | None = None, 81 | precision: int | None = None, 82 | ) -> None: 83 | """Initialize a select entity.""" 84 | super().__init__(deviceinfo, uniqueid, template, uom, deviceclass, stateclass, precision) 85 | self.last_value = 0 86 | self.lastValueUpdate = dt_util.utcnow() 87 | self._attr_native_value = 0.0 88 | 89 | async def async_added_to_hass(self) -> None: 90 | """Handle entity which will be added.""" 91 | await super().async_added_to_hass() 92 | state = await self.async_get_last_state() 93 | if state is not None and state.state != "unknown": 94 | self._attr_native_value = state.state 95 | _LOGGER.debug(f"Restored state for {self.entity_id}: {self._attr_native_value}") 96 | 97 | def aggregate(self, time: datetime, value: int) -> None: 98 | # Get the kWh value from the last value and the time since the last update 99 | if (self.last_reset is None or self.last_reset.date() != time.date()) and self.state_class != "total_increasing": 100 | self._attr_native_value = 0.0 101 | self._attr_last_reset = time 102 | else: 103 | kWh = self.last_value * (time.timestamp() - self.lastValueUpdate.timestamp()) / 3600000 104 | self._attr_native_value = kWh + float(self.state) 105 | 106 | self.last_value = value 107 | self.lastValueUpdate = time 108 | if self.hass and self.hass.loop.is_running(): 109 | self.schedule_update_ha_state() 110 | 111 | 112 | class ZendureCalcSensor(ZendureSensor): 113 | """Representation of a Zendure Calculated Sensor.""" 114 | 115 | def __init__( 116 | self, 117 | deviceinfo: DeviceInfo, 118 | uniqueid: str, 119 | calculate: Callable[[Any], Any] | None = None, 120 | uom: str | None = None, 121 | deviceclass: Any | None = None, 122 | stateclass: Any | None = None, 123 | precision: int | None = None, 124 | ) -> None: 125 | """Initialize a Zendure entity.""" 126 | super().__init__(deviceinfo, uniqueid, None, uom, deviceclass, stateclass, precision) 127 | self.calculate = calculate 128 | 129 | def update_value(self, value: Any) -> None: 130 | try: 131 | new_value = self._value_template.async_render_with_possible_json_value(value, None) if self._value_template is not None else value 132 | 133 | if self.hass and new_value != self._attr_native_value: 134 | self._attr_native_value = self.calculate(new_value) 135 | if self.hass and self.hass.loop.is_running(): 136 | self.schedule_update_ha_state() 137 | 138 | except Exception as err: 139 | self._attr_native_value = value 140 | _LOGGER.error(f"Error {err} setting state: {self._attr_unique_id} => {value}") 141 | _LOGGER.error(traceback.format_exc()) 142 | 143 | def calculate_version(self, value: Any) -> Any: 144 | """Calculate the version from the value.""" 145 | version = int(value) 146 | return f"v{(version & 0xF000) >> 12}.{(version & 0x0F00) >> 8}.{version & 0x00FF}" if version != 0 else "not provided" 147 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/switch.py: -------------------------------------------------------------------------------- 1 | """Interfaces with the Zendure Integration switch.""" 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from typing import Any 6 | 7 | from homeassistant.components.switch import (SwitchEntity, 8 | SwitchEntityDescription) 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.device_registry import DeviceInfo 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.helpers.template import Template 14 | from stringcase import snakecase 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: 20 | """Set up the Zendure switch.""" 21 | ZendureSwitch.add = async_add_entities 22 | 23 | 24 | class ZendureSwitch(SwitchEntity): 25 | add: AddEntitiesCallback 26 | 27 | def __init__( 28 | self, 29 | deviceinfo: DeviceInfo, 30 | uniqueid: str, 31 | onwrite: Callable, 32 | template: Template | None = None, 33 | deviceclass: Any | None = None, 34 | value: bool | None = None, 35 | ) -> None: 36 | """Initialize a switch entity.""" 37 | self._attr_has_entity_name = True 38 | self._attr_should_poll = False 39 | self.entity_description = SwitchEntityDescription(key=uniqueid, name=uniqueid, device_class=deviceclass) 40 | self._attr_device_info = deviceinfo 41 | self._attr_unique_id = f"{deviceinfo.get('name', None)}-{uniqueid}" 42 | self.entity_id = f"switch.{deviceinfo.get('name', None)}-{snakecase(uniqueid)}" 43 | self._attr_translation_key = snakecase(uniqueid) 44 | 45 | self._attr_available = True 46 | self._value_template: Template | None = template 47 | self._onwrite = onwrite 48 | if value is not None: 49 | self._attr_is_on = value 50 | 51 | def update_value(self, value: Any) -> None: 52 | try: 53 | is_on = bool( 54 | int(self._value_template.async_render_with_possible_json_value(value, None)) != 0 if self._value_template is not None else int(value) != 0 55 | ) 56 | 57 | if self._attr_is_on == is_on: 58 | return 59 | 60 | _LOGGER.info(f"Update switch: {self._attr_unique_id} => {is_on}") 61 | 62 | self._attr_is_on = is_on 63 | if self.hass and self.hass.loop.is_running(): 64 | self.schedule_update_ha_state() 65 | except Exception as err: 66 | _LOGGER.error(f"Error {err} setting state: {self._attr_unique_id} => {value}") 67 | 68 | async def async_turn_on(self, **_kwargs: Any) -> None: 69 | """Turn switch on.""" 70 | self._onwrite(self, 1) 71 | 72 | async def async_turn_off(self, **_kwargs: Any) -> None: 73 | """Turn switch off.""" 74 | self._onwrite(self, 0) 75 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Gerät ist bereits konfiguriert", 5 | "reconfigure_successful": "Neukonfiguration erfolgreich" 6 | }, 7 | "error": { 8 | "cannot_connect": "Verbindung fehlgeschlagen", 9 | "invalid_auth": "Ungültige Authentifizierung", 10 | "unknown": "Unerwarteter Fehler" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "username": "Zendure Benutzername", 16 | "password": "Zendure Passwort", 17 | "p1meter": "P1 Sensor für Smart Matching", 18 | "mqttlog": "MQTT-Kommunikation loggen", 19 | "mqttlocal": "Lokalen MQTT verwenden", 20 | "wifissid": "Wifi SSID", 21 | "wifipsw": "Wifi Passwort" 22 | } 23 | }, 24 | "reconfigure": { 25 | "data": { 26 | "username": "Zendure Benutzername", 27 | "password": "Zendure Passwort", 28 | "p1meter": "P1 Sensor für Smart Matching", 29 | "mqttlog": "MQTT-Kommunikation loggen", 30 | "mqttlocal": "Lokalen Mosquitto MQTT AddOn verwenden", 31 | "wifissid": "Wifi SSID", 32 | "wifipsw": "Wifi Passwort" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "data": { 41 | "scan_interval": "Scan-Intervall in Sekunden" 42 | }, 43 | "description": "Optionen anpassen", 44 | "title": "Zendure Integrationsoptionen" 45 | } 46 | } 47 | }, 48 | "entity": { 49 | "binary_sensor": { 50 | "master_switch": { 51 | "name": "Hauptschalter" 52 | }, 53 | "wifi_state": { 54 | "name": "WLAN Status" 55 | } 56 | }, 57 | "number": { 58 | "input_limit": { 59 | "name": "Eingangslimit" 60 | }, 61 | "output_limit": { 62 | "name": "Ausgangslimit" 63 | }, 64 | "manual_power": { 65 | "name": "Manuelle Leistung" 66 | }, 67 | "min_soc": { 68 | "name": "SOC Minimum" 69 | }, 70 | "soc_set": { 71 | "name": "SOC Maximum" 72 | } 73 | }, 74 | "sensor": { 75 | "ambient_light_color": { 76 | "name": "Ambient Lichtfarbe" 77 | }, 78 | "auto_model": { 79 | "name": "Hyper Energy Programm", 80 | "state": { 81 | "0": "Kein", 82 | "6": "Batterieprioritätsmodus", 83 | "7": "Zeitliche Leistungsregelung", 84 | "8": "Smart Leistungsregelung", 85 | "9": "Smart CT Leistungsregelung", 86 | "10": "Strompreis" 87 | } 88 | }, 89 | "aggr_charge_total": { 90 | "name": "Akku geladen" 91 | }, 92 | "aggr_discharge_total": { 93 | "name": "Akku entladen" 94 | }, 95 | "aggr_solar_total": { 96 | "name": "Solar-Energie erzeugt" 97 | }, 98 | "mqtt_reset": { 99 | "name": "Reset MQTT Verbindung" 100 | }, 101 | "connection_status": { 102 | "name": "Connection Status", 103 | "state": { 104 | "0": "Unbekannt", 105 | "1": "BLE", 106 | "2": "lokal MQTT", 107 | "3": "lokal MQTT und BLE", 108 | "4": "Zendure Cloud", 109 | "5": "Zendure Cloud und BLE", 110 | "8": "Zendure App", 111 | "9": "Zendure App und BLE", 112 | "10": "Lokal Mqtt und Zendure App", 113 | "11": "Lokal Mqtt, Zendure App und BLE", 114 | "16": "BLE Fehler" 115 | } 116 | }, 117 | "so_h": { 118 | "name": "Gesundsheitszustand" 119 | }, 120 | "soc_status": { 121 | "name": "SOC Status", 122 | "state": { 123 | "0": "Aktiv", 124 | "1": "Auto-Kalibrierung" 125 | } 126 | }, 127 | "hub_state": { 128 | "name": "Hub-Status", 129 | "state": { 130 | "0": "Output stoppen und in Standby gehen", 131 | "1": "Output stoppen und ausschalten" 132 | } 133 | }, 134 | "pack_num": { 135 | "name": "Batterieanzahl" 136 | }, 137 | "bat_volt": { 138 | "name": "Batteriespannung" 139 | }, 140 | "electric_level": { 141 | "name": "Batterie" 142 | }, 143 | "charging_mode": { 144 | "name": "Lademodus", 145 | "state": { 146 | "0": "Nichts", 147 | "1": "Modus 1", 148 | "2": "Modus 2" 149 | } 150 | }, 151 | "charging_type": { 152 | "name": "Ladetyp", 153 | "state": { 154 | "0": "Typ 0", 155 | "1": "Typ 1", 156 | "2": "Typ 2" 157 | } 158 | }, 159 | "charging_time": { 160 | "name": "Ladezeit" 161 | }, 162 | "pack_state": { 163 | "name": "Batterie Status", 164 | "state": { 165 | "0": "Ruhezustand", 166 | "1": "Laden", 167 | "2": "Entladen" 168 | } 169 | }, 170 | "state": { 171 | "name": "Status", 172 | "state": { 173 | "0": "Ruhezustand", 174 | "1": "Laden", 175 | "2": "Entladen" 176 | } 177 | }, 178 | "soc_level": { 179 | "name": "Ladezustand" 180 | }, 181 | "max_temp": { 182 | "name": "Temperatur" 183 | }, 184 | "total_vol": { 185 | "name": "Spannung" 186 | }, 187 | "max_vol": { 188 | "name": "Maximale Spannung" 189 | }, 190 | "min_vol": { 191 | "name": "Minimale Spannung" 192 | }, 193 | "batcur": { 194 | "name": "Strom" 195 | }, 196 | "pack_input_power": { 197 | "name": "Batterie Ausgangsleistung" 198 | }, 199 | "pass": { 200 | "name": "Bypass", 201 | "state": { 202 | "0": "Aus", 203 | "1": "An", 204 | "2": "Automatisch An" 205 | } 206 | }, 207 | "hyper_2000_pass": { 208 | "name": "Bypass Status" 209 | }, 210 | "output_pack_power": { 211 | "name": "Batterie Eingangsleistung" 212 | }, 213 | "hyper_tmp": { 214 | "name": "Hyper Temperatur" 215 | }, 216 | "strength": { 217 | "name": "WLAN-Signalstärke" 218 | }, 219 | "remain_out_time": { 220 | "name": "Verbleibende Entladezeit" 221 | }, 222 | "remain_input_time": { 223 | "name": "Verbleibende Ladezeit" 224 | }, 225 | "soft_version": { 226 | "name": "BMS Firmware" 227 | }, 228 | "dspversion": { 229 | "name": "AC Firmware" 230 | }, 231 | "master_soft_version": { 232 | "name": "Master Firmware" 233 | }, 234 | "masterhaer_version": { 235 | "name": "Hardware Version" 236 | } 237 | }, 238 | "select": { 239 | "ac_mode": { 240 | "name": "AC-Betriebsmodus", 241 | "state": { 242 | "input": "AC-Eingangsmodus", 243 | "output": "AC-Ausgangsmodus" 244 | } 245 | }, 246 | "cluster": { 247 | "name": "Gerätecluster", 248 | "state": { 249 | "clusterunknown": "Unbekannte Clusterkonfiguration, bitte prüfen!!", 250 | "clusterowncircuit": "Gerät hat einen eigenen Stromkreis oder eine eigene Phase", 251 | "cluster800": "Cluster max. 800 W", 252 | "cluster1200": "Cluster max. 1200 W", 253 | "cluster2400": "Cluster max. 2400 W", 254 | "cluster3600": "Cluster max. 3600w" 255 | } 256 | }, 257 | "operation": { 258 | "name": "Betriebsmodus", 259 | "state": { 260 | "off": "Aus", 261 | "manual": "Manuelle Leistungsregelung", 262 | "smart": "Smarte Leistungsregelung" 263 | } 264 | }, 265 | "pass_mode": { 266 | "name": "Bypass-Modus", 267 | "state": { 268 | "auto": "Automatisch", 269 | "off": "Immer aus", 270 | "on": "Immer an" 271 | } 272 | }, 273 | "grid_reverse": { 274 | "name": "Bypass-Modus", 275 | "state": { 276 | "auto": "Automatisch", 277 | "off": "Immer aus", 278 | "on": "Immer an" 279 | } 280 | } 281 | }, 282 | "switch": { 283 | "lamp_switch": { 284 | "name": "Led" 285 | } 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reconfigure_successful": "Reconfiguration successful" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "invalid_auth": "Invalid authentication", 10 | "unknown": "Unexpected error" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "username": "Zendure Username", 16 | "password": "Zendure Password", 17 | "p1meter": "P1 Sensor for smart matching", 18 | "mqttlocal": "Use local MQTT", 19 | "mqttlog": "Log MQTT communication", 20 | "wifissid": "Wifi SSID", 21 | "wifipsw": "Wifi Password" 22 | } 23 | }, 24 | "reconfigure": { 25 | "data": { 26 | "username": "Zendure Username", 27 | "password": "Zendure Password", 28 | "p1meter": "P1 Sensor for smart matching", 29 | "mqttlocal": "Use local MQTT", 30 | "mqttlog": "Log MQTT communication", 31 | "wifissid": "Wifi SSID", 32 | "wifipsw": "Wifi Password" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "data": { 41 | "scan_interval": "Scan Interval (seconds)" 42 | }, 43 | "description": "Amend your options.", 44 | "title": "Zendure Integration Options" 45 | } 46 | } 47 | }, 48 | "entity": { 49 | "binary_sensor": { 50 | "master_switch": { 51 | "name": "Master Switch" 52 | }, 53 | "wifi_state": { 54 | "name": "Wifi State" 55 | } 56 | }, 57 | "number": { 58 | "input_limit": { 59 | "name": "Limit Input" 60 | }, 61 | "output_limit": { 62 | "name": "Limit Output" 63 | }, 64 | "manual_power": { 65 | "name": "Manual Power" 66 | }, 67 | "min_soc": { 68 | "name": "SOC Minimum" 69 | }, 70 | "soc_set": { 71 | "name": "SOC Maximum" 72 | } 73 | }, 74 | "sensor": { 75 | "ambient_light_color": { 76 | "name": "Ambient light color" 77 | }, 78 | "auto_model": { 79 | "name": "Battery Program", 80 | "state": { 81 | "0": "None", 82 | "6": "Battery Priority Mode", 83 | "7": "Appointment Mode", 84 | "8": "Smart Matching Mode", 85 | "9": "Smart CT Mode", 86 | "10": "Electricity Price" 87 | } 88 | }, 89 | "aggr_charge_total": { 90 | "name": "Total Charged" 91 | }, 92 | "aggr_discharge_total": { 93 | "name": "Total Discharged" 94 | }, 95 | "aggr_solar_total": { 96 | "name": "Total Solar" 97 | }, 98 | "mqtt_reset": { 99 | "name": "Reset MQTT Connection" 100 | }, 101 | "connection_status": { 102 | "name": "Connection Status", 103 | "state": { 104 | "0": "None", 105 | "1": "BLE", 106 | "2": "Local MQTT", 107 | "3": "Local MQTT and BLE", 108 | "4": "Zendure Cloud", 109 | "5": "Zendure Cloud and BLE", 110 | "8": "Zendure App", 111 | "9": "Zendure App and BLE", 112 | "10": "Local Mqtt, Zendure App", 113 | "11": "Local Mqtt, Zendure App and BLE", 114 | "16": "BLE Error" 115 | } 116 | }, 117 | "so_h": { 118 | "name": "State of health" 119 | }, 120 | "soc_status": { 121 | "name": "Soc status", 122 | "state": { 123 | "0": "Active", 124 | "1": "Auto-Calibration" 125 | } 126 | }, 127 | "hub_state": { 128 | "name": "Hub status", 129 | "state": { 130 | "0": "Stop output and standby", 131 | "1": "Stop output and switch off" 132 | } 133 | }, 134 | "pack_num": { 135 | "name": "Battery Count" 136 | }, 137 | "bat_volt": { 138 | "name": "Battery Voltage" 139 | }, 140 | "electric_level": { 141 | "name": "Battery" 142 | }, 143 | "charging_mode": { 144 | "name": "Charging Mode", 145 | "state": { 146 | "0": "Nothing", 147 | "1": "mode 1", 148 | "2": "mode 2" 149 | } 150 | }, 151 | "charging_type": { 152 | "name": "Charging Type", 153 | "state": { 154 | "0": "Type 0", 155 | "1": "Type 1", 156 | "2": "Type 2" 157 | } 158 | }, 159 | "charging_time": { 160 | "name": "Charging Time" 161 | }, 162 | "pack_state": { 163 | "name": "Battery State", 164 | "state": { 165 | "0": "Sleeping", 166 | "1": "Charging", 167 | "2": "Discharging" 168 | } 169 | }, 170 | "state": { 171 | "name": "State", 172 | "state": { 173 | "0": "Sleeping", 174 | "1": "Charging", 175 | "2": "Discharging" 176 | } 177 | }, 178 | "soc_level": { 179 | "name": "Electricity Level" 180 | }, 181 | "max_temp": { 182 | "name": "Temperature" 183 | }, 184 | "total_vol": { 185 | "name": "Voltage" 186 | }, 187 | "max_vol": { 188 | "name": "Maximum Voltage" 189 | }, 190 | "min_vol": { 191 | "name": "Minimum Voltage" 192 | }, 193 | "batcur": { 194 | "name": "Current" 195 | }, 196 | "pack_input_power": { 197 | "name": "Battery Output" 198 | }, 199 | "pass": { 200 | "name": "Bypass", 201 | "state": { 202 | "0": "Off", 203 | "1": "On", 204 | "2": "Auto On" 205 | } 206 | }, 207 | "output_pack_power": { 208 | "name": "Battery Input" 209 | }, 210 | "hyper_tmp": { 211 | "name": "Hyper Temperature" 212 | }, 213 | "strength": { 214 | "name": "Wifi Signal Strength" 215 | }, 216 | "remain_out_time": { 217 | "name": "Remaining Discharge Time" 218 | }, 219 | "remain_input_time": { 220 | "name": "Remaining Charge Time" 221 | }, 222 | "soft_version": { 223 | "name": "BMS Firmware" 224 | }, 225 | "dspversion": { 226 | "name": "AC Firmware" 227 | }, 228 | "master_soft_version": { 229 | "name": "Master Firmware" 230 | }, 231 | "masterhaer_version": { 232 | "name": "Hardware Version" 233 | } 234 | }, 235 | "select": { 236 | "ac_mode": { 237 | "name": "AC Operation Mode", 238 | "state": { 239 | "input": "AC Input Mode", 240 | "output": "AC Output Mode" 241 | } 242 | }, 243 | "cluster": { 244 | "name": "Device cluster", 245 | "state": { 246 | "clusterunknown": "Unknown cluster configuration, please check!!", 247 | "clusterowncircuit": "Device has its own circuit or phase", 248 | "cluster800": "Cluster max. 800w", 249 | "cluster1200": "Cluster max. 1200w", 250 | "cluster2400": "Cluster max. 2400w", 251 | "cluster3600": "Cluster max. 3600w" 252 | } 253 | }, 254 | "operation": { 255 | "name": "Operation Mode", 256 | "state": { 257 | "off": "Off", 258 | "manual": "Manual Power", 259 | "smart": "Smart Matching" 260 | } 261 | }, 262 | "pass_mode": { 263 | "name": "Bypass Mode", 264 | "state": { 265 | "auto": "Auto", 266 | "off": "Always-Off", 267 | "on": "Always-On" 268 | } 269 | }, 270 | "grid_reverse": { 271 | "name": "Bypass Mode", 272 | "state": { 273 | "auto": "Auto", 274 | "off": "Always-Off", 275 | "on": "Always-On" 276 | } 277 | } 278 | }, 279 | "switch": { 280 | "lamp_switch": { 281 | "name": "Led" 282 | } 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "L'appareil est déjà configuré", 5 | "reconfigure_successful": "Reconfiguration réussie" 6 | }, 7 | "error": { 8 | "cannot_connect": "Échec de la connexion", 9 | "invalid_auth": "Authentification invalide", 10 | "unknown": "Erreur inattendue" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "username": "Nom d'utilisateur Zendure", 16 | "password": "Mot de passe Zendure", 17 | "p1meter": "Capteur P1 pour le couplage intelligent", 18 | "mqttlog": "Journaliser la communication MQTT", 19 | "mqttlocal": "Utiliser MQTT local", 20 | "wifissid": "SSID Wi-Fi", 21 | "wifipsw": "Mot de passe Wi-Fi" 22 | } 23 | }, 24 | "reconfigure": { 25 | "data": { 26 | "username": "Nom d'utilisateur Zendure", 27 | "password": "Mot de passe Zendure", 28 | "p1meter": "Capteur P1 pour le couplage intelligent", 29 | "mqttlog": "Journaliser la communication MQTT", 30 | "mqttlocal": "Utiliser Mosquitto MQTT AddOn local", 31 | "wifissid": "SSID Wi-Fi", 32 | "wifipsw": "Mot de passe Wi-Fi" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "data": { 41 | "scan_interval": "Intervalle d'analyse (secondes)" 42 | }, 43 | "description": "Modifiez vos options.", 44 | "title": "Options de l'intégration Zendure" 45 | } 46 | } 47 | }, 48 | "entity": { 49 | "binary_sensor": { 50 | "master_switch": { 51 | "name": "Interrupteur principal" 52 | }, 53 | "wifi_state": { 54 | "name": "État du Wi-Fi" 55 | } 56 | }, 57 | "number": { 58 | "input_limit": { 59 | "name": "Limite d'entrée" 60 | }, 61 | "output_limit": { 62 | "name": "Limite de sortie" 63 | }, 64 | "manual_power": { 65 | "name": "Puissance manuelle" 66 | }, 67 | "min_soc": { 68 | "name": "SOC minimum" 69 | }, 70 | "soc_set": { 71 | "name": "SOC maximum" 72 | } 73 | }, 74 | "sensor": { 75 | "ambient_light_color": { 76 | "name": "Couleur de l'éclairage ambiant" 77 | }, 78 | "auto_model": { 79 | "name": "Programme de batterie", 80 | "state": { 81 | "0": "Aucun", 82 | "6": "Mode priorité batterie", 83 | "7": "Mode sur rendez-vous", 84 | "8": "Mode de couplage intelligent", 85 | "9": "Mode CT intelligent", 86 | "10": "Prix de l'électricité" 87 | } 88 | }, 89 | "aggr_charge_total": { 90 | "name": "Total Charged" 91 | }, 92 | "aggr_discharge_total": { 93 | "name": "Total Discharged" 94 | }, 95 | "aggr_solar_total": { 96 | "name": "Total Solar" 97 | }, 98 | "mqtt_reset": { 99 | "name": "Reset MQTT Connection" 100 | }, 101 | "connection_status": { 102 | "name": "Connection Status", 103 | "state": { 104 | "0": "Inconnu", 105 | "1": "Inconnu et BLE", 106 | "2": "Local MQTT", 107 | "3": "Local MQTT et BLE", 108 | "4": "Zendure Cloud", 109 | "5": "Zendure Cloud et BLE", 110 | "8": "Zendure App", 111 | "9": "Zendure App et BLE", 112 | "10": "Local Mqtt et Zendure App", 113 | "11": "Local Mqtt, Zendure App et BLE", 114 | "16": "BLE Error" 115 | } 116 | }, 117 | "so_h": { 118 | "name": "État de santé" 119 | }, 120 | "soc_status": { 121 | "name": "État du SOC", 122 | "state": { 123 | "0": "Actif", 124 | "1": "Auto-calibration" 125 | } 126 | }, 127 | "hub_state": { 128 | "name": "État du hub", 129 | "state": { 130 | "0": "Arrêter la sortie et en veille", 131 | "1": "Arrêter la sortie et éteindre" 132 | } 133 | }, 134 | "pack_num": { 135 | "name": "Nombre de batteries" 136 | }, 137 | "bat_volt": { 138 | "name": "Tension de la batterie" 139 | }, 140 | "electric_level": { 141 | "name": "Niveau de batterie" 142 | }, 143 | "charging_mode": { 144 | "name": "Mode de charge", 145 | "state": { 146 | "0": "Aucun", 147 | "1": "Mode 1", 148 | "2": "Mode 2" 149 | } 150 | }, 151 | "charging_type": { 152 | "name": "Type de charge", 153 | "state": { 154 | "0": "Type 0", 155 | "1": "Type 1", 156 | "2": "Type 2" 157 | } 158 | }, 159 | "charging_time": { 160 | "name": "Temps de charge" 161 | }, 162 | "pack_state": { 163 | "name": "État de la batterie", 164 | "state": { 165 | "0": "En veille", 166 | "1": "En charge", 167 | "2": "En décharge" 168 | } 169 | }, 170 | "state": { 171 | "name": "État", 172 | "state": { 173 | "0": "En veille", 174 | "1": "En charge", 175 | "2": "En décharge" 176 | } 177 | }, 178 | "soc_level": { 179 | "name": "Niveau de charge" 180 | }, 181 | "max_temp": { 182 | "name": "Température maximale" 183 | }, 184 | "total_vol": { 185 | "name": "Tension totale" 186 | }, 187 | "max_vol": { 188 | "name": "Tension maximale" 189 | }, 190 | "min_vol": { 191 | "name": "Tension minimale" 192 | }, 193 | "batcur": { 194 | "name": "Ampérage" 195 | }, 196 | "pack_input_power": { 197 | "name": "Puissance de sortie de la batterie" 198 | }, 199 | "pass": { 200 | "name": "Bypass", 201 | "state": { 202 | "0": "Désactivé", 203 | "1": "Activé", 204 | "2": "Auto activé" 205 | } 206 | }, 207 | "output_pack_power": { 208 | "name": "Puissance d'entrée de la batterie" 209 | }, 210 | "hyper_tmp": { 211 | "name": "Température Hyper" 212 | }, 213 | "strength": { 214 | "name": "Puissance du signal Wi-Fi" 215 | }, 216 | "remain_out_time": { 217 | "name": "Temps de décharge restant" 218 | }, 219 | "remain_input_time": { 220 | "name": "Temps de charge restant" 221 | }, 222 | "soft_version": { 223 | "name": "BMS Firmware" 224 | }, 225 | "dspversion": { 226 | "name": "AC Firmware" 227 | }, 228 | "master_soft_version": { 229 | "name": "Master Firmware" 230 | }, 231 | "masterhaer_version": { 232 | "name": "Version du matériel" 233 | } 234 | }, 235 | "select": { 236 | "ac_mode": { 237 | "name": "Mode de fonctionnement AC", 238 | "state": { 239 | "input": "Mode entrée AC", 240 | "output": "Mode sortie AC" 241 | } 242 | }, 243 | "cluster": { 244 | "name": "Regroupement des appareils", 245 | "state": { 246 | "clusterunknown": "Configuration de cluster inconnue, veuillez vérifier !!", 247 | "clusterowncircuit": "L'appareil a son propre circuit ou phase", 248 | "cluster800": "Cluster max. 800 W", 249 | "cluster1200": "Cluster max. 1200 W", 250 | "cluster2400": "Cluster max. 2400 W", 251 | "cluster3600": "Cluster max. 3600w" 252 | } 253 | }, 254 | "operation": { 255 | "name": "Mode de fonctionnement", 256 | "state": { 257 | "off": "Arrêt", 258 | "manual": "Puissance manuelle", 259 | "smart": "Couplage intelligent" 260 | } 261 | }, 262 | "pass_mode": { 263 | "name": "Mode contournement ( bypass )", 264 | "state": { 265 | "auto": "Automatique", 266 | "off": "Toujours désactivé", 267 | "on": "Toujours activé" 268 | } 269 | }, 270 | "grid_reverse": { 271 | "name": "Mode contournement ( bypass )", 272 | "state": { 273 | "auto": "Automatique", 274 | "off": "Toujours désactivé", 275 | "on": "Toujours activé" 276 | } 277 | } 278 | }, 279 | "switch": { 280 | "lamp_switch": { 281 | "name": "LED" 282 | } 283 | } 284 | } 285 | } -------------------------------------------------------------------------------- /custom_components/zendure_ha/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Apparaat is al geconfigureerd", 5 | "reconfigure_successful": "Hergroepering geslaagd" 6 | }, 7 | "error": { 8 | "cannot_connect": "Verbinding mislukt", 9 | "invalid_auth": "Ongeldige authenticatie", 10 | "unknown": "Onverwachte fout" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "username": "Zendure Gebruikersnaam", 16 | "password": "Zendure Wachtwoord", 17 | "p1meter": "P1 Sensor voor slimme matching", 18 | "mqttlog": "MQTT-Communicatie loggen", 19 | "mqttlocal": "Lokale MQTT gebruiken", 20 | "wifissid": "Wifi SSID", 21 | "wifipsw": "Wifi Wachtwoord" 22 | } 23 | }, 24 | "reconfigure": { 25 | "data": { 26 | "username": "Zendure Gebruikersnaam", 27 | "password": "Zendure Wachtwoord", 28 | "p1meter": "P1 Sensor voor slimme matching", 29 | "mqttlog": "MQTT-Communicatie loggen", 30 | "mqttlocal": "Lokale Mosquitto MQTT AddOn gebruiken", 31 | "wifissid": "Wifi SSID", 32 | "wifipsw": "Wifi Wachtwoord" 33 | } 34 | } 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "data": { 41 | "scan_interval": "Scaninterval (seconden)" 42 | }, 43 | "description": "Pas je opties aan.", 44 | "title": "Zendure Integratie Opties" 45 | } 46 | } 47 | }, 48 | "entity": { 49 | "binary_sensor": { 50 | "master_switch": { 51 | "name": "Hoofdschakelaar" 52 | }, 53 | "wifi_state": { 54 | "name": "Wifi Status" 55 | } 56 | }, 57 | "number": { 58 | "input_limit": { 59 | "name": "Limiet Opladen" 60 | }, 61 | "output_limit": { 62 | "name": "Limiet Ontladen" 63 | }, 64 | "manual_power": { 65 | "name": "Handmatig vermogen" 66 | }, 67 | "min_soc": { 68 | "name": "SOC Minimaal" 69 | }, 70 | "soc_set": { 71 | "name": "SOC Maximaal" 72 | } 73 | }, 74 | "sensor": { 75 | "ambient_light_color": { 76 | "name": "Omgevingslichtkleur" 77 | }, 78 | "auto_model": { 79 | "name": "Batterijprogramma", 80 | "state": { 81 | "0": "Geen", 82 | "6": "Batterijprioriteitsmodus", 83 | "7": "Afspraakmodus", 84 | "8": "Slimme Matching Modus", 85 | "9": "Slimme CT Modus", 86 | "10": "Elektriciteitsprijs" 87 | } 88 | }, 89 | "aggr_charge_total": { 90 | "name": "Total geladen" 91 | }, 92 | "aggr_discharge_total": { 93 | "name": "Total geleverd" 94 | }, 95 | "aggr_solar_total": { 96 | "name": "Total Zonne-energie" 97 | }, 98 | "mqtt_reset": { 99 | "name": "Reset MQTT Connectie" 100 | }, 101 | "connection_status": { 102 | "name": "Connectie Status", 103 | "state": { 104 | "0": "Onbekend", 105 | "1": "BLE", 106 | "2": "Lokale MQTT", 107 | "3": "Lokale MQTT en BLE", 108 | "4": "Zendure Cloud", 109 | "5": "Zendure Cloud en BLE", 110 | "8": "Zendure App", 111 | "9": "Zendure App en BLE", 112 | "10": "Lokale Mqtt, Zendure App", 113 | "11": "Lokale Mqtt, Zendure App en BLE", 114 | "16": "BLE Fout" 115 | } 116 | }, 117 | "so_h": { 118 | "name": "Gezondheidsstatus" 119 | }, 120 | "soc_status": { 121 | "name": "Soc status", 122 | "state": { 123 | "0": "Actief", 124 | "1": "Automatische kalibratie" 125 | } 126 | }, 127 | "hub_state": { 128 | "name": "Hub Status" 129 | }, 130 | "pack_num": { 131 | "name": "Batterij Aantal" 132 | }, 133 | "bat_volt": { 134 | "name": "Batterijspanning" 135 | }, 136 | "electric_level": { 137 | "name": "Batterij" 138 | }, 139 | "input_limit": { 140 | "name": "Invoerlimiet" 141 | }, 142 | "charging_mode": { 143 | "name": "Laadmodus", 144 | "state": { 145 | "0": "Geen", 146 | "1": "Modus 1", 147 | "2": "Modus 2" 148 | } 149 | }, 150 | "charging_type": { 151 | "name": "Laadtype", 152 | "state": { 153 | "0": "Type 0", 154 | "1": "Type 1", 155 | "2": "Type 2" 156 | } 157 | }, 158 | "charging_time": { 159 | "name": "Laadtijd" 160 | }, 161 | "pack_state": { 162 | "name": "Batterijstatus", 163 | "state": { 164 | "0": "Inactief", 165 | "1": "Laden", 166 | "2": "Ontladen" 167 | } 168 | }, 169 | "pack_input_power": { 170 | "name": "Batterij Ontladen" 171 | }, 172 | "output_pack_power": { 173 | "name": "Batterij Laden" 174 | }, 175 | "hyper_tmp": { 176 | "name": "Hyper Temperatuur" 177 | }, 178 | "strength": { 179 | "name": "Wifi Signaalsterkte" 180 | }, 181 | "remain_out_time": { 182 | "name": "Resterende Ontlaadtijd" 183 | }, 184 | "remain_input_time": { 185 | "name": "Resterende Laadtijd" 186 | }, 187 | "soft_version": { 188 | "name": "BMS Firmware" 189 | }, 190 | "dspversion": { 191 | "name": "AC Firmware" 192 | }, 193 | "master_soft_version": { 194 | "name": "Master Firmware" 195 | }, 196 | "masterhaer_version": { 197 | "name": "Hardwareversie" 198 | } 199 | }, 200 | "select": { 201 | "ac_mode": { 202 | "name": "AC Bedrijfsmodus", 203 | "state": { 204 | "input": "AC Invoermodus", 205 | "output": "AC Uitvoermodus" 206 | } 207 | }, 208 | "operation": { 209 | "name": "Bedrijfsmodus", 210 | "state": { 211 | "off": "Uit", 212 | "manual": "Handmatig Vermogen", 213 | "smart": "Slimme Matching" 214 | } 215 | }, 216 | "pass_mode": { 217 | "name": "Bypass-modus", 218 | "state": { 219 | "auto": "Automatisch", 220 | "off": "Altijd Uit", 221 | "on": "Altijd Aan" 222 | } 223 | }, 224 | "grid_reverse": { 225 | "name": "Bypass-modus", 226 | "state": { 227 | "auto": "Automatisch", 228 | "off": "Altijd Uit", 229 | "on": "Altijd Aan" 230 | } 231 | } 232 | }, 233 | "switch": { 234 | "lamp_switch": { 235 | "name": "Led" 236 | } 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/zendurebase.py: -------------------------------------------------------------------------------- 1 | """Zendure Integration base class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import traceback 7 | from collections.abc import Callable 8 | from typing import Any 9 | 10 | from homeassistant.components.number import NumberMode 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.device_registry import DeviceInfo 13 | from homeassistant.helpers.entity import Entity 14 | from homeassistant.helpers.template import Template 15 | from homeassistant.util import dt as dt_util 16 | 17 | from .binary_sensor import ZendureBinarySensor 18 | from .const import DOMAIN 19 | from .number import ZendureNumber 20 | from .select import ZendureRestoreSelect, ZendureSelect 21 | from .sensor import ZendureCalcSensor, ZendureRestoreSensor, ZendureSensor 22 | from .switch import ZendureSwitch 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | class ZendureBase: 28 | """A Base Class for all zendure classes.""" 29 | 30 | empty = Entity() 31 | 32 | def __init__(self, hass: HomeAssistant, name: str, model: str, snNumber: str, parent: str | None = None, swVersion: str | None = None) -> None: 33 | """Initialize ZendureDevice.""" 34 | self._hass = hass 35 | self.name = name 36 | self.unique = "".join(self.name.split()) 37 | self.entities: dict[str, Entity | None] = {} 38 | self.attr_device_info = DeviceInfo( 39 | identifiers={(DOMAIN, self.name)}, 40 | name=self.name, 41 | manufacturer="Zendure", 42 | model=model, 43 | serial_number=snNumber, 44 | ) 45 | if parent is not None: 46 | self.attr_device_info["via_device"] = (DOMAIN, parent) 47 | if swVersion is not None: 48 | self.attr_device_info["sw_version"] = swVersion 49 | 50 | def entitiesCreate(self) -> None: 51 | return 52 | 53 | def entityAdd(self, entity: Entity, value: Any) -> None: 54 | try: 55 | _LOGGER.info(f"Add sensor: {entity.unique_id}") 56 | ZendureSensor.add([entity]) 57 | entity.update_value(value) 58 | 59 | except Exception as err: 60 | _LOGGER.error(err) 61 | _LOGGER.error(traceback.format_exc()) 62 | 63 | def entityChanged(self, _key: str, _entity: Entity, _value: Any) -> None: 64 | return 65 | 66 | def entityUpdated(self, _key: str, _entity: Entity, _value: Any) -> None: 67 | return 68 | 69 | def entityUpdate(self, key: Any, value: Any) -> bool: 70 | # check if entity is already created 71 | if (entity := self.entities.get(key, None)) is None: 72 | if key.endswith("Switch"): 73 | entity = self.binary(key, None, "switch") 74 | elif key.endswith("power"): 75 | entity = self.sensor(key, None, "w", "power", "measurement") 76 | elif key.endswith(("Temperature", "Temp")): 77 | entity = self.sensor(key, "{{ (value | float - 2731) / 10 | round(1) }}", "°C", "temperature", "measurement") 78 | elif key.endswith("PowerCycle"): 79 | entity = self.empty 80 | else: 81 | entity = ZendureSensor(self.attr_device_info, key) 82 | 83 | # set current entity to None in order to prevent error during async initialization 84 | self.entities[key] = entity 85 | if entity != self.empty: 86 | self._hass.loop.call_soon_threadsafe(self.entityAdd, entity, value) 87 | return False 88 | 89 | # update entity state 90 | if entity is not None and entity.platform: 91 | # update energy sensors 92 | if value is not None: 93 | self.entityUpdated(key, entity, value) 94 | 95 | if entity.state != value: 96 | entity.update_value(value) 97 | self.entityChanged(key, entity, value) 98 | return True 99 | return False 100 | 101 | def entityWrite(self, _entity: Entity, _value: Any) -> None: 102 | return 103 | 104 | def binary( 105 | self, 106 | uniqueid: str, 107 | template: str | None = None, 108 | deviceclass: Any | None = "switch", 109 | ) -> ZendureBinarySensor: 110 | tmpl = Template(template, self._hass) if template else None 111 | s = ZendureBinarySensor(self.attr_device_info, uniqueid, tmpl, deviceclass) 112 | self.entities[uniqueid] = s 113 | return s 114 | 115 | def number( 116 | self, 117 | uniqueid: str, 118 | template: str | None = None, 119 | uom: str | None = None, 120 | deviceclass: Any | None = None, 121 | minimum: int = 0, 122 | maximum: int = 2000, 123 | mode: NumberMode = NumberMode.AUTO, 124 | onwrite: Callable | None = None, 125 | ) -> ZendureNumber: 126 | def _write_property(entity: Entity, value: Any) -> None: 127 | self.entityWrite(entity, value) 128 | 129 | if onwrite is None: 130 | onwrite = _write_property 131 | 132 | tmpl = Template(template, self._hass) if template else None 133 | s = ZendureNumber( 134 | self.attr_device_info, 135 | uniqueid, 136 | onwrite, 137 | tmpl, 138 | uom, 139 | deviceclass, 140 | maximum, 141 | minimum, 142 | mode, 143 | ) 144 | self.entities[uniqueid] = s 145 | return s 146 | 147 | def select(self, uniqueid: str, options: dict[int, str], onwrite: Callable | None = None, persistent: bool = False) -> ZendureSelect: 148 | def _write_property(entity: Entity, value: Any) -> None: 149 | self.entityWrite(entity, value) 150 | 151 | if onwrite is None: 152 | onwrite = _write_property 153 | 154 | if persistent: 155 | s = ZendureRestoreSelect(self.attr_device_info, uniqueid, options, onwrite) 156 | else: 157 | s = ZendureSelect(self.attr_device_info, uniqueid, options, onwrite) 158 | self.entities[uniqueid] = s 159 | return s 160 | 161 | def sensor( 162 | self, 163 | uniqueid: str, 164 | template: str | None = None, 165 | uom: str | None = None, 166 | deviceclass: Any | None = None, 167 | stateclass: Any | None = None, 168 | precision: int | None = None, 169 | persistent: bool = False, 170 | ) -> ZendureSensor: 171 | tmpl = Template(template, self._hass) if template else None 172 | if persistent: 173 | s = ZendureRestoreSensor(self.attr_device_info, uniqueid, tmpl, uom, deviceclass, stateclass, precision) 174 | else: 175 | s = ZendureSensor(self.attr_device_info, uniqueid, tmpl, uom, deviceclass, stateclass, precision) 176 | self.entities[uniqueid] = s 177 | return s 178 | 179 | def nosensor(self, uniqueid: list[str]) -> None: 180 | for uid in uniqueid: 181 | self.entities[uid] = self.empty 182 | 183 | def version(self, uniqueid: str) -> ZendureSensor: 184 | s = ZendureCalcSensor(self.attr_device_info, uniqueid) 185 | s.calculate = s.calculate_version 186 | self.entities[uniqueid] = s 187 | return s 188 | 189 | def calculate( 190 | self, 191 | uniqueid: str, 192 | calculate: Callable[[Any], Any], 193 | uom: str | None = None, 194 | deviceclass: Any | None = None, 195 | stateclass: Any | None = None, 196 | precision: int | None = None, 197 | ) -> ZendureSensor: 198 | s = ZendureCalcSensor(self.attr_device_info, uniqueid, calculate, uom, deviceclass, stateclass, precision) 199 | self.entities[uniqueid] = s 200 | return s 201 | 202 | def switch( 203 | self, uniqueid: str, template: str | None = None, deviceclass: Any | None = None, onwrite: Callable | None = None, value: bool | None = None 204 | ) -> ZendureSwitch: 205 | def _write_property(entity: Entity, value: Any) -> None: 206 | self.entityWrite(entity, value) 207 | 208 | if onwrite is None: 209 | onwrite = _write_property 210 | 211 | tmpl = Template(template, self._hass) if template else None 212 | s = ZendureSwitch(self.attr_device_info, uniqueid, onwrite, tmpl, deviceclass, value) 213 | self.entities[uniqueid] = s 214 | return s 215 | 216 | def asInt(self, name: str) -> int: 217 | if (sensor := self.entities.get(name, None)) and sensor.state is not None: 218 | try: 219 | return int(sensor.state) 220 | except ValueError: 221 | return 0 222 | 223 | return 0 224 | 225 | def asFloat(self, name: str) -> float: 226 | if (sensor := self.entities.get(name, None)) and sensor.state is not None: 227 | try: 228 | return float(sensor.state) 229 | except ValueError: 230 | return 0 231 | 232 | if (sensor := self.entities.get(name, None)) and isinstance(sensor.state, (int, float)): 233 | return sensor.state 234 | return 0 235 | 236 | def isEqual(self, name: str, value: Any) -> bool: 237 | if (sensor := self.entities.get(name, None)) and sensor.state: 238 | return sensor.state == value 239 | return False 240 | 241 | def aggr(self, name: str, value: int) -> None: 242 | """Aggregate value to sensor.""" 243 | if (sensor := self.entities.get(name, None)) and isinstance(sensor, ZendureRestoreSensor): 244 | try: 245 | time = dt_util.now() 246 | sensor.aggregate(time, value) 247 | except Exception as err: 248 | _LOGGER.error(err) 249 | 250 | def setvalue(self, entity: str, value: Any) -> None: 251 | """Set value of entity.""" 252 | if (sensor := self.entities.get(entity, None)) is not None: 253 | try: 254 | sensor.update_value(value) 255 | except Exception as err: 256 | _LOGGER.error(err) 257 | 258 | def remainingOutput(self, value: Any) -> Any: 259 | """Calculate the remaining output time.""" 260 | if value is None: 261 | return None 262 | level = self.asInt("electricLevel") 263 | soc = self.asInt("minSoc") 264 | if value <= 0 or level <= soc: 265 | return 0 266 | value = float(value) / 60 267 | if value >= 999 or level == 0: 268 | return 999 269 | return value * (level - soc) / level 270 | 271 | def remainingInput(self, value: Any) -> Any: 272 | """Calculate the remaining input time.""" 273 | if value is None: 274 | return None 275 | level = self.asInt("electricLevel") 276 | soc = self.asInt("socSet") 277 | if value <= 0 or level >= soc: 278 | return 0 279 | value = float(value) / 60 280 | if value >= 999: 281 | return 999 282 | return value * (soc - level) / (100 - level) 283 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/zendurebattery.py: -------------------------------------------------------------------------------- 1 | """Zendure Integration device.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from typing import Any 8 | 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .sensor import ZendureSensor 12 | from .zendurebase import ZendureBase 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class ZendureBattery(ZendureBase): 18 | """A Zendure Battery.""" 19 | 20 | batterydict: dict[str, ZendureBattery] = {} 21 | 22 | def __init__(self, hass: HomeAssistant, name: str, model: str, snNumber: str, parent: str, kwh: float) -> None: 23 | """Initialize ZendureBattery.""" 24 | super().__init__(hass, name, model, snNumber, parent) 25 | self.batterydict[snNumber] = self 26 | self.kwh = kwh 27 | 28 | def entitiesCreate(self, addsensors: Callable[[ZendureBattery, list[ZendureSensor]], None], event: Any) -> None: 29 | sensors = [ 30 | self.sensor("totalVol", "{{ (value / 100) }}", "V", "voltage", "measurement"), 31 | self.sensor("maxVol", "{{ (value / 100) }}", "V", "voltage", "measurement"), 32 | self.sensor("minVol", "{{ (value / 100) }}", "V", "voltage", "measurement"), 33 | self.sensor("batcur", "{{ (value / 10) }}", "A", "current", "measurement"), 34 | self.sensor("state"), 35 | self.sensor("power", None, "W", "power", "measurement"), 36 | self.sensor("socLevel", None, "%", "battery", "measurement"), 37 | self.sensor("maxTemp", "{{ (value | float - 2731) / 10 | round(1) }}", "°C", "temperature", "measurement"), 38 | self.version("softVersion"), 39 | ] 40 | 41 | addsensors(self, sensors) 42 | ZendureSensor.add(sensors) 43 | event.set() 44 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/zenduredevice.py: -------------------------------------------------------------------------------- 1 | """Zendure Integration device.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | import threading 8 | import traceback 9 | from datetime import datetime 10 | from typing import Any 11 | 12 | from bleak import BleakClient 13 | from bleak.exc import BleakError 14 | from homeassistant.components import bluetooth 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity import Entity 17 | from paho.mqtt import client as mqtt_client 18 | from paho.mqtt import enums as mqtt_enums 19 | 20 | from .const import AcMode, MqttState 21 | from .select import ZendureSelect 22 | from .sensor import ZendureSensor 23 | from .switch import ZendureSwitch 24 | from .zendurebase import ZendureBase 25 | from .zendurebattery import ZendureBattery 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | SF_COMMAND_CHAR = "0000c304-0000-1000-8000-00805f9b34fb" 30 | 31 | 32 | class ZendureDevice(ZendureBase): 33 | """A Zendure Device.""" 34 | 35 | devicedict: dict[str, ZendureDevice] = {} 36 | devices: list[ZendureDevice] = [] 37 | clusters: list[ZendureDevice] = [] 38 | mqttClient = mqtt_client.Client() 39 | mqttCloud = mqtt_client.Client() 40 | mqttCloudUrl = "" 41 | mqttIsLocal: bool = False 42 | mqttLocalUrl = "" 43 | mqttLog: bool = False 44 | wifissid: str | None = None 45 | wifipsw: str | None = None 46 | _messageid = 700000 47 | 48 | def __init__(self, hass: HomeAssistant, deviceId: str, prodName: str, definition: Any, parent: str | None = None) -> None: 49 | """Initialize ZendureDevice.""" 50 | self.deviceId = deviceId 51 | self.snNumber = definition["snNumber"] 52 | self.prodkey = definition["productKey"] 53 | super().__init__(hass, definition["name"], prodName, self.snNumber, parent) 54 | self._topic_read = f"iot/{self.prodkey}/{self.deviceId}/properties/read" 55 | self._topic_write = f"iot/{self.prodkey}/{self.deviceId}/properties/write" 56 | self.topic_function = f"iot/{self.prodkey}/{self.deviceId}/function/invoke" 57 | 58 | self.devicedict[deviceId] = self 59 | self.devices.append(self) 60 | 61 | self.mqttDevice = self.mqttClient 62 | self.mqttLocal = 0 63 | self.mqttZendure = 0 64 | self.mqttZenApp = datetime.min 65 | self.bleInfo: bluetooth.BluetoothServiceInfoBleak | None = None 66 | self.bleErr = False 67 | 68 | self.powerMax = 0 69 | self.powerMin = 0 70 | self.powerAct = 0 71 | self.capacity = 0 72 | self.kwh = 0 73 | self.clusterType: Any = 0 74 | self.clusterdevices: list[ZendureDevice] = [] 75 | 76 | def entitiesCreate(self) -> None: 77 | super().entitiesCreate() 78 | if len(self.devices) > 1: 79 | clusters: dict[Any, str] = {0: "clusterunknown", 1: "clusterowncircuit", 2: "cluster800", 3: "cluster1200", 4: "cluster2400", 5: "cluster3600"} 80 | for d in self.devices: 81 | if d != self: 82 | clusters[d.deviceId] = f"Part of {d.name} cluster" 83 | 84 | ZendureSelect.add([self.select("cluster", clusters, self.clusterUpdate, True)]) 85 | 86 | ZendureSensor.add([ 87 | self.sensor("aggrChargeTotal", None, "kWh", "energy", "total_increasing", 2, True), 88 | self.sensor("aggrDischargeTotal", None, "kWh", "energy", "total_increasing", 2, True), 89 | self.sensor("aggrSolarTotal", None, "kWh", "energy", "total_increasing", 2, True), 90 | self.sensor("ConnectionStatus"), 91 | ]) 92 | 93 | def doMqttReset(entity: ZendureSwitch, value: Any) -> None: 94 | entity.update_value(value) 95 | self._hass.async_create_task(self.bleMqtt()) 96 | 97 | ZendureSwitch.add([self.switch("MqttReset", onwrite=doMqttReset, value=False)]) 98 | 99 | def entitiesBattery(self, _battery: ZendureBattery, _sensors: list[ZendureSensor]) -> None: 100 | return 101 | 102 | def entityChanged(self, key: str, _entity: Entity, value: Any) -> None: 103 | match key: 104 | case "outputPackPower": 105 | self.powerAct = int(value) 106 | self.aggr("aggrChargeTotal", int(value)) 107 | self.aggr("aggrDischargeTotal", 0) 108 | case "packInputPower": 109 | self.aggr("aggrChargeTotal", 0) 110 | self.aggr("aggrDischargeTotal", int(value)) 111 | case "solarInputPower": 112 | self.aggr("aggrSolarTotal", int(value)) 113 | 114 | def entityWrite(self, entity: Entity, value: Any) -> None: 115 | _LOGGER.info(f"Writing property {self.name} {entity.name} => {value}") 116 | if entity.unique_id is None: 117 | _LOGGER.error(f"Entity {entity.name} has no unique_id.") 118 | return 119 | 120 | property_name = entity.unique_id[(len(self.name) + 1) :] 121 | if property_name in {"minSoc", "socSet"}: 122 | value = int(value * 10) 123 | 124 | self.writeProperties({property_name: value}) 125 | 126 | def deviceMqttClient(self, mqttPsw: str) -> None: 127 | """Initialize MQTT client for device.""" 128 | self.mqttDevice = mqtt_client.Client(mqtt_enums.CallbackAPIVersion.VERSION1, client_id=self.deviceId, clean_session=False) 129 | self.mqttDevice.username_pw_set(username=self.deviceId, password=mqttPsw) 130 | self.mqttDevice.on_connect = self.deviceConnect 131 | self.mqttDevice.on_disconnect = self.deviceDisconnect 132 | self.mqttDevice.on_message = self.deviceMessage 133 | self.mqttDevice.suppress_exceptions = True 134 | 135 | def deviceConnect(self, _client: mqtt_client.Client, _userdata: Any, _flags: Any, _rc: Any) -> None: 136 | """Handle MQTT connection for device.""" 137 | self.mqttStatus() 138 | 139 | def deviceDisconnect(self, _client: Any, _userdata: Any, _rc: Any) -> None: 140 | """Handle MQTT disconnection for device.""" 141 | self.mqttStatus() 142 | 143 | def deviceMessage(self, _client: Any, _userdata: Any, msg: Any) -> None: 144 | """Handle MQTT message for device.""" 145 | _LOGGER.info(f"Device {self.name} received message: {msg.topic} {msg.payload}") 146 | 147 | def mqttInvoke(self, command: Any) -> None: 148 | self._messageid += 1 149 | command["messageId"] = self._messageid 150 | command["deviceKey"] = self.deviceId 151 | command["timestamp"] = int(datetime.now().timestamp()) 152 | payload = json.dumps(command, default=lambda o: o.__dict__) 153 | if self.mqttLog: 154 | _LOGGER.info(f"Invoke function {self.name} => {payload}") 155 | self.mqttClient.publish(self.topic_function, payload) 156 | 157 | def mqttMessage(self, topics: list[str], payload: Any) -> bool: 158 | try: 159 | parameter = topics[-1] 160 | match parameter: 161 | case "register": 162 | _LOGGER.info(f"Register {self.name} => {payload}") 163 | self.mqttRefresh(False) 164 | 165 | case "report": 166 | if properties := payload.get("properties", None): 167 | for key, value in properties.items(): 168 | self.entityUpdate(key, value) 169 | 170 | # update the battery properties 171 | if batprops := payload.get("packData", None): 172 | for b in batprops: 173 | sn = b.pop("sn") 174 | if not b: 175 | continue 176 | 177 | if (bat := ZendureBattery.batterydict.get(sn, None)) is None: 178 | match sn[0]: 179 | case "A": 180 | if sn[3] == "3": 181 | bat = ZendureBattery(self._hass, sn, "AIO2400", sn, self.name, 2.4) 182 | else: 183 | bat = ZendureBattery(self._hass, sn, "AB1000", sn, self.name, 0.96) 184 | case "B": 185 | bat = ZendureBattery(self._hass, sn, "AB1000S", sn, self.name, 0.96) 186 | case "C": 187 | bat = ZendureBattery(self._hass, sn, "AB2000" + ("S" if sn[3] == "F" else ""), sn, self.name, 1.92) 188 | case "F": 189 | bat = ZendureBattery(self._hass, sn, "AB3000", sn, self.name, 2.88) 190 | case _: 191 | bat = ZendureBattery(self._hass, sn, "AB????", sn, self.name, 2.88) 192 | self.kwh += bat.kwh 193 | done = threading.Event() 194 | self._hass.loop.call_soon_threadsafe(bat.entitiesCreate, self.entitiesBattery, done) 195 | done.wait(10) 196 | 197 | if bat.entities: 198 | for key, value in b.items(): 199 | bat.entityUpdate(key, value) 200 | return True 201 | 202 | case "reply": 203 | if self.mqttLog and topics[-3] == "function": 204 | _LOGGER.info(f"Receive: {self.name} => ready!") 205 | return True 206 | 207 | except Exception as err: 208 | _LOGGER.error(err) 209 | _LOGGER.error(traceback.format_exc()) 210 | 211 | return False 212 | 213 | def mqttRefresh(self, reset: bool) -> None: 214 | self.mqttClient.publish(self._topic_read, '{"properties": ["getAll"]}') 215 | self.mqttStatus() 216 | if reset: 217 | self.mqttZendure = 0 218 | self.mqttLocal = 0 219 | 220 | def mqttStatus(self) -> None: 221 | status = MqttState.UNKNOWN 222 | 223 | if self.bleErr: 224 | status |= MqttState.BLE_ERR 225 | elif self.bleInfo is not None and self.bleInfo.connectable: 226 | status |= MqttState.BLE 227 | 228 | if self.mqttDevice is not None and self.mqttDevice.is_connected(): 229 | status |= MqttState.APP 230 | elif self.mqttZendure > 0: 231 | status |= MqttState.CLOUD 232 | if self.mqttLocal > 0: 233 | status |= MqttState.LOCAL 234 | if self.mqttIsLocal and self.mqttDevice.host == "": 235 | self.mqttDevice.connect(self.mqttCloudUrl, 1883) 236 | self.mqttDevice.loop_start() 237 | 238 | self.entities["ConnectionStatus"].update_value(int(status.value)) 239 | 240 | def update_ac_mode(self, _entity: ZendureSelect, mode: int) -> None: 241 | if mode == AcMode.INPUT: 242 | self.writeProperties({"acMode": mode, "inputLimit": self.asInt("inputLimit")}) 243 | elif mode == AcMode.OUTPUT: 244 | self.writeProperties({"acMode": mode, "outputLimit": self.asInt("outputLimit")}) 245 | 246 | def writeProperties(self, props: dict[str, Any]) -> None: 247 | ZendureDevice._messageid += 1 248 | payload = json.dumps( 249 | { 250 | "deviceId": self.deviceId, 251 | "messageId": ZendureDevice._messageid, 252 | "timestamp": int(datetime.now().timestamp()), 253 | "properties": props, 254 | }, 255 | default=lambda o: o.__dict__, 256 | ) 257 | self.mqttClient.publish(self._topic_write, payload) 258 | 259 | def writePower(self, power: int, inprogram: bool) -> None: 260 | _LOGGER.info(f"Update power {self.name} => {power} capacity {self.capacity} [program {inprogram}]") 261 | 262 | async def bleMqtt(self, server: str | None = None) -> None: 263 | """Set the MQTT server for the device via BLE.""" 264 | try: 265 | self.bleErr = False 266 | if self.bleInfo is None: 267 | return 268 | if server is None: 269 | server = self.mqttLocalUrl if self.mqttIsLocal else self.mqttCloudUrl 270 | 271 | # get the bluetooth device 272 | if self.bleInfo.connectable: 273 | device = self.bleInfo.device 274 | elif connectable_device := bluetooth.async_ble_device_from_address(self._hass, self.bleInfo.device.address, True): 275 | device = connectable_device 276 | else: 277 | return 278 | 279 | try: 280 | _LOGGER.info(f"Set mqtt {self.name} to {server}") 281 | async with BleakClient(device) as client: 282 | try: 283 | await self.bleCommand( 284 | client, 285 | { 286 | "iotUrl": server, 287 | "messageId": 1002, 288 | "method": "token", 289 | "password": self.wifipsw, 290 | "ssid": self.wifissid, 291 | "timeZone": "GMT+01:00", 292 | "token": "abcdefgh", 293 | }, 294 | ) 295 | 296 | await self.bleCommand( 297 | client, 298 | { 299 | "messageId": 1003, 300 | "method": "station", 301 | }, 302 | ) 303 | finally: 304 | await client.disconnect() 305 | 306 | except TimeoutError: 307 | _LOGGER.error(f"Timeout when trying to connect to {self.name} {self.bleInfo.name}") 308 | self.bleErr = True 309 | except (AttributeError, BleakError) as err: 310 | _LOGGER.error(f"Could not connect to {self.name}: {err}") 311 | self.bleErr = True 312 | except Exception as err: 313 | _LOGGER.error(f"BLE error: {err}") 314 | _LOGGER.error(traceback.format_exc()) 315 | self.bleErr = True 316 | 317 | finally: 318 | self.setvalue("MqttReset", False) 319 | self.mqttStatus() 320 | 321 | async def bleCommand(self, client: BleakClient, command: Any) -> None: 322 | try: 323 | self._messageid += 1 324 | payload = json.dumps(command, default=lambda o: o.__dict__) 325 | b = bytearray() 326 | b.extend(map(ord, payload)) 327 | _LOGGER.info(f"BLE command: {self.name} => {payload}") 328 | await client.write_gatt_char(SF_COMMAND_CHAR, b, response=False) 329 | except Exception as err: 330 | _LOGGER.error(f"BLE error: {err}") 331 | 332 | def clusterUpdate(self, _entity: ZendureSelect, cluster: Any) -> None: 333 | try: 334 | _LOGGER.info(f"Update cluster: {self.name} => {cluster}") 335 | self.clusterType = cluster 336 | 337 | for d in self.devices: 338 | if self in d.clusterdevices: 339 | if d.deviceId != cluster: 340 | _LOGGER.info(f"Remove {self.name} from cluster {d.name}") 341 | if self in d.clusterdevices: 342 | d.clusterdevices.remove(self) 343 | elif d.deviceId == cluster: 344 | _LOGGER.info(f"Add {self.name} to cluster {d.name}") 345 | if self not in d.clusterdevices: 346 | d.clusterdevices.append(self) 347 | 348 | if cluster in [1, 2, 3, 4] and self not in self.clusters: 349 | self.clusters.append(self) 350 | if self not in self.clusterdevices: 351 | self.clusterdevices.append(self) 352 | 353 | except Exception as err: 354 | _LOGGER.error(err) 355 | _LOGGER.error(traceback.format_exc()) 356 | 357 | @property 358 | def clustercapacity(self) -> int: 359 | """Get the capacity of the cluster.""" 360 | if self.clusterType == 0: 361 | return 0 362 | return sum(d.capacity for d in self.clusterdevices) 363 | 364 | @property 365 | def clusterMax(self) -> int: 366 | """Get the maximum power of the cluster.""" 367 | cmax = sum(d.powerMax for d in self.clusterdevices) 368 | match self.clusterType: 369 | case 1: 370 | cmax = min(cmax, 3600) 371 | case 2: 372 | cmax = min(cmax, 800) 373 | case 3: 374 | cmax = min(cmax, 1200) 375 | case 4: 376 | cmax = min(cmax, 2400) 377 | case 4: 378 | cmax = min(cmax, 3600) 379 | case _: 380 | return 0 381 | return cmax 382 | 383 | @property 384 | def clusterMin(self) -> int: 385 | """Get the minimum power of the cluster.""" 386 | cmin = sum(d.powerMin for d in self.clusterdevices) 387 | match self.clusterType: 388 | case 1: 389 | cmin = min(cmin, -3600) 390 | case 2: 391 | cmin = min(cmin, -2400) 392 | case 3: 393 | cmin = min(cmin, -2400) 394 | case 4: 395 | cmin = min(cmin, -3600) 396 | case _: 397 | return 0 398 | return cmin 399 | -------------------------------------------------------------------------------- /custom_components/zendure_ha/zendurermanager.py: -------------------------------------------------------------------------------- 1 | """Zendure Integration manager using DataUpdateCoordinator.""" 2 | 3 | from __future__ import annotations 4 | 5 | import hashlib 6 | import json 7 | import logging 8 | import traceback 9 | from base64 import b64decode 10 | from datetime import datetime, timedelta 11 | from typing import Any 12 | 13 | from homeassistant.auth.const import GROUP_ID_USER 14 | from homeassistant.auth.providers import homeassistant as auth_ha 15 | from homeassistant.components import bluetooth, mqtt 16 | from homeassistant.components.number import NumberMode 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback 19 | from homeassistant.helpers.event import async_track_state_change_event 20 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 21 | from paho.mqtt import client as mqtt_client 22 | from paho.mqtt import enums as mqtt_enums 23 | 24 | from custom_components.zendure_ha import zenduredevice 25 | from custom_components.zendure_ha.devices.solarflow800Pro import SolarFlow800Pro 26 | 27 | from .api import Api 28 | from .const import CONF_MQTTLOCAL, CONF_MQTTLOG, CONF_P1METER, CONF_WIFIPSW, CONF_WIFISSID, DOMAIN, ManagerState, SmartMode 29 | from .devices.ace1500 import ACE1500 30 | from .devices.aio2400 import AIO2400 31 | from .devices.hub1200 import Hub1200 32 | from .devices.hub2000 import Hub2000 33 | from .devices.hyper2000 import Hyper2000 34 | from .devices.solarflow800 import SolarFlow800 35 | from .devices.solarflow2400ac import SolarFlow2400AC 36 | from .number import ZendureNumber 37 | from .select import ZendureSelect 38 | from .zendurebase import ZendureBase 39 | from .zenduredevice import ZendureDevice 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | class ZendureManager(DataUpdateCoordinator[int], ZendureBase): 45 | """The Zendure manager.""" 46 | 47 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 48 | """Initialize ZendureManager.""" 49 | super().__init__( 50 | hass, 51 | _LOGGER, 52 | name=f"{DOMAIN} ({config_entry.unique_id})", 53 | update_interval=timedelta(seconds=90), 54 | always_update=True, 55 | ) 56 | ZendureBase.__init__(self, hass, "Zendure Manager", "Zendure Manager", "1.0.41") 57 | 58 | self.p1meter = config_entry.data.get(CONF_P1METER) 59 | self.operation = 0 60 | self.setpoint = 0 61 | self.zero_idle = datetime.max 62 | self.zero_next = datetime.min 63 | self.zero_fast = datetime.min 64 | self.check_reset = datetime.min 65 | 66 | # initialize mqtt 67 | ZendureDevice.mqttIsLocal = config_entry.data.get(CONF_MQTTLOCAL, False) 68 | ZendureDevice.mqttLog = config_entry.data.get(CONF_MQTTLOG, False) 69 | ZendureDevice.wifissid = config_entry.data.get(CONF_WIFISSID, None) 70 | ZendureDevice.wifipsw = config_entry.data.get(CONF_WIFIPSW, None) 71 | 72 | # Create the api 73 | self.api = Api(hass, dict(config_entry.data)) 74 | 75 | async def load(self) -> bool: 76 | """Initialize the manager.""" 77 | try: 78 | if not await self.api.connect(): 79 | _LOGGER.error("Unable to connect to Zendure API") 80 | return False 81 | 82 | # create and initialize the devices 83 | await self.createDevices() 84 | _LOGGER.info(f"Found: {len(ZendureDevice.devicedict)} devices") 85 | 86 | # Add ZendureManager sensors 87 | _LOGGER.info(f"Adding sensors {self.name}") 88 | selects = [ 89 | self.select("Operation", {0: "off", 1: "manual", 2: "smart"}, self.update_operation, True), 90 | ] 91 | ZendureSelect.add(selects) 92 | 93 | numbers = [ 94 | self.number("manual_power", None, "W", "power", -10000, 10000, NumberMode.BOX, self._update_manual_energy), 95 | ] 96 | ZendureNumber.add(numbers) 97 | 98 | # Set sensors from values entered in config flow setup 99 | if self.p1meter: 100 | _LOGGER.info(f"Energy sensors: {self.p1meter} to _update_smart_energyp1") 101 | async_track_state_change_event(self.hass, [self.p1meter], self._update_smart_energyp1) 102 | 103 | # create the mqtt client 104 | ZendureDevice.mqttCloudUrl = self.api.mqttUrl 105 | ZendureDevice.mqttCloud.__init__(mqtt_enums.CallbackAPIVersion.VERSION1, self.api.token, False, 0) 106 | ZendureDevice.mqttCloud.username_pw_set("zenApp", b64decode(self.api.mqttinfo.encode()).decode("latin-1")) 107 | ZendureDevice.mqttCloud.connect(ZendureDevice.mqttCloudUrl, 1883) 108 | ZendureDevice.mqttCloud.on_connect = self.mqttConnect 109 | ZendureDevice.mqttCloud.on_message = self.mqttMsgZendure 110 | ZendureDevice.mqttCloud.suppress_exceptions = True 111 | ZendureDevice.mqttCloud.loop_start() 112 | 113 | info = self.hass.config_entries.async_loaded_entries(mqtt.DOMAIN) 114 | if ZendureDevice.mqttIsLocal and info is not None and len(info) > 0 and (data := info[0].data) is not None: 115 | _LOGGER.info("Use local MQTT broker") 116 | broker = data["broker"] 117 | if "core-mosquitto" in broker.lower(): 118 | broker = self.hass.config.api.local_ip 119 | ZendureDevice.mqttLocalUrl = broker 120 | ZendureDevice.mqttClient.__init__(mqtt_enums.CallbackAPIVersion.VERSION1, data["username"], False, 1) 121 | ZendureDevice.mqttClient.username_pw_set(data["username"], data["password"]) 122 | ZendureDevice.mqttClient.connect(ZendureDevice.mqttLocalUrl, 1883) 123 | ZendureDevice.mqttClient.on_connect = self.mqttConnect 124 | ZendureDevice.mqttClient.on_message = self.mqttMsgLocal 125 | ZendureDevice.mqttClient.suppress_exceptions = True 126 | ZendureDevice.mqttClient.loop_start() 127 | else: 128 | ZendureDevice.mqttClient = ZendureDevice.mqttCloud 129 | 130 | for device in ZendureDevice.devices: 131 | if ZendureDevice.mqttIsLocal: 132 | ZendureDevice.mqttCloud.publish(f"iot/{device.prodkey}/{device.deviceId}/register/replay", "", 0, True) 133 | ZendureDevice.mqttClient.publish(f"iot/{device.prodkey}/{device.deviceId}/register/replay", "", 0, True) 134 | device.setvalue("MqttReset", False) 135 | 136 | _LOGGER.info("Zendure Manager initialized") 137 | 138 | except Exception as err: 139 | _LOGGER.error(err) 140 | _LOGGER.error(traceback.format_exc()) 141 | return False 142 | return True 143 | 144 | async def unload(self) -> None: 145 | """Unload the manager.""" 146 | 147 | def closeMqtt(client: mqtt_client.Client) -> None: 148 | for device in ZendureDevice.devices: 149 | client.unsubscribe(f"/{device.prodkey}/{device.deviceId}/#") 150 | client.unsubscribe(f"iot/{device.prodkey}/{device.deviceId}/#") 151 | 152 | client.loop_stop() 153 | client.disconnect() 154 | 155 | if ZendureDevice.mqttClient != ZendureDevice.mqttCloud and ZendureDevice.mqttCloud.is_connected(): 156 | closeMqtt(ZendureDevice.mqttCloud) 157 | 158 | if ZendureDevice.mqttClient.is_connected(): 159 | closeMqtt(ZendureDevice.mqttClient) 160 | 161 | for device in ZendureDevice.devices: 162 | if device.mqttDevice is not None and device.mqttDevice.is_connected(): 163 | closeMqtt(device.mqttDevice) 164 | 165 | ZendureDevice.devicedict.clear() 166 | ZendureDevice.devices.clear() 167 | ZendureDevice.clusters.clear() 168 | 169 | async def createDevices(self) -> None: 170 | # Create the devices 171 | deviceInfo = await self.api.getDevices() 172 | for dev in deviceInfo: 173 | if (deviceId := dev["deviceKey"]) is None or (prodName := dev["productName"]) is None: 174 | continue 175 | _LOGGER.info(f"Adding device: {deviceId} {prodName}") 176 | _LOGGER.info(f"Data: {dev}") 177 | 178 | def findAce(hub: Any) -> None: 179 | if (packList := hub.get("packList", None)) is not None: 180 | for pack in packList: 181 | if pack.get("productName", None) == "Ace 1500": 182 | aceId = pack["deviceKey"] 183 | ZendureDevice.devicedict[aceId] = ACE1500(self.hass, aceId, pack["productName"], pack, device.name) 184 | 185 | try: 186 | match prodName.lower(): 187 | case "hyper 2000": 188 | device = Hyper2000(self.hass, deviceId, prodName, dev) 189 | case "solarflow 800": 190 | device = SolarFlow800(self.hass, deviceId, prodName, dev) 191 | case "solarflow2.0": 192 | device = Hub1200(self.hass, deviceId, prodName, dev) 193 | findAce(dev) 194 | case "solarflow hub 2000": 195 | device = Hub2000(self.hass, deviceId, prodName, dev) 196 | findAce(dev) 197 | case "solarflow aio zy": 198 | device = AIO2400(self.hass, deviceId, prodName, dev) 199 | case "ace 1500": 200 | device = ACE1500(self.hass, deviceId, prodName, dev) 201 | case "solarflow 800 pro": 202 | device = SolarFlow800Pro(self.hass, deviceId, prodName, dev) 203 | case "solarflow 2400 ac": 204 | device = SolarFlow2400AC(self.hass, deviceId, prodName, dev) 205 | case _: 206 | _LOGGER.info(f"Device {prodName} is not supported!") 207 | continue 208 | ZendureDevice.devicedict[deviceId] = device 209 | 210 | # if ZendureDevice.mqttIsLocal: 211 | device.deviceMqttClient(await self.mqttUser(device.deviceId)) 212 | 213 | except Exception as err: 214 | _LOGGER.error(err) 215 | _LOGGER.error(traceback.format_exc()) 216 | 217 | # create the sensors 218 | for device in ZendureDevice.devicedict.values(): 219 | device.entitiesCreate() 220 | 221 | async def _async_update_data(self) -> int: 222 | """Refresh the data of all devices's.""" 223 | _LOGGER.info("refresh devices") 224 | try: 225 | time = datetime.now() 226 | midnight = time.date() != self.check_reset.date() 227 | if checkreset := self.check_reset < time: 228 | self.check_reset = datetime.now() + timedelta(seconds=300) 229 | 230 | def isBleDevice(device: ZendureDevice, si: bluetooth.BluetoothServiceInfoBleak) -> bool: 231 | if si.name.startswith("Zen"): 232 | for d in si.manufacturer_data.values(): 233 | sn = d.decode("utf8")[:-1] 234 | if device.snNumber.endswith(sn): 235 | _LOGGER.info(f"Found Zendure Bluetooth device: {si}") 236 | return True 237 | return False 238 | 239 | for device in ZendureDevice.devices: 240 | # Reset MQTT server each day and when it is not responding 241 | if midnight or (checkreset and (device.mqttLocal + device.mqttZendure == 0 or device.bleErr)): 242 | await device.bleMqtt() 243 | 244 | # check for bluetooth device 245 | if device.bleInfo is None: 246 | device.bleInfo = next((si for si in bluetooth.async_discovered_service_info(self.hass, False) if isBleDevice(device, si)), None) 247 | if device.bleInfo is not None: 248 | device.mqttStatus() 249 | 250 | if device.mqttZenApp < time: 251 | device.mqttZenApp = datetime.min 252 | 253 | # query the properties and update the mqtt status of the device 254 | device.mqttRefresh(checkreset) 255 | 256 | except Exception as err: 257 | _LOGGER.error(err) 258 | _LOGGER.error(traceback.format_exc()) 259 | 260 | if self.hass and self.hass.loop.is_running(): 261 | self._schedule_refresh() 262 | return 0 263 | 264 | def update_operation(self, _entity: ZendureSelect, operation: int) -> None: 265 | _LOGGER.info(f"Update operation: {operation} from: {self.operation}") 266 | 267 | if operation == self.operation: 268 | return 269 | 270 | self.operation = operation 271 | if self.operation != SmartMode.MATCHING: 272 | for d in ZendureDevice.devices: 273 | d.writePower(0, self.operation == SmartMode.MANUAL) 274 | 275 | # One device always has it's own phase 276 | if len(ZendureDevice.devices) == 1 and not ZendureDevice.devices[0].clusterdevices: 277 | ZendureDevice.devices[0].clusterType = 1 278 | ZendureDevice.devices[0].clusterdevices = [ZendureDevice.devices[0]] 279 | ZendureDevice.clusters = [ZendureDevice.devices[0]] 280 | 281 | async def mqttUser(self, username: str) -> str: 282 | """Ensure the user exists.""" 283 | psw = hashlib.md5(username.encode()).hexdigest().upper()[8:24] # noqa: S324 284 | try: 285 | provider: auth_ha.HassAuthProvider = auth_ha.async_get_provider(self.hass) 286 | credentials = await provider.async_get_or_create_credentials({"username": username.lower()}) 287 | user = await self.hass.auth.async_get_user_by_credentials(credentials) 288 | if user is None: 289 | user = await self.hass.auth.async_create_user(username, group_ids=[GROUP_ID_USER], local_only=False) 290 | await provider.async_add_auth(username.lower(), psw) 291 | await self.hass.auth.async_link_user(user, credentials) 292 | else: 293 | await provider.async_change_password(username.lower(), psw) 294 | 295 | _LOGGER.info(f"Created MQTT user: {username} with password: {psw}") 296 | 297 | except Exception as err: 298 | _LOGGER.error(err) 299 | _LOGGER.error(traceback.format_exc()) 300 | return psw 301 | 302 | def mqttConnect(self, client: Any, _userdata: Any, _flags: Any, rc: Any) -> None: 303 | if rc == 0: 304 | for device in ZendureDevice.devices: 305 | client.subscribe(f"/{device.prodkey}/{device.deviceId}/#") 306 | client.subscribe(f"iot/{device.prodkey}/{device.deviceId}/#") 307 | device.mqttRefresh(False) 308 | else: 309 | _LOGGER.error(f"Unable to connect to MQTT broker, return code: {rc}") 310 | 311 | def mqttDisconnect(self, _client: Any, _userdata: Any, rc: Any, _props: Any) -> None: 312 | _LOGGER.info(f"Client disconnected from MQTT broker with return code {rc}") 313 | 314 | def mqttMsgLocal(self, _client: Any, _userdata: Any, msg: Any) -> None: 315 | try: 316 | # check for valid device in payload 317 | topics = msg.topic.split("/") 318 | payload = json.loads(msg.payload.decode()) 319 | payload.pop("deviceId", None) 320 | deviceId = topics[2] 321 | if (device := ZendureDevice.devicedict.get(deviceId, None)) is not None: 322 | topics[2] = device.name 323 | if ZendureDevice.mqttLog: 324 | _LOGGER.info(f"Topic: {self.name} {msg.topic.replace(deviceId, device.name)} => {payload}") 325 | 326 | if ZendureDevice.mqttIsLocal and device.mqttMessage(topics, payload): 327 | device.mqttLocal += 1 328 | if device.mqttLocal == 1: 329 | device.mqttStatus() 330 | 331 | if ZendureDevice.mqttIsLocal and (device.mqttLocal < 8 or device.mqttZenApp != datetime.min) and topics[0] == "": 332 | ZendureDevice.mqttCloud.publish(msg.topic, msg.payload) 333 | 334 | else: 335 | _LOGGER.info(f"Unknown device: {deviceId} => {msg.topic} => {payload}") 336 | 337 | except: # noqa: E722 338 | return 339 | 340 | def mqttMsgZendure(self, _client: Any, _userdata: Any, msg: Any) -> None: 341 | try: 342 | # check for valid device in payload 343 | topics = msg.topic.split("/") 344 | payload = json.loads(msg.payload.decode()) 345 | payload.pop("deviceId", None) 346 | deviceId = topics[2] 347 | if (device := ZendureDevice.devicedict.get(deviceId, None)) is not None: 348 | topics[2] = device.name 349 | if ZendureDevice.mqttLog: 350 | _LOGGER.info(f"Topic: {self.name} {msg.topic.replace(deviceId, device.name)} => {payload}") 351 | 352 | if not ZendureDevice.mqttIsLocal and device.mqttMessage(topics, payload): 353 | device.mqttZendure += 1 354 | if device.mqttZendure == 1: 355 | device.mqttStatus() 356 | 357 | if ZendureDevice.mqttIsLocal and topics[0] == "iot": 358 | device.mqttZenApp = datetime.now() + timedelta(seconds=60) 359 | ZendureDevice.mqttClient.publish(msg.topic, msg.payload) 360 | 361 | else: 362 | _LOGGER.info(f"Unknown device: {deviceId} => {msg.topic} => {payload}") 363 | 364 | except: # noqa: E722 365 | return 366 | 367 | def _update_manual_energy(self, _number: Any, power: float) -> None: 368 | try: 369 | if self.operation == SmartMode.MANUAL: 370 | self.setpoint = int(power) 371 | self.updateSetpoint(self.setpoint, ManagerState.DISCHARGING if power >= 0 else ManagerState.CHARGING) 372 | 373 | except Exception as err: 374 | _LOGGER.error(err) 375 | _LOGGER.error(traceback.format_exc()) 376 | 377 | @callback 378 | def _update_smart_energyp1(self, event: Event[EventStateChangedData]) -> None: 379 | try: 380 | # exit if there is nothing to do 381 | if not self.hass.is_running or not self.hass.is_running or (new_state := event.data["new_state"]) is None or self.operation == SmartMode.NONE: 382 | return 383 | 384 | # convert the state to a float 385 | try: 386 | p1 = int(float(new_state.state)) 387 | except ValueError: 388 | return 389 | 390 | # check minimal time between updates 391 | time = datetime.now() 392 | if time < self.zero_next or (time < self.zero_fast and abs(p1) < SmartMode.FAST_UPDATE): 393 | return 394 | 395 | # get the current power, exit if a device is waiting 396 | powerActual = 0 397 | for d in ZendureDevice.devices: 398 | d.powerAct = d.asInt("packInputPower") - (d.asInt("outputPackPower") - d.asInt("solarInputPower")) 399 | powerActual += d.powerAct 400 | 401 | _LOGGER.info(f"Update p1: {p1} power: {powerActual} operation: {self.operation}") 402 | # update the manual setpoint 403 | if self.operation == SmartMode.MANUAL: 404 | self.updateSetpoint(self.setpoint, ManagerState.DISCHARGING if self.setpoint >= 0 else ManagerState.CHARGING) 405 | 406 | # update when we are charging 407 | elif powerActual < 0: 408 | self.updateSetpoint(min(0, powerActual + p1), ManagerState.CHARGING) 409 | 410 | # update when we are discharging 411 | elif powerActual > 0: 412 | self.updateSetpoint(max(0, powerActual + p1), ManagerState.DISCHARGING) 413 | 414 | # check if it is the first time we are idle 415 | elif self.zero_idle == datetime.max: 416 | _LOGGER.info(f"Wait 10 sec for state change p1: {p1}") 417 | self.zero_idle = time + timedelta(seconds=SmartMode.TIMEIDLE) 418 | 419 | # update when we are idle for more than SmartMode.TIMEIDLE seconds 420 | elif self.zero_idle < time: 421 | if p1 < -SmartMode.MIN_POWER: 422 | _LOGGER.info(f"Start charging with p1: {p1}") 423 | self.updateSetpoint(p1, ManagerState.CHARGING) 424 | self.zero_idle = datetime.max 425 | elif p1 >= 0: 426 | _LOGGER.info(f"Start discharging with p1: {p1}") 427 | self.updateSetpoint(p1, ManagerState.DISCHARGING) 428 | self.zero_idle = datetime.max 429 | else: 430 | _LOGGER.info(f"Unable to charge/discharge p1: {p1}") 431 | 432 | self.zero_next = time + timedelta(seconds=SmartMode.TIMEZERO) 433 | self.zero_fast = time + timedelta(seconds=SmartMode.TIMEFAST) 434 | 435 | except Exception as err: 436 | _LOGGER.error(err) 437 | _LOGGER.error(traceback.format_exc()) 438 | 439 | def updateSetpoint(self, power: int, state: ManagerState) -> None: 440 | """Update the setpoint for all devices.""" 441 | totalCapacity = 0 442 | totalPower = 0 443 | for d in ZendureDevice.devices: 444 | if state == ManagerState.DISCHARGING: 445 | d.capacity = max(0, d.kwh * (d.asInt("electricLevel") - d.asInt("minSoc"))) 446 | totalPower += d.powerMax 447 | else: 448 | d.capacity = max(0, d.kwh * (d.asInt("socSet") - d.asInt("electricLevel"))) 449 | totalPower += abs(d.powerMin) 450 | if d.clusterType == 0: 451 | d.capacity = 0 452 | totalCapacity += d.capacity 453 | 454 | _LOGGER.info(f"Update setpoint: {power} state{state} capacity: {totalCapacity} max: {totalPower}") 455 | 456 | # redistribute the power on clusters 457 | isreverse = bool(abs(power) > totalPower / 2) 458 | active = sorted(ZendureDevice.clusters, key=lambda d: d.clustercapacity, reverse=isreverse) 459 | for c in active: 460 | clusterCapacity = c.clustercapacity 461 | clusterPower = int(power * clusterCapacity / totalCapacity) if totalCapacity > 0 else 0 462 | clusterPower = max(0, min(c.clusterMax, clusterPower)) if state == ManagerState.DISCHARGING else min(0, max(c.clusterMin, clusterPower)) 463 | totalCapacity -= clusterCapacity 464 | 465 | if totalCapacity == 0: 466 | clusterPower = max(0, min(c.clusterMax, power)) if state == ManagerState.DISCHARGING else min(0, max(c.clusterMin, power)) 467 | elif abs(clusterPower) > 0 and (abs(clusterPower) < SmartMode.MIN_POWER or (abs(clusterPower) < SmartMode.START_POWER and c.powerAct == 0)): 468 | clusterPower = 0 469 | 470 | for d in sorted(c.clusterdevices, key=lambda d: d.capacity, reverse=isreverse): 471 | if d.capacity == 0: 472 | continue 473 | pwr = int(clusterPower * d.capacity / clusterCapacity) if clusterCapacity > 0 else 0 474 | clusterCapacity -= d.capacity 475 | pwr = max(0, min(d.powerMax, pwr)) if state == ManagerState.DISCHARGING else min(0, max(d.powerMin, pwr)) 476 | if abs(pwr) > 0: 477 | if clusterCapacity == 0: 478 | pwr = max(0, min(d.powerMax, clusterPower)) if state == ManagerState.DISCHARGING else min(0, max(d.powerMin, clusterPower)) 479 | elif abs(pwr) > SmartMode.START_POWER or (abs(pwr) > SmartMode.MIN_POWER and d.powerAct != 0): 480 | clusterPower -= pwr 481 | else: 482 | pwr = 0 483 | power -= pwr 484 | 485 | # update the device 486 | d.writePower(pwr, True) 487 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Zendure Home Assistant Integration", 3 | "render_readme": true, 4 | "homeassistant": "2025.4.0" 5 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | homeassistant==2025.4.0 3 | pip>=21.3.1 4 | ruff==0.11.12 5 | aiohttp 6 | voluptuous 7 | paho.mqtt==2.1.0 8 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/zendure_ha 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix 9 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | --------------------------------------------------------------------------------