pax_global_header00006660000000000000000000000064146336535530014527gustar00rootroot0000000000000052 comment=a4b8c7fa6a45f037135b9061aa3500e5c3a7befb marcelblijleven-goodwe-a4b8c7f/000077500000000000000000000000001463365355300166475ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/.github/000077500000000000000000000000001463365355300202075ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/.github/workflows/000077500000000000000000000000001463365355300222445ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/.github/workflows/pr.yaml000066400000000000000000000016311463365355300235520ustar00rootroot00000000000000name: PR on: pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest marcelblijleven-goodwe-a4b8c7f/.github/workflows/publish.yaml000066400000000000000000000030131463365355300245730ustar00rootroot00000000000000name: Public to PyPi and TestPyPi on: push: branches: - master tags: - 'v*' jobs: build-and-publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: '0' - name: Setup Python uses: actions/setup-python@v5 with: python-version: 3.12 - run: pip install flake8 pytest - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: pytest - name: Create version file for pypi run: | tag=$(git describe --tags --abbrev=0) prefix=v version=${tag#"$prefix"} echo $version > VERSION - run: pip install -q build - run: python -m build --sdist --wheel --outdir dist/ . - name: Publish to Test PyPi uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ skip_existing: true - name: Publish to PyPi if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.PYPI_API_TOKEN }} marcelblijleven-goodwe-a4b8c7f/.gitignore000066400000000000000000000035121463365355300206400ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # PyCharm .idea # Version file needed for CI publishing VERSION marcelblijleven-goodwe-a4b8c7f/LICENSE000066400000000000000000000020611463365355300176530ustar00rootroot00000000000000MIT License Copyright (c) 2021 Marcel Blijleven Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. marcelblijleven-goodwe-a4b8c7f/README.md000066400000000000000000000045431463365355300201340ustar00rootroot00000000000000# GoodWe [![PyPi](https://img.shields.io/pypi/v/goodwe.svg)](https://pypi.python.org/pypi/goodwe/) [![Python Versions](https://img.shields.io/pypi/pyversions/goodwe.svg)](https://github.com/marcelblijleven/goodwe/) [![Build Status](https://github.com/marcelblijleven/goodwe/actions/workflows/publish.yaml/badge.svg)](https://github.com/marcelblijleven/goodwe/actions/workflows/publish.yaml) ![License](https://img.shields.io/github/license/marcelblijleven/goodwe.svg) Library for connecting to GoodWe inverter over local network and retrieving runtime sensor values and configuration parameters. It has been reported to work with GoodWe ET, EH, BT, BH, ES, EM, BP, DT, MS, D-NS, and XS families of inverters. It should work with other inverters as well, as long as they listen on UDP port 8899 and respond to one of supported communication protocols. In general, if you can connect to your inverter with the official mobile app (SolarGo/PvMaster) over Wi-Fi (not bluetooth), this library should work. (If you can't communicate with the inverter despite your model is listed above, it is possible you have old ARM firmware version. You should ask manufacturer support to upgrade your ARM firmware (not just inverter firmware) to be able to communicate with the inverter via UDP.) White-label (GoodWe manufactured) inverters may work as well, e.g. General Electric GEP (PSB, PSC) and GEH models are know to work properly. Since v0.4.x the library also supports standard Modbus/TCP over port 502. This protocol is supported by the V2.0 version of LAN+WiFi communication dongle (model WLA0000-01-00P). ## Usage 1. Install this package `pip install goodwe` 2. Write down your GoodWe inverter's IP address (or invoke `goodwe.search_inverters()`) 3. Connect to inverter and read all runtime data, example below ```python import asyncio import goodwe async def get_runtime_data(): ip_address = '192.168.1.14' inverter = await goodwe.connect(ip_address) runtime_data = await inverter.read_runtime_data() for sensor in inverter.sensors(): if sensor.id_ in runtime_data: print(f"{sensor.id_}: \t\t {sensor.name} = {runtime_data[sensor.id_]} {sensor.unit}") asyncio.run(get_runtime_data()) ``` ## References and useful links - https://github.com/mletenay/home-assistant-goodwe-inverter - https://github.com/yasko-pv/modbus-log - https://github.com/tkubec/GoodWe marcelblijleven-goodwe-a4b8c7f/goodwe/000077500000000000000000000000001463365355300201335ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/goodwe/__init__.py000066400000000000000000000132431463365355300222470ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging from .const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from .dt import DT from .es import ES from .et import ET from .exceptions import InverterError, RequestFailedException from .inverter import Inverter, OperationMode, Sensor, SensorKind from .model import DT_MODEL_TAGS, ES_MODEL_TAGS, ET_MODEL_TAGS from .protocol import ProtocolCommand, UdpInverterProtocol, Aa55ProtocolCommand logger = logging.getLogger(__name__) # Inverter family names ET_FAMILY = ["ET", "EH", "BT", "BH"] ES_FAMILY = ["ES", "EM", "BP"] DT_FAMILY = ["DT", "MS", "NS", "XS"] # Initial discovery command DISCOVERY_COMMAND = Aa55ProtocolCommand("010200", "0182") async def connect(host: str, port: int = GOODWE_UDP_PORT, family: str = None, comm_addr: int = 0, timeout: int = 1, retries: int = 3, do_discover: bool = True) -> Inverter: """Contact the inverter at the specified host/port and answer appropriate Inverter instance. The specific inverter family/type will be detected automatically, but it can be passed explicitly. Supported inverter family names are ET, EH, BT, BH, ES, EM, BP, DT, MS, D-NS and XS. Inverter communication address may be explicitly passed, if not the usual default value will be used (0xf7 for ET/EH/BT/BH/ES/EM/BP inverters, 0x7f for DT/MS/D-NS/XS inverters). Since the UDP communication is by definition unreliable, when no (valid) response is received by the specified timeout, it is considered lost and the command will be re-tried up to retries times. Raise InverterError if unable to contact or recognise supported inverter. """ if family in ET_FAMILY: inv = ET(host, port, comm_addr, timeout, retries) elif family in ES_FAMILY: inv = ES(host, port, comm_addr, timeout, retries) elif family in DT_FAMILY: inv = DT(host, port, comm_addr, timeout, retries) elif do_discover: return await discover(host, port, timeout, retries) else: raise InverterError("Specify either an inverter family or set do_discover True") logger.debug("Connecting to %s family inverter at %s:%s.", family, host, port) await inv.read_device_info() logger.debug("Connected to inverter %s, S/N:%s.", inv.model_name, inv.serial_number) return inv async def discover(host: str, port: int = GOODWE_UDP_PORT, timeout: int = 1, retries: int = 3) -> Inverter: """Contact the inverter at the specified value and answer appropriate Inverter instance Raise InverterError if unable to contact or recognise supported inverter """ failures = [] if port == GOODWE_UDP_PORT: # Try the common AA55C07F0102000241 command first and detect inverter type from serial_number try: logger.debug("Probing inverter at %s:%s.", host, port) response = await DISCOVERY_COMMAND.execute(UdpInverterProtocol(host, port, timeout, retries)) response = response.response_data() model_name = response[5:15].decode("ascii").rstrip() serial_number = response[31:47].decode("ascii") i: Inverter | None = None for model_tag in ET_MODEL_TAGS: if model_tag in serial_number: logger.debug("Detected ET/EH/BT/BH/GEH inverter %s, S/N:%s.", model_name, serial_number) i = ET(host, port, 0, timeout, retries) break if not i: for model_tag in ES_MODEL_TAGS: if model_tag in serial_number: logger.debug("Detected ES/EM/BP inverter %s, S/N:%s.", model_name, serial_number) i = ES(host, port, 0, timeout, retries) break if not i: for model_tag in DT_MODEL_TAGS: if model_tag in serial_number: logger.debug("Detected DT/MS/D-NS/XS/GEP inverter %s, S/N:%s.", model_name, serial_number) i = DT(host, port, 0, timeout, retries) break if i: await i.read_device_info() logger.debug("Connected to inverter %s, S/N:%s.", i.model_name, i.serial_number) return i except InverterError as ex: failures.append(ex) # Probe inverter specific protocols for inv in [ET, DT, ES]: i = inv(host, port, 0, timeout, retries) try: logger.debug("Probing %s inverter at %s.", inv.__name__, host) await i.read_device_info() await i.read_runtime_data() logger.debug("Detected %s family inverter %s, S/N:%s.", inv.__name__, i.model_name, i.serial_number) return i except InverterError as ex: failures.append(ex) raise InverterError( "Unable to connect to the inverter at " f"host={host}, or your inverter is not supported yet.\n" f"Failures={str(failures)}" ) async def search_inverters() -> bytes: """Scan the network for inverters. Answer the inverter discovery response string (which includes it IP address) Raise InverterError if unable to contact any inverter """ logger.debug("Searching inverters by broadcast to port 48899") command = ProtocolCommand("WIFIKIT-214028-READ".encode("utf-8"), lambda r: True) try: result = await command.execute(UdpInverterProtocol("255.255.255.255", 48899, 1, 0)) if result is not None: return result.response_data() else: raise InverterError("No response received to broadcast request.") except asyncio.CancelledError: raise InverterError("No valid response received to broadcast request.") from None marcelblijleven-goodwe-a4b8c7f/goodwe/const.py000066400000000000000000000173761463365355300216510ustar00rootroot00000000000000from typing import Dict GOODWE_TCP_PORT = 502 GOODWE_UDP_PORT = 8899 BATTERY_MODES: Dict[int, str] = { 0: "No battery", 1: "Standby", 2: "Discharge", 3: "Charge", 4: "To be charged", 5: "To be discharged", } ENERGY_MODES: Dict[int, str] = { 0: "Check Mode", 1: "Wait Mode", 2: "Normal (On-Grid)", 4: "Normal (Off-Grid)", 8: "Flash Mode", 16: "Fault Mode", 32: "Battery Standby", 64: "Battery Charging", 128: "Battery Discharging", } GRID_MODES: Dict[int, str] = { 0: "Not connected to grid", 1: "Connected to grid", 2: "Fault", } GRID_IN_OUT_MODES: Dict[int, str] = { 0: "Idle", 1: "Exporting", 2: "Importing", } LOAD_MODES: Dict[int, str] = { 0: "Inverter and the load is disconnected", 1: "The inverter is connected to a load", } PV_MODES: Dict[int, str] = { 0: "PV panels not connected", 1: "PV panels connected, no power", 2: "PV panels connected, producing power", } WORK_MODES: Dict[int, str] = { 0: "Wait Mode", 1: "Normal", 2: "Error", 4: "Check Mode", } WORK_MODES_ET: Dict[int, str] = { 0: "Wait Mode", 1: "Normal (On-Grid)", 2: "Normal (Off-Grid)", 3: "Fault Mode", 4: "Flash Mode", 5: "Check Mode", } WORK_MODES_ES: Dict[int, str] = { 0: "Inverter Off - Standby", 1: "Inverter On", 2: "Inverter Abnormal, stopping power", 3: "Inverter Severly Abnormal, 20 seconds to restart", } SAFETY_COUNTRIES: Dict[int, str] = { 0: "IT CEI 0-21", 1: "CZ-A1", 2: "DE LV with PV", 3: "ES-A", 4: "GR", 5: "DK2", 6: "BE", 7: "RO-A", 8: "GB G98", 9: "Australia A", 10: "FR mainland", 11: "China", 12: "60Hz 230Vac Default", 13: "PL LV", 14: "South Africa", 16: "Brazil 220Vac", 17: "Thailand MEA", 18: "Thailand PEA", 19: "Mauritius", 20: "NL-A", 21: "G98/NI", 22: "China Higher", 23: "FR island 50Hz", 24: "FR island 60Hz", 25: "Australia Ergon", 26: "Australia Energex", 27: "NL 16/20A", 28: "Korea", 29: "China Utility", 30: "AT-A", 31: "India", 32: "50Hz 230Vac Default", 33: "Warehouse", 34: "Philippines", 35: "IE-16/25A", 36: "Taiwan", 37: "BG", 38: "Barbados", 39: "China Highest", 40: "GB G99-A", 41: "SE LV", 42: "Chile BT", 43: "Brazil 127Vac", 44: "Newzealand", 45: "IEEE1547 208Vac", 46: "IEEE1547 220Vac", 47: "IEEE1547 240Vac", 48: "60Hz 127Vac Default", 49: "50Hz 127Vac Default", 50: "Australia WAPN", 51: "Australia MicroGrid", 52: "JP 50Hz", 53: "JP 60Hz", 54: "India Higher", 55: "DEWA LV", 56: "DEWA MV", 57: "SK", 58: "NZ GreenGrid", 59: "HU", 60: "Sri Lanka", 61: "ES island", 62: "Ergon30K", 63: "Energex30K", 64: "IEEE1547 230/400Vac", 65: "IEC61727 60Hz", 66: "CH", 67: "IT CEI 0-16", 68: "Australia Horizon", 69: "CY", 70: "Australia SAPN", 71: "Australia Ausgrid", 72: "Australia Essential", 73: "Australia Victoria", 74: "Hong Kong", 75: "PL MV", 76: "NL-B", 77: "SE MV", 78: "DE MV", 79: "DE LV without PV", 80: "ES-D", 81: "Australia Endeavour", 82: "Argentina", 83: "Israel LV", 84: "IEC61727 50Hz", 85: "Australia B", 86: "Australia C", 87: "Chile MT-A", 88: "Chile MT-B", 89: "Vietnam", 90: "reserve14", 91: "Israel-HV", 93: "NewZealand:2015", 94: "RO-D", 96: "US 208Vac Default", 97: "US 240Vac Default", 98: "US CA 208Vac", 99: "US CA 240Vac", 100: "cUSA_208VacCA_SDGE", 101: "cUSA_240VacCA_SDGE", 102: "cUSA_208VacCA_PGE", 103: "cUSA_240VacCA_PGE", 104: "US HI 208Vac", 105: "US HI 240Vac", 106: "USA_208VacHECO_14HM", 107: "USA_240VacHECO_14HM", 108: "US 480Vac", 109: "US CA 480Vac", 110: "US HI 480Vac", 111: "US Kauai 208Vac", 112: "US Kauai 240Vac", 113: "US Kauai 480Vac", 114: "US ISO-NE 208Vac", 115: "US ISO-NE 240Vac", 116: "US ISO-NE 480Vac", 118: "PR 208Vac", 119: "PR 240Vac", 120: "PR 480Vac", 128: "Poland_B", 129: "EE", 135: "CZ-A2", 136: "CZ-B1", 146: "Brazil 208Vac", 147: "Brazil 230Vac", 148: "Brazil 240Vac", 149: "Brazil 254Vac", } ERROR_CODES: Dict[int, str] = { 31: 'Internal Communication Failure', 30: 'EEPROM R/W Failure', 29: 'Fac Failure', 28: 'DSP communication failure', 27: 'PhaseAngleFailure', 26: '', 25: 'Relay Check Failure', 24: '', 23: 'Vac Consistency Failure', 22: 'Fac Consistency Failure', 21: '', 20: 'Back-Up Over Load', 19: 'DC Injection High', 18: 'Isolation Failure', 17: 'Vac Failure', 16: 'External Fan Failure', 15: 'PV Over Voltage', 14: 'Utility Phase Failure', 13: 'Over Temperature', 12: 'InternalFan Failure', 11: 'DC Bus High', 10: 'Ground I Failure', 9: 'Utility Loss', 8: 'AC HCT Failure', 7: 'Relay Device Failure', 6: 'GFCI Device Failure', 5: '', 4: 'GFCI Consistency Failure', 3: 'DCI Consistency Failure', 2: '', 1: 'AC HCT Check Failure', 0: 'GFCI Device Check Failure', } DIAG_STATUS_CODES: Dict[int, str] = { 0: "Battery voltage low", 1: "Battery SOC low", 2: "Battery SOC in back", 3: "BMS: Discharge disabled", 4: "Discharge time on", 5: "Charge time on", 6: "Discharge Driver On", 7: "BMS: Discharge current low", 8: "APP: Discharge current too low", 9: "Meter communication failure", 10: "Meter connection reversed", 11: "Self-use load light", 12: "EMS: discharge current is zero", 13: "Discharge BUS high PV voltage", 14: "Battery Disconnected", 15: "Battery Overcharged", 16: "BMS: Temperature too high", 17: "BMS: Charge too high", 18: "BMS: Charge disabled", 19: "Self-use off", 20: "SOC delta too volatile", 21: "Battery self discharge too high", 22: "Battery SOC low (off-grid)", 23: "Grid wave unstable", 24: "Export power limit set", 25: "PF value set", 26: "Real power limit set", 27: "DC output on", 28: "SOC protect off", } BMS_ALARM_CODES: Dict[int, str] = { 15: 'Charging over-voltage 3', 14: 'Discharging under-voltage 3', 13: 'Cell temperature high 3', 12: 'Communication failure 2', 11: 'Charging circuit failure', 10: 'Discharging circuit failure', 9: 'Battery lock', 8: 'Battery break', 7: 'DC bus fault', 6: 'Precharge fault', 5: 'Discharging over-current 2', 4: 'Charging over-current 2', 3: 'Cell temperature low 2', 2: 'Cell temperature high 2', 1: 'Discharging under-voltage 2', 0: 'Charging over-voltage 2', } BMS_WARNING_CODES: Dict[int, str] = { 11: 'System temperature high', 10: 'System temperature low 2', 9: 'System temperature low 1', 8: 'Cell imbalance', 7: 'System reboot', 6: 'Communication failure 1', 5: 'Discharging over-current 1', 4: 'Charging over-current 1', 3: 'Cell temperature low 1', 2: 'Cell temperature high 1', 1: 'Discharging under-voltage 1', 0: 'Charging over-voltage 1', } DERATING_MODE_CODES: Dict[int, str] = { 31: '', 30: '', 29: '', 28: '', 27: '', 26: '', 25: '', 24: '', 23: '', 22: '', 21: '', 20: '', 19: '', 18: '', 17: '', 16: '', 15: '', 14: '', 13: '', 12: '', 11: '', 10: 'Power calibration limit power (For ATS)', 9: 'Overvoltage derating (For GW)', 8: 'Maximum current derating', 7: 'Limited power start derating', 6: 'DRED derating', 5: 'Export power derating', 4: 'PU Curve', 3: 'Power VS Frequency', 2: 'Reactive power derating(PF/QU/FixQ)', 1: 'Active power derating', 0: 'Overtemperature derating', } marcelblijleven-goodwe-a4b8c7f/goodwe/dt.py000066400000000000000000000325371463365355300211260ustar00rootroot00000000000000from __future__ import annotations import logging from typing import Tuple from .exceptions import InverterError, RequestFailedException, RequestRejectedException from .inverter import Inverter from .inverter import OperationMode from .inverter import SensorKind as Kind from .modbus import ILLEGAL_DATA_ADDRESS from .model import is_3_mppt, is_single_phase from .protocol import ProtocolCommand from .sensor import * logger = logging.getLogger(__name__) class DT(Inverter): """Class representing inverter of DT/MS/D-NS/XS or GE's GEP(PSB/PSC) families""" __all_sensors: Tuple[Sensor, ...] = ( Timestamp("timestamp", 30100, "Timestamp"), Voltage("vpv1", 30103, "PV1 Voltage", Kind.PV), Current("ipv1", 30104, "PV1 Current", Kind.PV), Calculated("ppv1", lambda data: round(read_voltage(data, 30103) * read_current(data, 30104)), "PV1 Power", "W", Kind.PV), Voltage("vpv2", 30105, "PV2 Voltage", Kind.PV), Current("ipv2", 30106, "PV2 Current", Kind.PV), Calculated("ppv2", lambda data: round(read_voltage(data, 30105) * read_current(data, 30106)), "PV2 Power", "W", Kind.PV), Voltage("vpv3", 30107, "PV3 Voltage", Kind.PV), Current("ipv3", 30108, "PV3 Current", Kind.PV), Calculated("ppv3", lambda data: round(read_voltage(data, 30107) * read_current(data, 30108)), "PV3 Power", "W", Kind.PV), # ppv1 + ppv2 + ppv3 Calculated("ppv", lambda data: (round(read_voltage(data, 30103) * read_current(data, 30104))) + (round( read_voltage(data, 30105) * read_current(data, 30106))) + (round( read_voltage(data, 30107) * read_current(data, 30108))), "PV Power", "W", Kind.PV), # Voltage("vpv4", 14, "PV4 Voltage", Kind.PV), # Current("ipv4", 16, "PV4 Current", Kind.PV), # Voltage("vpv5", 14, "PV5 Voltage", Kind.PV), # Current("ipv5", 16, "PV5 Current", Kind.PV), # Voltage("vpv6", 14, "PV6 Voltage", Kind.PV), # Current("ipv6", 16, "PV7 Current", Kind.PV), Voltage("vline1", 30115, "On-grid L1-L2 Voltage", Kind.AC), Voltage("vline2", 30116, "On-grid L2-L3 Voltage", Kind.AC), Voltage("vline3", 30117, "On-grid L3-L1 Voltage", Kind.AC), Voltage("vgrid1", 30118, "On-grid L1 Voltage", Kind.AC), Voltage("vgrid2", 30119, "On-grid L2 Voltage", Kind.AC), Voltage("vgrid3", 30120, "On-grid L3 Voltage", Kind.AC), Current("igrid1", 30121, "On-grid L1 Current", Kind.AC), Current("igrid2", 30122, "On-grid L2 Current", Kind.AC), Current("igrid3", 30123, "On-grid L3 Current", Kind.AC), Frequency("fgrid1", 30124, "On-grid L1 Frequency", Kind.AC), Frequency("fgrid2", 30125, "On-grid L2 Frequency", Kind.AC), Frequency("fgrid3", 30126, "On-grid L3 Frequency", Kind.AC), Calculated("pgrid1", lambda data: round(read_voltage(data, 30118) * read_current(data, 30121)), "On-grid L1 Power", "W", Kind.AC), Calculated("pgrid2", lambda data: round(read_voltage(data, 30119) * read_current(data, 30122)), "On-grid L2 Power", "W", Kind.AC), Calculated("pgrid3", lambda data: round(read_voltage(data, 30120) * read_current(data, 30123)), "On-grid L3 Power", "W", Kind.AC), # 30127 reserved PowerS("total_inverter_power", 30128, "Total Power", Kind.AC), Integer("work_mode", 30129, "Work Mode code"), Enum2("work_mode_label", 30129, WORK_MODES, "Work Mode"), Long("error_codes", 30130, "Error Codes"), Integer("warning_code", 30132, "Warning code"), Apparent4("apparent_power", 30133, "Apparent Power", Kind.AC), Reactive4("reactive_power", 30135, "Reactive Power", Kind.AC), # 30137 reserved # 30138 reserved Decimal("power_factor", 30139, 1000, "Power Factor", "", Kind.GRID), # 30140 reserved Temp("temperature", 30141, "Inverter Temperature", Kind.AC), # 30142 reserved # 30143 reserved Energy("e_day", 30144, "Today's PV Generation", Kind.PV), Energy4("e_total", 30145, "Total PV Generation", Kind.PV), Long("h_total", 30147, "Hours Total", "h", Kind.PV), Integer("safety_country", 30149, "Safety Country code", "", Kind.AC), Enum2("safety_country_label", 30149, SAFETY_COUNTRIES, "Safety Country", Kind.AC), # 30150 reserved # 30151 reserved # 30152 reserved # 30153 reserved # 30154 reserved # 30155 reserved # 30156 reserved # 30157 reserved # 30158 reserved # 30159 reserved # 30160 reserved # 30161 reserved Integer("funbit", 30162, "FunBit", "", Kind.PV), Voltage("vbus", 30163, "Bus Voltage", Kind.PV), Voltage("vnbus", 30164, "NBus Voltage", Kind.PV), Long("derating_mode", 30165, "Derating Mode code"), EnumBitmap4("derating_mode_label", 30165, DERATING_MODE_CODES, "Derating Mode"), # 30167 reserved # 30168 reserved # 30169 reserved # 30170 reserved # 30171 reserved # 30172 reserved ) # Inverter's meter data # Modbus registers from offset 0x75f4 (30196) __all_sensors_meter: Tuple[Sensor, ...] = ( PowerS("active_power", 30196, "Active Power", Kind.GRID), ) # Modbus registers of inverter settings, offsets are modbus register addresses __all_settings: Tuple[Sensor, ...] = ( Timestamp("time", 40313, "Inverter time"), Integer("shadow_scan", 40326, "Shadow Scan", "", Kind.PV), Integer("grid_export", 40327, "Grid Export Enabled", "", Kind.GRID), Integer("grid_export_limit", 40328, "Grid Export Limit", "%", Kind.GRID), Integer("start", 40330, "Start / Power On", "", Kind.GRID), Integer("stop", 40331, "Stop / Power Off", "", Kind.GRID), Integer("restart", 40332, "Restart", "", Kind.GRID), Integer("grid_export_hw", 40345, "Grid Export Enabled (HW)", "", Kind.GRID), ) # Settings for single phase inverters __settings_single_phase: Tuple[Sensor, ...] = ( Long("grid_export_limit", 40328, "Grid Export Limit", "W", Kind.GRID), ) # Settings for three phase inverters __settings_three_phase: Tuple[Sensor, ...] = ( Integer("grid_export_limit", 40336, "Grid Export Limit", "%", Kind.GRID), ) def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3): super().__init__(host, port, comm_addr if comm_addr else 0x7f, timeout, retries) self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x7531, 0x0028) self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x7594, 0x0049) self._READ_METER_DATA: ProtocolCommand = self._read_command(0x75f4, 0x01) self._sensors = self.__all_sensors self._sensors_meter = self.__all_sensors_meter self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings} self._has_meter: bool = True @staticmethod def _single_phase_only(s: Sensor) -> bool: """Filter to exclude phase2/3 sensors on single phase inverters""" return not ((s.id_.endswith('2') or s.id_.endswith('3')) and 'pv' not in s.id_) @staticmethod def _pv1_pv2_only(s: Sensor) -> bool: """Filter to exclude sensors on < 3 PV inverters""" return not s.id_.endswith('pv3') async def read_device_info(self): response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO) response = response.response_data() try: self.model_name = response[22:32].decode("ascii").rstrip() except: print("No model name sent from the inverter.") # Modbus registers from 30001 - 30040 self.serial_number = self._decode(response[6:22]) # 30004 - 30012 self.dsp1_version = read_unsigned_int(response, 66) # 30034 self.dsp2_version = read_unsigned_int(response, 68) # 30035 self.arm_version = read_unsigned_int(response, 70) # 30036 self.dsp_svn_version = read_unsigned_int(response, 72) # 35037 self.arm_svn_version = read_unsigned_int(response, 74) # 35038 self.firmware = "{}.{}.{:02x}".format(self.dsp1_version, self.dsp2_version, self.arm_version) if is_single_phase(self): # this is single phase inverter, filter out all L2 and L3 sensors self._sensors = tuple(filter(self._single_phase_only, self.__all_sensors)) self._settings.update({s.id_: s for s in self.__settings_single_phase}) else: self._settings.update({s.id_: s for s in self.__settings_three_phase}) if is_3_mppt(self): # this is 3 PV strings inverter, keep all sensors pass else: # this is only 2 PV strings inverter self._sensors = tuple(filter(self._pv1_pv2_only, self._sensors)) pass async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_RUNNING_DATA) data = self._map_response(response, self._sensors) if self._has_meter: try: response = await self._read_from_socket(self._READ_METER_DATA) data.update(self._map_response(response, self._sensors_meter)) except (RequestRejectedException, RequestFailedException): logger.info("Meter values not supported, disabling further attempts.") self._has_meter = False return data async def read_setting(self, setting_id: str) -> Any: setting = self._settings.get(setting_id) if setting: return await self._read_setting(setting) else: if setting_id.startswith("modbus"): response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1)) return int.from_bytes(response.read(2), byteorder="big", signed=True) else: raise ValueError(f'Unknown setting "{setting_id}"') async def _read_setting(self, setting: Sensor) -> Any: try: count = (setting.size_ + (setting.size_ % 2)) // 2 response = await self._read_from_socket(self._read_command(setting.offset, count)) return setting.read_value(response) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.debug("Unsupported setting %s", setting.id_) self._settings.pop(setting.id_, None) raise ValueError(f'Unknown setting "{setting.id_}"') return None async def write_setting(self, setting_id: str, value: Any): setting = self._settings.get(setting_id) if setting: await self._write_setting(setting, value) else: if setting_id.startswith("modbus"): await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value))) else: raise ValueError(f'Unknown setting "{setting_id}"') async def _write_setting(self, setting: Sensor, value: Any): if setting.size_ == 1: # modbus can address/store only 16 bit values, read the other 8 bytes response = await self._read_from_socket(self._read_command(setting.offset, 1)) raw_value = setting.encode_value(value, response.response_data()[0:2]) else: raw_value = setting.encode_value(value) if len(raw_value) <= 2: value = int.from_bytes(raw_value, byteorder="big", signed=True) await self._read_from_socket(self._write_command(setting.offset, value)) else: await self._read_from_socket(self._write_multi_command(setting.offset, raw_value)) async def read_settings_data(self) -> Dict[str, Any]: data = {} for setting in self.settings(): value = await self.read_setting(setting.id_) data[setting.id_] = value return data async def get_grid_export_limit(self) -> int: return await self.read_setting('grid_export_limit') async def set_grid_export_limit(self, export_limit: int) -> None: if export_limit >= 0: return await self.write_setting('grid_export_limit', export_limit) async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]: return () async def get_operation_mode(self) -> OperationMode: raise InverterError("Operation not supported.") async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100, eco_mode_soc: int = 100) -> None: raise InverterError("Operation not supported.") async def get_ongrid_battery_dod(self) -> int: raise InverterError("Operation not supported, inverter has no batteries.") async def set_ongrid_battery_dod(self, dod: int) -> None: raise InverterError("Operation not supported, inverter has no batteries.") def sensors(self) -> Tuple[Sensor, ...]: result = self._sensors if self._has_meter: result = result + self._sensors_meter return result def settings(self) -> Tuple[Sensor, ...]: return tuple(self._settings.values()) marcelblijleven-goodwe-a4b8c7f/goodwe/es.py000066400000000000000000000547411463365355300211270ustar00rootroot00000000000000from __future__ import annotations import logging from typing import Tuple from .exceptions import InverterError from .inverter import Inverter from .inverter import OperationMode from .inverter import SensorKind as Kind from .protocol import ProtocolCommand, Aa55ProtocolCommand, Aa55ReadCommand, Aa55WriteCommand, Aa55WriteMultiCommand from .sensor import * logger = logging.getLogger(__name__) class ES(Inverter): """Class representing inverter of ES/EM/BP family AKA platform 105""" _READ_DEVICE_VERSION_INFO: ProtocolCommand = Aa55ProtocolCommand("010200", "0182") _READ_DEVICE_RUNNING_DATA: ProtocolCommand = Aa55ProtocolCommand("010600", "0186") _READ_DEVICE_SETTINGS_DATA: ProtocolCommand = Aa55ProtocolCommand("010900", "0189") __sensors: Tuple[Sensor, ...] = ( Voltage("vpv1", 0, "PV1 Voltage", Kind.PV), # modbus 0x500 Current("ipv1", 2, "PV1 Current", Kind.PV), Calculated("ppv1", lambda data: round(read_voltage(data, 0) * read_current(data, 2)), "PV1 Power", "W", Kind.PV), Byte("pv1_mode", 4, "PV1 Mode code", "", Kind.PV), Enum("pv1_mode_label", 4, PV_MODES, "PV1 Mode", Kind.PV), Voltage("vpv2", 5, "PV2 Voltage", Kind.PV), Current("ipv2", 7, "PV2 Current", Kind.PV), Calculated("ppv2", lambda data: round(read_voltage(data, 5) * read_current(data, 7)), "PV2 Power", "W", Kind.PV), Byte("pv2_mode", 9, "PV2 Mode code", "", Kind.PV), Enum("pv2_mode_label", 9, PV_MODES, "PV2 Mode", Kind.PV), Calculated("ppv", lambda data: round(read_voltage(data, 0) * read_current(data, 2)) + round( read_voltage(data, 5) * read_current(data, 7)), "PV Power", "W", Kind.PV), Voltage("vbattery1", 10, "Battery Voltage", Kind.BAT), # modbus 0x506 # Voltage("vbattery2", 12, "Battery Voltage 2", Kind.BAT), Integer("battery_status", 14, "Battery Status", "", Kind.BAT), Temp("battery_temperature", 16, "Battery Temperature", Kind.BAT), Calculated("ibattery1", lambda data: abs(read_current(data, 18)) * (-1 if read_byte(data, 30) == 3 else 1), "Battery Current", "A", Kind.BAT), # round(vbattery1 * ibattery1), Calculated("pbattery1", lambda data: abs( round(read_voltage(data, 10) * read_current(data, 18)) ) * (-1 if read_byte(data, 30) == 3 else 1), "Battery Power", "W", Kind.BAT), Integer("battery_charge_limit", 20, "Battery Charge Limit", "A", Kind.BAT), Integer("battery_discharge_limit", 22, "Battery Discharge Limit", "A", Kind.BAT), Integer("battery_error", 24, "Battery Error Code", "", Kind.BAT), Byte("battery_soc", 26, "Battery State of Charge", "%", Kind.BAT), # modbus 0x50E # Byte("cbattery2", 27, "Battery State of Charge 2", "%", Kind.BAT), # Byte("cbattery3", 28, "Battery State of Charge 3", "%", Kind.BAT), Byte("battery_soh", 29, "Battery State of Health", "%", Kind.BAT), Byte("battery_mode", 30, "Battery Mode code", "", Kind.BAT), Enum("battery_mode_label", 30, BATTERY_MODES, "Battery Mode", Kind.BAT), Integer("battery_warning", 31, "Battery Warning", "", Kind.BAT), Byte("meter_status", 33, "Meter Status code", "", Kind.AC), Voltage("vgrid", 34, "On-grid Voltage", Kind.AC), Current("igrid", 36, "On-grid Current", Kind.AC), Calculated("pgrid", lambda data: abs(read_bytes2_signed(data, 38)) * (-1 if read_byte(data, 80) == 2 else 1), "On-grid Export Power", "W", Kind.AC), Frequency("fgrid", 40, "On-grid Frequency", Kind.AC), Byte("grid_mode", 42, "Work Mode code", "", Kind.GRID), Enum("grid_mode_label", 42, WORK_MODES_ES, "Work Mode", Kind.GRID), Voltage("vload", 43, "Back-up Voltage", Kind.UPS), # modbus 0x51b Current("iload", 45, "Back-up Current", Kind.UPS), Power("pload", 47, "On-grid Power", Kind.AC), Frequency("fload", 49, "Back-up Frequency", Kind.UPS), Byte("load_mode", 51, "Load Mode code", "", Kind.AC), Enum("load_mode_label", 51, LOAD_MODES, "Load Mode", Kind.AC), Byte("work_mode", 52, "Energy Mode code", "", Kind.AC), Enum("work_mode_label", 52, ENERGY_MODES, "Energy Mode", Kind.AC), Temp("temperature", 53, "Inverter Temperature"), Long("error_codes", 55, "Error Codes"), Energy4("e_total", 59, "Total PV Generation", Kind.PV), Long("h_total", 63, "Hours Total", "h", Kind.PV), Energy("e_day", 67, "Today's PV Generation", Kind.PV), Energy("e_load_day", 69, "Today's Load", Kind.AC), Energy4("e_load_total", 71, "Total Load", Kind.AC), PowerS("total_power", 75, "Total Power", Kind.AC), # modbus 0x52c Byte("effective_work_mode", 77, "Effective Work Mode code"), Integer("effective_relay_control", 78, "Effective Relay Control", "", None), Byte("grid_in_out", 80, "On-grid Mode code", "", Kind.GRID), Enum("grid_in_out_label", 80, GRID_IN_OUT_MODES, "On-grid Mode", Kind.GRID), Power("pback_up", 81, "Back-up Power", Kind.UPS), # pload + pback_up Calculated("plant_power", lambda data: round(read_bytes2(data, 47, 0) + read_bytes2(data, 81, 0)), "Plant Power", "W", Kind.AC), Decimal("meter_power_factor", 83, 1000, "Meter Power Factor", "", Kind.GRID), # modbus 0x531 # Integer("xx85", 85, "Unknown sensor@85"), # Integer("xx87", 87, "Unknown sensor@87"), Long("diagnose_result", 89, "Diag Status Code"), EnumBitmap4("diagnose_result_label", 89, DIAG_STATUS_CODES, "Diag Status"), # Energy4("e_total_exp", 93, "Total Energy (export)", Kind.GRID), # Energy4("e_total_imp", 97, "Total Energy (import)", Kind.GRID), # Voltage("vpv3", 101, "PV3 Voltage", Kind.PV), # modbus 0x500 # Current("ipv3", 103, "PV3 Current", Kind.PV), # Byte("pv3_mode", 104, "PV1 Mode", "", Kind.PV), # Voltage("vgrid_uo", 105, "On-grid Uo Voltage", Kind.AC), # Current("igrid_uo", 107, "On-grid Uo Current", Kind.AC), # Voltage("vgrid_wo", 109, "On-grid Wo Voltage", Kind.AC), # Current("igrid_wo", 111, "On-grid Wo Current", Kind.AC), # Energy4("e_bat_charge_total", 113, "Total Battery Charge", Kind.BAT), # Energy4("e_bat_discharge_total", 117, "Total Battery Discharge", Kind.BAT), # ppv1 + ppv2 + pbattery - pgrid Calculated("house_consumption", lambda data: round(read_voltage(data, 0) * read_current(data, 2)) + round(read_voltage(data, 5) * read_current(data, 7)) + (abs(round(read_voltage(data, 10) * read_current(data, 18))) * (-1 if read_byte(data, 30) == 3 else 1)) - (abs(read_bytes2_signed(data, 38)) * (-1 if read_byte(data, 80) == 2 else 1)), "House Consumption", "W", Kind.AC), ) __all_settings: Tuple[Sensor, ...] = ( Integer("backup_supply", 12, "Backup Supply"), Integer("off-grid_charge", 14, "Off-grid Charge"), Integer("shadow_scan", 16, "Shadow Scan", "", Kind.PV), Integer("grid_export", 18, "Export Limit Enabled", "", Kind.GRID), Integer("capacity", 22, "Capacity"), Decimal("charge_v", 24, 10, "Charge Voltage", "V"), Integer("charge_i", 26, "Charge Current", "A", ), Integer("discharge_i", 28, "Discharge Current", "A", ), Decimal("discharge_v", 30, 10, "Discharge Voltage", "V"), Calculated("dod", lambda data: 100 - read_bytes2(data, 32, 0), "Depth of Discharge", "%"), Integer("battery_activated", 34, "Battery Activated"), Integer("bp_off_grid_charge", 36, "BP Off-grid Charge"), Integer("bp_pv_discharge", 38, "BP PV Discharge"), Integer("bp_bms_protocol", 40, "BP BMS Protocol"), Integer("power_factor", 42, "Power Factor"), Integer("grid_export_limit", 52, "Grid Export Limit", "W", Kind.GRID), Integer("battery_soc_protection", 56, "Battery SoC Protection", "", Kind.BAT), Integer("work_mode", 66, "Work Mode"), Integer("grid_quality_check", 68, "Grid Quality Check"), EcoModeV1("eco_mode_1", 1793, "Eco Mode Group 1"), # 0x701 ByteH("eco_mode_1_switch", 1796, "Eco Mode Group 1 Switch", "", Kind.BAT), EcoModeV1("eco_mode_2", 1797, "Eco Mode Group 2"), ByteH("eco_mode_2_switch", 1800, "Eco Mode Group 2 Switch", "", Kind.BAT), EcoModeV1("eco_mode_3", 1801, "Eco Mode Group 3"), ByteH("eco_mode_3_switch", 1804, "Eco Mode Group 3 Switch", "", Kind.BAT), EcoModeV1("eco_mode_4", 1805, "Eco Mode Group 4"), ByteH("eco_mode_4_switch", 1808, "Eco Mode Group 4 Switch", "", Kind.BAT), ) # Settings added in ARM firmware 14 __settings_arm_fw_14: Tuple[Sensor, ...] = ( EcoModeV2("eco_mode_1", 47547, "Eco Mode Group 1"), ByteH("eco_mode_1_switch", 47549, "Eco Mode Group 1 Switch"), EcoModeV2("eco_mode_2", 47553, "Eco Mode Group 2"), ByteH("eco_mode_2_switch", 47555, "Eco Mode Group 2 Switch"), EcoModeV2("eco_mode_3", 47559, "Eco Mode Group 3"), ByteH("eco_mode_3_switch", 47561, "Eco Mode Group 3 Switch"), EcoModeV2("eco_mode_4", 47565, "Eco Mode Group 4"), ByteH("eco_mode_4_switch", 47567, "Eco Mode Group 4 Switch"), ) def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3): super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries) self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings} def _supports_eco_mode_v2(self) -> bool: if self.arm_version < 14: return False if "EMU" in self.serial_number or "EMJ" in self.serial_number: return self.dsp1_version >= 11 if "ESU" in self.serial_number or "ESA" in self.serial_number: return self.dsp1_version >= 22 if "BPS" in self.serial_number or "BPU" in self.serial_number: return self.dsp1_version >= 10 return False async def read_device_info(self): response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO) response = response.response_data() self.firmware = self._decode(response[0:5]).rstrip() self.model_name = self._decode(response[5:15]).rstrip() self.serial_number = self._decode(response[31:47]) self.arm_firmware = self._decode(response[51:63]) # AKA software_version try: if len(self.firmware) >= 2: self.dsp1_version = int(self.firmware[0:2]) if len(self.firmware) >= 4: self.dsp2_version = int(self.firmware[2:4]) if len(self.firmware) >= 5: self.arm_version = int(self.firmware[4], base=36) except ValueError: logger.exception("Error decoding firmware version %s.", self.firmware) if self._supports_eco_mode_v2(): self._settings.update({s.id_: s for s in self.__settings_arm_fw_14}) async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_DEVICE_RUNNING_DATA) data = self._map_response(response, self.__sensors) return data async def read_setting(self, setting_id: str) -> Any: if setting_id == 'time': # Fake setting, just to enable write_setting to work (if checked as pair in read as in HA) # There does not seem to be time setting/sensor available (or is not known) return datetime.now() elif setting_id in ('eco_mode_1', 'eco_mode_2', 'eco_mode_3', 'eco_mode_4'): setting: Sensor | None = self._settings.get(setting_id) if not setting: raise ValueError(f'Unknown setting "{setting_id}"') return await self._read_setting(setting) elif setting_id.startswith("modbus"): response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1)) return int.from_bytes(response.read(2), byteorder="big", signed=True) elif setting_id in self._settings: logger.debug("Reading setting %s", setting_id) all_settings = await self.read_settings_data() return all_settings.get(setting_id) else: raise ValueError(f'Unknown setting "{setting_id}"') async def _read_setting(self, setting: Sensor) -> Any: count = (setting.size_ + (setting.size_ % 2)) // 2 if self._is_modbus_setting(setting): response = await self._read_from_socket(self._read_command(setting.offset, count)) return setting.read_value(response) else: response = await self._read_from_socket(Aa55ReadCommand(setting.offset, count)) return setting.read_value(response) async def write_setting(self, setting_id: str, value: Any): if setting_id == 'time': await self._read_from_socket( Aa55ProtocolCommand("030206" + Timestamp("time", 0, "").encode_value(value).hex(), "0382") ) elif setting_id.startswith("modbus"): await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value))) else: setting: Sensor | None = self._settings.get(setting_id) if not setting: raise ValueError(f'Unknown setting "{setting_id}"') await self._write_setting(setting, value) async def _write_setting(self, setting: Sensor, value: Any): if setting.size_ == 1: # modbus can address/store only 16 bit values, read the other 8 bytes if self._is_modbus_setting(setting): response = await self._read_from_socket(self._read_command(setting.offset, 1)) else: response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1)) raw_value = setting.encode_value(value, response.response_data()[0:2]) else: raw_value = setting.encode_value(value) if len(raw_value) <= 2: value = int.from_bytes(raw_value, byteorder="big", signed=True) if self._is_modbus_setting(setting): await self._read_from_socket(self._write_command(setting.offset, value)) else: await self._read_from_socket(Aa55WriteCommand(setting.offset, value)) else: if self._is_modbus_setting(setting): await self._read_from_socket(self._write_multi_command(setting.offset, raw_value)) else: await self._read_from_socket(Aa55WriteMultiCommand(setting.offset, raw_value)) async def read_settings_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_DEVICE_SETTINGS_DATA) data = self._map_response(response, self.settings()) return data async def get_grid_export_limit(self) -> int: return await self.read_setting('grid_export_limit') async def set_grid_export_limit(self, export_limit: int) -> None: if export_limit >= 0: await self._read_from_socket( Aa55ProtocolCommand("033502" + "{:04x}".format(export_limit), "03b5") ) async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]: result = [e for e in OperationMode] result.remove(OperationMode.PEAK_SHAVING) result.remove(OperationMode.SELF_USE) if not include_emulated: result.remove(OperationMode.ECO_CHARGE) result.remove(OperationMode.ECO_DISCHARGE) return tuple(result) async def get_operation_mode(self) -> OperationMode | None: mode_id = await self.read_setting('work_mode') try: mode = OperationMode(mode_id) except ValueError: logger.debug("Unknown work_mode value %s", mode_id) return None if OperationMode.ECO != mode: return mode eco_mode = await self.read_setting('eco_mode_1') if eco_mode.is_eco_charge_mode(): return OperationMode.ECO_CHARGE elif eco_mode.is_eco_discharge_mode(): return OperationMode.ECO_DISCHARGE else: return OperationMode.ECO async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100, eco_mode_soc: int = 100) -> None: if operation_mode == OperationMode.GENERAL: await self._set_general_mode() elif operation_mode == OperationMode.OFF_GRID: await self._set_offgrid_mode() elif operation_mode == OperationMode.BACKUP: await self._set_backup_mode() elif operation_mode == OperationMode.ECO: await self._set_eco_mode() elif operation_mode == OperationMode.PEAK_SHAVING: raise InverterError("Operation not supported.") elif operation_mode in (OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE): if eco_mode_power < 0 or eco_mode_power > 100: raise ValueError() if eco_mode_soc < 0 or eco_mode_soc > 100: raise ValueError() eco_mode: EcoMode | Sensor = self._settings.get('eco_mode_1') await self._read_setting(eco_mode) if operation_mode == OperationMode.ECO_CHARGE: await self.write_setting('eco_mode_1', eco_mode.encode_charge(eco_mode_power, eco_mode_soc)) else: await self.write_setting('eco_mode_1', eco_mode.encode_discharge(eco_mode_power)) await self.write_setting('eco_mode_2_switch', 0) await self.write_setting('eco_mode_3_switch', 0) await self.write_setting('eco_mode_4_switch', 0) await self._set_eco_mode() async def get_ongrid_battery_dod(self) -> int: return await self.read_setting('dod') async def set_ongrid_battery_dod(self, dod: int) -> None: if 0 <= dod <= 100: await self._read_from_socket(Aa55WriteCommand(0x560, 100 - dod)) async def _reset_inverter(self) -> None: await self._read_from_socket(Aa55ProtocolCommand("031d00", "039d")) def sensors(self) -> Tuple[Sensor, ...]: return self.__sensors def settings(self) -> Tuple[Sensor, ...]: return tuple(self._settings.values()) async def _set_general_mode(self) -> None: if self.arm_version >= 7: if self._supports_eco_mode_v2(): await self._clear_battery_mode_param() else: await self._set_limit_power_for_charge(0, 0, 0, 0, 0) await self._set_limit_power_for_discharge(0, 0, 0, 0, 0) await self._clear_battery_mode_param() else: await self._set_limit_power_for_charge(0, 0, 0, 0, 0) await self._set_limit_power_for_discharge(0, 0, 0, 0, 0) await self._set_offgrid_work_mode(0) await self._set_work_mode(OperationMode.GENERAL) async def _set_offgrid_mode(self) -> None: if self.arm_version >= 7: await self._clear_battery_mode_param() else: await self._set_limit_power_for_charge(0, 0, 23, 59, 0) await self._set_limit_power_for_discharge(0, 0, 0, 0, 0) await self._set_offgrid_work_mode(1) await self._set_relay_control(3) await self._set_store_energy_mode(0) await self._set_work_mode(OperationMode.OFF_GRID) async def _set_backup_mode(self) -> None: if self.arm_version >= 7: if self._supports_eco_mode_v2(): await self._clear_battery_mode_param() else: await self._clear_battery_mode_param() await self._set_limit_power_for_charge(0, 0, 23, 59, 10) else: await self._set_limit_power_for_charge(0, 0, 23, 59, 10) await self._set_limit_power_for_discharge(0, 0, 0, 0, 0) await self._set_offgrid_work_mode(0) await self._set_work_mode(OperationMode.BACKUP) async def _set_eco_mode(self) -> None: await self._set_offgrid_work_mode(0) await self._set_work_mode(OperationMode.ECO) async def _clear_battery_mode_param(self) -> None: await self._read_from_socket(Aa55WriteCommand(0x0700, 1)) async def _set_limit_power_for_charge(self, startH: int, startM: int, stopH: int, stopM: int, limit: int) -> None: if limit < 0 or limit > 100: raise ValueError() await self._read_from_socket(Aa55ProtocolCommand("032c05" + "{:02x}".format(startH) + "{:02x}".format(startM) + "{:02x}".format(stopH) + "{:02x}".format(stopM) + "{:02x}".format(limit), "03AC")) async def _set_limit_power_for_discharge(self, startH: int, startM: int, stopH: int, stopM: int, limit: int) -> None: if limit < 0 or limit > 100: raise ValueError() await self._read_from_socket(Aa55ProtocolCommand("032d05" + "{:02x}".format(startH) + "{:02x}".format(startM) + "{:02x}".format(stopH) + "{:02x}".format(stopM) + "{:02x}".format(limit), "03AD")) async def _set_offgrid_work_mode(self, mode: int) -> None: await self._read_from_socket(Aa55ProtocolCommand("033601" + "{:02x}".format(mode), "03B6")) async def _set_relay_control(self, mode: int) -> None: param = 0 if mode == 2: param = 16 elif mode == 3: param = 48 await self._read_from_socket(Aa55ProtocolCommand("03270200" + "{:02x}".format(param), "03B7")) async def _set_store_energy_mode(self, mode: int) -> None: param = 0 if mode == 0: param = 4 elif mode == 1: param = 2 elif mode == 2: param = 8 elif mode == 3: param = 1 await self._read_from_socket(Aa55ProtocolCommand("032601" + "{:02x}".format(param), "03B6")) async def _set_work_mode(self, mode: int) -> None: await self._read_from_socket(Aa55ProtocolCommand("035901" + "{:02x}".format(mode), "03D9")) def _is_modbus_setting(self, sensor: Sensor) -> bool: return sensor.offset > 30000 marcelblijleven-goodwe-a4b8c7f/goodwe/et.py000066400000000000000000001316621463365355300211260ustar00rootroot00000000000000from __future__ import annotations import logging from typing import Tuple from .exceptions import RequestFailedException, RequestRejectedException from .inverter import Inverter from .inverter import OperationMode from .inverter import SensorKind as Kind from .modbus import ILLEGAL_DATA_ADDRESS from .model import is_2_battery, is_4_mppt, is_745_platform, is_single_phase from .protocol import ProtocolCommand from .sensor import * logger = logging.getLogger(__name__) class ET(Inverter): """Class representing inverter of ET/EH/BT/BH or GE's GEH families AKA platform 205 or 745""" # Modbus registers from offset 0x891c (35100), count 0x7d (125) __all_sensors: Tuple[Sensor, ...] = ( Timestamp("timestamp", 35100, "Timestamp"), Voltage("vpv1", 35103, "PV1 Voltage", Kind.PV), Current("ipv1", 35104, "PV1 Current", Kind.PV), Power4("ppv1", 35105, "PV1 Power", Kind.PV), Voltage("vpv2", 35107, "PV2 Voltage", Kind.PV), Current("ipv2", 35108, "PV2 Current", Kind.PV), Power4("ppv2", 35109, "PV2 Power", Kind.PV), Voltage("vpv3", 35111, "PV3 Voltage", Kind.PV), Current("ipv3", 35112, "PV3 Current", Kind.PV), Power4("ppv3", 35113, "PV3 Power", Kind.PV), Voltage("vpv4", 35115, "PV4 Voltage", Kind.PV), Current("ipv4", 35116, "PV4 Current", Kind.PV), Power4("ppv4", 35117, "PV4 Power", Kind.PV), # ppv1 + ppv2 + ppv3 + ppv4 Calculated("ppv", lambda data: max(0, read_bytes4(data, 35105, 0)) + max(0, read_bytes4(data, 35109, 0)) + max(0, read_bytes4(data, 35113, 0)) + max(0, read_bytes4(data, 35117, 0)), "PV Power", "W", Kind.PV), ByteH("pv4_mode", 35119, "PV4 Mode code", "", Kind.PV), EnumH("pv4_mode_label", 35119, PV_MODES, "PV4 Mode", Kind.PV), ByteL("pv3_mode", 35119, "PV3 Mode code", "", Kind.PV), EnumL("pv3_mode_label", 35119, PV_MODES, "PV3 Mode", Kind.PV), ByteH("pv2_mode", 35120, "PV2 Mode code", "", Kind.PV), EnumH("pv2_mode_label", 35120, PV_MODES, "PV2 Mode", Kind.PV), ByteL("pv1_mode", 35120, "PV1 Mode code", "", Kind.PV), EnumL("pv1_mode_label", 35120, PV_MODES, "PV1 Mode", Kind.PV), Voltage("vgrid", 35121, "On-grid L1 Voltage", Kind.AC), Current("igrid", 35122, "On-grid L1 Current", Kind.AC), Frequency("fgrid", 35123, "On-grid L1 Frequency", Kind.AC), # 35124 reserved PowerS("pgrid", 35125, "On-grid L1 Power", Kind.AC), Voltage("vgrid2", 35126, "On-grid L2 Voltage", Kind.AC), Current("igrid2", 35127, "On-grid L2 Current", Kind.AC), Frequency("fgrid2", 35128, "On-grid L2 Frequency", Kind.AC), # 35129 reserved PowerS("pgrid2", 35130, "On-grid L2 Power", Kind.AC), Voltage("vgrid3", 35131, "On-grid L3 Voltage", Kind.AC), Current("igrid3", 35132, "On-grid L3 Current", Kind.AC), Frequency("fgrid3", 35133, "On-grid L3 Frequency", Kind.AC), # 35134 reserved PowerS("pgrid3", 35135, "On-grid L3 Power", Kind.AC), Integer("grid_mode", 35136, "Grid Mode code", "", Kind.PV), Enum2("grid_mode_label", 35136, GRID_MODES, "Grid Mode", Kind.PV), # 35137 reserved PowerS("total_inverter_power", 35138, "Total Power", Kind.AC), # 35139 reserved PowerS("active_power", 35140, "Active Power", Kind.GRID), Calculated("grid_in_out", lambda data: read_grid_mode(data, 35140), "On-grid Mode code", "", Kind.GRID), EnumCalculated("grid_in_out_label", lambda data: read_grid_mode(data, 35140), GRID_IN_OUT_MODES, "On-grid Mode", Kind.GRID), # 35141 reserved Reactive("reactive_power", 35142, "Reactive Power", Kind.GRID), # 35143 reserved Apparent("apparent_power", 35144, "Apparent Power", Kind.GRID), Voltage("backup_v1", 35145, "Back-up L1 Voltage", Kind.UPS), Current("backup_i1", 35146, "Back-up L1 Current", Kind.UPS), Frequency("backup_f1", 35147, "Back-up L1 Frequency", Kind.UPS), Integer("load_mode1", 35148, "Load Mode L1"), # 35149 reserved PowerS("backup_p1", 35150, "Back-up L1 Power", Kind.UPS), Voltage("backup_v2", 35151, "Back-up L2 Voltage", Kind.UPS), Current("backup_i2", 35152, "Back-up L2 Current", Kind.UPS), Frequency("backup_f2", 35153, "Back-up L2 Frequency", Kind.UPS), Integer("load_mode2", 35154, "Load Mode L2"), # 35155 reserved PowerS("backup_p2", 35156, "Back-up L2 Power", Kind.UPS), Voltage("backup_v3", 35157, "Back-up L3 Voltage", Kind.UPS), Current("backup_i3", 35158, "Back-up L3 Current", Kind.UPS), Frequency("backup_f3", 35159, "Back-up L3 Frequency", Kind.UPS), Integer("load_mode3", 35160, "Load Mode L3"), # 35161 reserved PowerS("backup_p3", 35162, "Back-up L3 Power", Kind.UPS), # 35163 reserved PowerS("load_p1", 35164, "Load L1", Kind.AC), # 35165 reserved PowerS("load_p2", 35166, "Load L2", Kind.AC), # 35167 reserved PowerS("load_p3", 35168, "Load L3", Kind.AC), # 35169 reserved PowerS("backup_ptotal", 35170, "Back-up Load", Kind.UPS), # 35171 reserved PowerS("load_ptotal", 35172, "Load", Kind.AC), Integer("ups_load", 35173, "Ups Load", "%", Kind.UPS), Temp("temperature_air", 35174, "Inverter Temperature (Air)", Kind.AC), Temp("temperature_module", 35175, "Inverter Temperature (Module)"), Temp("temperature", 35176, "Inverter Temperature (Radiator)", Kind.AC), Integer("function_bit", 35177, "Function Bit"), Voltage("bus_voltage", 35178, "Bus Voltage", None), Voltage("nbus_voltage", 35179, "NBus Voltage", None), Voltage("vbattery1", 35180, "Battery Voltage", Kind.BAT), CurrentS("ibattery1", 35181, "Battery Current", Kind.BAT), Power4S("pbattery1", 35182, "Battery Power", Kind.BAT), Integer("battery_mode", 35184, "Battery Mode code", "", Kind.BAT), Enum2("battery_mode_label", 35184, BATTERY_MODES, "Battery Mode", Kind.BAT), Integer("warning_code", 35185, "Warning code"), Integer("safety_country", 35186, "Safety Country code", "", Kind.AC), Enum2("safety_country_label", 35186, SAFETY_COUNTRIES, "Safety Country", Kind.AC), Integer("work_mode", 35187, "Work Mode code"), Enum2("work_mode_label", 35187, WORK_MODES_ET, "Work Mode"), Integer("operation_mode", 35188, "Operation Mode code"), Long("error_codes", 35189, "Error Codes"), EnumBitmap4("errors", 35189, ERROR_CODES, "Errors"), Energy4("e_total", 35191, "Total PV Generation", Kind.PV), Energy4("e_day", 35193, "Today's PV Generation", Kind.PV), Energy4("e_total_exp", 35195, "Total Energy (export)", Kind.AC), Long("h_total", 35197, "Hours Total", "h", Kind.PV), Energy("e_day_exp", 35199, "Today Energy (export)", Kind.AC), Energy4("e_total_imp", 35200, "Total Energy (import)", Kind.AC), Energy("e_day_imp", 35202, "Today Energy (import)", Kind.AC), Energy4("e_load_total", 35203, "Total Load", Kind.AC), Energy("e_load_day", 35205, "Today Load", Kind.AC), Energy4("e_bat_charge_total", 35206, "Total Battery Charge", Kind.BAT), Energy("e_bat_charge_day", 35208, "Today Battery Charge", Kind.BAT), Energy4("e_bat_discharge_total", 35209, "Total Battery Discharge", Kind.BAT), Energy("e_bat_discharge_day", 35211, "Today Battery Discharge", Kind.BAT), Long("diagnose_result", 35220, "Diag Status Code"), EnumBitmap4("diagnose_result_label", 35220, DIAG_STATUS_CODES, "Diag Status"), # ppv1 + ppv2 + ppv3 + ppv4 + pbattery1 - active_power Calculated("house_consumption", lambda data: read_bytes4(data, 35105, 0) + read_bytes4(data, 35109, 0) + read_bytes4(data, 35113, 0) + read_bytes4(data, 35117, 0) + read_bytes4_signed(data, 35182) - read_bytes2_signed(data, 35140), "House Consumption", "W", Kind.AC), # Power4S("pbattery2", 35264, "Battery2 Power", Kind.BAT), # Integer("battery2_mode", 35266, "Battery2 Mode code", "", Kind.BAT), # Enum2("battery2_mode_label", 35184, BATTERY_MODES, "Battery2 Mode", Kind.BAT), ) # Modbus registers from offset 0x9088 (37000) __all_sensors_battery: Tuple[Sensor, ...] = ( Integer("battery_bms", 37000, "Battery BMS", "", Kind.BAT), Integer("battery_index", 37001, "Battery Index", "", Kind.BAT), Integer("battery_status", 37002, "Battery Status", "", Kind.BAT), Temp("battery_temperature", 37003, "Battery Temperature", Kind.BAT), Integer("battery_charge_limit", 37004, "Battery Charge Limit", "A", Kind.BAT), Integer("battery_discharge_limit", 37005, "Battery Discharge Limit", "A", Kind.BAT), Integer("battery_error_l", 37006, "Battery Error L", "", Kind.BAT), Integer("battery_soc", 37007, "Battery State of Charge", "%", Kind.BAT), Integer("battery_soh", 37008, "Battery State of Health", "%", Kind.BAT), Integer("battery_modules", 37009, "Battery Modules", "", Kind.BAT), Integer("battery_warning_l", 37010, "Battery Warning L", "", Kind.BAT), Integer("battery_protocol", 37011, "Battery Protocol", "", Kind.BAT), Integer("battery_error_h", 37012, "Battery Error H", "", Kind.BAT), EnumBitmap22("battery_error", 37012, 37006, BMS_ALARM_CODES, "Battery Error", Kind.BAT), Integer("battery_warning_h", 37013, "Battery Warning H", "", Kind.BAT), EnumBitmap22("battery_warning", 37013, 37010, BMS_WARNING_CODES, "Battery Warning", Kind.BAT), Integer("battery_sw_version", 37014, "Battery Software Version", "", Kind.BAT), Integer("battery_hw_version", 37015, "Battery Hardware Version", "", Kind.BAT), Integer("battery_max_cell_temp_id", 37016, "Battery Max Cell Temperature ID", "", Kind.BAT), Integer("battery_min_cell_temp_id", 37017, "Battery Min Cell Temperature ID", "", Kind.BAT), Integer("battery_max_cell_voltage_id", 37018, "Battery Max Cell Voltage ID", "", Kind.BAT), Integer("battery_min_cell_voltage_id", 37019, "Battery Min Cell Voltage ID", "", Kind.BAT), Temp("battery_max_cell_temp", 37020, "Battery Max Cell Temperature", Kind.BAT), Temp("battery_min_cell_temp", 37021, "Battery Min Cell Temperature", Kind.BAT), CellVoltage("battery_max_cell_voltage", 37022, "Battery Max Cell Voltage", Kind.BAT), CellVoltage("battery_min_cell_voltage", 37023, "Battery Min Cell Voltage", Kind.BAT), # Energy4("battery_total_charge", 37056, "Total Battery 1 Charge", Kind.BAT), # Energy4("battery_total_discharge", 37058, "Total Battery 1 Discharge", Kind.BAT), # String8("battery_sn", 37060, "Battery S/N", Kind.BAT), ) # Modbus registers from offset 0x9858 (39000) __all_sensors_battery2: Tuple[Sensor, ...] = ( Integer("battery2_status", 39000, "Battery 2 Status", "", Kind.BAT), Temp("battery2_temperature", 39001, "Battery 2 Temperature", Kind.BAT), Integer("battery2_charge_limit", 39002, "Battery 2 Charge Limit", "A", Kind.BAT), Integer("battery2_discharge_limit", 39003, "Battery 2 Discharge Limit", "A", Kind.BAT), Integer("battery2_error_l", 39004, "Battery 2 rror L", "", Kind.BAT), Integer("battery2_soc", 39005, "Battery 2 State of Charge", "%", Kind.BAT), Integer("battery2_soh", 39006, "Battery 2 State of Health", "%", Kind.BAT), Integer("battery2_modules", 39007, "Battery 2 Modules", "", Kind.BAT), Integer("battery2_warning_l", 39008, "Battery 2 Warning L", "", Kind.BAT), Integer("battery2_protocol", 39009, "Battery 2 Protocol", "", Kind.BAT), Integer("battery2_error_h", 39010, "Battery 2 Error H", "", Kind.BAT), EnumBitmap22("battery2_error", 39010, 39004, BMS_ALARM_CODES, "Battery 2 Error", Kind.BAT), Integer("battery2_warning_h", 39011, "Battery 2 Warning H", "", Kind.BAT), EnumBitmap22("battery2_warning", 39011, 39008, BMS_WARNING_CODES, "Battery 2 Warning", Kind.BAT), Integer("battery2_sw_version", 39012, "Battery 2 Software Version", "", Kind.BAT), Integer("battery2_hw_version", 39013, "Battery 2 Hardware Version", "", Kind.BAT), Integer("battery2_max_cell_temp_id", 39014, "Battery 2 Max Cell Temperature ID", "", Kind.BAT), Integer("battery2_min_cell_temp_id", 39015, "Battery 2 Min Cell Temperature ID", "", Kind.BAT), Integer("battery2_max_cell_voltage_id", 39016, "Battery 2 Max Cell Voltage ID", "", Kind.BAT), Integer("battery2_min_cell_voltage_id", 39017, "Battery 2 Min Cell Voltage ID", "", Kind.BAT), Temp("battery2_max_cell_temp", 39018, "Battery 2 Max Cell Temperature", Kind.BAT), Temp("battery2_min_cell_temp", 39019, "Battery 2 Min Cell Temperature", Kind.BAT), CellVoltage("battery2_max_cell_voltage", 39020, "Battery 2 Max Cell Voltage", Kind.BAT), CellVoltage("battery2_min_cell_voltage", 39021, "Battery 2 Min Cell Voltage", Kind.BAT), # Energy4("battery2_total_charge", 39054, "Total Battery 2 Charge", Kind.BAT), # Energy4("battery2_total_discharge", 39056, "Total Battery 2 Discharge", Kind.BAT), # String8("battery2_sn", 39058, "Battery 2 S/N", Kind.BAT), ) # Inverter's meter data # Modbus registers from offset 0x8ca0 (36000) __all_sensors_meter: Tuple[Sensor, ...] = ( Integer("commode", 36000, "Commode"), Integer("rssi", 36001, "RSSI"), Integer("manufacture_code", 36002, "Manufacture Code"), Integer("meter_test_status", 36003, "Meter Test Status"), # 1: correct,2: reverse,3: incorrect,0: not checked Integer("meter_comm_status", 36004, "Meter Communication Status"), # 1 OK, 0 NotOK PowerS("active_power1", 36005, "Active Power L1", Kind.GRID), PowerS("active_power2", 36006, "Active Power L2", Kind.GRID), PowerS("active_power3", 36007, "Active Power L3", Kind.GRID), PowerS("active_power_total", 36008, "Active Power Total", Kind.GRID), Reactive("reactive_power_total", 36009, "Reactive Power Total", Kind.GRID), Decimal("meter_power_factor1", 36010, 1000, "Meter Power Factor L1", "", Kind.GRID), Decimal("meter_power_factor2", 36011, 1000, "Meter Power Factor L2", "", Kind.GRID), Decimal("meter_power_factor3", 36012, 1000, "Meter Power Factor L3", "", Kind.GRID), Decimal("meter_power_factor", 36013, 1000, "Meter Power Factor", "", Kind.GRID), Frequency("meter_freq", 36014, "Meter Frequency", Kind.GRID), Float("meter_e_total_exp", 36015, 1000, "Meter Total Energy (export)", "kWh", Kind.GRID), Float("meter_e_total_imp", 36017, 1000, "Meter Total Energy (import)", "kWh", Kind.GRID), Power4S("meter_active_power1", 36019, "Meter Active Power L1", Kind.GRID), Power4S("meter_active_power2", 36021, "Meter Active Power L2", Kind.GRID), Power4S("meter_active_power3", 36023, "Meter Active Power L3", Kind.GRID), Power4S("meter_active_power_total", 36025, "Meter Active Power Total", Kind.GRID), Reactive4("meter_reactive_power1", 36027, "Meter Reactive Power L1", Kind.GRID), Reactive4("meter_reactive_power2", 36029, "Meter Reactive Power L2", Kind.GRID), Reactive4("meter_reactive_power3", 36031, "Meter Reactive Power L2", Kind.GRID), Reactive4("meter_reactive_power_total", 36033, "Meter Reactive Power Total", Kind.GRID), Apparent4("meter_apparent_power1", 36035, "Meter Apparent Power L1", Kind.GRID), Apparent4("meter_apparent_power2", 36037, "Meter Apparent Power L2", Kind.GRID), Apparent4("meter_apparent_power3", 36039, "Meter Apparent Power L3", Kind.GRID), Apparent4("meter_apparent_power_total", 36041, "Meter Apparent Power Total", Kind.GRID), Integer("meter_type", 36043, "Meter Type", "", Kind.GRID), # (0: Single phase, 1: 3P3W, 2: 3P4W, 3: HomeKit) Integer("meter_sw_version", 36044, "Meter Software Version", "", Kind.GRID), # Sensors added in some ARM fw update (or platform 745/753), read when flag _has_meter_extended is on Power4S("meter2_active_power", 36045, "Meter 2 Active Power", Kind.GRID), Float("meter2_e_total_exp", 36047, 1000, "Meter 2 Total Energy (export)", "kWh", Kind.GRID), Float("meter2_e_total_imp", 36049, 1000, "Meter 2 Total Energy (import)", "kWh", Kind.GRID), Integer("meter2_comm_status", 36051, "Meter 2 Communication Status"), Voltage("meter_voltage1", 36052, "Meter L1 Voltage", Kind.GRID), Voltage("meter_voltage2", 36053, "Meter L2 Voltage", Kind.GRID), Voltage("meter_voltage3", 36054, "Meter L3 Voltage", Kind.GRID), Current("meter_current1", 36055, "Meter L1 Current", Kind.GRID), Current("meter_current2", 36056, "Meter L2 Current", Kind.GRID), Current("meter_current3", 36057, "Meter L3 Current", Kind.GRID), Energy8("meter_e_total_exp1", 36092, "Meter Total Energy (export) L1", Kind.GRID), Energy8("meter_e_total_exp2", 36096, "Meter Total Energy (export) L2", Kind.GRID), Energy8("meter_e_total_exp3", 36100, "Meter Total Energy (export) L3", Kind.GRID), Energy8("meter_e_total_exp", 36104, "Meter Total Energy (export)", Kind.GRID), Energy8("meter_e_total_imp1", 36108, "Meter Total Energy (import) L1", Kind.GRID), Energy8("meter_e_total_imp2", 36112, "Meter Total Energy (import) L2", Kind.GRID), Energy8("meter_e_total_imp3", 36116, "Meter Total Energy (import) L3", Kind.GRID), Energy8("meter_e_total_imp", 36120, "Meter Total Energy (import)", Kind.GRID), ) # Inverter's MPPT data # Modbus registers from offset 0x89e5 (35301) __all_sensors_mppt: Tuple[Sensor, ...] = ( Power4("ppv_total", 35301, "PV Power Total", Kind.PV), Integer("pv_channel", 35303, "PV Channel", "", Kind.PV), Voltage("vpv5", 35304, "PV5 Voltage", Kind.PV), Current("ipv5", 35305, "PV5 Current", Kind.PV), Voltage("vpv6", 35306, "PV6 Voltage", Kind.PV), Current("ipv6", 35307, "PV6 Current", Kind.PV), Voltage("vpv7", 35308, "PV7 Voltage", Kind.PV), Current("ipv7", 35309, "PV7 Current", Kind.PV), Voltage("vpv8", 35310, "PV8 Voltage", Kind.PV), Current("ipv8", 35311, "PV8 Current", Kind.PV), Voltage("vpv9", 35312, "PV9 Voltage", Kind.PV), Current("ipv9", 35313, "PV9 Current", Kind.PV), Voltage("vpv10", 35314, "PV10 Voltage", Kind.PV), Current("ipv10", 35315, "PV10 Current", Kind.PV), Voltage("vpv11", 35316, "PV11 Voltage", Kind.PV), Current("ipv11", 35317, "PV11 Current", Kind.PV), Voltage("vpv12", 35318, "PV12 Voltage", Kind.PV), Current("ipv12", 35319, "PV12 Current", Kind.PV), Voltage("vpv13", 35320, "PV13 Voltage", Kind.PV), Current("ipv13", 35321, "PV13 Current", Kind.PV), Voltage("vpv14", 35322, "PV14 Voltage", Kind.PV), Current("ipv14", 35323, "PV14 Current", Kind.PV), Voltage("vpv15", 35324, "PV15 Voltage", Kind.PV), Current("ipv15", 35325, "PV15 Current", Kind.PV), Voltage("vpv16", 35326, "PV16 Voltage", Kind.PV), Current("ipv16", 35327, "PV16 Current", Kind.PV), # 35328 Warning Message # 35330 Grid10minAvgVoltR # 35331 Grid10minAvgVoltS # 35332 Grid10minAvgVoltT # 35333 Error Message Extend # 35335 Warning Message Extend Power("pmppt1", 35337, "MPPT1 Power", Kind.PV), Power("pmppt2", 35338, "MPPT2 Power", Kind.PV), Power("pmppt3", 35339, "MPPT3 Power", Kind.PV), Power("pmppt4", 35340, "MPPT4 Power", Kind.PV), Power("pmppt5", 35341, "MPPT5 Power", Kind.PV), Power("pmppt6", 35342, "MPPT6 Power", Kind.PV), Power("pmppt7", 35343, "MPPT7 Power", Kind.PV), Power("pmppt8", 35344, "MPPT8 Power", Kind.PV), Current("imppt1", 35345, "MPPT1 Current", Kind.PV), Current("imppt2", 35346, "MPPT2 Current", Kind.PV), Current("imppt3", 35347, "MPPT3 Current", Kind.PV), Current("imppt4", 35348, "MPPT4 Current", Kind.PV), Current("imppt5", 35349, "MPPT5 Current", Kind.PV), Current("imppt6", 35350, "MPPT6 Current", Kind.PV), Current("imppt7", 35351, "MPPT7 Current", Kind.PV), Current("imppt8", 35352, "MPPT8 Current", Kind.PV), Reactive4("reactive_power1", 35353, "Reactive Power L1", Kind.GRID), Reactive4("reactive_power2", 35355, "Reactive Power L2", Kind.GRID), Reactive4("reactive_power3", 35357, "Reactive Power L3", Kind.GRID), Apparent4("apparent_power1", 35359, "Apparent Power L1", Kind.GRID), Apparent4("apparent_power2", 35361, "Apparent Power L2", Kind.GRID), Apparent4("apparent_power3", 35363, "Apparent Power L3", Kind.GRID), ) # Modbus registers of inverter settings, offsets are modbus register addresses __all_settings: Tuple[Sensor, ...] = ( Integer("comm_address", 45127, "Communication Address", ""), Long("modbus_baud_rate", 45132, "Modbus Baud rate", ""), Timestamp("time", 45200, "Inverter time"), Integer("sensitivity_check", 45246, "Sensitivity Check Mode", "", Kind.AC), Integer("cold_start", 45248, "Cold Start", "", Kind.AC), Integer("shadow_scan", 45251, "Shadow Scan", "", Kind.PV), Integer("backup_supply", 45252, "Backup Supply", "", Kind.UPS), Integer("unbalanced_output", 45264, "Unbalanced Output", "", Kind.AC), Integer("pen_relay", 45288, "PE-N Relay", "", Kind.AC), Integer("battery_capacity", 45350, "Battery Capacity", "Ah", Kind.BAT), Integer("battery_modules", 45351, "Battery Modules", "", Kind.BAT), Voltage("battery_charge_voltage", 45352, "Battery Charge Voltage", Kind.BAT), Current("battery_charge_current", 45353, "Battery Charge Current", Kind.BAT), Voltage("battery_discharge_voltage", 45354, "Battery Discharge Voltage", Kind.BAT), Current("battery_discharge_current", 45355, "Battery Discharge Current", Kind.BAT), Integer("battery_discharge_depth", 45356, "Battery Discharge Depth", "%", Kind.BAT), Voltage("battery_discharge_voltage_offline", 45357, "Battery Discharge Voltage (off-line)", Kind.BAT), Integer("battery_discharge_depth_offline", 45358, "Battery Discharge Depth (off-line)", "%", Kind.BAT), Decimal("power_factor", 45482, 100, "Power Factor"), Integer("work_mode", 47000, "Work Mode", "", Kind.AC), Integer("dred", 47010, "DRED/Remote Shutdown", "", Kind.AC), Integer("meter_target_power_offset", 47120, "Meter Target Power Offset", "W", Kind.AC), Integer("battery_soc_protection", 47500, "Battery SoC Protection", "", Kind.BAT), Integer("grid_export", 47509, "Grid Export Enabled", "", Kind.GRID), Integer("grid_export_limit", 47510, "Grid Export Limit", "W", Kind.GRID), Integer("battery_protocol_code", 47514, "Battery Protocol Code", "", Kind.BAT), EcoModeV1("eco_mode_1", 47515, "Eco Mode Group 1"), ByteH("eco_mode_1_switch", 47518, "Eco Mode Group 1 Switch"), EcoModeV1("eco_mode_2", 47519, "Eco Mode Group 2"), ByteH("eco_mode_2_switch", 47522, "Eco Mode Group 2 Switch"), EcoModeV1("eco_mode_3", 47523, "Eco Mode Group 3"), ByteH("eco_mode_3_switch", 47526, "Eco Mode Group 3 Switch"), EcoModeV1("eco_mode_4", 47527, "Eco Mode Group 4"), ByteH("eco_mode_4_switch", 47530, "Eco Mode Group 4 Switch"), # Direct BMS communication for EMS Control Integer("bms_version", 47900, "BMS Version"), Integer("bms_bat_modules", 47901, "BMS Battery Modules"), # Real time read from BMS Voltage("bms_bat_charge_v_max", 47902, "BMS Battery Charge Voltage (max)", Kind.BMS), Current("bms_bat_charge_i_max", 47903, "BMS Battery Charge Current (max)", Kind.BMS), Voltage("bms_bat_discharge_v_min", 47904, "BMS min. Battery Discharge Voltage (min)", Kind.BMS), Current("bms_bat_discharge_i_max", 47905, "BMS max. Battery Discharge Current (max)", Kind.BMS), Voltage("bms_bat_voltage", 47906, "BMS Battery Voltage", Kind.BMS), Current("bms_bat_current", 47907, "BMS Battery Current", Kind.BMS), # Integer("bms_bat_soc", 47908, "BMS Battery State of Charge", "%", Kind.BMS), Integer("bms_bat_soh", 47909, "BMS Battery State of Health", "%", Kind.BMS), Temp("bms_bat_temperature", 47910, "BMS Battery Temperature", Kind.BMS), Long("bms_bat_warning-code", 47911, "BMS Battery Warning Code"), # Reserved Long("bms_bat_alarm-code", 47913, "BMS Battery Alarm Code"), Integer("bms_status", 47915, "BMS Status"), Integer("bms_comm_loss_disable", 47916, "BMS Communication Loss Disable"), # RW settings of BMS voltage rate Integer("bms_battery_string_rate_v", 47917, "BMS Battery String Rate Voltage"), # Direct BMS communication for EMS Control Integer("bms2_version", 47918, "BMS2 Version"), Integer("bms2_bat_modules", 47919, "BMS2 Battery Modules"), # Real time read from BMS Voltage("bms2_bat_charge_v_max", 47920, "BMS2 Battery Charge Voltage (max)", Kind.BMS), Current("bms2_bat_charge_i_max", 47921, "BMS2 Battery Charge Current (max)", Kind.BMS), Voltage("bms2_bat_discharge_v_min", 47922, "BMS2 min. Battery Discharge Voltage (min)", Kind.BMS), Current("bms2_bat_discharge_i_max", 47923, "BMS2 max. Battery Discharge Current (max)", Kind.BMS), Voltage("bms2_bat_voltage", 47924, "BMS2 Battery Voltage", Kind.BMS), Current("bms2_bat_current", 47925, "BMS2 Battery Current", Kind.BMS), # Integer("bms2_bat_soc", 47926, "BMS2 Battery State of Charge", "%", Kind.BMS), Integer("bms2_bat_soh", 47927, "BMS2 Battery State of Health", "%", Kind.BMS), Temp("bms2_bat_temperature", 47928, "BMS2 Battery Temperature", Kind.BMS), Long("bms2_bat_warning-code", 47929, "BMS2 Battery Warning Code"), # Reserved Long("bms2_bat_alarm-code", 47931, "BMS2 Battery Alarm Code"), Integer("bms2_status", 47933, "BMS2 Status"), Integer("bms2_comm_loss_disable", 47934, "BMS2 Communication Loss Disable"), # RW settings of BMS voltage rate Integer("bms2_battery_string_rate_v", 47935, "BMS2 Battery String Rate Voltage"), ) # Settings added in ARM firmware 19 __settings_arm_fw_19: Tuple[Sensor, ...] = ( Integer("fast_charging", 47545, "Fast Charging Enabled", "", Kind.BAT), Integer("fast_charging_soc", 47546, "Fast Charging SoC", "%", Kind.BAT), EcoModeV2("eco_mode_1", 47547, "Eco Mode Group 1"), ByteH("eco_mode_1_switch", 47549, "Eco Mode Group 1 Switch"), EcoModeV2("eco_mode_2", 47553, "Eco Mode Group 2"), ByteH("eco_mode_2_switch", 47555, "Eco Mode Group 2 Switch"), EcoModeV2("eco_mode_3", 47559, "Eco Mode Group 3"), ByteH("eco_mode_3_switch", 47561, "Eco Mode Group 3 Switch"), EcoModeV2("eco_mode_4", 47565, "Eco Mode Group 4"), ByteH("eco_mode_4_switch", 47567, "Eco Mode Group 4 Switch"), Integer("load_control_mode", 47595, "Load Control Mode", "", Kind.AC), Integer("load_control_switch", 47596, "Load Control Switch", "", Kind.AC), Integer("load_control_soc", 47597, "Load Control SoC", "", Kind.AC), Integer("hardware_feed_power", 47599, "Hardware Feed Power"), Integer("fast_charging_power", 47603, "Fast Charging Power", "%", Kind.BAT), ) # Settings added in ARM firmware 22 __settings_arm_fw_22: Tuple[Sensor, ...] = ( Long("peak_shaving_power_limit", 47542, "Peak Shaving Power Limit"), Integer("peak_shaving_soc", 47544, "Peak Shaving SoC"), # EcoModeV2("eco_modeV2_5", 47571, "Eco Mode Version 2 Power Group 5"), # EcoModeV2("eco_modeV2_6", 47577, "Eco Mode Version 2 Power Group 6"), # EcoModeV2("eco_modeV2_7", 47583, "Eco Mode Version 2 Power Group 7"), PeakShavingMode("peak_shaving_mode", 47589, "Peak Shaving Mode"), Integer("dod_holding", 47602, "DoD Holding", "", Kind.BAT), Integer("backup_mode_enable", 47605, "Backup Mode Switch"), Integer("max_charge_power", 47606, "Max Charge Power"), Integer("smart_charging_enable", 47609, "Smart Charging Mode Switch"), Integer("eco_mode_enable", 47612, "Eco Mode Switch"), ) def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3): super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries) self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x88b8, 0x0021) self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x891c, 0x007d) self._READ_METER_DATA: ProtocolCommand = self._read_command(0x8ca0, 0x2d) self._READ_METER_DATA_EXTENDED: ProtocolCommand = self._read_command(0x8ca0, 0x3a) self._READ_METER_DATA_EXTENDED2: ProtocolCommand = self._read_command(0x8ca0, 0x7d) self._READ_BATTERY_INFO: ProtocolCommand = self._read_command(0x9088, 0x0018) self._READ_BATTERY2_INFO: ProtocolCommand = self._read_command(0x9858, 0x0016) self._READ_MPPT_DATA: ProtocolCommand = self._read_command(0x89e5, 0x3d) self._has_eco_mode_v2: bool = True self._has_peak_shaving: bool = True self._has_battery: bool = True self._has_battery2: bool = False self._has_meter_extended: bool = False self._has_meter_extended2: bool = False self._has_mppt: bool = False self._sensors = self.__all_sensors self._sensors_battery = self.__all_sensors_battery self._sensors_battery2 = self.__all_sensors_battery2 self._sensors_meter = self.__all_sensors_meter self._sensors_mppt = self.__all_sensors_mppt self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings} @staticmethod def _single_phase_only(s: Sensor) -> bool: """Filter to exclude phase2/3 sensors on single phase inverters""" return not ((s.id_.endswith('2') or s.id_.endswith('3')) and 'pv' not in s.id_) @staticmethod def _not_extended_meter(s: Sensor) -> bool: """Filter to exclude extended meter sensors""" return s.offset < 36045 @staticmethod def _not_extended_meter2(s: Sensor) -> bool: """Filter to exclude extended meter sensors""" return s.offset < 36058 async def read_device_info(self): response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO) response = response.response_data() # Modbus registers from 35000 - 35032 self.modbus_version = read_unsigned_int(response, 0) self.rated_power = read_unsigned_int(response, 2) self.ac_output_type = read_unsigned_int(response, 4) # 0: 1-phase, 1: 3-phase (4 wire), 2: 3-phase (3 wire) self.serial_number = self._decode(response[6:22]) # 35003 - 350010 self.model_name = self._decode(response[22:32]) # 35011 - 35015 self.dsp1_version = read_unsigned_int(response, 32) # 35016 self.dsp2_version = read_unsigned_int(response, 34) # 35017 self.dsp_svn_version = read_unsigned_int(response, 36) # 35018 self.arm_version = read_unsigned_int(response, 38) # 35019 self.arm_svn_version = read_unsigned_int(response, 40) # 35020 self.firmware = self._decode(response[42:54]) # 35021 - 35027 self.arm_firmware = self._decode(response[54:66]) # 35027 - 35032 if not is_4_mppt(self) and self.rated_power < 15000: # This inverter does not have 4 MPPTs or PV strings self._sensors = tuple(filter(lambda s: not ('pv4' in s.id_), self._sensors)) self._sensors = tuple(filter(lambda s: not ('pv3' in s.id_), self._sensors)) if is_single_phase(self): # this is single phase inverter, filter out all L2 and L3 sensors self._sensors = tuple(filter(self._single_phase_only, self._sensors)) self._sensors_meter = tuple(filter(self._single_phase_only, self._sensors_meter)) if is_2_battery(self) or self.rated_power >= 25000: self._has_battery2 = True if is_745_platform(self) or self.rated_power >= 15000: self._has_mppt = True self._has_meter_extended = True self._has_meter_extended2 = True else: self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter)) # Check and add EcoModeV2 settings added in (ETU fw 19) try: await self._read_from_socket(self._read_command(47547, 6)) self._settings.update({s.id_: s for s in self.__settings_arm_fw_19}) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.debug("EcoModeV2 settings not supported, switching to EcoModeV1.") self._has_eco_mode_v2 = False except RequestFailedException: logger.debug("Cannot read EcoModeV2 settings, switching to EcoModeV1.") self._has_eco_mode_v2 = False # Check and add Peak Shaving settings added in (ETU fw 22) try: await self._read_from_socket(self._read_command(47589, 6)) self._settings.update({s.id_: s for s in self.__settings_arm_fw_22}) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.debug("PeakShaving setting not supported, disabling it.") self._has_peak_shaving = False except RequestFailedException: logger.debug("Cannot read _has_peak_shaving settings, disabling it.") self._has_peak_shaving = False async def read_runtime_data(self) -> Dict[str, Any]: response = await self._read_from_socket(self._READ_RUNNING_DATA) data = self._map_response(response, self._sensors) self._has_battery = data.get('battery_mode', 0) != 0 if self._has_battery: try: response = await self._read_from_socket(self._READ_BATTERY_INFO) data.update(self._map_response(response, self._sensors_battery)) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.info("Battery values not supported, disabling further attempts.") self._has_battery = False else: raise ex if self._has_battery2: try: response = await self._read_from_socket(self._READ_BATTERY2_INFO) data.update( self._map_response(response, self._sensors_battery2)) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.info("Battery 2 values not supported, disabling further attempts.") self._has_battery2 = False else: raise ex if self._has_meter_extended2: try: response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED2) data.update(self._map_response(response, self._sensors_meter)) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.info("Extended meter values not supported, disabling further attempts.") self._has_meter_extended2 = False self._sensors_meter = tuple(filter(self._not_extended_meter2, self._sensors_meter)) response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED) data.update( self._map_response(response, self._sensors_meter)) else: raise ex elif self._has_meter_extended: try: response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED) data.update(self._map_response(response, self._sensors_meter)) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.info("Extended meter values not supported, disabling further attempts.") self._has_meter_extended = False self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter)) response = await self._read_from_socket(self._READ_METER_DATA) data.update( self._map_response(response, self._sensors_meter)) else: raise ex else: response = await self._read_from_socket(self._READ_METER_DATA) data.update(self._map_response(response, self._sensors_meter)) if self._has_mppt: try: response = await self._read_from_socket(self._READ_MPPT_DATA) data.update(self._map_response(response, self._sensors_mppt)) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.info("MPPT values not supported, disabling further attempts.") self._has_mppt = False else: raise ex return data async def read_setting(self, setting_id: str) -> Any: setting = self._settings.get(setting_id) if setting: return await self._read_setting(setting) else: if setting_id.startswith("modbus"): response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1)) return int.from_bytes(response.read(2), byteorder="big", signed=True) else: raise ValueError(f'Unknown setting "{setting_id}"') async def _read_setting(self, setting: Sensor) -> Any: try: count = (setting.size_ + (setting.size_ % 2)) // 2 response = await self._read_from_socket(self._read_command(setting.offset, count)) return setting.read_value(response) except RequestRejectedException as ex: if ex.message == ILLEGAL_DATA_ADDRESS: logger.debug("Unsupported setting %s", setting.id_) self._settings.pop(setting.id_, None) raise ValueError(f'Unknown setting "{setting.id_}"') return None async def write_setting(self, setting_id: str, value: Any): setting = self._settings.get(setting_id) if setting: await self._write_setting(setting, value) else: if setting_id.startswith("modbus"): await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value))) else: raise ValueError(f'Unknown setting "{setting_id}"') async def _write_setting(self, setting: Sensor, value: Any): if setting.size_ == 1: # modbus can address/store only 16 bit values, read the other 8 bytes response = await self._read_from_socket(self._read_command(setting.offset, 1)) raw_value = setting.encode_value(value, response.response_data()[0:2]) else: raw_value = setting.encode_value(value) if len(raw_value) <= 2: value = int.from_bytes(raw_value, byteorder="big", signed=True) await self._read_from_socket(self._write_command(setting.offset, value)) else: await self._read_from_socket(self._write_multi_command(setting.offset, raw_value)) async def read_settings_data(self) -> Dict[str, Any]: data = {} for setting in self.settings(): try: value = await self.read_setting(setting.id_) data[setting.id_] = value except (ValueError, RequestFailedException): logger.exception("Error reading setting %s.", setting.id_) data[setting.id_] = None return data async def get_grid_export_limit(self) -> int: return await self.read_setting('grid_export_limit') async def set_grid_export_limit(self, export_limit: int) -> None: if export_limit >= 0: await self.write_setting('grid_export_limit', export_limit) async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]: result = [e for e in OperationMode] if not self._has_peak_shaving: result.remove(OperationMode.PEAK_SHAVING) if not is_745_platform(self): result.remove(OperationMode.SELF_USE) if not include_emulated: result.remove(OperationMode.ECO_CHARGE) result.remove(OperationMode.ECO_DISCHARGE) return tuple(result) async def get_operation_mode(self) -> OperationMode | None: mode_id = await self.read_setting('work_mode') try: mode = OperationMode(mode_id) except ValueError: logger.debug("Unknown work_mode value %s", mode_id) return None if OperationMode.ECO != mode: return mode eco_mode = await self.read_setting('eco_mode_1') if eco_mode.is_eco_charge_mode(): return OperationMode.ECO_CHARGE elif eco_mode.is_eco_discharge_mode(): return OperationMode.ECO_DISCHARGE else: return OperationMode.ECO async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100, eco_mode_soc: int = 100) -> None: if operation_mode == OperationMode.GENERAL: await self.write_setting('work_mode', 0) await self._set_offline(False) await self._clear_battery_mode_param() elif operation_mode == OperationMode.OFF_GRID: await self.write_setting('work_mode', 1) await self._set_offline(True) await self.write_setting('backup_supply', 1) await self.write_setting('cold_start', 4) await self._clear_battery_mode_param() elif operation_mode == OperationMode.BACKUP: await self.write_setting('work_mode', 2) await self._set_offline(False) await self._clear_battery_mode_param() elif operation_mode == OperationMode.ECO: await self.write_setting('work_mode', 3) await self._set_offline(False) elif operation_mode == OperationMode.PEAK_SHAVING: await self.write_setting('work_mode', 4) await self._set_offline(False) await self._clear_battery_mode_param() elif operation_mode == OperationMode.SELF_USE: await self.write_setting('work_mode', 5) await self._set_offline(False) await self._clear_battery_mode_param() elif operation_mode in (OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE): if eco_mode_power < 0 or eco_mode_power > 100: raise ValueError() if eco_mode_soc < 0 or eco_mode_soc > 100: raise ValueError() eco_mode: EcoMode | Sensor = self._settings.get('eco_mode_1') # Load the current values to try to detect schedule type try: await self._read_setting(eco_mode) except ValueError: pass eco_mode.set_schedule_type(ScheduleType.ECO_MODE, is_745_platform(self)) if operation_mode == OperationMode.ECO_CHARGE: await self.write_setting('eco_mode_1', eco_mode.encode_charge(eco_mode_power, eco_mode_soc)) else: await self.write_setting('eco_mode_1', eco_mode.encode_discharge(eco_mode_power)) await self.write_setting('eco_mode_2_switch', 0) await self.write_setting('eco_mode_3_switch', 0) await self.write_setting('eco_mode_4_switch', 0) await self.write_setting('work_mode', 3) await self._set_offline(False) async def get_ongrid_battery_dod(self) -> int: return 100 - await self.read_setting('battery_discharge_depth') async def set_ongrid_battery_dod(self, dod: int) -> None: if 0 <= dod <= 100: await self.write_setting('battery_discharge_depth', 100 - dod) def sensors(self) -> Tuple[Sensor, ...]: result = self._sensors + self._sensors_meter if self._has_battery: result = result + self._sensors_battery if self._has_battery2: result = result + self._sensors_battery2 if self._has_mppt: result = result + self._sensors_mppt return result def settings(self) -> Tuple[Sensor, ...]: return tuple(self._settings.values()) async def _clear_battery_mode_param(self) -> None: await self._read_from_socket(self._write_command(0xb9ad, 1)) async def _set_offline(self, mode: bool) -> None: value = bytes.fromhex('00070000') if mode else bytes.fromhex('00010000') await self._read_from_socket(self._write_multi_command(0xb997, value)) marcelblijleven-goodwe-a4b8c7f/goodwe/exceptions.py000066400000000000000000000025731463365355300226750ustar00rootroot00000000000000class InverterError(Exception): """Indicates error communicating with inverter""" class RequestFailedException(InverterError): """ Indicates request sent to inverter has failed and did not yield in valid response, even after several retries. Attributes: message -- explanation of the error consecutive_failures_count -- number requests failed in a consecutive streak """ def __init__(self, message: str = '', consecutive_failures_count: int = 0): self.message: str = message self.consecutive_failures_count: int = consecutive_failures_count class RequestRejectedException(InverterError): """ Indicates request sent to inverter was rejected and protocol exception response was received. Attributes: message -- rejection reason """ def __init__(self, message: str = ''): self.message: str = message class PartialResponseException(InverterError): """ Indicates the received response data are incomplete and is probably fragmented to multiple packets. Attributes: length -- received data length expected -- expected data lenght """ def __init__(self, lenght: int, expected: int): self.length: int = lenght self.expected: int = expected class MaxRetriesException(InverterError): """Indicates the maximum number of retries has been reached""" marcelblijleven-goodwe-a4b8c7f/goodwe/inverter.py000066400000000000000000000243331463365355300223500ustar00rootroot00000000000000from __future__ import annotations import logging from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, IntEnum from typing import Any, Callable, Dict, Tuple, Optional from .exceptions import MaxRetriesException, RequestFailedException from .protocol import InverterProtocol, ProtocolCommand, ProtocolResponse, TcpInverterProtocol, UdpInverterProtocol logger = logging.getLogger(__name__) class SensorKind(Enum): """ Enumeration of sensor kinds. Possible values are: PV - inverter photo-voltaic (e.g. dc voltage of pv panels) AC - inverter grid output (e.g. ac voltage of grid connected output) UPS - inverter ups/eps/backup output (e.g. ac voltage of backup/off-grid connected output) BAT - battery (e.g. dc voltage of connected battery pack) GRID - power grid/smart meter (e.g. active power exported to grid) BMS - BMS direct data (e.g. dc voltage of) """ PV = 1 AC = 2 UPS = 3 BAT = 4 GRID = 5 BMS = 6 @dataclass class Sensor: """Definition of inverter sensor and its attributes""" id_: str offset: int name: str size_: int unit: str kind: Optional[SensorKind] def read_value(self, data: ProtocolResponse) -> Any: """Read the sensor value from data at current position""" raise NotImplementedError() def read(self, data: ProtocolResponse) -> Any: """Read the sensor value from data (at sensor offset)""" data.seek(self.offset) return self.read_value(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: """Encode the (setting mostly) value to (usually) 2 bytes raw register value""" raise NotImplementedError() class OperationMode(IntEnum): """ Enumeration of sensor kinds. Possible values are: GENERAL - General mode OFF_GRID - Off grid mode BACKUP - Backup mode ECO - Eco mode PEAK_SHAVING - Peak shaving mode ECO_CHARGE - Eco mode with a single "Charge" group valid all the time (from 00:00-23:59, Mon-Sun) ECO_DISCHARGE - Eco mode with a single "Discharge" group valid all the time (from 00:00-23:59, Mon-Sun) """ GENERAL = 0 OFF_GRID = 1 BACKUP = 2 ECO = 3 PEAK_SHAVING = 4 SELF_USE = 5 ECO_CHARGE = 98 ECO_DISCHARGE = 99 class Inverter(ABC): """ Common superclass for various inverter models implementations. Represents the inverter state and its basic behavior """ def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3): self._protocol: InverterProtocol = self._create_protocol(host, port, comm_addr, timeout, retries) self._consecutive_failures_count: int = 0 self.model_name: str | None = None self.serial_number: str | None = None self.rated_power: int = 0 self.ac_output_type: int | None = None self.firmware: str | None = None self.arm_firmware: str | None = None self.modbus_version: int | None = None self.dsp1_version: int = 0 self.dsp2_version: int = 0 self.dsp_svn_version: int | None = None self.arm_version: int = 0 self.arm_svn_version: int | None = None def _read_command(self, offset: int, count: int) -> ProtocolCommand: """Create read protocol command.""" return self._protocol.read_command(offset, count) def _write_command(self, register: int, value: int) -> ProtocolCommand: """Create write protocol command.""" return self._protocol.write_command(register, value) def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand: """Create write multiple protocol command.""" return self._protocol.write_multi_command(offset, values) async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse: try: result = await command.execute(self._protocol) self._consecutive_failures_count = 0 return result except MaxRetriesException: self._consecutive_failures_count += 1 raise RequestFailedException(f'No valid response received even after {self._protocol.retries} retries', self._consecutive_failures_count) from None except RequestFailedException as ex: self._consecutive_failures_count += 1 raise RequestFailedException(ex.message, self._consecutive_failures_count) from None def set_keep_alive(self, keep_alive: bool) -> None: self._protocol.keep_alive = keep_alive @abstractmethod async def read_device_info(self): """ Request the device information from the inverter. The inverter instance variables will be loaded with relevant data. """ raise NotImplementedError() @abstractmethod async def read_runtime_data(self) -> Dict[str, Any]: """ Request the runtime data from the inverter. Answer dictionary of individual sensors and their values. List of supported sensors (and their definitions) is provided by sensors() method. """ raise NotImplementedError() @abstractmethod async def read_setting(self, setting_id: str) -> Any: """ Read the value of specific inverter setting/configuration parameter. Setting must be in list provided by settings() method, otherwise ValueError is raised. """ raise NotImplementedError() @abstractmethod async def write_setting(self, setting_id: str, value: Any): """ Set the value of specific inverter settings/configuration parameter. Setting must be in list provided by settings() method, otherwise ValueError is raised. BEWARE !!! This method modifies inverter operational parameter (usually accessible to installers only). Use with caution and at your own risk ! """ raise NotImplementedError() @abstractmethod async def read_settings_data(self) -> Dict[str, Any]: """ Request the settings data from the inverter. Answer dictionary of individual settings and their values. List of supported settings (and their definitions) is provided by settings() method. """ raise NotImplementedError() async def send_command( self, command: bytes, validator: Callable[[bytes], bool] = lambda x: True ) -> ProtocolResponse: """ Send low level command (as bytes). Answer ProtocolResponse with command's raw response data. """ return await self._read_from_socket(ProtocolCommand(command, validator)) @abstractmethod async def get_grid_export_limit(self) -> int: """ Get the current grid export limit in W """ raise NotImplementedError() @abstractmethod async def set_grid_export_limit(self, export_limit: int) -> None: """ BEWARE !!! This method modifies inverter operational parameter accessible to installers only. Use with caution and at your own risk ! Set the grid export limit in W """ raise NotImplementedError() @abstractmethod async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]: """ Answer list of supported inverter operation modes """ return () @abstractmethod async def get_operation_mode(self) -> OperationMode: """ Get the inverter operation mode """ raise NotImplementedError() @abstractmethod async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100, eco_mode_soc: int = 100) -> None: """ BEWARE !!! This method modifies inverter operational parameter accessible to installers only. Use with caution and at your own risk ! Set the inverter operation mode The modes ECO_CHARGE and ECO_DISCHARGE are not real inverter operation modes, but a convenience shortcuts to enter Eco Mode with a single group valid all the time (from 00:00-23:59, Mon-Sun) charging or discharging with optional charging power and SoC (%) parameters. """ raise NotImplementedError() @abstractmethod async def get_ongrid_battery_dod(self) -> int: """ Get the On-Grid Battery DoD 0% - 89% """ raise NotImplementedError() @abstractmethod async def set_ongrid_battery_dod(self, dod: int) -> None: """ BEWARE !!! This method modifies On-Grid Battery DoD parameter accessible to installers only. Use with caution and at your own risk ! Set the On-Grid Battery DoD 0% - 89% """ raise NotImplementedError() @abstractmethod def sensors(self) -> Tuple[Sensor, ...]: """ Return tuple of sensor definitions """ raise NotImplementedError() @abstractmethod def settings(self) -> Tuple[Sensor, ...]: """ Return tuple of settings definitions """ raise NotImplementedError() @staticmethod def _create_protocol(host: str, port: int, comm_addr: int, timeout: int, retries: int) -> InverterProtocol: if port == 502: return TcpInverterProtocol(host, port, comm_addr, timeout, retries) else: return UdpInverterProtocol(host, port, comm_addr, timeout, retries) @staticmethod def _map_response(response: ProtocolResponse, sensors: Tuple[Sensor, ...]) -> Dict[str, Any]: """Process the response data and return dictionary with runtime values""" result = {} for sensor in sensors: try: result[sensor.id_] = sensor.read(response) except ValueError: logger.exception("Error reading sensor %s.", sensor.id_) result[sensor.id_] = None return result @staticmethod def _decode(data: bytes) -> str: """Decode the bytes to ascii string""" try: if any(x < 32 for x in data): return data.decode("utf-16be").rstrip().replace('\x00', '') return data.decode("ascii").rstrip() except ValueError: return data.hex() marcelblijleven-goodwe-a4b8c7f/goodwe/modbus.py000066400000000000000000000203221463365355300217750ustar00rootroot00000000000000import logging from typing import Union from .exceptions import PartialResponseException, RequestRejectedException logger = logging.getLogger(__name__) MODBUS_READ_CMD: int = 0x3 MODBUS_WRITE_CMD: int = 0x6 MODBUS_WRITE_MULTI_CMD: int = 0x10 ILLEGAL_DATA_ADDRESS: str = 'ILLEGAL DATA ADDRESS' FAILURE_CODES = { 1: "ILLEGAL FUNCTION", 2: ILLEGAL_DATA_ADDRESS, 3: "ILLEGAL DATA VALUE", 4: "SLAVE DEVICE FAILURE", 5: "ACKNOWLEDGE", 6: "SLAVE DEVICE BUSY", 7: "NEGATIVE ACKNOWLEDGEMENT", 8: "MEMORY PARITY ERROR", 10: "GATEWAY PATH UNAVAILABLE", 11: "GATEWAY TARGET DEVICE FAILED TO RESPOND", } def _create_crc16_table() -> tuple: """Construct (modbus) CRC-16 table""" table = [] for i in range(256): buffer = i << 1 crc = 0 for _ in range(8, 0, -1): buffer >>= 1 if (buffer ^ crc) & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 table.append(crc) return tuple(table) _CRC_16_TABLE = _create_crc16_table() def _modbus_checksum(data: Union[bytearray, bytes]) -> int: """ Calculate modbus crc-16 checksum """ crc = 0xFFFF for ch in data: crc = (crc >> 8) ^ _CRC_16_TABLE[(crc ^ ch) & 0xFF] return crc def create_modbus_rtu_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes: """ Create modbus RTU request. data[0] is inverter address data[1] is modbus command data[2:3] is command offset parameter data[4:5] is command value parameter data[6:7] is crc-16 checksum """ data: bytearray = bytearray(6) data[0] = comm_addr data[1] = cmd data[2] = (offset >> 8) & 0xFF data[3] = offset & 0xFF data[4] = (value >> 8) & 0xFF data[5] = value & 0xFF checksum = _modbus_checksum(data) data.append(checksum & 0xFF) data.append((checksum >> 8) & 0xFF) return bytes(data) def create_modbus_tcp_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes: """ Create modbus TCP request. data[0:1] is transaction identifier data[2:3] is protocol identifier (0) data[4:5] message length data[6] is inverter address data[7] is modbus command data[8:9] is command offset parameter data[10:11] is command value parameter """ data: bytearray = bytearray(12) data[0] = 0 data[1] = 1 # Not transaction ID support yet data[2] = 0 data[3] = 0 data[4] = 0 data[5] = 6 data[6] = comm_addr data[7] = cmd data[8] = (offset >> 8) & 0xFF data[9] = offset & 0xFF data[10] = (value >> 8) & 0xFF data[11] = value & 0xFF return bytes(data) def create_modbus_rtu_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes: """ Create modbus RTU (multi value) request. data[0] is inverter address data[1] is modbus command data[2:3] is command offset parameter data[4:5] is number of registers data[6] is number of bytes data[7-n] is data payload data[n+1:n+2] is crc-16 checksum """ data: bytearray = bytearray(7) data[0] = comm_addr data[1] = cmd data[2] = (offset >> 8) & 0xFF data[3] = offset & 0xFF data[4] = 0 data[5] = len(values) // 2 data[6] = len(values) data.extend(values) checksum = _modbus_checksum(data) data.append(checksum & 0xFF) data.append((checksum >> 8) & 0xFF) return bytes(data) def create_modbus_tcp_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes: """ Create modbus TCP (multi value) request. data[0:1] is transaction identifier data[2:3] is protocol identifier (0) data[4:5] message length data[6] is inverter address data[7] is modbus command data[8:9] is command offset parameter data[10:11] is number of registers data[12] is number of bytes data[13-n] is data payload """ data: bytearray = bytearray(13) data[0] = 0 data[1] = 1 # Not transaction ID support yet data[2] = 0 data[3] = 0 data[4] = 0 data[5] = 7 + len(values) data[6] = comm_addr data[7] = cmd data[8] = (offset >> 8) & 0xFF data[9] = offset & 0xFF data[10] = 0 data[11] = len(values) // 2 data[12] = len(values) data.extend(values) return bytes(data) def validate_modbus_rtu_response(data: bytes, cmd: int, offset: int, value: int) -> bool: """ Validate the modbus RTU response. data[0:1] is header data[2] is source address data[3] is command return type data[4] is response payload length (for read commands) data[-2:] is crc-16 checksum """ if len(data) <= 4: logger.debug("Response is too short.") return False if data[3] == MODBUS_READ_CMD: if data[4] != value * 2: logger.debug("Response has unexpected length: %d, expected %d.", data[4], value * 2) return False expected_length = data[4] + 7 if len(data) < expected_length: raise PartialResponseException(len(data), expected_length) elif data[3] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD): if len(data) < 10: logger.debug("Response has unexpected length: %d, expected %d.", len(data), 10) return False expected_length = 10 response_offset = int.from_bytes(data[4:6], byteorder='big', signed=False) if response_offset != offset: logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset) return False response_value = int.from_bytes(data[6:8], byteorder='big', signed=True) if response_value != value: logger.debug("Response has wrong value: %X, expected %X.", response_value, value) return False else: expected_length = len(data) checksum_offset = expected_length - 2 if _modbus_checksum(data[2:checksum_offset]) != ((data[checksum_offset + 1] << 8) + data[checksum_offset]): logger.debug("Response CRC-16 checksum does not match.") return False if data[3] != cmd: failure_code = FAILURE_CODES.get(data[4], "UNKNOWN") logger.debug("Response is command failure: %s.", FAILURE_CODES.get(data[4], "UNKNOWN")) raise RequestRejectedException(failure_code) return True def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int) -> bool: """ Validate the modbus TCP response. data[0:1] is transaction identifier data[2:3] is protocol identifier (0) data[4:5] message length data[6] is source address data[7] is command return type data[8] is response payload length (for read commands) """ if len(data) <= 8: logger.debug("Response is too short.") return False # The Modbus/TCP message length check is completely ignore due to Goodwe bugs # expected_length = int.from_bytes(data[4:6], byteorder='big', signed=False) + 6 # if len(data) < expected_length: # raise PartialResponseException(len(data), expected_length) if data[7] == MODBUS_READ_CMD: expected_length = data[8] + 9 if len(data) < expected_length: raise PartialResponseException(len(data), expected_length) if data[8] != value * 2: logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2) return False elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD): if len(data) < 12: logger.debug("Response has unexpected length: %d, expected %d.", len(data), 12) return False response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False) if response_offset != offset: logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset) return False response_value = int.from_bytes(data[10:12], byteorder='big', signed=True) if response_value != value: logger.debug("Response has wrong value: %X, expected %X.", response_value, value) return False if data[7] != cmd: failure_code = FAILURE_CODES.get(data[8], "UNKNOWN") logger.debug("Response is command failure: %s.", FAILURE_CODES.get(data[8], "UNKNOWN")) raise RequestRejectedException(failure_code) return True marcelblijleven-goodwe-a4b8c7f/goodwe/model.py000066400000000000000000000042301463365355300216040ustar00rootroot00000000000000# Serial number tags to identify inverter type from .inverter import Inverter PLATFORM_105_MODELS = ("ESU", "EMU", "ESA", "BPS", "BPU", "EMJ", "IJL") PLATFORM_205_MODELS = ("ETU", "ETL", "ETR", "BHN", "EHU", "BHU", "EHR", "BTU") PLATFORM_745_LV_MODELS = ("ESN", "EBN", "EMN", "SPN", "ERN", "ESC", "HLB", "HMB", "HBB", "EOA") PLATFORM_745_HV_MODELS = ("ETT", "HTA", "HUB", "AEB", "SPB", "CUB", "EUB", "HEB", "ERB", "BTT", "ETF", "ARB", "URB", "EBR") PLATFORM_753_MODELS = ("AES", "HHI", "ABP", "EHB", "HSB", "HUA", "CUA") ET_MODEL_TAGS = PLATFORM_205_MODELS + PLATFORM_745_LV_MODELS + PLATFORM_745_HV_MODELS + PLATFORM_753_MODELS + ( "ETC", "BTC", "BTN") # Qianhai ES_MODEL_TAGS = PLATFORM_105_MODELS DT_MODEL_TAGS = ("DTU", "DTS", "MSU", "MST", "MSC", "DSN", "DTN", "DST", "NSU", "SSN", "SST", "SSX", "SSY", "PSB", "PSC") SINGLE_PHASE_MODELS = ("DSN", "DST", "NSU", "SSN", "SST", "SSX", "SSY", # DT "MSU", "MST", "PSB", "PSC", "MSC", # Found on third gen MS "EHU", "EHR", "HSB", # ET "ESN", "EMN", "ERN", "EBN", "HLB", "HMB", "HBB", "SPN") # ES Gen 2 MPPT3_MODELS = ("MSU", "MST", "PSC", "MSC", "25KET", "29K9ET") MPPT4_MODELS = ("HSB",) BAT_2_MODELS = ("25KET", "29K9ET") def is_single_phase(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in SINGLE_PHASE_MODELS) def is_3_mppt(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in MPPT3_MODELS) def is_4_mppt(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in MPPT4_MODELS) def is_2_battery(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in BAT_2_MODELS) def is_745_platform(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in PLATFORM_745_LV_MODELS) or any( model in inverter.serial_number for model in PLATFORM_745_HV_MODELS) def is_753_platform(inverter: Inverter) -> bool: return any(model in inverter.serial_number for model in PLATFORM_753_MODELS) marcelblijleven-goodwe-a4b8c7f/goodwe/protocol.py000066400000000000000000000727231463365355300223610ustar00rootroot00000000000000from __future__ import annotations import asyncio import io import logging import platform import socket from asyncio.futures import Future from typing import Tuple, Optional, Callable from .exceptions import MaxRetriesException, PartialResponseException, RequestFailedException, RequestRejectedException from .modbus import create_modbus_rtu_request, create_modbus_rtu_multi_request, create_modbus_tcp_request, \ create_modbus_tcp_multi_request, validate_modbus_rtu_response, validate_modbus_tcp_response, MODBUS_READ_CMD, \ MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD logger = logging.getLogger(__name__) _modbus_tcp_tx = 0 def _next_tx() -> bytes: global _modbus_tcp_tx _modbus_tcp_tx += 1 if _modbus_tcp_tx == 0xFFFF: _modbus_tcp_tx = 1 return int.to_bytes(_modbus_tcp_tx, length=2, byteorder="big", signed=False) class InverterProtocol: def __init__(self, host: str, port: int, comm_addr: int, timeout: int, retries: int): self._host: str = host self._port: int = port self._comm_addr: int = comm_addr self._running_loop: asyncio.AbstractEventLoop | None = None self._lock: asyncio.Lock | None = None self._timer: asyncio.TimerHandle | None = None self.timeout: int = timeout self.retries: int = retries self.keep_alive: bool = False self.protocol: asyncio.Protocol | None = None self.response_future: Future | None = None self.command: ProtocolCommand | None = None self._partial_data: bytes | None = None self._partial_missing: int = 0 def _ensure_lock(self) -> asyncio.Lock: """Validate (or create) asyncio Lock. The asyncio.Lock must always be created from within's asyncio loop, so it cannot be eagerly created in constructor. Additionally, since asyncio.run() creates and closes its own loop, the lock's scope (its creating loop) mus be verified to support proper behavior in subsequent asyncio.run() invocations. """ if self._lock and self._running_loop == asyncio.get_event_loop(): return self._lock else: logger.debug("Creating lock instance for current event loop.") self._lock = asyncio.Lock() self._running_loop = asyncio.get_event_loop() self._close_transport() return self._lock def _max_retries_reached(self) -> Future: logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command) self._close_transport() self.response_future = asyncio.get_running_loop().create_future() self.response_future.set_exception(MaxRetriesException) return self.response_future def _close_transport(self) -> None: if self._transport: try: self._transport.close() except RuntimeError: logger.debug("Failed to close transport.") self._transport = None # Cancel Future on connection lost if self.response_future and not self.response_future.done(): self.response_future.cancel() async def close(self) -> None: """Close the underlying transport/connection.""" raise NotImplementedError() async def send_request(self, command: ProtocolCommand) -> Future: """Convert command to request and send it to inverter.""" raise NotImplementedError() def read_command(self, offset: int, count: int) -> ProtocolCommand: """Create read protocol command.""" raise NotImplementedError() def write_command(self, register: int, value: int) -> ProtocolCommand: """Create write protocol command.""" raise NotImplementedError() def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand: """Create write multiple protocol command.""" raise NotImplementedError() class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol): def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 3): super().__init__(host, port, comm_addr, timeout, retries) self._transport: asyncio.transports.DatagramTransport | None = None self._retry: int = 0 def read_command(self, offset: int, count: int) -> ProtocolCommand: """Create read protocol command.""" return ModbusRtuReadCommand(self._comm_addr, offset, count) def write_command(self, register: int, value: int) -> ProtocolCommand: """Create write protocol command.""" return ModbusRtuWriteCommand(self._comm_addr, register, value) def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand: """Create write multiple protocol command.""" return ModbusRtuWriteMultiCommand(self._comm_addr, offset, values) async def _connect(self) -> None: if not self._transport or self._transport.is_closing(): self._transport, self.protocol = await asyncio.get_running_loop().create_datagram_endpoint( lambda: self, remote_addr=(self._host, self._port), ) def connection_made(self, transport: asyncio.DatagramTransport) -> None: """On connection made""" self._transport = transport def connection_lost(self, exc: Optional[Exception]) -> None: """On connection lost""" if exc: logger.debug("Socket closed with error: %s.", exc) else: logger.debug("Socket closed.") self._close_transport() def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None: """On datagram received""" if self._timer: self._timer.cancel() self._timer = None try: if self._partial_data and self._partial_missing == len(data): logger.debug("Composed fragmented response: %s + %s", self._partial_data.hex(), data.hex()) data = self._partial_data + data self._partial_data = None self._partial_missing = 0 if self.command.validator(data): logger.debug("Received: %s", data.hex()) self._retry = 0 self.response_future.set_result(data) else: logger.debug("Received invalid response: %s", data.hex()) asyncio.get_running_loop().call_soon(self._timeout_mechanism) except PartialResponseException as ex: logger.debug("Received response fragment (%d of %d): %s", ex.length, ex.expected, data.hex()) self._partial_data = data self._partial_missing = ex.expected - ex.length self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism) except asyncio.InvalidStateError: logger.debug("Response already handled: %s", data.hex()) except RequestRejectedException as ex: logger.debug("Received exception response: %s", data.hex()) if self.response_future and not self.response_future.done(): self.response_future.set_exception(ex) self._close_transport() def error_received(self, exc: Exception) -> None: """On error received""" logger.debug("Received error: %s", exc) self.response_future.set_exception(exc) self._close_transport() async def send_request(self, command: ProtocolCommand) -> Future: """Send message via transport""" await self._ensure_lock().acquire() try: await self._connect() response_future = asyncio.get_running_loop().create_future() self._send_request(command, response_future) await response_future return response_future except asyncio.CancelledError: if self._retry < self.retries: self._retry += 1 if self._lock and self._lock.locked(): self._lock.release() if not self.keep_alive: self._close_transport() return await self.send_request(command) else: return self._max_retries_reached() finally: if self._lock and self._lock.locked(): self._lock.release() if not self.keep_alive: self._close_transport() def _send_request(self, command: ProtocolCommand, response_future: Future) -> None: """Send message via transport""" self.command = command self.response_future = response_future self._partial_data = None self._partial_missing = 0 payload = command.request_bytes() if self._retry > 0: logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries) else: logger.debug("Sending: %s", self.command) self._transport.sendto(payload) self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism) def _timeout_mechanism(self) -> None: """Timeout mechanism to prevent hanging transport""" if self.response_future and self.response_future.done(): logger.debug("Response already received.") self._retry = 0 else: if self._timer: logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout) self._timer = None if self.response_future and not self.response_future.done(): self.response_future.cancel() async def close(self): self._close_transport() class TcpInverterProtocol(InverterProtocol, asyncio.Protocol): def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 0): super().__init__(host, port, comm_addr, timeout, retries) self._transport: asyncio.transports.Transport | None = None self._retry: int = 0 def read_command(self, offset: int, count: int) -> ProtocolCommand: """Create read protocol command.""" return ModbusTcpReadCommand(self._comm_addr, offset, count) def write_command(self, register: int, value: int) -> ProtocolCommand: """Create write protocol command.""" return ModbusTcpWriteCommand(self._comm_addr, register, value) def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand: """Create write multiple protocol command.""" return ModbusTcpWriteMultiCommand(self._comm_addr, offset, values) async def _connect(self) -> None: if not self._transport or self._transport.is_closing(): logger.debug("Opening connection.") self._transport, self.protocol = await asyncio.get_running_loop().create_connection( lambda: self, host=self._host, port=self._port, ) if self.keep_alive: try: sock = self._transport.get_extra_info('socket') if sock is not None: sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) if platform.system() == 'Windows': sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 10000, 10000)) except AttributeError as ex: logger.debug("Failed to apply KEEPALIVE: %s", ex) def connection_made(self, transport: asyncio.DatagramTransport) -> None: """On connection made""" logger.debug("Connection opened.") pass def eof_received(self) -> None: logger.debug("EOF received.") self._close_transport() def connection_lost(self, exc: Optional[Exception]) -> None: """On connection lost""" if exc: logger.debug("Connection closed with error: %s.", exc) else: logger.debug("Connection closed.") self._close_transport() def data_received(self, data: bytes) -> None: """On data received""" if self._timer: self._timer.cancel() try: if self._partial_data and self._partial_missing == len(data): logger.debug("Composed fragmented response: %s + %s", self._partial_data.hex(), data.hex()) data = self._partial_data + data self._partial_data = None self._partial_missing = 0 if self.command.validator(data): logger.debug("Received: %s", data.hex()) self._retry = 0 self.response_future.set_result(data) else: logger.debug("Received invalid response: %s", data.hex()) self.response_future.set_exception(RequestRejectedException()) self._close_transport() except PartialResponseException as ex: logger.debug("Received response fragment (%d of %d): %s", ex.length, ex.expected, data.hex()) self._partial_data = data self._partial_missing = ex.expected - ex.length self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism) except asyncio.InvalidStateError: logger.debug("Response already handled: %s", data.hex()) except RequestRejectedException as ex: logger.debug("Received exception response: %s", data.hex()) if self.response_future and not self.response_future.done(): self.response_future.set_exception(ex) # self._close_transport() def error_received(self, exc: Exception) -> None: """On error received""" logger.debug("Received error: %s", exc) self.response_future.set_exception(exc) self._close_transport() async def send_request(self, command: ProtocolCommand) -> Future: """Send message via transport""" await self._ensure_lock().acquire() try: await asyncio.wait_for(self._connect(), timeout=5) response_future = asyncio.get_running_loop().create_future() self._send_request(command, response_future) await response_future return response_future except asyncio.CancelledError: if self._retry < self.retries: if self._timer: logger.debug("Connection broken error.") self._retry += 1 if self._lock and self._lock.locked(): self._lock.release() self._close_transport() return await self.send_request(command) else: return self._max_retries_reached() except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError): if self._retry < self.retries: logger.debug("Connection refused error.") self._retry += 1 if self._lock and self._lock.locked(): self._lock.release() return await self.send_request(command) else: return self._max_retries_reached() finally: if self._lock and self._lock.locked(): self._lock.release() def _send_request(self, command: ProtocolCommand, response_future: Future) -> None: """Send message via transport""" self.command = command self.response_future = response_future self._partial_data = None self._partial_missing = 0 payload = command.request_bytes() if self._retry > 0: logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries) else: logger.debug("Sending: %s", self.command) self._transport.write(payload) self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism) def _timeout_mechanism(self) -> None: """Retry mechanism to prevent hanging transport""" if self.response_future.done(): self._retry = 0 else: if self._timer: logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout) self._timer = None self._close_transport() async def close(self): await self._ensure_lock().acquire() try: self._close_transport() finally: if self._lock and self._lock.locked(): self._lock.release() class ProtocolResponse: """Definition of response to protocol command""" def __init__(self, raw_data: bytes, command: Optional[ProtocolCommand]): self.raw_data: bytes = raw_data self.command: ProtocolCommand = command self._bytes: io.BytesIO = io.BytesIO(self.response_data()) def __repr__(self): return self.raw_data.hex() def response_data(self) -> bytes: if self.command is not None: return self.command.trim_response(self.raw_data) else: return self.raw_data def seek(self, address: int) -> None: if self.command is not None: self._bytes.seek(self.command.get_offset(address)) else: self._bytes.seek(address) def read(self, size: int) -> bytes: return self._bytes.read(size) class ProtocolCommand: """Definition of inverter protocol command""" def __init__(self, request: bytes, validator: Callable[[bytes], bool]): self.request: bytes = request self.validator: Callable[[bytes], bool] = validator def __eq__(self, other): if not isinstance(other, ProtocolCommand): # don't attempt to compare against unrelated types return NotImplemented return self.request == other.request def __hash__(self): return hash(self.request) def __repr__(self): return self.request.hex() def request_bytes(self) -> bytes: """Return raw bytes payload, optionally pre-processed""" return self.request def trim_response(self, raw_response: bytes): """Trim raw response from header and checksum data""" return raw_response def get_offset(self, address: int): """Calculate relative offset to start of the response bytes""" return address async def execute(self, protocol: InverterProtocol) -> ProtocolResponse: """ Execute the protocol command on the specified connection. Return ProtocolResponse with raw response data """ try: response_future = await protocol.send_request(self) result = response_future.result() if result is not None: return ProtocolResponse(result, self) else: raise RequestFailedException( "No response received to '" + self.request.hex() + "' request." ) except (asyncio.CancelledError, ConnectionRefusedError): raise RequestFailedException( "No valid response received to '" + self.request.hex() + "' request." ) from None finally: if not protocol.keep_alive: await protocol.close() class Aa55ProtocolCommand(ProtocolCommand): """ Inverter communication protocol seen mostly on older generations of inverters. Quite probably it is some variation of the protocol used on RS-485 serial link, extended/adapted to UDP transport layer. Each request starts with header of 0xAA, 0x55, then 0xC0, 0x7F (client addr, inverter addr) followed by actual payload data. It is suffixed with 2 bytes of plain checksum of header+payload. Response starts again with 0xAA, 0x55, then 0x7F, 0xC0. 5-6th bytes are some response type, byte 7 is length of the response payload. The last 2 bytes are again plain checksum of header+payload. """ def __init__(self, payload: str, response_type: str, offset: int = 0, value: int = 0): super().__init__( bytes.fromhex( "AA55C07F" + payload + self._checksum(bytes.fromhex("AA55C07F" + payload)).hex() ), lambda x: self._validate_aa55_response(x, response_type), ) self.first_address: int = offset self.value = value @staticmethod def _checksum(data: bytes) -> bytes: checksum = 0 for each in data: checksum += each return checksum.to_bytes(2, byteorder="big", signed=False) @staticmethod def _validate_aa55_response(data: bytes, response_type: str) -> bool: """ Validate the response. data[0:3] is header data[4:5] is response type data[6] is response payload length data[-2:] is checksum (plain sum of response data incl. header) """ if len(data) <= 8: logger.debug("Response too short.") return False elif len(data) < data[6] + 9: raise PartialResponseException(len(data), data[6] + 9) elif len(data) > data[6] + 9: logger.debug("Response invalid - too long (%d).", len(data)) return False elif response_type: data_rt_int = int.from_bytes(data[4:6], byteorder="big", signed=True) if int(response_type, 16) != data_rt_int: logger.debug("Response type unexpected: %04x, expected %s.", data_rt_int, response_type) return False checksum = 0 for each in data[:-2]: checksum += each if checksum != int.from_bytes(data[-2:], byteorder="big", signed=True): logger.debug("Response checksum does not match.") return False return True def trim_response(self, raw_response: bytes): """Trim raw response from header and checksum data""" return raw_response[7:-2] def __repr__(self): if self.request[4] == 1: if self.request[5] == 2: return f'READ device info ({self.request.hex()})' elif self.request[5] == 6: return f'READ runtime data ({self.request.hex()})' elif self.request[5] == 9: return f'READ settings ({self.request.hex()})' else: return self.request.hex() class Aa55ReadCommand(Aa55ProtocolCommand): """ Inverter modbus READ command for retrieving modbus registers starting at register # """ def __init__(self, offset: int, count: int): super().__init__("011A03" + "{:04x}".format(offset) + "{:02x}".format(count), "019A", offset, count) def __repr__(self): if self.value > 1: return f'READ {self.value} registers from {self.first_address} ({self.request.hex()})' else: return f'READ register {self.first_address} ({self.request.hex()})' class Aa55WriteCommand(Aa55ProtocolCommand): """ Inverter aa55 WRITE command setting single register # value """ def __init__(self, register: int, value: int): super().__init__("023905" + "{:04x}".format(register) + "01" + "{:04x}".format(value), "02B9", register, value) def __repr__(self): return f'WRITE {self.value} to register {self.first_address} ({self.request.hex()})' class Aa55WriteMultiCommand(Aa55ProtocolCommand): """ Inverter aa55 WRITE command setting multiple register # value """ def __init__(self, offset: int, values: bytes): super().__init__("02390B" + "{:04x}".format(offset) + "{:02x}".format(len(values)) + values.hex(), "02B9", offset, len(values) // 2) class ModbusRtuProtocolCommand(ProtocolCommand): """ Inverter communication protocol seen on newer generation of inverters, based on Modbus protocol over UDP transport layer. The modbus communication is rather simple, there are "registers" at specified addresses/offsets, each represented by 2 bytes. The protocol may query/update individual or range of these registers. Each register represents some measured value or operational settings. It's inverter implementation specific which register means what. Some values may span more registers (i.e. 4bytes measurement value over 2 registers). Every request usually starts with communication address (usually 0xF7, but can be changed). Second byte is the modbus command - 0x03 read multiple, 0x06 write single, 0x10 write multiple. Bytes 3-4 represent the register address (or start of range) Bytes 5-6 represent the command parameter (range size or actual value for write). Last 2 bytes of request is the CRC-16 (modbus flavor) of the request. Responses seem to always start with 0xAA, 0x55, then the comm_addr and modbus command. (If the command fails, the highest bit of command is set to 1 ?) For read requests, next byte is response payload length, then the actual payload. Last 2 bytes of response is again the CRC-16 of the response. """ def __init__(self, request: bytes, cmd: int, offset: int, value: int): super().__init__( request, lambda x: validate_modbus_rtu_response(x, cmd, offset, value), ) self.first_address: int = offset self.value = value def trim_response(self, raw_response: bytes): """Trim raw response from header and checksum data""" return raw_response[5:-2] def get_offset(self, address: int): """Calculate relative offset to start of the response bytes""" return (address - self.first_address) * 2 class ModbusRtuReadCommand(ModbusRtuProtocolCommand): """ Inverter Modbus/RTU READ command for retrieving modbus registers starting at register # """ def __init__(self, comm_addr: int, offset: int, count: int): super().__init__( create_modbus_rtu_request(comm_addr, MODBUS_READ_CMD, offset, count), MODBUS_READ_CMD, offset, count) def __repr__(self): if self.value > 1: return f'READ {self.value} registers from {self.first_address} ({self.request.hex()})' else: return f'READ register {self.first_address} ({self.request.hex()})' class ModbusRtuWriteCommand(ModbusRtuProtocolCommand): """ Inverter Modbus/RTU WRITE command setting single modbus register # value """ def __init__(self, comm_addr: int, register: int, value: int): super().__init__( create_modbus_rtu_request(comm_addr, MODBUS_WRITE_CMD, register, value), MODBUS_WRITE_CMD, register, value) def __repr__(self): return f'WRITE {self.value} to register {self.first_address} ({self.request.hex()})' class ModbusRtuWriteMultiCommand(ModbusRtuProtocolCommand): """ Inverter Modbus/RTU WRITE command setting multiple modbus register # value """ def __init__(self, comm_addr: int, offset: int, values: bytes): super().__init__( create_modbus_rtu_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values), MODBUS_WRITE_MULTI_CMD, offset, len(values) // 2) class ModbusTcpProtocolCommand(ProtocolCommand): """ Modbus/TCP inverter communication protocol. """ def __init__(self, request: bytes, cmd: int, offset: int, value: int): super().__init__( request, lambda x: validate_modbus_tcp_response(x, cmd, offset, value), ) self.first_address: int = offset self.value = value def request_bytes(self) -> bytes: """Return raw bytes payload, optionally pre-processed""" # Apply sequential Modbus/TCP transaction identifier self.request = _next_tx() + self.request[2:] return self.request def trim_response(self, raw_response: bytes): """Trim raw response from header and checksum data""" return raw_response[9:] def get_offset(self, address: int): """Calculate relative offset to start of the response bytes""" return (address - self.first_address) * 2 class ModbusTcpReadCommand(ModbusTcpProtocolCommand): """ Inverter Modbus/TCP READ command for retrieving modbus registers starting at register # """ def __init__(self, comm_addr: int, offset: int, count: int): super().__init__( create_modbus_tcp_request(comm_addr, MODBUS_READ_CMD, offset, count), MODBUS_READ_CMD, offset, count) def __repr__(self): if self.value > 1: return f'READ {self.value} registers from {self.first_address} ({self.request.hex()})' else: return f'READ register {self.first_address} ({self.request.hex()})' class ModbusTcpWriteCommand(ModbusTcpProtocolCommand): """ Inverter Modbus/TCP WRITE command setting single modbus register # value """ def __init__(self, comm_addr: int, register: int, value: int): super().__init__( create_modbus_tcp_request(comm_addr, MODBUS_WRITE_CMD, register, value), MODBUS_WRITE_CMD, register, value) def __repr__(self): return f'WRITE {self.value} to register {self.first_address} ({self.request.hex()})' class ModbusTcpWriteMultiCommand(ModbusTcpProtocolCommand): """ Inverter Modbus/TCP WRITE command setting multiple modbus register # value """ def __init__(self, comm_addr: int, offset: int, values: bytes): super().__init__( create_modbus_tcp_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values), MODBUS_WRITE_MULTI_CMD, offset, len(values) // 2) marcelblijleven-goodwe-a4b8c7f/goodwe/sensor.py000066400000000000000000001130431463365355300220200ustar00rootroot00000000000000from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime from enum import IntEnum from struct import unpack from typing import Any, Callable, Optional from .const import * from .inverter import Sensor, SensorKind from .protocol import ProtocolResponse DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] class ScheduleType(IntEnum): ECO_MODE = 0, DRY_CONTACT_LOAD = 1, DRY_CONTACT_SMART_LOAD = 2, PEAK_SHAVING = 3, BACKUP_MODE = 4, SMART_CHARGE_MODE = 5, ECO_MODE_745 = 6, NOT_SET = 85 @classmethod def detect_schedule_type(cls, value: int) -> ScheduleType: """Detect schedule type from its on/off value""" if value in (0, -1): return ScheduleType.ECO_MODE elif value in (1, -2): return ScheduleType.DRY_CONTACT_LOAD elif value in (2, -3): return ScheduleType.DRY_CONTACT_SMART_LOAD elif value in (3, -4): return ScheduleType.PEAK_SHAVING elif value in (4, -5): return ScheduleType.BACKUP_MODE elif value in (5, -6): return ScheduleType.SMART_CHARGE_MODE elif value in (6, -7): return ScheduleType.ECO_MODE_745 elif value == 85: return ScheduleType.NOT_SET else: raise ValueError(f"{value}: on_off value {value} out of range.") def power_unit(self): """Return unit of power parameter""" if self == ScheduleType.PEAK_SHAVING: return "W" else: return "%" def decode_power(self, value: int) -> int: """Decode human readable value of power parameter""" if self == ScheduleType.PEAK_SHAVING: return value * 10 elif self == ScheduleType.ECO_MODE_745: return int(value / 10) elif self == ScheduleType.NOT_SET: # Prevent out of range values when changing mode return value if -100 <= value <= 100 else int(value / 10) else: return value def encode_power(self, value: int) -> int: """Encode human readable value of power parameter""" if self == ScheduleType.ECO_MODE: return value elif self == ScheduleType.PEAK_SHAVING: return int(value / 10) elif self == ScheduleType.ECO_MODE_745: return value * 10 else: return value def is_in_range(self, value: int) -> bool: """Check if the value fits in allowed values range""" if self == ScheduleType.ECO_MODE: return -100 <= value <= 100 elif self == ScheduleType.ECO_MODE_745: return -1000 <= value <= 1000 else: return True class Voltage(Sensor): """Sensor representing voltage [V] value encoded in 2 (unsigned) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "V", kind) def read_value(self, data: ProtocolResponse): return read_voltage(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return encode_voltage(value) class Current(Sensor): """Sensor representing current [A] value encoded in 2 (unsigned) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "A", kind) def read_value(self, data: ProtocolResponse): return read_current(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return encode_current(value) class CurrentS(Sensor): """Sensor representing current [A] value encoded in 2 (signed) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "A", kind) def read_value(self, data: ProtocolResponse): return read_current_signed(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return encode_current_signed(value) class Frequency(Sensor): """Sensor representing frequency [Hz] value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "Hz", kind) def read_value(self, data: ProtocolResponse): return read_freq(data) class Power(Sensor): """Sensor representing power [W] value encoded in 2 (unsigned) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "W", kind) def read_value(self, data: ProtocolResponse): return read_bytes2(data) class PowerS(Sensor): """Sensor representing power [W] value encoded in 2 (signed) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "W", kind) def read_value(self, data: ProtocolResponse): return read_bytes2_signed(data) class Power4(Sensor): """Sensor representing power [W] value encoded in 4 (unsigned) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 4, "W", kind) def read_value(self, data: ProtocolResponse): return read_bytes4(data) class Power4S(Sensor): """Sensor representing power [W] value encoded in 4 (signed) bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 4, "W", kind) def read_value(self, data: ProtocolResponse): return read_bytes4_signed(data) class Energy(Sensor): """Sensor representing energy [kWh] value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "kWh", kind) def read_value(self, data: ProtocolResponse): value = read_bytes2(data) return float(value) / 10 if value is not None else None class Energy4(Sensor): """Sensor representing energy [kWh] value encoded in 4 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 4, "kWh", kind) def read_value(self, data: ProtocolResponse): value = read_bytes4(data) return float(value) / 10 if value is not None else None class Energy8(Sensor): """Sensor representing energy [kWh] value encoded in 8 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 8, "kWh", kind) def read_value(self, data: ProtocolResponse): value = read_bytes8(data) return float(value) / 100 if value is not None else None class Apparent(Sensor): """Sensor representing apparent power [VA] value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "VA", kind) def read_value(self, data: ProtocolResponse): return read_bytes2_signed(data) class Apparent4(Sensor): """Sensor representing apparent power [VA] value encoded in 4 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "VA", kind) def read_value(self, data: ProtocolResponse): return read_bytes4_signed(data) class Reactive(Sensor): """Sensor representing reactive power [var] value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "var", kind) def read_value(self, data: ProtocolResponse): return read_bytes2_signed(data) class Reactive4(Sensor): """Sensor representing reactive power [var] value encoded in 4 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "var", kind) def read_value(self, data: ProtocolResponse): return read_bytes4_signed(data) class Temp(Sensor): """Sensor representing temperature [C] value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 2, "C", kind) def read_value(self, data: ProtocolResponse): return read_temp(data) class CellVoltage(Sensor): """Sensor representing battery cell voltage [V] value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]): super().__init__(id_, offset, name, 2, "V", kind) def read_value(self, data: ProtocolResponse): return read_voltage(data) / 100 class Byte(Sensor): """Sensor representing signed int value encoded in 1 byte""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 1, unit, kind) def read_value(self, data: ProtocolResponse): return read_byte(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: raise NotImplementedError() class ByteH(Byte): """Sensor representing signed int value encoded in 1 byte (high 8 bits of 16bit register)""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, unit, kind) def read_value(self, data: ProtocolResponse): return read_byte(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: word = bytearray(register_value) word[0] = int.to_bytes(int(value), length=1, byteorder="big", signed=True)[0] return bytes(word) class ByteL(Byte): """Sensor representing signed int value encoded in 1 byte (low 8 bits of 16bit register)""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, unit, kind) def read_value(self, data: ProtocolResponse): read_byte(data) return read_byte(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: word = bytearray(register_value) word[1] = int.to_bytes(int(value), length=1, byteorder="big", signed=True)[0] return bytes(word) class Integer(Sensor): """Sensor representing unsigned int value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 2, unit, kind) def read_value(self, data: ProtocolResponse): return read_bytes2(data, None, 0) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return int.to_bytes(int(value), length=2, byteorder="big", signed=False) class IntegerS(Sensor): """Sensor representing signed int value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 2, unit, kind) def read_value(self, data: ProtocolResponse): return read_bytes2_signed(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return int.to_bytes(int(value), length=2, byteorder="big", signed=True) class Long(Sensor): """Sensor representing unsigned int value encoded in 4 bytes""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 4, unit, kind) def read_value(self, data: ProtocolResponse): return read_bytes4(data, None, 0) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return int.to_bytes(int(value), length=4, byteorder="big", signed=False) class LongS(Sensor): """Sensor representing signed int value encoded in 4 bytes""" def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 4, unit, kind) def read_value(self, data: ProtocolResponse): return read_bytes4_signed(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return int.to_bytes(int(value), length=4, byteorder="big", signed=True) class Decimal(Sensor): """Sensor representing signed decimal value encoded in 2 bytes""" def __init__(self, id_: str, offset: int, scale: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 2, unit, kind) self.scale = scale def read_value(self, data: ProtocolResponse): return read_decimal2(data, self.scale) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return int.to_bytes(int(float(value) * self.scale), length=2, byteorder="big", signed=True) class Float(Sensor): """Sensor representing signed int value encoded in 4 bytes""" def __init__(self, id_: str, offset: int, scale: int, name: str, unit: str = "", kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 4, unit, kind) self.scale = scale def read_value(self, data: ProtocolResponse): return round(read_float4(data) / self.scale, 3) class Timestamp(Sensor): """Sensor representing datetime value encoded in 6 bytes""" def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 6, "", kind) def read_value(self, data: ProtocolResponse): return read_datetime(data) def encode_value(self, value: Any, register_value: bytes = None) -> bytes: return encode_datetime(value) class Enum(Sensor): """Sensor representing label from enumeration encoded in 1 bytes""" def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 1, "", kind) self._labels: Dict = labels def read_value(self, data: ProtocolResponse): return self._labels.get(read_byte(data)) class EnumH(Sensor): """Sensor representing label from enumeration encoded in 1 (high 8 bits of 16bit register)""" def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 1, "", kind) self._labels: Dict = labels def read_value(self, data: ProtocolResponse): return self._labels.get(read_byte(data)) class EnumL(Sensor): """Sensor representing label from enumeration encoded in 1 byte (low 8 bits of 16bit register)""" def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 1, "", kind) self._labels: Dict = labels def read_value(self, data: ProtocolResponse): read_byte(data) return self._labels.get(read_byte(data)) class Enum2(Sensor): """Sensor representing label from enumeration encoded in 2 bytes""" def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 2, "", kind) self._labels: Dict = labels def read_value(self, data: ProtocolResponse): return self._labels.get(read_bytes2(data, None, 0)) class EnumBitmap4(Sensor): """Sensor representing label from bitmap encoded in 4 bytes""" def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offset, name, 4, "", kind) self._labels: Dict = labels def read_value(self, data: ProtocolResponse) -> Any: raise NotImplementedError() def read(self, data: ProtocolResponse): bits = read_bytes4_signed(data, self.offset) return decode_bitmap(bits if bits != -1 else 0, self._labels) class EnumBitmap22(Sensor): """Sensor representing label from bitmap encoded in 2+2 bytes""" def __init__(self, id_: str, offsetH: int, offsetL: int, labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, offsetH, name, 2, "", kind) self._labels: Dict = labels self._offsetL: int = offsetL def read_value(self, data: ProtocolResponse) -> Any: raise NotImplementedError() def read(self, data: ProtocolResponse): return decode_bitmap(read_bytes2(data, self.offset, 0) << 16 + read_bytes2(data, self._offsetL, 0), self._labels) class EnumCalculated(Sensor): """Sensor representing label from enumeration of calculated value""" def __init__(self, id_: str, getter: Callable[[ProtocolResponse], Any], labels: Dict, name: str, kind: Optional[SensorKind] = None): super().__init__(id_, 0, name, 0, "", kind) self._getter: Callable[[ProtocolResponse], Any] = getter self._labels: Dict = labels def read_value(self, data: ProtocolResponse) -> Any: raise NotImplementedError() def read(self, data: ProtocolResponse): return self._labels.get(self._getter(data)) class EcoMode(ABC): """Sensor representing Eco Mode Battery Power Group API""" @abstractmethod def encode_charge(self, eco_mode_power: int, eco_mode_soc: int = 100) -> bytes: """Answer bytes representing all the time enabled charging eco mode group""" @abstractmethod def encode_discharge(self, eco_mode_power: int) -> bytes: """Answer bytes representing all the time enabled discharging eco mode group""" @abstractmethod def encode_off(self) -> bytes: """Answer bytes representing empty and disabled eco mode group""" @abstractmethod def is_eco_charge_mode(self) -> bool: """Answer if it represents the emulated 24/7 fulltime discharge mode""" @abstractmethod def is_eco_discharge_mode(self) -> bool: """Answer if it represents the emulated 24/7 fulltime discharge mode""" @abstractmethod def get_schedule_type(self) -> ScheduleType: """Answer the schedule type""" @abstractmethod def set_schedule_type(self, schedule_type: ScheduleType, is745: bool): """Set the schedule type""" @abstractmethod def get_power(self) -> int: """Answer the power value""" @abstractmethod def get_power_unit(self) -> str: """Answer the power unit""" class EcoModeV1(Sensor, EcoMode): """Sensor representing Eco Mode Battery Power Group encoded in 8 bytes""" def __init__(self, id_: str, offset: int, name: str): super().__init__(id_, offset, name, 8, "", SensorKind.BAT) self.start_h: int | None = None self.start_m: int | None = None self.end_h: int | None = None self.end_m: int | None = None self.power: int | None = None self.on_off: int | None = None self.day_bits: int | None = None self.days: str | None = None self.soc: int = 100 # just to keep same API with V2 def __str__(self): return f"{self.start_h}:{self.start_m}-{self.end_h}:{self.end_m} {self.days} " \ f"{self.power}% " \ f"{'On' if self.on_off != 0 else 'Off'}" def read_value(self, data: ProtocolResponse): self.start_h = read_byte(data) if (self.start_h < 0 or self.start_h > 23) and self.start_h != 48: raise ValueError(f"{self.id_}: start_h value {self.start_h} out of range.") self.start_m = read_byte(data) if self.start_m < 0 or self.start_m > 59: raise ValueError(f"{self.id_}: start_m value {self.start_m} out of range.") self.end_h = read_byte(data) if (self.end_h < 0 or self.end_h > 23) and self.end_h != 48: raise ValueError(f"{self.id_}: end_h value {self.end_h} out of range.") self.end_m = read_byte(data) if self.end_m < 0 or self.end_m > 59: raise ValueError(f"{self.id_}: end_m value {self.end_m} out of range.") self.power = read_bytes2_signed(data) # negative=charge, positive=discharge if self.power < -100 or self.power > 100: raise ValueError(f"{self.id_}: power value {self.power} out of range.") self.on_off = read_byte(data) if self.on_off not in (0, -1): raise ValueError(f"{self.id_}: on_off value {self.on_off} out of range.") self.day_bits = read_byte(data) self.days = decode_day_of_week(self.day_bits) return self def encode_value(self, value: Any, register_value: bytes = None) -> bytes: if isinstance(value, bytes) and len(value) == 8: # try to read_value to check if values are valid if self.read_value(ProtocolResponse(value, None)): return value raise ValueError def encode_charge(self, eco_mode_power: int, eco_mode_soc: int = 100) -> bytes: """Answer bytes representing all the time enabled charging eco mode group""" return bytes.fromhex("0000173b{:04x}ff7f".format((-1 * abs(eco_mode_power)) & (2 ** 16 - 1))) def encode_discharge(self, eco_mode_power: int) -> bytes: """Answer bytes representing all the time enabled discharging eco mode group""" return bytes.fromhex("0000173b{:04x}ff7f".format(abs(eco_mode_power))) def encode_off(self) -> bytes: """Answer bytes representing empty and disabled eco mode group""" return bytes.fromhex("3000300000640000") def is_eco_charge_mode(self) -> bool: """Answer if it represents the emulated 24/7 fulltime discharge mode""" return self.start_h == 0 \ and self.start_m == 0 \ and self.end_h == 23 \ and self.end_m == 59 \ and self.on_off != 0 \ and self.day_bits == 127 \ and self.power < 0 def is_eco_discharge_mode(self) -> bool: """Answer if it represents the emulated 24/7 fulltime discharge mode""" return self.start_h == 0 \ and self.start_m == 0 \ and self.end_h == 23 \ and self.end_m == 59 \ and self.on_off != 0 \ and self.day_bits == 127 \ and self.power > 0 def get_schedule_type(self) -> ScheduleType: """Answer the schedule type""" return ScheduleType.ECO_MODE def set_schedule_type(self, schedule_type: ScheduleType, is745: bool): """Set the schedule type""" pass def get_power(self) -> int: """Answer the power value""" return self.power def get_power_unit(self) -> str: """Answer the power unit""" return "%" def as_eco_mode_v2(self) -> EcoModeV2: """Convert V1 to V2 EcoMode""" result = EcoModeV2(self.id_, self.offset, self.name) result.start_h = self.start_h result.start_m = self.start_m result.end_h = self.end_h result.end_m = self.end_m result.power = self.power result.on_off = self.on_off result.day_bits = self.day_bits result.days = decode_day_of_week(self.day_bits) result.soc = 100 return result class Schedule(Sensor, EcoMode): """Sensor representing Schedule Group encoded in 12 bytes""" def __init__(self, id_: str, offset: int, name: str, schedule_type: ScheduleType = ScheduleType.ECO_MODE): super().__init__(id_, offset, name, 12, "", SensorKind.BAT) self.start_h: int | None = None self.start_m: int | None = None self.end_h: int | None = None self.end_m: int | None = None self.on_off: int | None = None self.day_bits: int | None = None self.days: str | None = None self.power: int | None = None self.soc: int | None = None self.month_bits: int | None = None self.months: str | None = None self.schedule_type: ScheduleType = schedule_type def __str__(self): return f"{self.start_h}:{self.start_m}-{self.end_h}:{self.end_m} {self.days} " \ f"{self.months + ' ' if self.months else ''}" \ f"{self.get_power()}{self.get_power_unit()} (SoC {self.soc}%) " \ f"{'On' if -10 < self.on_off < 0 else 'Off' if 10 > self.on_off >= 0 else 'Unset'}" def read_value(self, data: ProtocolResponse): self.start_h = read_byte(data) if (self.start_h < 0 or self.start_h > 23) and self.start_h != 48 and self.start_h != -1: raise ValueError(f"{self.id_}: start_h value {self.start_h} out of range.") self.start_m = read_byte(data) if (self.start_m < 0 or self.start_m > 59) and self.start_m != -1: raise ValueError(f"{self.id_}: start_m value {self.start_m} out of range.") self.end_h = read_byte(data) if (self.end_h < 0 or self.end_h > 23) and self.end_h != 48 and self.end_h != -1: raise ValueError(f"{self.id_}: end_h value {self.end_h} out of range.") self.end_m = read_byte(data) if (self.end_m < 0 or self.end_m > 59) and self.end_m != -1: raise ValueError(f"{self.id_}: end_m value {self.end_m} out of range.") self.on_off = read_byte(data) self.schedule_type = ScheduleType.detect_schedule_type(self.on_off) self.day_bits = read_byte(data) self.days = decode_day_of_week(self.day_bits) self.power = read_bytes2_signed(data) # negative=charge, positive=discharge if not self.schedule_type.is_in_range(self.power): raise ValueError(f"{self.id_}: power value {self.power} out of range.") self.soc = read_bytes2_signed(data) if self.soc < 0 or self.soc > 100: raise ValueError(f"{self.id_}: SoC value {self.soc} out of range.") self.month_bits = read_bytes2_signed(data) self.months = decode_months(self.month_bits) return self def encode_value(self, value: Any, register_value: bytes = None) -> bytes: if isinstance(value, bytes) and len(value) == 12: # try to read_value to check if values are valid if self.read_value(ProtocolResponse(value, None)): return value raise ValueError def encode_charge(self, eco_mode_power: int, eco_mode_soc: int = 100) -> bytes: """Answer bytes representing all the time enabled charging eco mode group""" return bytes.fromhex( "0000173b{:02x}7f{:04x}{:04x}{:04x}".format( 255 - self.schedule_type, (-1 * abs(self.schedule_type.encode_power(eco_mode_power))) & (2 ** 16 - 1), eco_mode_soc, 0 if self.schedule_type != ScheduleType.ECO_MODE_745 else 0x0fff)) def encode_discharge(self, eco_mode_power: int) -> bytes: """Answer bytes representing all the time enabled discharging eco mode group""" return bytes.fromhex("0000173b{:02x}7f{:04x}0064{:04x}".format( 255 - self.schedule_type, abs(self.schedule_type.encode_power(eco_mode_power)), 0 if self.schedule_type != ScheduleType.ECO_MODE_745 else 0x0fff)) def encode_off(self) -> bytes: """Answer bytes representing empty and disabled schedule group""" return bytes.fromhex("30003000{:02x}00{:04x}00640000".format( self.schedule_type.value, self.schedule_type.encode_power(100))) def is_eco_charge_mode(self) -> bool: """Answer if it represents the emulated 24/7 fulltime discharge mode""" return self.start_h == 0 \ and self.start_m == 0 \ and self.end_h == 23 \ and self.end_m == 59 \ and self.on_off == (-1 - self.schedule_type) \ and self.day_bits == 127 \ and self.power < 0 \ and (self.month_bits == 0 or self.month_bits == 0x0fff) def is_eco_discharge_mode(self) -> bool: """Answer if it represents the emulated 24/7 fulltime discharge mode""" return self.start_h == 0 \ and self.start_m == 0 \ and self.end_h == 23 \ and self.end_m == 59 \ and self.on_off == (-1 - self.schedule_type) \ and self.day_bits == 127 \ and self.power > 0 \ and (self.month_bits == 0 or self.month_bits == 0x0fff) def get_schedule_type(self) -> ScheduleType: """Answer the schedule type""" return self.schedule_type def set_schedule_type(self, schedule_type: ScheduleType, is745: bool): """Set the schedule type""" if schedule_type == ScheduleType.ECO_MODE: # try to keep-reuse the type, use is745 only when necessary if self.schedule_type not in (ScheduleType.ECO_MODE, ScheduleType.ECO_MODE_745): self.schedule_type = ScheduleType.ECO_MODE_745 if is745 else ScheduleType.ECO_MODE else: self.schedule_type = schedule_type def get_power(self) -> int: """Answer the power value""" return self.schedule_type.decode_power(self.power) def get_power_unit(self) -> str: """Answer the power unit""" return self.schedule_type.power_unit() def as_eco_mode_v1(self) -> EcoModeV1: """Convert V2 to V1 EcoMode""" result = EcoModeV1(self.id_, self.offset, self.name) result.start_h = self.start_h result.start_m = self.start_m result.end_h = self.end_h result.end_m = self.end_m result.power = self.power result.on_off = -1 if self.on_off == -1 else 0 result.day_bits = self.day_bits result.days = self.days return result class EcoModeV2(Schedule): """Sensor representing Eco Mode Group encoded in 12 bytes""" def __init__(self, id_: str, offset: int, name: str): super().__init__(id_, offset, name, ScheduleType.ECO_MODE) class PeakShavingMode(Schedule): """Sensor representing Peak Shaving Mode encoded in 12 bytes""" def __init__(self, id_: str, offset: int, name: str): super().__init__(id_, offset, name, ScheduleType.PEAK_SHAVING) class Calculated(Sensor): """Sensor representing calculated value""" def __init__(self, id_: str, getter: Callable[[ProtocolResponse], Any], name: str, unit: str, kind: Optional[SensorKind] = None): super().__init__(id_, 0, name, 0, unit, kind) self._getter: Callable[[ProtocolResponse], Any] = getter def read_value(self, data: ProtocolResponse) -> Any: raise NotImplementedError() def read(self, data: ProtocolResponse): return self._getter(data) def read_byte(buffer: ProtocolResponse, offset: int = None) -> int: """Retrieve single byte (signed int) value from buffer""" if offset is not None: buffer.seek(offset) return int.from_bytes(buffer.read(1), byteorder="big", signed=True) def read_bytes2(buffer: ProtocolResponse, offset: int = None, undef: int = None) -> int: """Retrieve 2 byte (unsigned int) value from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(2), byteorder="big", signed=False) return undef if value == 0xffff else value def read_bytes2_signed(buffer: ProtocolResponse, offset: int = None) -> int: """Retrieve 2 byte (signed int) value from buffer""" if offset is not None: buffer.seek(offset) return int.from_bytes(buffer.read(2), byteorder="big", signed=True) def read_bytes4(buffer: ProtocolResponse, offset: int = None, undef: int = None) -> int: """Retrieve 4 byte (unsigned int) value from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(4), byteorder="big", signed=False) return undef if value == 0xffffffff else value def read_bytes4_signed(buffer: ProtocolResponse, offset: int = None) -> int: """Retrieve 4 byte (signed int) value from buffer""" if offset is not None: buffer.seek(offset) return int.from_bytes(buffer.read(4), byteorder="big", signed=True) def read_bytes8(buffer: ProtocolResponse, offset: int = None, undef: int = None) -> int: """Retrieve 8 byte (unsigned int) value from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(8), byteorder="big", signed=False) return undef if value == 0xffffffffffffffff else value def read_decimal2(buffer: ProtocolResponse, scale: int, offset: int = None) -> float: """Retrieve 2 byte (signed float) value from buffer""" if offset is not None: buffer.seek(offset) return float(int.from_bytes(buffer.read(2), byteorder="big", signed=True)) / scale def read_float4(buffer: ProtocolResponse, offset: int = None) -> float: """Retrieve 4 byte (signed float) value from buffer""" if offset is not None: buffer.seek(offset) data = buffer.read(4) if len(data) == 4: return unpack('>f', data)[0] else: return float(0) def read_voltage(buffer: ProtocolResponse, offset: int = None) -> float: """Retrieve voltage [V] value (2 unsigned bytes) from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(2), byteorder="big", signed=False) return float(value) / 10 if value != 0xffff else 0 def encode_voltage(value: Any) -> bytes: """Encode voltage value to raw (2 unsigned bytes) payload""" return int.to_bytes(int(float(value) * 10), length=2, byteorder="big", signed=False) def read_current(buffer: ProtocolResponse, offset: int = None) -> float: """Retrieve current [A] value (2 unsigned bytes) from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(2), byteorder="big", signed=False) return float(value) / 10 if value != 0xffff else 0 def read_current_signed(buffer: ProtocolResponse, offset: int = None) -> float: """Retrieve current [A] value (2 signed bytes) from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(2), byteorder="big", signed=True) return float(value) / 10 def encode_current(value: Any) -> bytes: """Encode current value to raw (2 unsigned bytes) payload""" return int.to_bytes(int(float(value) * 10), length=2, byteorder="big", signed=False) def encode_current_signed(value: Any) -> bytes: """Encode current value to raw (2 signed bytes) payload""" return int.to_bytes(int(float(value) * 10), length=2, byteorder="big", signed=True) def read_freq(buffer: ProtocolResponse, offset: int = None) -> float: """Retrieve frequency [Hz] value (2 bytes) from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(2), byteorder="big", signed=True) return float(value) / 100 def read_temp(buffer: ProtocolResponse, offset: int = None) -> float | None: """Retrieve temperature [C] value (2 bytes) from buffer""" if offset is not None: buffer.seek(offset) value = int.from_bytes(buffer.read(2), byteorder="big", signed=True) if value == -1 or value == 32767: return None else: return float(value) / 10 def read_datetime(buffer: ProtocolResponse, offset: int = None) -> datetime: """Retrieve datetime value (6 bytes) from buffer""" if offset is not None: buffer.seek(offset) year = 2000 + int.from_bytes(buffer.read(1), byteorder='big') month = int.from_bytes(buffer.read(1), byteorder='big') day = int.from_bytes(buffer.read(1), byteorder='big') hour = int.from_bytes(buffer.read(1), byteorder='big') minute = int.from_bytes(buffer.read(1), byteorder='big') second = int.from_bytes(buffer.read(1), byteorder='big') return datetime(year=year, month=month, day=day, hour=hour, minute=minute, second=second) def encode_datetime(value: Any) -> bytes: """Encode datetime value to raw (6 bytes) payload""" timestamp = value if isinstance(value, str): timestamp = datetime.fromisoformat(value) result = bytes([ timestamp.year - 2000, timestamp.month, timestamp.day, timestamp.hour, timestamp.minute, timestamp.second, ]) return result def read_grid_mode(buffer: ProtocolResponse, offset: int = None) -> int: """Retrieve 'grid mode' sign value from buffer""" value = read_bytes2_signed(buffer, offset) if value < -90: return 2 elif value >= 90: return 1 else: return 0 def read_unsigned_int(data: bytes, offset: int) -> int: """Retrieve 2 byte (unsigned int) value from bytes at specified offset""" return int.from_bytes(data[offset:offset + 2], byteorder="big", signed=False) def decode_bitmap(value: int, bitmap: Dict[int, str]) -> str: bits = value result = [] for i in range(32): if bits & 0x1 == 1: if bitmap.get(i, f'err{i}'): result.append(bitmap.get(i, f'err{i}')) bits = bits >> 1 return ", ".join(result) def decode_day_of_week(data: int) -> str: if data == -1: return "Mon-Sun" elif data == 0: return "" bits = bin(data)[2:] daynames = list(DAY_NAMES) days = "" for each in bits[::-1]: if each == '1': if len(days) > 0: days += "," days += daynames[0] daynames.pop(0) return days def decode_months(data: int) -> str | None: if data <= 0 or data == 0x0fff: return None bits = bin(data)[2:] monthnames = list(MONTH_NAMES) months = "" for each in bits[::-1]: if each == '1': if len(months) > 0: months += "," months += monthnames[0] monthnames.pop(0) return months marcelblijleven-goodwe-a4b8c7f/pyproject.toml000066400000000000000000000001361463365355300215630ustar00rootroot00000000000000[build-system] requires = ["setuptools>=45", "wheel"] build-backend = "setuptools.build_meta" marcelblijleven-goodwe-a4b8c7f/setup.cfg000066400000000000000000000021461463365355300204730ustar00rootroot00000000000000[metadata] name = goodwe description = Read data from GoodWe inverter via local network long_description = file: README.md long_description_content_type = text/markdown version: file: VERSION keywords = GoodWe, Solar Panel, Inverter, Photovoltaics, PV license = MIT author = Martin Letenay, Marcel Blijleven author_email = 'marcelblijleven@gmail.com url = https://github.com/marcelblijleven/goodwe classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers Topic :: Software Development :: Libraries :: Python Modules License :: OSI Approved :: MIT License Programming Language :: Python :: 3 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 [options] packages = find: python_requires = >= 3.8 [options.packages.find] exclude = tests* [flake8] exclude = venv max-complexity = 10 max-line-length=160 inline-quotes = single avoid-escape = False multiline-quotes = """ marcelblijleven-goodwe-a4b8c7f/tests/000077500000000000000000000000001463365355300200115ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/tests/__init__.py000066400000000000000000000000001463365355300221100ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/tests/inverter_check.py000066400000000000000000000115101463365355300233540ustar00rootroot00000000000000"""Simple test script to check inverter UDP protocol communication""" import asyncio import logging import sys from importlib.metadata import version # Force the local files, not pip installed lib sys.path.insert(0, '../goodwe') sys.path.insert(0, '../../goodwe') import goodwe logging.basicConfig( format="%(asctime)-15s %(funcName)s(%(lineno)d) - %(levelname)s: %(message)s", stream=sys.stderr, level=getattr(logging, "DEBUG", None), ) if sys.platform.startswith('win'): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) module_ver = None try: module_ver = version('goodwe') except ModuleNotFoundError: pass if module_ver: print("WARNING !!!") print("==============================") print(f"You are executing code with installed pip version goodwe:{module_ver}") print("You are not testing the local files, if that was what you meant !!!") print("==============================") # Set the appropriate IP address IP_ADDRESS = "192.168.2.14" PORT = 8899 FAMILY = "ET" # One of ET, EH, ES, EM, DT, NS, XS or None to detect family automatically COMM_ADDR = 0xf7 # Usually 0xf7 for ET/EH/EM/ES or 0x7f for DT/D-NS/XS, or None for default value TIMEOUT = 1 RETRIES = 3 inverter = asyncio.run(goodwe.connect(IP_ADDRESS, PORT, FAMILY, COMM_ADDR, TIMEOUT, RETRIES)) print(f"Identified inverter\n" f"- Model: {inverter.model_name}\n" f"- SerialNr: {inverter.serial_number}\n" f"- Rated power: {inverter.rated_power}\n" f"- A/C output type: {inverter.ac_output_type}\n" f"- Firmware: {inverter.firmware}\n" f"- ARM firmware: {inverter.arm_firmware}\n" f"- Modbus version: {inverter.modbus_version}\n" f"- DSP1 version: {inverter.dsp1_version}\n" f"- DSP2 version: {inverter.dsp2_version}\n" f"- DSP svn version: {inverter.dsp_svn_version}\n" f"- Arm version: {inverter.arm_version}\n" f"- ARM svn version: {inverter.arm_svn_version}\n" ) # ----------------- # Read runtime data # ----------------- response = asyncio.run(inverter.read_runtime_data()) for sensor in inverter.sensors(): if sensor.id_ in response: print(f"{sensor.id_}: \t\t {sensor.name} = {response[sensor.id_]} {sensor.unit}") # ------------- # Read settings # ------------- # response = asyncio.run(inverter.read_settings_data()) # for setting in inverter.settings(): # if setting.id_ in response: # print(f"{setting.id_}: \t\t {setting.name} = {response[setting.id_]} {setting.unit}") # ----------------- # Set inverter time # ----------------- # print(asyncio.run(inverter.read_setting('time'))) # asyncio.run(inverter.write_setting('time', datetime.datetime.now())) # print(asyncio.run(inverter.read_setting('time'))) # ------------------------------ # Set inverter grid export limit # ------------------------------ # print(asyncio.run(inverter.get_grid_export_limit())) # asyncio.run(inverter.set_grid_export_limit(4000)) # print(asyncio.run(inverter.get_grid_export_limit())) # --------------------------- # Set inverter operation mode # --------------------------- # print(asyncio.run(inverter.get_operation_mode())) # asyncio.run(inverter.set_operation_mode(goodwe.OperationMode.BACKUP)) # print(asyncio.run(inverter.get_operation_mode())) # -------------------- # Set inverter setting # -------------------- # print(asyncio.run(inverter.read_setting('grid_export_limit'))) # asyncio.run(inverter.write_setting('grid_export_limit', 4000)) # print(asyncio.run(inverter.read_setting('grid_export_limit'))) # -------------------- # Get inverter modbus setting # -------------------- # print(asyncio.run(inverter.read_setting('modbus-47000'))) # ------------------------------- # Execute modbus RTU protocol command # ------------------------------- # response = asyncio.run(goodwe.protocol.ModbusRtuReadCommand(COMM_ADDR, 0x88b8, 0x21).execute( # goodwe.protocol.UdpInverterProtocol(IP_ADDRESS, PORT, TIMEOUT, RETRIES))) # print(response) # ------------------------------- # Execute modbus TCP protocol command # ------------------------------- # response = asyncio.run(goodwe.protocol.ModbusTcpReadCommand(180, 301, 3).execute( # goodwe.protocol.TcpInverterProtocol('192.168.1.13', 502, TIMEOUT, RETRIES))) # print(response.response_data().hex()) # ------------------------------- # Execute AA55 protocol command # ------------------------------- # response = asyncio.run(goodwe.protocol.Aa55ProtocolCommand("010200", "0182").execute( # goodwe.protocol.UdpInverterProtocol(IP_ADDRESS, PORT, TIMEOUT, RETRIES))) # print(response) # ----------------- # Test parallel requests # # async def run_in_parallel(inverter): # a, b, c, = await asyncio.gather(inverter.get_grid_export_limit(), inverter.get_ongrid_battery_dod(), # inverter.read_runtime_data()) # print(a) # print(b) # print(c) # # asyncio.run(run_in_parallel(inverter)) marcelblijleven-goodwe-a4b8c7f/tests/inverter_scan.py000066400000000000000000000025251463365355300232310ustar00rootroot00000000000000"""Simple test script to scan inverter present on local network""" import asyncio import logging import sys import goodwe from goodwe.exceptions import InverterError from goodwe.protocol import ProtocolCommand, UdpInverterProtocol logging.basicConfig( format="%(asctime)-15s %(funcName)s(%(lineno)d) - %(levelname)s: %(message)s", stream=sys.stderr, level=getattr(logging, "DEBUG", None), ) if sys.platform.startswith('win'): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) def try_command(command, ip): print(f"Trying command: {command}") try: response = asyncio.run( ProtocolCommand(bytes.fromhex(command), lambda x: True).execute(UdpInverterProtocol(ip, 8899))) print(f"Response to {command} command: {response.raw_data.hex()}") except InverterError: print(f"No response to {command} command") result = asyncio.run(goodwe.search_inverters()).decode("utf-8").split(",") print(f"Located inverter at IP: {result[0]}, mac: {result[1]}, name: {result[2]}") # EM/ES try_command("AA55C07F0102000241", result[0]) # DT (SolarGo) try_command("7F03753100280409", result[0]) print(f"Identifying inverter at IP: {result[0]}") inverter = asyncio.run(goodwe.discover(result[0])) print( f"Identified inverter model: {inverter.model_name}, serialNr: {inverter.serial_number}" ) marcelblijleven-goodwe-a4b8c7f/tests/mock_aa55_server.py000066400000000000000000000060261463365355300235210ustar00rootroot00000000000000import asyncio class EchoServerProtocol(asyncio.Protocol): def connection_made(self, transport): peername = transport.get_extra_info('peername') print('Connection from {}'.format(peername)) self.transport = transport def datagram_received(self, data, addr): payload = data.hex() print(payload) if payload == "aa55c07f0102000241": # Device info self.transport.sendto(bytes.fromhex( "aa557fc001824d323532354b4757353034382d4553412331300000000000000000000000000039353034384553413030305730303030333630303431302d30343032352d3235203431302d30323033342d323001102f"), addr) elif payload == "aa55c07f0106000245": # Running data self.transport.sendto(bytes.fromhex( "aa557fc001868c09270047020fe60042010214000200500118000400000032000064006464020000010a11009e0ce11389010a11000303e11389010202010000000000023780000053c3012600770001ad1510c30100200100000001000003e500000840000018051a0e0e120000000000000000000000000000000000000000000000000000ca260000baab0200000000000012e1"), addr) elif payload == "aa55c07f0109000248": # Settings data self.transport.sendto(bytes.fromhex( "aa557fc00189560000000000000000000000000001000100010000000a00d2024000f0008001e0000a000f0000000000000064024003e8001e00f20000000000000000023f000000070000000000000001038403e801e0000a000000220c12"), addr) elif payload == "aa55c07f011a030701040268": # Read eco_mode_1 self.transport.sendto(bytes.fromhex( "aa557fc0019a08000000000000007f0360"), addr) elif payload == "aa55c07f032c0500000000000272": self.transport.sendto(bytes.fromhex( "aa557fc003ac010602f4"), addr) elif payload == "aa55c07f032d0500000000000273": self.transport.sendto(bytes.fromhex( "aa557fc003ad010602f5"), addr) elif payload == "aa55c07f02390507000100010287": self.transport.sendto(bytes.fromhex( "aa557fc002b901060300"), addr) elif payload == "aa55c07f033601000278": self.transport.sendto(bytes.fromhex( "aa557fc003b6010602fe"), addr) elif payload == "aa55c07f03590100029b": self.transport.sendto(bytes.fromhex( "aa557fc003d901060321"), addr) else: self.transport.sendto(bytes.fromhex("00000000"), addr) # print('Close the client socket') # self.transport.close() async def main(): # Get a reference to the event loop as we plan to use # low-level APIs. loop = asyncio.get_running_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: EchoServerProtocol(), local_addr=('127.0.0.1', 8899)) try: await asyncio.sleep(3600) # Serve for 1 hour. finally: transport.close() asyncio.run(main()) marcelblijleven-goodwe-a4b8c7f/tests/mock_tcp_server.py000066400000000000000000000057471463365355300235650ustar00rootroot00000000000000import asyncio class EchoServerProtocol(asyncio.Protocol): def connection_made(self, transport): peername = transport.get_extra_info('peername') print('Connection from {}'.format(peername)) self.transport = transport def data_received(self, data): print(data.hex()) tx = data[0:2] payload = data[2:].hex() if payload == "00000006f70388b80021": # Device info - READ 33 registers from 35000 self.transport.write(tx + bytes.fromhex( "00000045f70342007913ba0000353530303045534e303030")) # Intentional split to test packet fragmentation self.transport.write(bytes.fromhex( "5730303030475736303030455332300006000619ce0008016a30343034382d30362d53303630323032302d30382d533031")) elif payload == "00000006f703b9bb0006": # Eco mode - READ 6 registers from 47547 self.transport.write(tx + bytes.fromhex("0000000ff7030c0000173b5500fc1800640fff")) elif payload == "00000006f703b9e50006": # Peak shaving - READ 6 registers from 47589 self.transport.write(tx + bytes.fromhex("0000000ff7030c0000091efa7f03e800000fff")) elif payload == "00000006f703891c007d": # Running data - READ 125 registers from 35100 self.transport.write(tx + bytes.fromhex( "000000fdf703fa180510070e1b0fca00020000005e07e80004000000520000000000000000000000000000000000000201096d00121385000001a4000000000000000000000000000000000000000000010000019c00000015fffffffc000001a40970000313850001000000000000000000000000000000000000000000000000000000000000019f00000000000000000000000000000176000001ff000001be00000fd2ffff020b00390000012b00020000005100010201000000000000646a0000000000006ad0000008920025000010c00000000089dd0025000028150000000025f8002c0000000000000000000000000000000010000140000301290000")) elif payload == "00000006f70390880018": # Battery data - READ 24 registers from 37000 self.transport.write(tx + bytes.fromhex( "00000033f7033000ff007d000100de006400780000002d00640001000000220000000000000000000100010001000100e300d80cc90cc4")) elif payload == "00000006f7038ca0002d": # Meter data - READ 45 registers from 36000 self.transport.write(tx + bytes.fromhex( "0000005df7035a00060000000a00000001fffc00000000fffc02160514863c863c051413860000000000000000fffffffc0000000000000000fffffffc00000216000000000000000000000216fffffd8c0000000000000000fffffd8c00000005")) else: self.transport.write(bytes.fromhex("00000000")) # print('Close the client socket') # self.transport.close() async def main(): # Get a reference to the event loop as we plan to use # low-level APIs. loop = asyncio.get_running_loop() server = await loop.create_server( lambda: EchoServerProtocol(), '127.0.0.1', 502) async with server: await server.serve_forever() asyncio.run(main()) marcelblijleven-goodwe-a4b8c7f/tests/mock_udp_server.py000066400000000000000000000071501463365355300235550ustar00rootroot00000000000000import asyncio class EchoServerProtocol(asyncio.Protocol): def connection_made(self, transport): peername = transport.get_extra_info('peername') print('Connection from {}'.format(peername)) self.transport = transport def datagram_received(self, data, addr): payload = data.hex() print(payload) if payload == "f70388b800213ac1": # Device info - READ 33 registers from 35000 self.transport.sendto(bytes.fromhex( "aa55f703420002271000fe393031304b4554553030305730303030"), addr) # Intentional split to test packet fragmentation self.transport.sendto(bytes.fromhex( "475731304b2d45542020000a000a00a7001700ed30343032392d31302d53313130323034312d32332d5330300d8f"), addr) elif payload == "f703b9bb00068427": # Eco mode - READ 6 registers from 47547 self.transport.sendto(bytes.fromhex("aa55f7030c00000937007f00000014000089eb"), addr) elif payload == "f703b9e50006e5f5": # Peak shaving - READ 6 registers from 47589 self.transport.sendto(bytes.fromhex("aa55f7030c0000173b030003e8005000005268"), addr) elif payload == "f703891c007d7ae7": # Running data - READ 125 registers from 35100 self.transport.sendto(bytes.fromhex( "aa55f703fa180510163a0a0000000000000000000000000000000000000000000000000000000000000000000000000993001713870000020c0999000613860000004309b3000c1386000000f00001000003310000000a00000000000000000993000c13860001000000ae09970006138600010000007709b500031387000100000003000000ba00000078000000db00000148000001df000501700000014a40001f500fa80f8100140000033c00020000002000010000000000000004b900000001640004321c000081ae013a000002bf000000056c8300d10001845700560001538f004d0008000000000000000000000000000002000040000303310000c258aa55f703fa180510163a0a0000000000000000000000000000000000000000000000000000000000000000000000000993001713870000020c0999000613860000004309b3000c1386000000f00001000003310000000a00000000000000000993000c13860001000000ae09970006138600010000007709b500031387000100000003000000ba00000078000000db00000148000001df000501700000014a40001f500fa80f8100140000033c00020000002000010000000000000004b900000001640004321c000081ae013a000002bf000000056c8300d10001845700560001538f004d0008000000000000000000000000000002000040000303310000c258"), addr) elif payload == "f70390880018fc7c": # Battery data - READ 24 registers from 37000 self.transport.sendto(bytes.fromhex( "aa55f7033000ff010000010154000a00190000004e006000080000010100000000000000000000000000000000000000000000000016eb"), addr) elif payload == "f7038ca0002dbbf3": # Meter data - READ 45 registers from 36000 self.transport.sendto(bytes.fromhex( "aa55f7035a0001004a000a0000000100aeff53001d001e02500216fccd0030001c13874a8cd9d04b333e0e000000aeffffff530000001d0000001e0000008400000003000001c90000025000000149ffffff2e000001ef0000040b00010003166c"), addr) else: self.transport.sendto(bytes.fromhex("00000000"), addr) # print('Close the client socket') # self.transport.close() async def main(): # Get a reference to the event loop as we plan to use # low-level APIs. loop = asyncio.get_running_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: EchoServerProtocol(), local_addr=('127.0.0.1', 8899)) try: await asyncio.sleep(3600) # Serve for 1 hour. finally: transport.close() asyncio.run(main()) marcelblijleven-goodwe-a4b8c7f/tests/sample/000077500000000000000000000000001463365355300212725ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/000077500000000000000000000000001463365355300217015ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW10K-MS-30_device_info.hex000066400000000000000000000002561463365355300262720ustar00rootroot00000000000000aa557f0350000000000000353031304b4d53433030305730303030ffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000201f8000d00010000b8a1marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW10K-MS-30_running_data.hex000066400000000000000000000004621463365355300264700ustar00rootroot00000000000000aa557f0392180109160814000000000000000000000000000000000000000000000000000000000000093a000000000000000000001388000000000000000000000000000000000000000000000000000000000000000000f30000000002ce0000861e000003cb000900000000000000000000000000000000000000000000000000000033000000000000000000000000000000000046ac9dmarcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW10K-MS-30_tcp_running_data.hex000066400000000000000000000004661463365355300273420ustar00rootroot000000000000008103000000957f03921806020907110fa600450e3800240d09003effffffffffffffffffffffffffffffffffff096bffffffff00f7ffffffff1386ffffffff0000171a000100000000000000001745fffffffaffffffff03e7ffff0168ffffffff002b000026fe000000f60020ffffffffffffffffffffffffffffffffffffffffffffffffffff0f85ffffffffffff011a00000007270fffff0000marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW17K-DT_device_info.hex000066400000000000000000000002561463365355300260510ustar00rootroot00000000000000aa557f0350000200aa0001353031374b4454543030425730303030475731374b542d445420ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000c000c000d03a300820001000011f6marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW17K-DT_running_data.hex000066400000000000000000000004621463365355300262470ustar00rootroot00000000000000aa557f03921805140a23371518006912930094ffffffffffffffffffffffffffffffff102210130fff093f094f094500b000af00af138a138a138a000030b600010000000000000000000000000000000000000000ffff01c9ffffffff012500049344000020a500010000000000000000ffffffffffffffffffffffffffffffff0222184a0c4600000004000003a300f7000400000064b2f2marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW20KAU-DT_device_info.hex000066400000000000000000000002561463365355300262710ustar00rootroot00000000000000aa557f0350000200c80001303030304B4454413030303030303030475732304b41552d4454ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000f000f0010044b00bb00030000e4bdmarcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW20KAU-DT_running_data.hex000066400000000000000000000004621463365355300264670ustar00rootroot00000000000000aa557f0392160a1513172a0f4100440dbc0047ffffffffffffffffffffffffffffffff0f2d0f4d0f6908d508bc08eb0048004a00471384138413850000135d000100000000000000000000000000cd0000000003e7ffff016cffffffff00c60000a8280000047300200000000000000000ffffffffffffffffffffffffffffffff0000174b0bad000000040000044b00000004000000696b04marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW5000-MS_device_info.hex000066400000000000000000000002561463365355300261030ustar00rootroot00000000000000aa557f03500002ffffffff30303030304d535530303030303030304757353030302d4d53204f444d20436c69656e7420534e000000000000000000000000000000000000000000000c000c0010ffff010f00010000a681marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW5000-MS_running_data.hex000066400000000000000000000004621463365355300263010ustar00rootroot00000000000000aa557f0392150a0f09030c0c7c000205c8000305980004ffffffffffffffffffffffffffffffffffff0961ffffffff0009ffffffff1386ffffffff000001270001000000000000ffffffffffffffffffffffffffffffff006bffffffff0004000000440000000700490000ffff0000ffff0000ffff0000ffffffffffffffffffff09500f63ffffffffffff01e1ffffffffffff0103002a4038marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW5000D-NS_running_data.hex000066400000000000000000000004621463365355300264060ustar00rootroot00000000000000aa557f039215090606380108c400000b660000ffffffffffffffffffffffffffffffffffffffffffff0965ffffffff0000ffffffff1385ffffffff000000000000000000000000ffffffffffffffffffffffffffffffff000effffffff000000002271000003bb00490000ffff0000ffff0000ffff0000ffffffffffffffffffff09600b65ffffffffffff0190ffffffffffff01940054e213marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW6000-DT_running_data.hex000066400000000000000000000004621463365355300262720ustar00rootroot00000000000000aa557f039215081f0c03020c88001f0ca90020ffffffffffffffffffffffffffffffffffffffffffff08d008f90906001b001a001b1386138613860000072b0001000000000000ffffffffffffffffffffffff0000ffff019dffffffff003c0002097e0000210300140000ffff0000ffff0000ffff0000ffffffffffffffffffff0000177c0beeffffffff00cf016302f00000000000649f03marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW8K-DT_device_info.hex000066400000000000000000000004701463365355300257670ustar00rootroot00000000000000 aa557f0350000000000001303030303044545330303030303030304757384b2d4454202020ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000003f203f2000802d80031000100001593marcelblijleven-goodwe-a4b8c7f/tests/sample/dt/GW8K-DT_running_data.hex000066400000000000000000000004621463365355300261670ustar00rootroot00000000000000aa557f0392150818102b1b0ac3000613f40008ffffffffffffffffffffffffffffffff102910220ff0094409650930000a000a000a1390138c13880000028300010000000000000000000000000000000000000000ffff01c5ffffffffffffffffffffffffffff00200000000000000000ffffffffffffffffffffffffffffffff020018620c6000000000000002d800810000ffff0054f42fmarcelblijleven-goodwe-a4b8c7f/tests/sample/dt/Mock_device_info.hex000066400000000000000000000002561463365355300256350ustar00rootroot00000000000000aa55f703500000177000fe303030303044534E30303030303030304757363030302d454820000300030145001000bc30343033342d30332d53313030323034312d31362d53303000000000000000000000000000007d38marcelblijleven-goodwe-a4b8c7f/tests/sample/es/000077500000000000000000000000001463365355300217015ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5000S-BP_running_data.hex000066400000000000000000000004521463365355300264050ustar00rootroot00000000000000aa557fc001868c0ea5001600000000020001f7000000520096006300630062000048004800030000010961001300b81383010961000105051383010200c300000000000001ee000000d30002001b0000079dfd760100300100000001000000d300080020000015081d090e0000000000000000000000000000000000000000000000000000000217000001f2040000361c084f1283marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048-EM-no-bat_running_data.hex000066400000000000000000000004341463365355300274540ustar00rootroot00000000000000aa557fc00186850d0f000402085c0005020000000000000000000000000000010000000000000000010968000a0007137f010000000000f900000002012b00000000000016f50000012b00e900cd0000158000d401002000000000010000ffdd0000484500001409140f2d1300000000000000000000000000000000000000000000000000000000000000000d76marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048-EM_device_info.hex000066400000000000000000000002551463365355300261000ustar00rootroot00000000000000aa557fc001824d31303130424757353034382d454d20233130202020202020202020202020203030303030454d553030415730303030333630303431302d30343030302d3130203431302d30323033342d313110119a marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048-EM_running_data.hex000066400000000000000000000004521463365355300262760ustar00rootroot00000000000000aa557fc001868c051c0003000d51000302021100000052012200b90063006300006100610002000001096f002b0002137b01096f002400d1137b0102015c000000000000256a0000053f005800b000004675040801003000034f00010000fffd0000004000001409140f2b16000000000000000000000000000000000000000000000000000012cb0000115b020000000000001161marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048-ESA_device_info.hex000066400000000000000000000002551463365355300262070ustar00rootroot00000000000000aa557fc001824d31373137414757353034382d4553412331302020202020202020202020202039353034384553413232335730303030333630303431302d30343032352d3233003431302d30323033342d31350311b2 marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048-ESA_discovery.hex000066400000000000000000000002541463365355300257430ustar00rootroot00000000000000aa557fc001824d31373137414757353034382d4553412331302020202020202020202020202039353034384553413232335730303030333630303431302d30343032352d3233003431302d30323033342d31350311b2marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048-ESA_running_data.hex000066400000000000000000000004521463365355300264050ustar00rootroot00000000000000aa557fc001868c045f000000031f00010002180002005000fa000200000064000064006464020000010908000603271388010908000303941388010200fb0000000000000002000000030002009600000096ff9301002001000000010000039301000c40000017021812112b0000000000000000000000000000000000000000000000000000005500000000020000000000000e94marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048D-ES_device_info.hex000066400000000000000000000002541463365355300262110ustar00rootroot00000000000000aa557fc001824d3233323347475735303438442d45532331300000000000000000000000000039353034384553553232375730303030333630303431302d30343032352d3233213431302d30323033342d313603104fmarcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048D-ES_running_data.hex000066400000000000000000000003201463365355300264020ustar00rootroot00000000000000aa557fc001865f0000000100000000000001ee0000005000f6005f004a004a00003c003c620200000108eb0017000213770108eb001a009c1377010200e500000000000381000000b4fc007b00890004c88801890100300002170001000000050700004000001189marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW5048D-ES_settings_data.hex000066400000000000000000000002761463365355300265740ustar00rootroot00000000000000aa557fc00189560000000000000000000000000001000100000001000a004a02140062002e01bd00640000000000000000006402140172001e00792710000000000000023f0000000700030000000000010172016f01b3000a0000000c08f1marcelblijleven-goodwe-a4b8c7f/tests/sample/es/GW6000-ES-20_device_info.hex000066400000000000000000000002521463365355300263070ustar00rootroot00000000000000aa557fc001824c00020002050000000000000000000000000000000000000000000000000000353630303045534e3030415730303030000000000000000000000000000000000e30323032302d30302d53000508c3marcelblijleven-goodwe-a4b8c7f/tests/sample/et/000077500000000000000000000000001463365355300217025ustar00rootroot00000000000000marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GEH10-1U-10_device_info.hex000066400000000000000000000002221463365355300262030ustar00rootroot00000000000000aa55f703420000177000fe303030303048534230303030303030304757363030302d454820000300030145001000bc30343033342d30332d53313030323034312d31362d5330304232marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GEH10-1U-10_running_data.hex000066400000000000000000000010021463365355300263770ustar00rootroot00000000000000aa55f703fa17011a0b22070977007300000ad908570073000009970d05006e00000e38073500680000077b02020202097d016d13860000220606fcffffffff7fffffff0320ffffffff7fffffff000100002206000010b5fffff98e000022a109600007138600010000004d06fb0006ffffffff000000000000ffffffffffffffffffff000011047fffffff800000000000004d0000110400010000ff9c029e010111e8ffff0fddffdafffff9e20003000000090001ffff0000000000018f72000000e70001914d00000cb800a60000000000000000aba3006b0000058b006000000497001a000800070001000000000000000000000200098000020658ffffcef3marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_battery_info.hex000066400000000000000000000001561463365355300262560ustar00rootroot00000000000000aa55f7033000ff01000001015e001900190000004400630005000001010000000000000000000000000000000000000000000000006447marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_device_info_fw1023.hex000066400000000000000000000002221463365355300270370ustar00rootroot00000000000000aa55f703420002271000fe393031304b4554553030305730303030475731304b2d45542020000a000a00a7001700ed30343032392d31302d53313130323034312d32332d5330300d8fmarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_device_info_fw617.hex000066400000000000000000000002221463365355300267670ustar00rootroot00000000000000aa55f703420001271000fe393031304b4554553030305730303030475731304b2d45542020000600060098001100c030343032392d30362d53313130323034312d31372d5330304fc8marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_device_info_fw819.hex000066400000000000000000000002221463365355300267730ustar00rootroot00000000000000aa55f703420001271000fe393031304b455455303030303030303030475731304b2d45542000080008009f001300cf30343032392d30382d53313130323034312d31392d5330300b61marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_meter_data.hex000066400000000000000000000003021463365355300256670ustar00rootroot00000000000000aa55f7035a00010023000a00000001ffc7ffd2fffaff920538ff6fff84fff2ffb0138d462449804a46a2f9ffffffc7ffffffd2fffffffaffffff920000016c000001650000026600000538fffffe6efffffe8cfffffd8dfffffa8500010003a1famarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_running_data.hex000066400000000000000000000010021463365355300262310ustar00rootroot00000000000000aa55f703fa1508160b0b0c0cfe00330000069f0cfe0035000006e100000000000000000000000000000000000002020959000f138700000150096f000d13870000011f096b000b1387000000ce00010000033ffffffffd000000000000000009560006138600010000006b096d000913880001000000bd096c00021387000100000000000000e000000050000000e9000001380000020a000401fe0000024b00001f640fb209eeff9efffff63000030000002000010000000000000000edb50000007d0000b8520000241e00620000024400000001588a007400006bbd003500005f65001d0005000000010000000000000000000107000800000209ee000055aemarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW10K-ET_running_data_fw1023.hex000066400000000000000000000020041463365355300272360ustar00rootroot00000000000000aa55f703fa18050b000322000000000000000000000000000000000000000000000000000000000000000000000000098c00141380000001b3098b000c1380000000f10991000813800000008400010000032cfffffff50000000000000000098a000d13800001000000d9098600061380000100000072098f00031380000100000005000000810000006c000000f700000141000001f60005016c0000014540001ee30f6f0f8300140000033400020000002000010000000000000004ac8500000000000426d50000811f0000000002bf00000005658000000001821b00000001519600010008000000000000000000000000000002000040000303580000e9feaa55f703fa18050b000322000000000000000000000000000000000000000000000000000000000000000000000000098c00141380000001b3098b000c1380000000f10991000813800000008400010000032cfffffff50000000000000000098a000d13800001000000d9098600061380000100000072098f00031380000100000005000000810000006c000000f700000141000001f60005016c0000014540001ee30f6f0f8300140000033400020000002000010000000000000004ac8500000000000426d50000811f0000000002bf00000005658000000001821b00000001519600010008000000000000000000000000000002000040000303580000e9femarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW25K-ET_battery_info.hex000066400000000000000000000001561463365355300262640ustar00rootroot00000000000000aa55f7033000ff0137000100e600000028000000640064000400000105000000000316000000000000000000000000000000000000dc7amarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW25K-ET_device_info.hex000066400000000000000000000002221463365355300260430ustar00rootroot00000000000000aa55f70342000061a80001393032354b4554543030303030303030000000000000000000000006000617810008016a00300034003000360032002d30323032302d30382d533031572amarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW25K-ET_meter_data.hex000066400000000000000000000003661463365355300257070ustar00rootroot00000000000000aa55f7037400020064000a01110001ff30ff5aff8efe1704ad5eac7e5091d879a013870000000000000000ffffff30ffffff5affffff8efffffe17000001a5000001b80000014f000004adfffffe01fffffe03fffffe40fffffa4200020005000000000000000000000000000008f208f808f00016001600130540marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW25K-ET_mppt_data.hex000066400000000000000000000004021463365355300255420ustar00rootroot00000000000000aa55f7037a0000021100020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009030903090300000000ffffffff00e8012b00000000000000000000000000030004000000000000000000000000000000000000000000000000000000000000026emarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW25K-ET_running_data.hex000066400000000000000000000010021463365355300262370ustar00rootroot00000000000000aa55f703fa170c030e07071cd3000e000004091cd30000000003d51d82000d000000001d82000000000000000002020905001d13830000024d0906001b1385000002290900002a138500000323000100000799000005e7000004d7000008d308f7001e138300000000003408fc0012138500000000000f08f6002013850000000001580000002c0000001000000153000001980000001a000701ce000001ae00001e350f1a0868000000000000000200000020000100000000000000000643000000930000056100000184001d00000094000a000000ac000200000391006e000002b800000004000000000000000000000000000002040180000200008f005ecemarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW29K9-ET_battery2_info.hex000066400000000000000000000001461463365355300264420ustar00rootroot00000000000000aa55f7032c0000000000000000100000000000000600000120000000000000000000000000000000000000000000000000f59bmarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW29K9-ET_battery_info.hex000066400000000000000000000001561463365355300263610ustar00rootroot00000000000000aa55f7033000ff01ff00000000000000001000000000000006000001ff0000000000000000000000000000000000000000000000009440marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW29K9-ET_device_info.hex000066400000000000000000000002221463365355300261400ustar00rootroot00000000000000aa55f70342000074cc00013932394b3945545430304357303030300000000000000000000000020002004f0003004300300034003000360032002d30323032302d30332d533031b87amarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW29K9-ET_meter_data.hex000066400000000000000000000003661463365355300260040ustar00rootroot00000000000000aa55f7037400000064000a00000001fda9fdc9f4a1f0130b0fe714ecaadc2ee2dc13870000000000000000fffffda9fffffdc9fffff4a1fffff01300000287000004510000043500000b0ffffffbccfffffa7afffff399ffffe9dd0002ffff00000000000000000000000000000907091c0916002e003c0088d515marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW29K9-ET_mppt_data.hex000066400000000000000000000004021463365355300256370ustar00rootroot00000000000000aa55f7037a0000061d00030bc700140bc700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000091d091d091d00000000ffffffff01d702d9016d000000000000000000000007000d000d0000000000000000000000000000000000000000000000000000000079bfmarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW29K9-ET_running_data.hex000066400000000000000000000010021463365355300263340ustar00rootroot00000000000000aa55f703fa1801110e310e1aad000f000001de1aad0000000002a7168d001200000186168d000000000000000202020909001d1387000002470919001b1387000002350920001d13850000024b0001000006c7ffffeae500000133000007b708fb00071386000000000015090b0007138800000000000509190006138500000000002500000287000002800000028b0000004200001ba0000100f1000000cd00001db40eda0000ffff0000000000000000002000010000000000000000b237000000090000af6100000497000c0000005700000001a39e01b600000000000000000000000000060000000000000000000000000000020400ce00000000030064b6marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW5K-BT_device_info.hex000066400000000000000000000002221463365355300257560ustar00rootroot00000000000000aa55f703420000138800fe393530303042545532303357303030304757354b2d425420202000030003007c000b009330343032392d30332d53313030323034312d31312d533030828fmarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW6000-ES-20_device_info.hex000066400000000000000000000002221463365355300263050ustar00rootroot00000000000000aa55f70342007917a20000353630303045534e303041573030303047573630303045533230000200020dc700050108ffffffffffffffffffffffff30323032302d30352d533031a4b2marcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW6000_EH_device_info.hex000066400000000000000000000002221463365355300261350ustar00rootroot00000000000000aa55f703420000177000fe303030303045485530303030303030304757363030302d454820000300030145001000bc30343033342d30332d53313030323034312d31362d533030d99bmarcelblijleven-goodwe-a4b8c7f/tests/sample/et/GW6000_EH_running_data.hex000066400000000000000000000010021463365355300263310ustar00rootroot00000000000000aa55f703fa1508081228090ce7001a000003590ce00015000002b3ffffffffffffffffffffffffffffffff00000202093e0042138500000619ffffffffffff7fffffffffffffffffff7fffffff0001000006190000ff5c7fffffffffffffff000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffff000006bc7fffffff7fffffff00000000000006bd0000025c7fff018201000edeffff00000001000000000000000000030001ffff0000000000000252000000dc0000024a0000002100d8000000000000000002bd010f000000000000000000000000000700000000000000000000400000010708484700000000ffffbcb6marcelblijleven-goodwe-a4b8c7f/tests/sample/et/eco_mode_v1.hex000066400000000000000000000000361463365355300245670ustar00rootroot00000000000000aa55f703080000173b0014007f6f5amarcelblijleven-goodwe-a4b8c7f/tests/sample/et/eco_mode_v2.hex000066400000000000000000000000461463365355300245710ustar00rootroot00000000000000aa55f7030c0000173b007f0014006400008d53marcelblijleven-goodwe-a4b8c7f/tests/sample/inverter_data000066400000000000000000000002271463365355300240450ustar00rootroot00000000000000 14æ +н/ÞFmarcelblijleven-goodwe-a4b8c7f/tests/sample/mock_data.txt000066400000000000000000000036571463365355300237700ustar00rootroot00000000000000# def mock_data() -> bytes: # data = b'\x00\x00\x00\x00\x00\x15\x08\x08\n14\x03\xe6\x00\x12\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ # b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\t+\x00\x00\x00\x00\x00\x07\x00' \ # b'\x00\x00\x00\x13\x8a\x00\x00\x00\x00\x00\x00\x00\xbd\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ # b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01/\x00\x00\x00\x00\x00\x03\x00\x00\x07\xde' \ # b'\x00\x00\x03\x7f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ # b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ # b'\x00\x00\x00F' # data = bytes() # zero = (0).to_bytes(1, 'big') # data += zero * 5 # data += (21).to_bytes(1, 'big') # pos 5 # data += (8).to_bytes(1, 'big') # pos 6 # data += (8).to_bytes(1, 'big') # pos 7 # data += (10).to_bytes(1, 'big') # pos 8 # data += (49).to_bytes(1, 'big') # pos 9 # data += (52).to_bytes(1, 'big') # pos 10 # data += (998).to_bytes(2, 'big') # pos 11 # data += (18).to_bytes(2, 'big') # pos 13 # data += zero * 26 # data += (2347).to_bytes(2, 'big') # pos 41 # data += zero * 4 # data += (7).to_bytes(2, 'big') # pos 47 # data += zero * 4 # data += (5002).to_bytes(2, 'big') # pos 53 # data += zero * 6 # data += (189).to_bytes(2, 'big') # pos 61 # data += (1).to_bytes(2, 'big') # pos 63 # data += zero * 22 # data += (303).to_bytes(2, 'big') # pos 87 # data += zero * 4 # data += (3).to_bytes(2, 'big') # pos 93 # data += zero * 2 # data += (2014).to_bytes(2, 'big') # pos 97 # data += zero * 2 # data += (895).to_bytes(2, 'big') # pos 101 # data += zero * 46 # data += (70).to_bytes(2, 'big') # pos 149 # return data marcelblijleven-goodwe-a4b8c7f/tests/stability_check.py000066400000000000000000000057211463365355300235310ustar00rootroot00000000000000import asyncio import logging import sys from importlib.metadata import version from pymodbus.client import AsyncModbusTcpClient # Force the local files, not pip installed lib sys.path.insert(0, '..') sys.path.insert(0, '../../../GoodWe') import goodwe logging.basicConfig( format="%(asctime)-15s %(funcName)s(%(lineno)d) - %(levelname)s: %(message)s", stream=sys.stderr, level=getattr(logging, "DEBUG", None), ) logger = logging.getLogger(__name__) if sys.platform.startswith('win'): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) try: module_ver = version('goodwe') print("WARNING !!!") print("==============================") print(f"You are executing code with installed pip version goodwe:{module_ver}") print("You are not testing the local files, if that was what you meant !!!") print("==============================") except ModuleNotFoundError: pass async def pymodbus(ip): client = AsyncModbusTcpClient(host=ip) # Create client object await client.connect() # connect to device, reconnect automatically await client.read_holding_registers(35000, 33, slave=0xf7) await client.read_holding_registers(47547, 6, slave=0xf7) await client.read_holding_registers(47589, 6, slave=0xf7) i = 4 while True: logger.info("################################") logger.info(" Request %d", i) logger.info("################################") await client.read_holding_registers(35100, 125, slave=0xf7) await client.read_holding_registers(37000, 24, slave=0xf7) await client.read_holding_registers(36000, 45, slave=0xf7) # await client.read_holding_registers(36000, 58, slave=0xf7) # await client.read_holding_registers(35301, 61, slave=0xf7) await asyncio.sleep(10) i += 1 async def read_modbus_range(ip, port, register, length): inverter = await goodwe.connect(host=ip, port=port, family="ET", timeout=1, retries=3) # inverter.set_keep_alive(False) i = 1 while True: logger.info("################################") logger.info(" Request %d", i) logger.info("################################") await goodwe.protocol.ModbusRtuReadCommand(0xf7, register, length).execute( goodwe.protocol.UdpInverterProtocol(ip, port, 1, 3)) await asyncio.sleep(5) i += 1 async def get_runtime_data(ip, port): inverter = await goodwe.connect(host=ip, port=port, family="ET", timeout=1, retries=3) # inverter.set_keep_alive(False) i = 1 while True: logger.info("################################") logger.info(" Request %d", i) logger.info("################################") await inverter.read_runtime_data() await asyncio.sleep(5) i += 1 # asyncio.run(pymodbus('127.0.0.1')) # asyncio.run(read_modbus_range('192.168.2.14', 8899, 35100, 125)) asyncio.run(get_runtime_data('127.0.0.1', 502)) marcelblijleven-goodwe-a4b8c7f/tests/test_dt.py000066400000000000000000000677061463365355300220510ustar00rootroot00000000000000import asyncio import os from datetime import datetime from unittest import TestCase from goodwe.dt import DT from goodwe.exceptions import RequestFailedException, RequestRejectedException from goodwe.modbus import ILLEGAL_DATA_ADDRESS from goodwe.protocol import ProtocolCommand, ProtocolResponse class DtMock(TestCase, DT): def __init__(self, methodName='runTest', port=8899): TestCase.__init__(self, methodName) DT.__init__(self, "localhost", port) self.sensor_map = {s.id_: s for s in self.sensors()} self._mock_responses = {} def mock_response(self, command: ProtocolCommand, filename: str): self._mock_responses[command] = filename async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse: """Mock UDP communication""" root_dir = os.path.dirname(os.path.abspath(__file__)) filename = self._mock_responses.get(command) if filename is not None: if ILLEGAL_DATA_ADDRESS == filename: raise RequestRejectedException(ILLEGAL_DATA_ADDRESS) if 'NO RESPONSE' == filename: raise RequestFailedException() with open(root_dir + '/sample/dt/' + filename, 'r') as f: response = bytes.fromhex(f.read()) if not command.validator(response): raise RequestFailedException return ProtocolResponse(response, command) else: self.request = command.request return ProtocolResponse(bytes.fromhex("aa557f00010203040506070809"), command) def assertSensor(self, sensor_name, expected_value, expected_unit, data): self.assertEqual(expected_value, data.get(sensor_name)) sensor = self.sensor_map.get(sensor_name); self.assertEqual(expected_unit, sensor.unit) self.sensor_map.pop(sensor_name) @classmethod def setUpClass(cls): cls.loop = asyncio.get_event_loop() class GW6000_DT_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_RUNNING_DATA, 'GW6000-DT_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW6000_DT_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(42, len(data)) self.sensor_map = {s.id_: s for s in self.sensors()} self.assertSensor('timestamp', datetime.strptime('2021-08-31 12:03:02', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 320.8, 'V', data) self.assertSensor('ipv1', 3.1, 'A', data) self.assertSensor('ppv1', 994, 'W', data) self.assertSensor('vpv2', 324.1, 'V', data) self.assertSensor('ipv2', 3.2, 'A', data) self.assertSensor('ppv2', 1037, 'W', data) self.assertSensor('ppv', 2031, 'W', data) self.assertSensor('vline1', 0, 'V', data) self.assertSensor('vline2', 0, 'V', data) self.assertSensor('vline3', 0, 'V', data) self.assertSensor('vgrid1', 225.6, 'V', data) self.assertSensor('vgrid2', 229.7, 'V', data) self.assertSensor('vgrid3', 231.0, 'V', data) self.assertSensor('igrid1', 2.7, 'A', data) self.assertSensor('igrid2', 2.6, 'A', data) self.assertSensor('igrid3', 2.7, 'A', data) self.assertSensor('fgrid1', 49.98, 'Hz', data) self.assertSensor('fgrid2', 49.98, 'Hz', data) self.assertSensor('fgrid3', 49.98, 'Hz', data) self.assertSensor('pgrid1', 609, 'W', data) self.assertSensor('pgrid2', 597, 'W', data) self.assertSensor('pgrid3', 624, 'W', data) self.assertSensor('total_inverter_power', 1835, 'W', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor("apparent_power", -1, "VA", data), self.assertSensor("reactive_power", -1, "var", data), self.assertSensor("power_factor", 0.0, "", data), self.assertSensor('temperature', 41.3, 'C', data) self.assertSensor('e_day', 6.0, 'kWh', data) self.assertSensor('e_total', 13350.2, 'kWh', data) self.assertSensor('h_total', 8451, 'h', data) self.assertSensor('safety_country', 20, '', data) self.assertSensor('safety_country_label', 'NL-A', '', data) self.assertSensor('funbit', 0, '', data) self.assertSensor('vbus', 601.2, 'V', data) self.assertSensor('vnbus', 305.4, 'V', data) self.assertSensor('derating_mode', 0, '', data) self.assertSensor('derating_mode_label', '', '', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") def test_GW6000_DT_setting(self): self.assertEqual(8, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('Timestamp', type(settings.get("time")).__name__) self.assertEqual('Integer', type(settings.get("grid_export")).__name__) self.assertEqual('Integer', type(settings.get("grid_export_limit")).__name__) def test_GW6000_DT_read_setting(self): self.loop.run_until_complete(self.read_setting('shadow_scan')) self.assertEqual('7f039d8600014051', self.request.hex()) def test_GW6000_DT_write_setting(self): self.loop.run_until_complete(self.write_setting('shadow_scan', 1)) self.assertEqual('7f069d8600018c51', self.request.hex()) class GW8K_DT_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW8K-DT_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW8K-DT_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW8K_DT_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW8K-DT', self.model_name) self.assertEqual('00000DTS00000000', self.serial_number) self.assertEqual(1010, self.dsp1_version) self.assertEqual(1010, self.dsp2_version) self.assertEqual(728, self.dsp_svn_version) self.assertEqual(8, self.arm_version) self.assertEqual(49, self.arm_svn_version) self.assertEqual('1010.1010.08', self.firmware) def test_GW8K_DT_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(42, len(data)) self.assertSensor('timestamp', datetime.strptime('2021-08-24 16:43:27', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 275.5, 'V', data) self.assertSensor('ipv1', 0.6, 'A', data) self.assertSensor('ppv1', 165, 'W', data) self.assertSensor('vpv2', 510.8, 'V', data) self.assertSensor('ipv2', 0.8, 'A', data) self.assertSensor('ppv2', 409, 'W', data) self.assertSensor('ppv', 574, 'W', data) self.assertSensor('vline1', 413.7, 'V', data) self.assertSensor('vline2', 413.0, 'V', data) self.assertSensor('vline3', 408.0, 'V', data) self.assertSensor('vgrid1', 237.2, 'V', data) self.assertSensor('vgrid2', 240.5, 'V', data) self.assertSensor('vgrid3', 235.2, 'V', data) self.assertSensor('igrid1', 1.0, 'A', data) self.assertSensor('igrid2', 1.0, 'A', data) self.assertSensor('igrid3', 1.0, 'A', data) self.assertSensor('fgrid1', 50.08, 'Hz', data) self.assertSensor('fgrid2', 50.04, 'Hz', data) self.assertSensor('fgrid3', 50.0, 'Hz', data) self.assertSensor('pgrid1', 237, 'W', data) self.assertSensor('pgrid2', 240, 'W', data) self.assertSensor('pgrid3', 235, 'W', data) self.assertSensor('total_inverter_power', 643, 'W', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor("apparent_power", 0, "VA", data), self.assertSensor("reactive_power", 0, "var", data), self.assertSensor("power_factor", 0.0, "", data), self.assertSensor('temperature', 45.3, 'C', data) self.assertSensor('e_day', None, 'kWh', data) self.assertSensor('e_total', None, 'kWh', data) self.assertSensor('h_total', 0, 'h', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('funbit', 512, '', data) self.assertSensor('vbus', 624.2, 'V', data) self.assertSensor('vnbus', 316.8, 'V', data) self.assertSensor('derating_mode', 0, '', data) self.assertSensor('derating_mode_label', '', '', data) def test_get_grid_export_limit(self): self.loop.run_until_complete(self.read_device_info()) self.loop.run_until_complete(self.get_grid_export_limit()) self.assertEqual('7f039d900001a195', self.request.hex()) def test_set_grid_export_limit(self): self.loop.run_until_complete(self.read_device_info()) self.loop.run_until_complete(self.set_grid_export_limit(100)) self.assertEqual('7f069d900064adbe', self.request.hex()) class GW5000D_NS_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'Mock_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW5000D-NS_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW5000D_NS_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(32, len(data)) self.assertSensor('timestamp', datetime.strptime('2021-09-06 06:56:01', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 224.4, 'V', data) self.assertSensor('ipv1', 0.0, 'A', data) self.assertSensor('ppv1', 0, 'W', data) self.assertSensor('vpv2', 291.8, 'V', data) self.assertSensor('ipv2', 0, 'A', data) self.assertSensor('ppv2', 0, 'W', data) self.assertSensor('ppv', 0, 'W', data) self.assertSensor('vline1', 0, 'V', data) self.assertSensor('vgrid1', 240.5, 'V', data) self.assertSensor('igrid1', 0.0, 'A', data) self.assertSensor('fgrid1', 49.97, 'Hz', data) self.assertSensor('pgrid1', 0, 'W', data) self.assertSensor('total_inverter_power', 0, 'W', data) self.assertSensor('work_mode', 0, '', data) self.assertSensor('work_mode_label', 'Wait Mode', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor("apparent_power", -1, "VA", data), self.assertSensor("reactive_power", -1, "var", data), self.assertSensor("power_factor", -0.001, "", data), self.assertSensor('temperature', 1.4, 'C', data) self.assertSensor('e_day', 0, 'kWh', data) self.assertSensor('e_total', 881.7, 'kWh', data) self.assertSensor('h_total', 955, 'h', data) self.assertSensor('safety_country', 73, '', data) self.assertSensor('safety_country_label', 'Australia Victoria', '', data) self.assertSensor('funbit', 2400, '', data) self.assertSensor('vbus', 291.7, 'V', data) self.assertSensor('vnbus', 0, 'V', data) self.assertSensor('derating_mode', 0, '', data) self.assertSensor('derating_mode_label', '', '', data) def test_get_grid_export_limit(self): self.loop.run_until_complete(self.read_device_info()) self.loop.run_until_complete(self.get_grid_export_limit()) self.assertEqual('7f039d8800026193', self.request.hex()) def test_set_grid_export_limit(self): self.loop.run_until_complete(self.read_device_info()) self.loop.run_until_complete(self.set_grid_export_limit(5000)) self.assertEqual('7f109d88000204000013889d80', self.request.hex()) class GW5000_MS_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW5000-MS_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW5000-MS_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW6000_MS_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW5000-MS', self.model_name) self.assertEqual('00000MSU00000000', self.serial_number) self.assertEqual(12, self.dsp1_version) self.assertEqual(12, self.dsp2_version) self.assertEqual(65535, self.dsp_svn_version) self.assertEqual(16, self.arm_version) self.assertEqual(271, self.arm_svn_version) self.assertEqual('12.12.10', self.firmware) def test_GW5000_MS_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(35, len(data)) self.assertSensor('timestamp', datetime.strptime('2021-10-15 09:03:12', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 319.6, 'V', data) self.assertSensor('ipv1', 0.2, 'A', data) self.assertSensor('ppv1', 64, 'W', data) self.assertSensor('vpv2', 148.0, 'V', data) self.assertSensor('ipv2', 0.3, 'A', data) self.assertSensor('ppv2', 44, 'W', data) self.assertSensor('vpv3', 143.2, 'V', data) self.assertSensor('ipv3', 0.4, 'A', data) self.assertSensor('ppv3', 57, 'W', data) self.assertSensor('ppv', 165, 'W', data) self.assertSensor('vline1', 0, 'V', data) self.assertSensor('vgrid1', 240.1, 'V', data) self.assertSensor('igrid1', 0.9, 'A', data) self.assertSensor('fgrid1', 49.98, 'Hz', data) self.assertSensor('pgrid1', 216, 'W', data) self.assertSensor('total_inverter_power', 295, 'W', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor("apparent_power", -1, "VA", data), self.assertSensor("reactive_power", -1, "var", data), self.assertSensor("power_factor", -0.001, "", data), self.assertSensor('temperature', 10.7, 'C', data) self.assertSensor('e_day', 0.4, 'kWh', data) self.assertSensor('e_total', 6.8, 'kWh', data) self.assertSensor('h_total', 7, 'h', data) self.assertSensor('safety_country', 73, '', data) self.assertSensor('safety_country_label', 'Australia Victoria', '', data) self.assertSensor('funbit', 2384, '', data) self.assertSensor('vbus', 393.9, 'V', data) self.assertSensor('vnbus', 0, 'V', data) self.assertSensor('derating_mode', 0, '', data) self.assertSensor('derating_mode_label', '', '', data) class GW10K_MS_30_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW10K-MS-30_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW10K-MS-30_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW10K_MS_30_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual(None, self.model_name) self.assertEqual('5010KMSC000W0000', self.serial_number) self.assertEqual(0, self.dsp1_version) self.assertEqual(0, self.dsp2_version) self.assertEqual(504, self.dsp_svn_version) self.assertEqual(2, self.arm_version) self.assertEqual(13, self.arm_svn_version) self.assertEqual('0.0.02', self.firmware) def test_GW10K_MS_30_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(35, len(data)) self.assertSensor('timestamp', datetime.strptime('2024-01-09 22:08:20', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 0.0, 'V', data) self.assertSensor('ipv1', 0.0, 'A', data) self.assertSensor('ppv1', 0, 'W', data) self.assertSensor('vpv2', 0.0, 'V', data) self.assertSensor('ipv2', 0.0, 'A', data) self.assertSensor('ppv2', 0, 'W', data) self.assertSensor('vpv3', 0.0, 'V', data) self.assertSensor('ipv3', 0.0, 'A', data) self.assertSensor('ppv3', 0, 'W', data) self.assertSensor('ppv', 0, 'W', data) self.assertSensor('vline1', 0.0, 'V', data) self.assertSensor('vgrid1', 236.2, 'V', data) self.assertSensor('igrid1', 0.0, 'A', data) self.assertSensor('fgrid1', 50.0, 'Hz', data) self.assertSensor('pgrid1', 0, 'W', data) self.assertSensor('total_inverter_power', 0, 'W', data) self.assertSensor('work_mode', 0, '', data) self.assertSensor('work_mode_label', 'Wait Mode', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor("apparent_power", 0, "VA", data), self.assertSensor("reactive_power", 0, "var", data), self.assertSensor("power_factor", 0.0, "", data), self.assertSensor('temperature', 24.3, 'C', data) self.assertSensor('e_day', 71.8, 'kWh', data) self.assertSensor('e_total', 3433.4, 'kWh', data) self.assertSensor('h_total', 971, 'h', data) self.assertSensor('safety_country', 9, '', data) self.assertSensor('safety_country_label', 'Australia A', '', data) self.assertSensor('funbit', 0, '', data) self.assertSensor('vbus', 5.1, 'V', data) self.assertSensor('vnbus', 0.0, 'V', data) self.assertSensor('derating_mode', 0, '', data) self.assertSensor('derating_mode_label', '', '', data) class GW10K_MS_TCP_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName, 502) self.mock_response(self._READ_RUNNING_DATA, 'GW10K-MS-30_tcp_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW10K_MS_TCP_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(42, len(data)) self.assertSensor('timestamp', datetime.strptime('2024-06-02 09:07:17', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 400.6, 'V', data) self.assertSensor('ipv1', 6.9, 'A', data) self.assertSensor('ppv1', 2764, 'W', data) self.assertSensor('vpv2', 364.0, 'V', data) self.assertSensor('ipv2', 3.6, 'A', data) self.assertSensor('ppv2', 1310, 'W', data) self.assertSensor('ppv', 6143, 'W', data) self.assertSensor('vline1', 0, 'V', data) self.assertSensor('vline2', 0, 'V', data) self.assertSensor('vline3', 0, 'V', data) self.assertSensor('vgrid1', 241.1, 'V', data) self.assertSensor('vgrid2', 0, 'V', data) self.assertSensor('vgrid3', 0, 'V', data) self.assertSensor('igrid1', 24.7, 'A', data) self.assertSensor('igrid2', 0, 'A', data) self.assertSensor('igrid3', 0, 'A', data) self.assertSensor('fgrid1', 49.98, 'Hz', data) self.assertSensor('fgrid2', -0.01, 'Hz', data) self.assertSensor('fgrid3', -0.01, 'Hz', data) self.assertSensor('pgrid1', 5955, 'W', data) self.assertSensor('pgrid2', 0, 'W', data) self.assertSensor('pgrid3', 0, 'W', data) self.assertSensor('total_inverter_power', 5914, 'W', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('apparent_power', 5957, 'VA', data) self.assertSensor('reactive_power', -6, 'var', data) self.assertSensor("power_factor", 0.999, "", data), self.assertSensor('temperature', 36.0, 'C', data) self.assertSensor('e_day', 4.3, 'kWh', data) self.assertSensor('e_total', 998.2, 'kWh', data) self.assertSensor('h_total', 246, 'h', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('funbit', 0, '', data) self.assertSensor('vbus', 397.3, 'V', data) self.assertSensor('vnbus', 0, 'V', data) self.assertSensor('derating_mode', 0, '', data) self.assertSensor('derating_mode_label', '', '', data) class GW20KAU_DT_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW20KAU-DT_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW20KAU-DT_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW20KAU_DT_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW20KAU-DT', self.model_name) self.assertEqual('0000KDTA00000000', self.serial_number) self.assertEqual(15, self.dsp1_version) self.assertEqual(15, self.dsp2_version) self.assertEqual(1099, self.dsp_svn_version) self.assertEqual(16, self.arm_version) self.assertEqual(187, self.arm_svn_version) self.assertEqual('15.15.10', self.firmware) def test_GW20KAU_DT_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(42, len(data)) self.assertSensor('timestamp', datetime.strptime('2022-10-21 19:23:42', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 390.5, 'V', data) self.assertSensor('ipv1', 6.8, 'A', data) self.assertSensor('ppv1', 2655, 'W', data) self.assertSensor('vpv2', 351.6, 'V', data) self.assertSensor('ipv2', 7.1, 'A', data) self.assertSensor('ppv2', 2496, 'W', data) self.assertSensor('ppv', 5151, 'W', data) self.assertSensor('vline1', 388.5, 'V', data) self.assertSensor('vline2', 391.7, 'V', data) self.assertSensor('vline3', 394.5, 'V', data) self.assertSensor('vgrid1', 226.1, 'V', data) self.assertSensor('vgrid2', 223.6, 'V', data) self.assertSensor('vgrid3', 228.3, 'V', data) self.assertSensor('igrid1', 7.2, 'A', data) self.assertSensor('igrid2', 7.4, 'A', data) self.assertSensor('igrid3', 7.1, 'A', data) self.assertSensor('fgrid1', 49.96, 'Hz', data) self.assertSensor('fgrid2', 49.96, 'Hz', data) self.assertSensor('fgrid3', 49.97, 'Hz', data) self.assertSensor('pgrid1', 1628, 'W', data) self.assertSensor('pgrid2', 1655, 'W', data) self.assertSensor('pgrid3', 1621, 'W', data) self.assertSensor('total_inverter_power', 4957, 'W', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor("apparent_power", 0, "VA", data), self.assertSensor("reactive_power", 205, "var", data), self.assertSensor("power_factor", 0.999, "", data), self.assertSensor('temperature', 36.4, 'C', data) self.assertSensor('e_day', 19.8, 'kWh', data) self.assertSensor('e_total', 4304.8, 'kWh', data) self.assertSensor('h_total', 1139, 'h', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('funbit', 0, '', data) self.assertSensor('vbus', 596.3, 'V', data) self.assertSensor('vnbus', 298.9, 'V', data) self.assertSensor('derating_mode', 4, '', data) self.assertSensor('derating_mode_label', 'Reactive power derating(PF/QU/FixQ)', '', data) class GW17K_DT_Test(DtMock): def __init__(self, methodName='runTest'): DtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW17K-DT_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW17K-DT_running_data.hex') self.mock_response(self._READ_METER_DATA, ILLEGAL_DATA_ADDRESS) def test_GW20KAU_DT_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW17KT-DT', self.model_name) self.assertEqual('5017KDTT00BW0000', self.serial_number) self.assertEqual(12, self.dsp1_version) self.assertEqual(12, self.dsp2_version) self.assertEqual(931, self.dsp_svn_version) self.assertEqual(13, self.arm_version) self.assertEqual(130, self.arm_svn_version) self.assertEqual('12.12.0d', self.firmware) def test_GW20KAU_DT_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(42, len(data)) self.assertSensor('timestamp', datetime.strptime('2024-05-20 10:35:55', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 540.0, 'V', data) self.assertSensor('ipv1', 10.5, 'A', data) self.assertSensor('ppv1', 5670, 'W', data) self.assertSensor('vpv2', 475.5, 'V', data) self.assertSensor('ipv2', 14.8, 'A', data) self.assertSensor('ppv2', 7037, 'W', data) self.assertSensor('ppv', 12707, 'W', data) self.assertSensor('vline1', 413.0, 'V', data) self.assertSensor('vline2', 411.5, 'V', data) self.assertSensor('vline3', 409.5, 'V', data) self.assertSensor('vgrid1', 236.7, 'V', data) self.assertSensor('vgrid2', 238.3, 'V', data) self.assertSensor('vgrid3', 237.3, 'V', data) self.assertSensor('igrid1', 17.6, 'A', data) self.assertSensor('igrid2', 17.5, 'A', data) self.assertSensor('igrid3', 17.5, 'A', data) self.assertSensor('fgrid1', 50.02, 'Hz', data) self.assertSensor('fgrid2', 50.02, 'Hz', data) self.assertSensor('fgrid3', 50.02, 'Hz', data) self.assertSensor('pgrid1', 4166, 'W', data) self.assertSensor('pgrid2', 4170, 'W', data) self.assertSensor('pgrid3', 4153, 'W', data) self.assertSensor('total_inverter_power', 12470, 'W', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal', '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('apparent_power', 0, 'VA', data) self.assertSensor('reactive_power', 0, 'var', data) self.assertSensor("power_factor", 0.0, "", data), self.assertSensor('temperature', 45.7, 'C', data) self.assertSensor('e_day', 29.3, 'kWh', data) self.assertSensor('e_total', 29984.4, 'kWh', data) self.assertSensor('h_total', 8357, 'h', data) self.assertSensor('safety_country', 1, '', data) self.assertSensor('safety_country_label', 'CZ-A1', '', data) self.assertSensor('funbit', 546, '', data) self.assertSensor('vbus', 621.8, 'V', data) self.assertSensor('vnbus', 314.2, 'V', data) self.assertSensor('derating_mode', 4, '', data) self.assertSensor('derating_mode_label', 'Reactive power derating(PF/QU/FixQ)', '', data) marcelblijleven-goodwe-a4b8c7f/tests/test_es.py000066400000000000000000000722731463365355300220440ustar00rootroot00000000000000import asyncio import os from datetime import datetime from unittest import TestCase from goodwe import DISCOVERY_COMMAND from goodwe.es import ES from goodwe.exceptions import RequestFailedException from goodwe.inverter import OperationMode from goodwe.protocol import Aa55ReadCommand, ProtocolCommand, ProtocolResponse class EsMock(TestCase, ES): def __init__(self, methodName='runTest'): TestCase.__init__(self, methodName) ES.__init__(self, "localhost", 8899) self.sensor_map = {s.id_: s.unit for s in self.sensors()} self._mock_responses = {} def mock_response(self, command: ProtocolCommand, filename: str): self._mock_responses[command] = filename async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse: """Mock UDP communication""" root_dir = os.path.dirname(os.path.abspath(__file__)) filename = self._mock_responses.get(command) if filename is not None: if filename.startswith('aa55'): return ProtocolResponse(bytes.fromhex(filename), command) with open(root_dir + '/sample/es/' + filename, 'r') as f: response = bytes.fromhex(f.read()) if not command.validator(response): raise RequestFailedException return ProtocolResponse(response, command) else: self.request = command.request return ProtocolResponse(bytes.fromhex("010203040506070809"), command) def assertSensor(self, sensor, expected_value, expected_unit, data): self.assertEqual(expected_value, data.get(sensor)) self.assertEqual(expected_unit, self.sensor_map.get(sensor)) self.sensor_map.pop(sensor) @classmethod def setUpClass(cls): cls.loop = asyncio.get_event_loop() class GW5048D_ES_Test(EsMock): def __init__(self, methodName='runTest'): EsMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW5048D-ES_device_info.hex') self.mock_response(self._READ_DEVICE_RUNNING_DATA, 'GW5048D-ES_running_data.hex') self.mock_response(self._READ_DEVICE_SETTINGS_DATA, 'GW5048D-ES_settings_data.hex') self.mock_response(Aa55ReadCommand(1793, 1), 'aa557fc0019a08000000000000007f0360') self.mock_response(Aa55ReadCommand(1800, 1), 'aa557fc0019a02007f035a') def test_GW5048D_ES_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW5048D-ES', self.model_name) self.assertEqual('95048ESU227W0000', self.serial_number) self.assertEqual('2323G', self.firmware) self.assertEqual(23, self.dsp1_version) self.assertEqual(23, self.dsp2_version) self.assertEqual(16, self.arm_version) def test_GW5048D_ES_runtime_data(self): data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(57, len(data)) self.assertSensor('vpv1', 0.0, 'V', data) self.assertSensor('ipv1', 0.1, 'A', data) self.assertSensor('ppv1', 0, 'W', data) self.assertSensor('pv1_mode', 0, '', data) self.assertSensor('pv1_mode_label', 'PV panels not connected', '', data) self.assertSensor('vpv2', 0.0, 'V', data) self.assertSensor('ipv2', 0.0, 'A', data) self.assertSensor('ppv2', 0, 'W', data) self.assertSensor('pv2_mode', 0, '', data) self.assertSensor('pv2_mode_label', 'PV panels not connected', '', data) self.assertSensor('ppv', 0, 'W', data) self.assertSensor('vbattery1', 49.4, 'V', data) self.assertSensor('battery_temperature', 24.6, 'C', data) self.assertSensor('ibattery1', 9.5, 'A', data) self.assertSensor('pbattery1', 469, 'W', data) self.assertSensor('battery_charge_limit', 74, 'A', data) self.assertSensor('battery_discharge_limit', 74, 'A', data) self.assertSensor('battery_status', 80, '', data) self.assertSensor('battery_error', 0, '', data) self.assertSensor('battery_soc', 60, '%', data) self.assertSensor('battery_soh', 98, '%', data) self.assertSensor('battery_mode', 2, '', data) self.assertSensor('battery_mode_label', 'Discharge', '', data) self.assertSensor('battery_warning', 0, '', data) self.assertSensor('meter_status', 1, '', data) self.assertSensor('vgrid', 228.3, 'V', data) self.assertSensor('igrid', 2.3, 'A', data) self.assertSensor('pgrid', 2, 'W', data) self.assertSensor('fgrid', 49.83, 'Hz', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Inverter On', '', data) self.assertSensor('vload', 228.3, 'V', data) self.assertSensor('iload', 2.6, 'A', data) self.assertSensor('pload', 156, 'W', data) self.assertSensor('fload', 49.83, 'Hz', data) self.assertSensor('load_mode', 1, '', data) self.assertSensor('load_mode_label', 'The inverter is connected to a load', '', data) self.assertSensor('work_mode', 2, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('temperature', 22.9, 'C', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('e_total', 22963.2, 'kWh', data) self.assertSensor('h_total', 46332, 'h', data) self.assertSensor('e_day', 12.3, 'kWh', data) self.assertSensor('e_load_day', 13.7, 'kWh', data) self.assertSensor('e_load_total', 31348.0, 'kWh', data) self.assertSensor('total_power', 393, 'W', data) self.assertSensor('effective_work_mode', 1, '', data) self.assertSensor('effective_relay_control', 48, '', data) self.assertSensor('grid_in_out', 0, '', data) self.assertSensor('grid_in_out_label', 'Idle', '', data) self.assertSensor('pback_up', 535, 'W', data) self.assertSensor('plant_power', 691, 'W', data) self.assertSensor('meter_power_factor', 0.001, '', data) self.assertSensor('diagnose_result', 117440576, '', data) self.assertSensor('diagnose_result_label', 'Discharge Driver On, Export power limit set, PF value set, Real power limit set', '', data) # self.assertSensor('e_total_exp', 0, 'kWh', data) # self.assertSensor('e_total_imp', 0, 'kWh', data) # self.assertSensor('vgrid_uo', 0, 'V', data) # self.assertSensor('igrid_uo', 0, 'A', data) # self.assertSensor('vgrid_wo', 0, 'V', data) # self.assertSensor('igrid_wo', 0, 'A', data) # self.assertSensor('e_bat_charge_total', 0, 'kWh', data) # self.assertSensor('e_bat_discharge_total', 0, 'kWh', data) self.assertSensor('house_consumption', 467, 'W', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") def test_get_operation_modes(self): self.assertEqual((OperationMode.GENERAL, OperationMode.OFF_GRID, OperationMode.BACKUP, OperationMode.ECO), self.loop.run_until_complete(self.get_operation_modes(False))) self.assertEqual((OperationMode.GENERAL, OperationMode.OFF_GRID, OperationMode.BACKUP, OperationMode.ECO, OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE), self.loop.run_until_complete(self.get_operation_modes(True))) def test_settings(self): self.assertEqual(27, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('EcoModeV1', type(settings.get("eco_mode_1")).__name__) def test_read_setting(self): data = self.loop.run_until_complete(self.read_setting('capacity')) self.assertEqual(74, data) data = self.loop.run_until_complete(self.read_setting('charge_v')) self.assertEqual(53.2, data) data = self.loop.run_until_complete(self.read_setting('charge_i')) self.assertEqual(98, data) data = self.loop.run_until_complete(self.read_setting('discharge_i')) self.assertEqual(46, data) data = self.loop.run_until_complete(self.read_setting('discharge_v')) self.assertEqual(44.5, data) data = self.loop.run_until_complete(self.read_setting('dod')) self.assertEqual(0, data) data = self.loop.run_until_complete(self.read_setting('grid_export_limit')) self.assertEqual(10000, data) data = self.loop.run_until_complete(self.read_setting('eco_mode_1')) self.assertEqual( "EcoModeV1(id_='eco_mode_1', offset=1793, name='Eco Mode Group 1', size_=8, unit='', kind=)", repr(data)) data = self.loop.run_until_complete(self.read_setting('eco_mode_2_switch')) self.assertEqual(0, data) def test_write_setting(self): self.loop.run_until_complete(self.write_setting('eco_mode_2_switch', 0)) class GW5048_EM_Test(EsMock): def __init__(self, methodName='runTest'): EsMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_RUNNING_DATA, 'GW5048-EM_running_data.hex') self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW5048-EM_device_info.hex') def test_GW5048_EM_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW5048-EM', self.model_name) self.assertEqual('00000EMU00AW0000', self.serial_number) self.assertEqual('1010B', self.firmware) self.assertEqual(10, self.dsp1_version) self.assertEqual(10, self.dsp2_version) self.assertEqual(11, self.arm_version) self.assertFalse(self._supports_eco_mode_v2()) def test_GW5048_EM_runtime_data(self): data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(57, len(data)) self.assertSensor('vpv1', 130.8, 'V', data) self.assertSensor('ipv1', 0.3, 'A', data) self.assertSensor('ppv1', 39, 'W', data) self.assertSensor('pv1_mode', 0, '', data) self.assertSensor('pv1_mode_label', 'PV panels not connected', '', data) self.assertSensor('vpv2', 340.9, 'V', data) self.assertSensor('ipv2', 0.3, 'A', data) self.assertSensor('ppv2', 102, 'W', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('ppv', 141, 'W', data) self.assertSensor('vbattery1', 52.9, 'V', data) self.assertSensor('battery_temperature', 29.0, 'C', data) self.assertSensor('ibattery1', 18.5, 'A', data) self.assertSensor('pbattery1', 979, 'W', data) self.assertSensor('battery_charge_limit', 99, 'A', data) self.assertSensor('battery_discharge_limit', 99, 'A', data) self.assertSensor('battery_status', 82, '', data) self.assertSensor('battery_error', 0, '', data) self.assertSensor('battery_soc', 97, '%', data) self.assertSensor('battery_soh', 0, '%', data) self.assertSensor('battery_mode', 2, '', data) self.assertSensor('battery_mode_label', 'Discharge', '', data) self.assertSensor('battery_warning', 0, '', data) self.assertSensor('meter_status', 1, '', data) self.assertSensor('vgrid', 241.5, 'V', data) self.assertSensor('igrid', 4.3, 'A', data) self.assertSensor('pgrid', 2, 'W', data) self.assertSensor('fgrid', 49.87, 'Hz', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Inverter On', '', data) self.assertSensor('vload', 241.5, 'V', data) self.assertSensor('iload', 3.6, 'A', data) self.assertSensor('pload', 209, 'W', data) self.assertSensor('fload', 49.87, 'Hz', data) self.assertSensor('load_mode', 1, '', data) self.assertSensor('load_mode_label', 'The inverter is connected to a load', '', data) self.assertSensor('work_mode', 2, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('temperature', 34.8, 'C', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('e_total', 957.8, 'kWh', data) self.assertSensor('h_total', 1343, 'h', data) self.assertSensor('e_day', 8.8, 'kWh', data) self.assertSensor('e_load_day', 17.6, 'kWh', data) self.assertSensor('e_load_total', 1803.7, 'kWh', data) self.assertSensor('total_power', 1032, 'W', data) self.assertSensor('effective_work_mode', 1, '', data) self.assertSensor('effective_relay_control', 48, '', data) self.assertSensor('grid_in_out', 0, '', data) self.assertSensor('grid_in_out_label', 'Idle', '', data) self.assertSensor('pback_up', 847, 'W', data) self.assertSensor('plant_power', 1056, 'W', data) self.assertSensor('meter_power_factor', 0.001, '', data) self.assertSensor('diagnose_result', 64, '', data) self.assertSensor('diagnose_result_label', 'Discharge Driver On', '', data) # self.assertSensor('e_total_exp', 512.9, 'kWh', data) # self.assertSensor('e_total_imp', 33653839.0, 'kWh', data) # self.assertSensor('vgrid_uo', 0, 'V', data) # self.assertSensor('igrid_uo', 0, 'A', data) # self.assertSensor('vgrid_wo', 0, 'V', data) # self.assertSensor('igrid_wo', 0, 'A', data) # self.assertSensor('e_bat_charge_total', 0, 'kWh', data) # self.assertSensor('e_bat_discharge_total', 0, 'kWh', data) self.assertSensor('house_consumption', 1118, 'W', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") class GW5048_EM_No_Batt_Test(EsMock): def __init__(self, methodName='runTest'): EsMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_RUNNING_DATA, 'GW5048-EM-no-bat_running_data.hex') def test_GW5048_EM_no_batt_runtime_data(self): data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(57, len(data)) self.assertSensor('vpv1', 334.3, 'V', data) self.assertSensor('ipv1', 0.4, 'A', data) self.assertSensor('ppv1', 134, 'W', data) self.assertSensor('pv1_mode', 2, '', data) self.assertSensor('pv1_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('vpv2', 214.0, 'V', data) self.assertSensor('ipv2', 0.5, 'A', data) self.assertSensor('ppv2', 107, 'W', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('ppv', 241, 'W', data) self.assertSensor('vbattery1', 0.0, 'V', data) self.assertSensor('battery_temperature', 0.0, 'C', data) self.assertSensor('ibattery1', 0.0, 'A', data) self.assertSensor('pbattery1', 0, 'W', data) self.assertSensor('battery_charge_limit', 0, 'A', data) self.assertSensor('battery_discharge_limit', 0, 'A', data) self.assertSensor('battery_status', 0, '', data) self.assertSensor('battery_error', 256, '', data) self.assertSensor('battery_soc', 0, '%', data) self.assertSensor('battery_soh', 0, '%', data) self.assertSensor('battery_mode', 0, '', data) self.assertSensor('battery_mode_label', 'No battery', '', data) self.assertSensor('battery_warning', 0, '', data) self.assertSensor('meter_status', 1, '', data) self.assertSensor('vgrid', 240.8, 'V', data) self.assertSensor('igrid', 1.0, 'A', data) self.assertSensor('pgrid', 7, 'W', data) self.assertSensor('fgrid', 49.91, 'Hz', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Inverter On', '', data) self.assertSensor('vload', 0.0, 'V', data) self.assertSensor('iload', 0.0, 'A', data) self.assertSensor('pload', 249, 'W', data) self.assertSensor('fload', 0.0, 'Hz', data) self.assertSensor('load_mode', 0, '', data) self.assertSensor('load_mode_label', 'Inverter and the load is disconnected', '', data) self.assertSensor('work_mode', 2, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('temperature', 29.9, 'C', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('e_total', 587.7, 'kWh', data) self.assertSensor('h_total', 299, 'h', data) self.assertSensor('e_day', 23.3, 'kWh', data) self.assertSensor('e_load_day', 20.5, 'kWh', data) self.assertSensor('e_load_total', 550.4, 'kWh', data) self.assertSensor('total_power', 212, 'W', data) self.assertSensor('effective_work_mode', 1, '', data) self.assertSensor('effective_relay_control', 32, '', data) self.assertSensor('grid_in_out', 0, '', data) self.assertSensor('grid_in_out_label', 'Idle', '', data) self.assertSensor('pback_up', 0, 'W', data) self.assertSensor('plant_power', 249, 'W', data) self.assertSensor('meter_power_factor', 0.001, '', data) self.assertSensor('diagnose_result', 18501, '', data) self.assertSensor('diagnose_result_label', 'Battery voltage low, Battery SOC in back, Discharge Driver On, Self-use load light, Battery Disconnected', '', data) # self.assertSensor('e_total_exp', 512.9, 'kWh', data) # self.assertSensor('e_total_imp', 33653889.9, 'kWh', data) # self.assertSensor('vgrid_uo', 0, 'V', data) # self.assertSensor('igrid_uo', 0, 'A', data) # self.assertSensor('vgrid_wo', 0, 'V', data) # self.assertSensor('igrid_wo', 0, 'A', data) # self.assertSensor('e_bat_charge_total', 0, 'kWh', data) # self.assertSensor('e_bat_discharge_total', 0, 'kWh', data) self.assertSensor('house_consumption', 234, 'W', data) class GW5000S_BP_Test(EsMock): def __init__(self, methodName='runTest'): EsMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_RUNNING_DATA, 'GW5000S-BP_running_data.hex') def test_GW5000S_BP_runtime_data(self): data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(57, len(data)) self.assertSensor('vpv1', 374.9, 'V', data) self.assertSensor('ipv1', 2.2, 'A', data) self.assertSensor('ppv1', 825, 'W', data) self.assertSensor('pv1_mode', 0, '', data) self.assertSensor('pv1_mode_label', 'PV panels not connected', '', data) self.assertSensor('vpv2', 0.0, 'V', data) self.assertSensor('ipv2', 0.2, 'A', data) self.assertSensor('ppv2', 0, 'W', data) self.assertSensor('pv2_mode', 0, '', data) self.assertSensor('pv2_mode_label', 'PV panels not connected', '', data) self.assertSensor('ppv', 825, 'W', data) self.assertSensor('vbattery1', 50.3, 'V', data) self.assertSensor('battery_temperature', 15.0, 'C', data) self.assertSensor('ibattery1', -9.9, 'A', data) self.assertSensor('pbattery1', -498, 'W', data) self.assertSensor('battery_charge_limit', 99, 'A', data) self.assertSensor('battery_discharge_limit', 98, 'A', data) self.assertSensor('battery_status', 82, '', data) self.assertSensor('battery_error', 0, '', data) self.assertSensor('battery_soc', 72, '%', data) self.assertSensor('battery_soh', 0, '%', data) self.assertSensor('battery_mode', 3, '', data) self.assertSensor('battery_mode_label', 'Charge', '', data) self.assertSensor('battery_warning', 0, '', data) self.assertSensor('meter_status', 1, '', data) self.assertSensor('vgrid', 240.1, 'V', data) self.assertSensor('igrid', 1.9, 'A', data) self.assertSensor('pgrid', 184, 'W', data) self.assertSensor('fgrid', 49.95, 'Hz', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Inverter On', '', data) self.assertSensor('vload', 240.1, 'V', data) self.assertSensor('iload', 0.1, 'A', data) self.assertSensor('pload', 1285, 'W', data) self.assertSensor('fload', 49.95, 'Hz', data) self.assertSensor('load_mode', 1, '', data) self.assertSensor('load_mode_label', 'The inverter is connected to a load', '', data) self.assertSensor('work_mode', 2, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('temperature', 19.5, 'C', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('e_total', 49.4, 'kWh', data) self.assertSensor('h_total', 211, 'h', data) self.assertSensor('e_day', 0.2, 'kWh', data) self.assertSensor('e_load_day', 2.7, 'kWh', data) self.assertSensor('e_load_total', 194.9, 'kWh', data) self.assertSensor('total_power', -650, 'W', data) self.assertSensor('effective_work_mode', 1, '', data) self.assertSensor('effective_relay_control', 48, '', data) self.assertSensor('grid_in_out', 1, '', data) self.assertSensor('grid_in_out_label', 'Exporting', '', data) self.assertSensor('pback_up', 0, 'W', data) self.assertSensor('plant_power', 1285, 'W', data) self.assertSensor('meter_power_factor', 0.001, '', data) self.assertSensor('diagnose_result', 524320, '', data) self.assertSensor('diagnose_result_label', 'Charge time on, Self-use off', '', data) # self.assertSensor('e_total_exp', 538.4, 'kWh', data) # self.assertSensor('e_total_imp', 48713267.2, 'kWh', data) # self.assertSensor('vgrid_uo', 0, 'V', data) # self.assertSensor('igrid_uo', 0, 'A', data) # self.assertSensor('vgrid_wo', 0, 'V', data) # self.assertSensor('igrid_wo', 0, 'A', data) # self.assertSensor('e_bat_charge_total', 0, 'kWh', data) # self.assertSensor('e_bat_discharge_total', 0, 'kWh', data) self.assertSensor('house_consumption', 143, 'W', data) def test_get_grid_export_limit(self): self.loop.run_until_complete(self.get_grid_export_limit()) self.assertEqual('aa55c07f0109000248', self.request.hex()) def test_set_grid_export_limit(self): self.loop.run_until_complete(self.set_grid_export_limit(100)) self.assertEqual('aa55c07f033502006402dc', self.request.hex()) def test_get_operation_modes(self): self.assertEqual((OperationMode.GENERAL, OperationMode.OFF_GRID, OperationMode.BACKUP, OperationMode.ECO), self.loop.run_until_complete(self.get_operation_modes(False))) self.assertEqual((OperationMode.GENERAL, OperationMode.OFF_GRID, OperationMode.BACKUP, OperationMode.ECO, OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE), self.loop.run_until_complete(self.get_operation_modes(True))) def test_get_operation_mode(self): self.loop.run_until_complete(self.get_operation_mode()) self.assertEqual('aa55c07f0109000248', self.request.hex()) # def test_set_operation_mode(self): # self.loop.run_until_complete(self.set_operation_mode(1)) # self.assertEqual('aa55c07f03590101029c', self.request.hex()) def test_get_ongrid_battery_dod(self): self.loop.run_until_complete(self.get_ongrid_battery_dod()) self.assertEqual('aa55c07f0109000248', self.request.hex()) def test_set_ongrid_battery_dod(self): self.loop.run_until_complete(self.set_ongrid_battery_dod(80)) self.assertEqual('aa55c07f023905056001001402f8', self.request.hex()) def test_write_setting(self): self.loop.run_until_complete(self.write_setting('time', datetime(2022, 1, 4, 18, 30, 25))) self.assertEqual('aa55c07f030206160104121e1902ad', self.request.hex()) class GW5048_ESA_Test(EsMock): def __init__(self, methodName='runTest'): EsMock.__init__(self, methodName) self.mock_response(DISCOVERY_COMMAND, 'GW5048-ESA_discovery.hex') self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW5048-ESA_device_info.hex') self.mock_response(self._READ_DEVICE_RUNNING_DATA, 'GW5048-ESA_running_data.hex') def test_GW5048_ESA_discovery(self): response = self.loop.run_until_complete(self._read_from_socket(DISCOVERY_COMMAND)) raw_data = response.raw_data self.assertEqual(86, len(raw_data)) self.assertEqual('GW5048-ESA', raw_data[12:22].decode("ascii").rstrip()) self.assertEqual('95048ESA223W0000', raw_data[38:54].decode("ascii")) def test_GW5048_ESA_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW5048-ESA', self.model_name) self.assertEqual('95048ESA223W0000', self.serial_number) self.assertEqual('1717A', self.firmware) self.assertEqual(17, self.dsp1_version) self.assertEqual(17, self.dsp2_version) self.assertEqual(10, self.arm_version) def test_GW5048_ESA_runtime_data(self): data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(57, len(data)) self.assertSensor('vpv1', 111.9, 'V', data) self.assertSensor('ipv1', 0.0, 'A', data) self.assertSensor('ppv1', 0, 'W', data) self.assertSensor('pv1_mode', 0, '', data) self.assertSensor('pv1_mode_label', 'PV panels not connected', '', data) self.assertSensor('vpv2', 79.9, 'V', data) self.assertSensor('ipv2', 0.1, 'A', data) self.assertSensor('ppv2', 8, 'W', data) self.assertSensor('pv2_mode', 0, '', data) self.assertSensor('pv2_mode_label', 'PV panels not connected', '', data) self.assertSensor('ppv', 8, 'W', data) self.assertSensor('vbattery1', 53.6, 'V', data) self.assertSensor('battery_status', 80, '', data) self.assertSensor('battery_temperature', 25.0, 'C', data) self.assertSensor('ibattery1', 0.2, 'A', data) self.assertSensor('pbattery1', 11, 'W', data) self.assertSensor('battery_charge_limit', 0, 'A', data) self.assertSensor('battery_discharge_limit', 100, 'A', data) self.assertSensor('battery_error', 0, '', data) self.assertSensor('battery_soc', 100, '%', data) self.assertSensor('battery_soh', 100, '%', data) self.assertSensor('battery_mode', 2, '', data) self.assertSensor('battery_mode_label', 'Discharge', '', data) self.assertSensor('battery_warning', 0, '', data) self.assertSensor('meter_status', 1, '', data) self.assertSensor('vgrid', 231.2, 'V', data) self.assertSensor('igrid', 0.6, 'A', data) self.assertSensor('pgrid', 807, 'W', data) self.assertSensor('fgrid', 50.0, 'Hz', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Inverter On', '', data) self.assertSensor('vload', 231.2, 'V', data) self.assertSensor('iload', 0.3, 'A', data) self.assertSensor('pload', 916, 'W', data) self.assertSensor('fload', 50.0, 'Hz', data) self.assertSensor('load_mode', 1, '', data) self.assertSensor('load_mode_label', 'The inverter is connected to a load', '', data) self.assertSensor('work_mode', 2, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('temperature', 25.1, 'C', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('e_total', 0.2, 'kWh', data) self.assertSensor('h_total', 3, 'h', data) self.assertSensor('e_day', 0.2, 'kWh', data) self.assertSensor('e_load_day', 15.0, 'kWh', data) self.assertSensor('e_load_total', 15.0, 'kWh', data) self.assertSensor('total_power', -109, 'W', data) self.assertSensor('effective_work_mode', 1, '', data) self.assertSensor('effective_relay_control', 32, '', data) self.assertSensor('grid_in_out', 1, '', data) self.assertSensor('grid_in_out_label', 'Exporting', '', data) self.assertSensor('pback_up', 0, 'W', data) self.assertSensor('plant_power', 916, 'W', data) self.assertSensor('meter_power_factor', 0.001, '', data) self.assertSensor('diagnose_result', 16780352, '', data) self.assertSensor('diagnose_result_label', 'Discharge Driver On, Meter connection reversed, Self-use load light, Export power limit set', '', data) self.assertSensor('house_consumption', -788, 'W', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") class GW6000_ES_20_Test(EsMock): """This is Gen 2 ES inverter, actually a modbus (ET) talking inverter, not ES""" def __init__(self, methodName='runTest'): EsMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW6000-ES-20_device_info.hex') def test_GW6000_ES_20_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('', self.model_name) self.assertEqual('56000ESN00AW0000', self.serial_number) self.assertEqual('0002000205', self.firmware) self.assertEqual(0, self.dsp1_version) self.assertEqual(2, self.dsp2_version) self.assertEqual(0, self.arm_version) marcelblijleven-goodwe-a4b8c7f/tests/test_et.py000066400000000000000000002156601463365355300220440ustar00rootroot00000000000000import asyncio import os from datetime import datetime from unittest import TestCase from goodwe.et import ET from goodwe.exceptions import RequestRejectedException, RequestFailedException from goodwe.inverter import OperationMode from goodwe.modbus import ILLEGAL_DATA_ADDRESS from goodwe.protocol import ModbusRtuReadCommand, ProtocolCommand, ProtocolResponse class EtMock(TestCase, ET): def __init__(self, methodName='runTest'): TestCase.__init__(self, methodName) ET.__init__(self, "localhost", 8899) self.sensor_map = {s.id_: s for s in self.sensors()} self._mock_responses = {} self._list_of_requests = [] def mock_response(self, command: ProtocolCommand, filename: str): self._mock_responses[command] = filename async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse: """Mock UDP communication""" root_dir = os.path.dirname(os.path.abspath(__file__)) filename = self._mock_responses.get(command) if filename is not None: if ILLEGAL_DATA_ADDRESS == filename: raise RequestRejectedException(ILLEGAL_DATA_ADDRESS) if 'NO RESPONSE' == filename: raise RequestFailedException() with open(root_dir + '/sample/et/' + filename, 'r') as f: response = bytes.fromhex(f.read()) if not command.validator(response): raise RequestFailedException return ProtocolResponse(response, command) else: self.request = command.request self._list_of_requests.append(command.request) return ProtocolResponse(bytes.fromhex("aa55f700010203040506070809"), command) def assertSensor(self, sensor_name, expected_value, expected_unit, data): self.assertEqual(expected_value, data.get(sensor_name)) sensor = self.sensor_map.get(sensor_name); self.assertEqual(expected_unit, sensor.unit) self.sensor_map.pop(sensor_name) @classmethod def setUpClass(cls): cls.loop = asyncio.get_event_loop() class GW10K_ET_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW10K-ET_device_info_fw617.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW10K-ET_running_data.hex') self.mock_response(self._READ_METER_DATA, 'GW10K-ET_meter_data.hex') self.mock_response(self._READ_BATTERY_INFO, 'GW10K-ET_battery_info.hex') self.mock_response(ModbusRtuReadCommand(0xf7, 47547, 6), ILLEGAL_DATA_ADDRESS) self.mock_response(ModbusRtuReadCommand(0xf7, 47589, 6), ILLEGAL_DATA_ADDRESS) self.mock_response(ModbusRtuReadCommand(0xf7, 47515, 4), 'eco_mode_v1.hex') def test_GW10K_ET_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW10K-ET', self.model_name) self.assertEqual('9010KETU000W0000', self.serial_number) self.assertEqual(10000, self.rated_power) self.assertEqual(1, self.modbus_version) self.assertEqual(254, self.ac_output_type) self.assertEqual(6, self.dsp1_version) self.assertEqual(6, self.dsp2_version) self.assertEqual(152, self.dsp_svn_version) self.assertEqual(17, self.arm_version) self.assertEqual(192, self.arm_svn_version) self.assertEqual('04029-06-S11', self.firmware) self.assertEqual('02041-17-S00', self.arm_firmware) def test_GW10K_ET_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) self.sensor_map = {s.id_: s for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(145, len(data)) self.assertEqual(36015, self.sensor_map.get("meter_e_total_exp").offset) # for sensor in self.sensors(): # print(f"self.assertSensor('{sensor.id_}', {data[sensor.id_]}, '{self.sensor_map.get(sensor.id_).unit}', data)") self.assertSensor('timestamp', datetime.strptime('2021-08-22 11:11:12', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 332.6, 'V', data) self.assertSensor('ipv1', 5.1, 'A', data) self.assertSensor('ppv1', 1695, 'W', data) self.assertSensor('vpv2', 332.6, 'V', data) self.assertSensor('ipv2', 5.3, 'A', data) self.assertSensor('ppv2', 1761, 'W', data) self.assertSensor('ppv', 3456, 'W', data) self.assertSensor('pv1_mode', 2, '', data) self.assertSensor('pv1_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('vgrid', 239.3, 'V', data) self.assertSensor('igrid', 1.5, 'A', data) self.assertSensor('fgrid', 49.99, 'Hz', data) self.assertSensor('pgrid', 336, 'W', data) self.assertSensor('vgrid2', 241.5, 'V', data) self.assertSensor('igrid2', 1.3, 'A', data) self.assertSensor('fgrid2', 49.99, 'Hz', data) self.assertSensor('pgrid2', 287, 'W', data) self.assertSensor('vgrid3', 241.1, 'V', data) self.assertSensor('igrid3', 1.1, 'A', data) self.assertSensor('fgrid3', 49.99, 'Hz', data) self.assertSensor('pgrid3', 206, 'W', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Connected to grid', '', data) self.assertSensor('total_inverter_power', 831, 'W', data) self.assertSensor('active_power', -3, 'W', data) self.assertSensor('grid_in_out', 0, '', data) self.assertSensor('grid_in_out_label', 'Idle', '', data) self.assertSensor('reactive_power', 0, 'var', data) self.assertSensor('apparent_power', 0, 'VA', data) self.assertSensor('backup_v1', 239.0, 'V', data) self.assertSensor('backup_i1', 0.6, 'A', data) self.assertSensor('backup_f1', 49.98, 'Hz', data) self.assertSensor('load_mode1', 1, '', data) self.assertSensor('backup_p1', 107, 'W', data) self.assertSensor('backup_v2', 241.3, 'V', data) self.assertSensor('backup_i2', 0.9, 'A', data) self.assertSensor('backup_f2', 50.0, 'Hz', data) self.assertSensor('load_mode2', 1, '', data) self.assertSensor('backup_p2', 189, 'W', data) self.assertSensor('backup_v3', 241.2, 'V', data) self.assertSensor('backup_i3', 0.2, 'A', data) self.assertSensor('backup_f3', 49.99, 'Hz', data) self.assertSensor('load_mode3', 1, '', data) self.assertSensor('backup_p3', 0, 'W', data) self.assertSensor('load_p1', 224, 'W', data) self.assertSensor('load_p2', 80, 'W', data) self.assertSensor('load_p3', 233, 'W', data) self.assertSensor('load_ptotal', 522, 'W', data) self.assertSensor('backup_ptotal', 312, 'W', data) self.assertSensor('ups_load', 4, '%', data) self.assertSensor('temperature_air', 51.0, 'C', data) self.assertSensor('temperature_module', 0, 'C', data) self.assertSensor('temperature', 58.7, 'C', data) self.assertSensor('function_bit', 0, '', data) self.assertSensor('bus_voltage', 803.6, 'V', data) self.assertSensor('nbus_voltage', 401.8, 'V', data) self.assertSensor('vbattery1', 254.2, 'V', data) self.assertSensor('ibattery1', -9.8, 'A', data) self.assertSensor('pbattery1', -2512, 'W', data) self.assertSensor('battery_mode', 3, '', data) self.assertSensor('battery_mode_label', 'Charge', '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('operation_mode', 0, '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('errors', '', '', data) self.assertSensor("e_total", 6085.3, 'kWh', data) self.assertSensor("e_day", 12.5, 'kWh', data) self.assertSensor("e_total_exp", 4718.6, 'kWh', data) self.assertSensor('h_total', 9246, 'h', data) self.assertSensor("e_day_exp", 9.8, 'kWh', data) self.assertSensor("e_total_imp", 58.0, 'kWh', data) self.assertSensor("e_day_imp", 0, 'kWh', data) self.assertSensor("e_load_total", 8820.2, 'kWh', data) self.assertSensor("e_load_day", 11.6, 'kWh', data) self.assertSensor("e_bat_charge_total", 2758.1, 'kWh', data) self.assertSensor("e_bat_charge_day", 5.3, 'kWh', data) self.assertSensor("e_bat_discharge_total", 2442.1, 'kWh', data) self.assertSensor("e_bat_discharge_day", 2.9, 'kWh', data) self.assertSensor('diagnose_result', 117442560, '', data) self.assertSensor('diagnose_result_label', 'Self-use load light, Export power limit set, PF value set, Real power limit set', '', data) self.assertSensor('house_consumption', 947, 'W', data) self.assertSensor('battery_bms', 255, '', data) self.assertSensor('battery_index', 256, '', data) self.assertSensor('battery_status', 1, '', data) self.assertSensor('battery_temperature', 35.0, 'C', data) self.assertSensor('battery_charge_limit', 25, 'A', data) self.assertSensor('battery_discharge_limit', 25, 'A', data) self.assertSensor("battery_error_l", 0, "", data), self.assertSensor('battery_soc', 68, '%', data) self.assertSensor('battery_soh', 99, '%', data) self.assertSensor("battery_modules", 5, "", data) self.assertSensor("battery_warning_l", 0, "", data) self.assertSensor("battery_protocol", 257, "", data) self.assertSensor("battery_error_h", 0, "", data) self.assertSensor("battery_error", "", "", data) self.assertSensor("battery_warning_h", 0, "", data) self.assertSensor("battery_warning", "", "", data) self.assertSensor("battery_sw_version", 0, "", data) self.assertSensor("battery_hw_version", 0, "", data) self.assertSensor("battery_max_cell_temp_id", 0, "", data) self.assertSensor("battery_min_cell_temp_id", 0, "", data) self.assertSensor("battery_max_cell_voltage_id", 0, "", data) self.assertSensor("battery_min_cell_voltage_id", 0, "", data) self.assertSensor("battery_max_cell_temp", 0, "C", data) self.assertSensor("battery_min_cell_temp", 0, "C", data) self.assertSensor("battery_max_cell_voltage", 0, "V", data) self.assertSensor("battery_min_cell_voltage", 0, "V", data) self.assertSensor('commode', 1, '', data) self.assertSensor('rssi', 35, '', data) self.assertSensor('manufacture_code', 10, '', data) self.assertSensor('meter_test_status', 0, '', data) self.assertSensor('meter_comm_status', 1, '', data) self.assertSensor('active_power1', -57, 'W', data) self.assertSensor('active_power2', -46, 'W', data) self.assertSensor('active_power3', -6, 'W', data) self.assertSensor('active_power_total', -110, 'W', data) self.assertSensor('reactive_power_total', 1336, 'var', data) self.assertSensor('meter_power_factor1', -0.145, '', data) self.assertSensor('meter_power_factor2', -0.124, '', data) self.assertSensor('meter_power_factor3', -0.014, '', data) self.assertSensor('meter_power_factor', -0.08, '', data) self.assertSensor('meter_freq', 50.05, 'Hz', data) self.assertSensor('meter_e_total_exp', 10.514, 'kWh', data) self.assertSensor('meter_e_total_imp', 3254.462, 'kWh', data) self.assertSensor('meter_active_power1', -57, 'W', data) self.assertSensor('meter_active_power2', -46, 'W', data) self.assertSensor('meter_active_power3', -6, 'W', data) self.assertSensor('meter_active_power_total', -110, 'W', data) self.assertSensor('meter_reactive_power1', 364, 'var', data) self.assertSensor('meter_reactive_power2', 357, 'var', data) self.assertSensor('meter_reactive_power3', 614, 'var', data) self.assertSensor('meter_reactive_power_total', 1336, 'var', data) self.assertSensor('meter_apparent_power1', -402, 'VA', data) self.assertSensor('meter_apparent_power2', -372, 'VA', data) self.assertSensor('meter_apparent_power3', -627, 'VA', data) self.assertSensor('meter_apparent_power_total', -1403, 'VA', data) self.assertSensor('meter_type', 1, '', data) self.assertSensor('meter_sw_version', 3, '', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") def test_GW10K_ET_setting(self): self.assertEqual(66, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('Timestamp', type(settings.get("time")).__name__) self.assertEqual('EcoModeV1', type(settings.get("eco_mode_1")).__name__) def test_GW10K_ET_read_setting(self): self.loop.run_until_complete(self.read_setting('work_mode')) self.assertEqual('f703b798000136c7', self.request.hex()) self.loop.run_until_complete(self.read_setting('grid_export_limit')) self.assertEqual('f703b996000155ec', self.request.hex()) self.loop.run_until_complete(self.read_setting('time')) self.assertEqual('f703b090000337b0', self.request.hex()) self.loop.run_until_complete(self.read_setting('modbus_47000')) self.assertEqual('f703b798000136c7', self.request.hex()) def test_GW10K_ET_write_setting(self): self.loop.run_until_complete(self.write_setting('grid_export_limit', 100)) self.assertEqual('f706b996006459c7', self.request.hex()) self.loop.run_until_complete(self.write_setting('modbus_47510', 100)) self.assertEqual('f706b996006459c7', self.request.hex()) self.loop.run_until_complete(self.write_setting('time', datetime(2022, 1, 4, 18, 30, 25))) self.assertEqual('f710b090000306160104121e19a961', self.request.hex()) def test_get_grid_export_limit(self): self.loop.run_until_complete(self.get_grid_export_limit()) self.assertEqual('f703b996000155ec', self.request.hex()) def test_set_grid_export_limit(self): self.loop.run_until_complete(self.set_grid_export_limit(100)) self.assertEqual('f706b996006459c7', self.request.hex()) def test_get_operation_modes(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual((OperationMode.GENERAL, OperationMode.OFF_GRID, OperationMode.BACKUP, OperationMode.ECO), self.loop.run_until_complete(self.get_operation_modes(False))) self.assertEqual((OperationMode.GENERAL, OperationMode.OFF_GRID, OperationMode.BACKUP, OperationMode.ECO, OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE), self.loop.run_until_complete(self.get_operation_modes(True))) # def test_get_operation_mode(self): # self.loop.run_until_complete(self.get_operation_mode()) # self.assertEqual('f703b798000136c7', self.request.hex()) # def test_set_operation_mode(self): # self.loop.run_until_complete(self.set_operation_mode(1)) # self.assertEqual('f706b7980001fac7', self.request.hex()) def test_set_operation_mode_ECO_CHARGE(self): self.loop.run_until_complete(self.read_device_info()) self.loop.run_until_complete(self.set_operation_mode(OperationMode.ECO_CHARGE, eco_mode_power=40)) self.assertEqual('f710b99b0004080000173bffd8ff7f1343', self._list_of_requests[-9].hex()) self.loop.run_until_complete( self.set_operation_mode(OperationMode.ECO_CHARGE, eco_mode_power=40, eco_mode_soc=80)) self.assertEqual('f710b99b0004080000173bffd8ff7f1343', self._list_of_requests[-9].hex()) def test_set_operation_mode_DISCHARGE(self): self.loop.run_until_complete(self.read_device_info()) self.loop.run_until_complete(self.set_operation_mode(OperationMode.ECO_DISCHARGE, eco_mode_power=50)) self.assertEqual('f710b99b0004080000173b0032ff7f02a3', self._list_of_requests[-9].hex()) def test_get_ongrid_battery_dod(self): self.loop.run_until_complete(self.get_ongrid_battery_dod()) self.assertEqual('f703b12c00017669', self.request.hex()) def test_set_ongrid_battery_dod(self): self.loop.run_until_complete(self.set_ongrid_battery_dod(80)) self.assertEqual('f706b12c00147ba6', self.request.hex()) class GW10K_ET_fw819_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW10K-ET_device_info_fw819.hex') self.mock_response(ModbusRtuReadCommand(0xf7, 47547, 6), 'eco_mode_v2.hex') self.mock_response(ModbusRtuReadCommand(0xf7, 47589, 6), ILLEGAL_DATA_ADDRESS) asyncio.get_event_loop().run_until_complete(self.read_device_info()) def test_GW10K_ET_fw819_device_info(self): self.assertEqual('0GW10K-ET', self.model_name) self.assertEqual('9010KETU00000000', self.serial_number) self.assertEqual(10000, self.rated_power) self.assertEqual(1, self.modbus_version) self.assertEqual(254, self.ac_output_type) self.assertEqual(8, self.dsp1_version) self.assertEqual(8, self.dsp2_version) self.assertEqual(159, self.dsp_svn_version) self.assertEqual(19, self.arm_version) self.assertEqual(207, self.arm_svn_version) self.assertEqual('04029-08-S11', self.firmware) self.assertEqual('02041-19-S00', self.arm_firmware) def test_GW10K_ET_settings_fw819(self): self.assertEqual(73, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('EcoModeV2', type(settings.get("eco_mode_1")).__name__) self.assertEqual(None, settings.get("peak_shaving_mode")) def test_set_operation_mode_ECO_CHARGE(self): self.loop.run_until_complete( self.set_operation_mode(OperationMode.ECO_CHARGE, eco_mode_power=40, eco_mode_soc=80)) self.assertEqual('f710b9bb00060c0000173bff7fffd80050000002cc', self._list_of_requests[-9].hex()) self.loop.run_until_complete( self.set_operation_mode(OperationMode.ECO_CHARGE, eco_mode_power=40)) self.assertEqual('f710b9bb00060c0000173bff7fffd8006400004302', self._list_of_requests[-9].hex()) def test_set_operation_mode_ECO_DISCHARGE(self): self.loop.run_until_complete(self.set_operation_mode(OperationMode.ECO_DISCHARGE, eco_mode_power=50)) self.assertEqual('f710b9bb00060c0000173bff7f0032006400004eda', self._list_of_requests[-9].hex()) class GW10K_ET_fw1023_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW10K-ET_device_info_fw1023.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW10K-ET_running_data_fw1023.hex') asyncio.get_event_loop().run_until_complete(self.read_device_info()) def test_GW10K_ET_fw1023_device_info(self): self.assertEqual('GW10K-ET', self.model_name) self.assertEqual('9010KETU000W0000', self.serial_number) self.assertEqual(10000, self.rated_power) self.assertEqual(2, self.modbus_version) self.assertEqual(254, self.ac_output_type) self.assertEqual(10, self.dsp1_version) self.assertEqual(10, self.dsp2_version) self.assertEqual(167, self.dsp_svn_version) self.assertEqual(23, self.arm_version) self.assertEqual(237, self.arm_svn_version) self.assertEqual('04029-10-S11', self.firmware) self.assertEqual('02041-23-S00', self.arm_firmware) def test_GW10K_ET_setting_fw1023(self): self.assertEqual(81, len(self.settings())) settings = {s.id_: s for s in self.settings()} self.assertEqual('PeakShavingMode', type(settings.get("peak_shaving_mode")).__name__) def test_GW10K_ET_runtime_data_fw1023(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) self.sensor_map = {s.id_: s for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(145, len(data)) self.assertSensor('timestamp', datetime.strptime('2024-05-11 00:03:34', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 0.0, 'V', data) self.assertSensor('ipv1', 0.0, 'A', data) self.assertSensor('ppv1', 0, 'W', data) self.assertSensor('vpv2', 0.0, 'V', data) self.assertSensor('ipv2', 0.0, 'A', data) self.assertSensor('ppv2', 0, 'W', data) self.assertSensor('ppv', 0, 'W', data) self.assertSensor('pv2_mode', 0, '', data) self.assertSensor('pv2_mode_label', 'PV panels not connected', '', data) self.assertSensor('pv1_mode', 0, '', data) self.assertSensor('pv1_mode_label', 'PV panels not connected', '', data) self.assertSensor('vgrid', 244.4, 'V', data) self.assertSensor('igrid', 2.0, 'A', data) self.assertSensor('fgrid', 49.92, 'Hz', data) self.assertSensor('pgrid', 435, 'W', data) self.assertSensor('vgrid2', 244.3, 'V', data) self.assertSensor('igrid2', 1.2, 'A', data) self.assertSensor('fgrid2', 49.92, 'Hz', data) self.assertSensor('pgrid2', 241, 'W', data) self.assertSensor('vgrid3', 244.9, 'V', data) self.assertSensor('igrid3', 0.8, 'A', data) self.assertSensor('fgrid3', 49.92, 'Hz', data) self.assertSensor('pgrid3', 132, 'W', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Connected to grid', '', data) self.assertSensor('total_inverter_power', 812, 'W', data) self.assertSensor('active_power', -11, 'W', data) self.assertSensor('grid_in_out', 0, '', data) self.assertSensor('grid_in_out_label', 'Idle', '', data) self.assertSensor('reactive_power', 0, 'var', data) self.assertSensor('apparent_power', 0, 'VA', data) self.assertSensor('backup_v1', 244.2, 'V', data) self.assertSensor('backup_i1', 1.3, 'A', data) self.assertSensor('backup_f1', 49.92, 'Hz', data) self.assertSensor('load_mode1', 1, '', data) self.assertSensor('backup_p1', 217, 'W', data) self.assertSensor('backup_v2', 243.8, 'V', data) self.assertSensor('backup_i2', 0.6, 'A', data) self.assertSensor('backup_f2', 49.92, 'Hz', data) self.assertSensor('load_mode2', 1, '', data) self.assertSensor('backup_p2', 114, 'W', data) self.assertSensor('backup_v3', 244.7, 'V', data) self.assertSensor('backup_i3', 0.3, 'A', data) self.assertSensor('backup_f3', 49.92, 'Hz', data) self.assertSensor('load_mode3', 1, '', data) self.assertSensor('backup_p3', 5, 'W', data) self.assertSensor('load_p1', 129, 'W', data) self.assertSensor('load_p2', 108, 'W', data) self.assertSensor('load_p3', 247, 'W', data) self.assertSensor('backup_ptotal', 321, 'W', data) self.assertSensor('load_ptotal', 502, 'W', data) self.assertSensor('ups_load', 5, '%', data) self.assertSensor('temperature_air', 36.4, 'C', data) self.assertSensor('temperature_module', 0.0, 'C', data) self.assertSensor('temperature', 32.5, 'C', data) self.assertSensor('function_bit', 16384, '', data) self.assertSensor('bus_voltage', 790.7, 'V', data) self.assertSensor('nbus_voltage', 395.1, 'V', data) self.assertSensor('vbattery1', 397.1, 'V', data) self.assertSensor('ibattery1', 2.0, 'A', data) self.assertSensor('pbattery1', 820, 'W', data) self.assertSensor('battery_mode', 2, '', data) self.assertSensor('battery_mode_label', 'Discharge', '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('operation_mode', 0, '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('errors', '', '', data) self.assertSensor('e_total', 30630.9, 'kWh', data) self.assertSensor('e_day', 0, 'kWh', data) self.assertSensor('e_total_exp', 27208.5, 'kWh', data) self.assertSensor('h_total', 33055, 'h', data) self.assertSensor('e_day_exp', 0, 'kWh', data) self.assertSensor('e_total_imp', 70.3, 'kWh', data) self.assertSensor('e_day_imp', 0, 'kWh', data) self.assertSensor('e_load_total', 35366.4, 'kWh', data) self.assertSensor('e_load_day', 0, 'kWh', data) self.assertSensor('e_bat_charge_total', 9884.3, 'kWh', data) self.assertSensor('e_bat_charge_day', 0, 'kWh', data) self.assertSensor('e_bat_discharge_total', 8642.2, 'kWh', data) self.assertSensor('e_bat_discharge_day', 0.1, 'kWh', data) self.assertSensor('diagnose_result', 33554496, '', data) self.assertSensor('diagnose_result_label', 'Discharge Driver On, PF value set', '', data) self.assertSensor('house_consumption', 831, 'W', data) self.assertSensor('commode', 515, '', data) self.assertSensor('rssi', 1029, '', data) self.assertSensor('manufacture_code', 1543, '', data) class GW6000_EH_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_RUNNING_DATA, 'GW6000_EH_running_data.hex') self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW6000_EH_device_info.hex') def test_GW6000_EH_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW6000-EH', self.model_name) self.assertEqual('00000EHU00000000', self.serial_number) self.assertEqual(6000, self.rated_power) self.assertEqual(0, self.modbus_version) self.assertEqual(254, self.ac_output_type) self.assertEqual(3, self.dsp1_version) self.assertEqual(3, self.dsp2_version) self.assertEqual(325, self.dsp_svn_version) self.assertEqual(16, self.arm_version) self.assertEqual(188, self.arm_svn_version) self.assertEqual('04034-03-S10', self.firmware) self.assertEqual('02041-16-S00', self.arm_firmware) def test_GW6000_EH_runtime_data(self): self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(89, len(data)) self.assertSensor('vpv1', 330.3, 'V', data) self.assertSensor('ipv1', 2.6, 'A', data) self.assertSensor('ppv1', 857, 'W', data) self.assertSensor('vpv2', 329.6, 'V', data) self.assertSensor('ipv2', 2.1, 'A', data) self.assertSensor('ppv2', 691, 'W', data) self.assertSensor('ppv', 1548, 'W', data) self.assertSensor('pv1_mode', 2, '', data) self.assertSensor('pv1_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('vgrid', 236.6, 'V', data) self.assertSensor('igrid', 6.6, 'A', data) self.assertSensor('fgrid', 49.97, 'Hz', data) self.assertSensor('pgrid', 1561, 'W', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Connected to grid', '', data) self.assertSensor('total_inverter_power', 1561, 'W', data) self.assertSensor('active_power', -164, 'W', data) self.assertSensor('grid_in_out', 2, '', data) self.assertSensor('grid_in_out_label', 'Importing', '', data) self.assertSensor('reactive_power', -1, 'var', data) self.assertSensor('apparent_power', -1, 'VA', data) self.assertSensor('backup_v1', 0.0, 'V', data) self.assertSensor('backup_i1', 0.0, 'A', data) self.assertSensor('backup_f1', 0.0, 'Hz', data) self.assertSensor('load_mode1', 0, '', data) self.assertSensor('backup_p1', 0, 'W', data) self.assertSensor('load_p1', 1724, 'W', data) self.assertSensor('load_ptotal', 1725, 'W', data) self.assertSensor('backup_ptotal', 0, 'W', data) self.assertSensor('ups_load', 0, '%', data) self.assertSensor('temperature_air', 60.4, 'C', data) self.assertSensor('temperature_module', None, 'C', data) self.assertSensor('temperature', 38.6, 'C', data) self.assertSensor('function_bit', 256, '', data) self.assertSensor('bus_voltage', 380.6, 'V', data) self.assertSensor('nbus_voltage', 0, 'V', data) self.assertSensor('vbattery1', 0.0, 'V', data) self.assertSensor('ibattery1', 0.1, 'A', data) self.assertSensor('pbattery1', 0, 'W', data) self.assertSensor('battery_mode', 0, '', data) self.assertSensor('battery_mode_label', 'No battery', '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('safety_country', 3, '', data) self.assertSensor('safety_country_label', 'ES-A', '', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('operation_mode', 0, '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('errors', '', '', data) self.assertSensor("e_total", 59.4, 'kWh', data) self.assertSensor("e_day", 22.0, 'kWh', data) self.assertSensor("e_total_exp", 58.6, 'kWh', data) self.assertSensor('h_total', 33, 'h', data) self.assertSensor("e_day_exp", 21.6, 'kWh', data) self.assertSensor("e_total_imp", 0, 'kWh', data) self.assertSensor("e_day_imp", 0, 'kWh', data) self.assertSensor("e_load_total", 70.1, 'kWh', data) self.assertSensor("e_load_day", 27.1, 'kWh', data) self.assertSensor("e_bat_charge_total", 0, 'kWh', data) self.assertSensor("e_bat_charge_day", 0, 'kWh', data) self.assertSensor("e_bat_discharge_total", 0, 'kWh', data) self.assertSensor("e_bat_discharge_day", 0, 'kWh', data) self.assertSensor('diagnose_result', 117983303, '', data) self.assertSensor('diagnose_result_label', 'Battery voltage low, Battery SOC low, Battery SOC in back, Discharge Driver On, Self-use load light, Battery Disconnected, Self-use off, Export power limit set, PF value set, Real power limit set', '', data) self.assertSensor('house_consumption', 1712, 'W', data) class GEH10_1U_10_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_RUNNING_DATA, 'GEH10-1U-10_running_data.hex') self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GEH10-1U-10_device_info.hex') def test_GEH10_1U_10_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('00000HSB00000000', self.serial_number) def test_GEH10_1U_10_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) self.sensor_map = {s.id_: s for s in self.sensors()} data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(125, len(data)) self.assertSensor('timestamp', datetime.strptime('2023-01-26 11:34:07', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 242.3, 'V', data) self.assertSensor('ipv1', 11.5, 'A', data) self.assertSensor('ppv1', 2777, 'W', data) self.assertSensor('vpv2', 213.5, 'V', data) self.assertSensor('ipv2', 11.5, 'A', data) self.assertSensor('ppv2', 2455, 'W', data) self.assertSensor('vpv3', 333.3, 'V', data) self.assertSensor('ipv3', 11.0, 'A', data) self.assertSensor('ppv3', 3640, 'W', data) self.assertSensor('vpv4', 184.5, 'V', data) self.assertSensor('ipv4', 10.4, 'A', data) self.assertSensor('ppv4', 1915, 'W', data) self.assertSensor('ppv', 10787, 'W', data) self.assertSensor('pv4_mode', 2, '', data) self.assertSensor('pv4_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv3_mode', 2, '', data) self.assertSensor('pv3_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv1_mode', 2, '', data) self.assertSensor('pv1_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('vgrid', 242.9, 'V', data) self.assertSensor('igrid', 36.5, 'A', data) self.assertSensor('fgrid', 49.98, 'Hz', data) self.assertSensor('pgrid', 8710, 'W', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Connected to grid', '', data) self.assertSensor('total_inverter_power', 8710, 'W', data) self.assertSensor('active_power', 4277, 'W', data) self.assertSensor('grid_in_out', 1, '', data) self.assertSensor('grid_in_out_label', 'Exporting', '', data) self.assertSensor('reactive_power', -1650, 'var', data) self.assertSensor('apparent_power', 8865, 'VA', data) self.assertSensor('backup_v1', 240.0, 'V', data) self.assertSensor('backup_i1', 0.7, 'A', data) self.assertSensor('backup_f1', 49.98, 'Hz', data) self.assertSensor('load_mode1', 1, '', data) self.assertSensor('backup_p1', 77, 'W', data) self.assertSensor('load_p1', 4356, 'W', data) self.assertSensor('backup_ptotal', 77, 'W', data) self.assertSensor('load_ptotal', 4356, 'W', data) self.assertSensor('ups_load', 1, '%', data) self.assertSensor('temperature_air', 0.0, 'C', data) self.assertSensor('temperature_module', -10.0, 'C', data) self.assertSensor('temperature', 67.0, 'C', data) self.assertSensor('function_bit', 257, '', data) self.assertSensor('bus_voltage', 458.4, 'V', data) self.assertSensor('nbus_voltage', 0, 'V', data) self.assertSensor('vbattery1', 406.1, 'V', data) self.assertSensor('ibattery1', -3.8, 'A', data) self.assertSensor('pbattery1', -1566, 'W', data) self.assertSensor('battery_mode', 3, '', data) self.assertSensor('battery_mode_label', 'Charge', '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('safety_country', 9, '', data) self.assertSensor('safety_country_label', 'Australia A', '', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('operation_mode', 0, '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('errors', '', '', data) self.assertSensor('e_total', 10225.8, 'kWh', data) self.assertSensor('e_day', 23.1, 'kWh', data) self.assertSensor('e_total_exp', 10273.3, 'kWh', data) self.assertSensor('h_total', 3256, 'h', data) self.assertSensor('e_day_exp', 16.6, 'kWh', data) self.assertSensor('e_total_imp', 0, 'kWh', data) self.assertSensor('e_day_imp', 0, 'kWh', data) self.assertSensor('e_load_total', 4393.9, 'kWh', data) self.assertSensor('e_load_day', 10.7, 'kWh', data) self.assertSensor('e_bat_charge_total', 141.9, 'kWh', data) self.assertSensor('e_bat_charge_day', 9.6, 'kWh', data) self.assertSensor('e_bat_discharge_total', 117.5, 'kWh', data) self.assertSensor('e_bat_discharge_day', 2.6, 'kWh', data) self.assertSensor('diagnose_result', 33556864, '', data) self.assertSensor('diagnose_result_label', 'BMS: Discharge current low, APP: Discharge current too low, Self-use load light, PF value set', '', data) self.assertSensor('house_consumption', 4944, 'W', data) self.assertSensor('battery_bms', 515, '', data) self.assertSensor('battery_index', 1029, '', data) self.assertSensor('battery_status', 1543, '', data) self.assertSensor('battery_temperature', 0.0, 'C', data) self.assertSensor('battery_charge_limit', 0, 'A', data) self.assertSensor('battery_discharge_limit', 0, 'A', data) self.assertSensor('battery_error_l', 0, '', data) self.assertSensor('battery_soc', 0, '%', data) self.assertSensor('battery_soh', 0, '%', data) self.assertSensor('battery_modules', 0, '', data) self.assertSensor('battery_warning_l', 0, '', data) self.assertSensor('battery_protocol', 0, '', data) self.assertSensor('battery_error_h', 0, '', data) self.assertSensor('battery_error', '', '', data) self.assertSensor('battery_warning_h', 0, '', data) self.assertSensor('battery_warning', '', '', data) self.assertSensor('battery_sw_version', 0, '', data) self.assertSensor('battery_hw_version', 0, '', data) self.assertSensor('battery_max_cell_temp_id', 0, '', data) self.assertSensor('battery_min_cell_temp_id', 0, '', data) self.assertSensor('battery_max_cell_voltage_id', 0, '', data) self.assertSensor('battery_min_cell_voltage_id', 0, '', data) self.assertSensor('battery_max_cell_temp', 0.0, 'C', data) self.assertSensor('battery_min_cell_temp', 0.0, 'C', data) self.assertSensor('battery_max_cell_voltage', 0.0, 'V', data) self.assertSensor('battery_min_cell_voltage', 0.0, 'V', data) self.assertSensor('commode', 515, '', data) self.assertSensor('rssi', 1029, '', data) self.assertSensor('manufacture_code', 1543, '', data) self.assertSensor('meter_test_status', 0, '', data) self.assertSensor('meter_comm_status', 0, '', data) self.assertSensor('active_power1', 0, 'W', data) self.assertSensor('active_power_total', 0, 'W', data) self.assertSensor('reactive_power_total', 0, 'var', data) self.assertSensor('meter_power_factor1', 0.0, '', data) self.assertSensor('meter_power_factor', 0.0, '', data) self.assertSensor('meter_freq', 0.0, 'Hz', data) self.assertSensor('meter_e_total_exp', 0.0, 'kWh', data) self.assertSensor('meter_e_total_imp', 0.0, 'kWh', data) self.assertSensor('meter_active_power1', 0, 'W', data) self.assertSensor('meter_active_power_total', 0, 'W', data) self.assertSensor('meter_reactive_power1', 0, 'var', data) self.assertSensor('meter_reactive_power_total', 0, 'var', data) self.assertSensor('meter_apparent_power1', 0, 'VA', data) self.assertSensor('meter_apparent_power_total', 0, 'VA', data) self.assertSensor('meter_type', 0, '', data) self.assertSensor('meter_sw_version', 0, '', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") class GW6000_ES_20_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW6000-ES-20_device_info.hex') def test_GW6000_ES_20_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW6000ES20', self.model_name) self.assertEqual('56000ESN00AW0000', self.serial_number) self.assertEqual(6050, self.rated_power) self.assertEqual(121, self.modbus_version) self.assertEqual(0, self.ac_output_type) self.assertEqual(2, self.dsp1_version) self.assertEqual(2, self.dsp2_version) self.assertEqual(3527, self.dsp_svn_version) self.assertEqual(5, self.arm_version) self.assertEqual(264, self.arm_svn_version) self.assertEqual('ffffffffffffffffffffffff', self.firmware) self.assertEqual('02020-05-S01', self.arm_firmware) class GW25K_ET_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW25K-ET_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW25K-ET_running_data.hex') self.mock_response(self._READ_METER_DATA_EXTENDED2, ILLEGAL_DATA_ADDRESS) self.mock_response(self._READ_METER_DATA_EXTENDED, 'GW25K-ET_meter_data.hex') self.mock_response(self._READ_BATTERY_INFO, 'GW25K-ET_battery_info.hex') self.mock_response(self._READ_MPPT_DATA, 'GW25K-ET_mppt_data.hex') def test_GW25K_ET_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('', self.model_name) self.assertEqual('9025KETT00000000', self.serial_number) self.assertEqual(25000, self.rated_power) self.assertEqual(0, self.modbus_version) self.assertEqual(1, self.ac_output_type) self.assertEqual(6, self.dsp1_version) self.assertEqual(6, self.dsp2_version) self.assertEqual(6017, self.dsp_svn_version) self.assertEqual(8, self.arm_version) self.assertEqual(362, self.arm_svn_version) self.assertEqual('04062-', self.firmware) self.assertEqual('02020-08-S01', self.arm_firmware) def test_GW25K_ET_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(237, len(data)) self.sensor_map = {s.id_: s for s in self.sensors()} # self.assertEqual(36104, self.sensor_map.get("meter_e_total_exp").offset) self.assertSensor('timestamp', datetime.strptime('2023-12-03 14:07:07', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 737.9, 'V', data) self.assertSensor('ipv1', 1.4, 'A', data) self.assertSensor('ppv1', 1033, 'W', data) self.assertSensor('vpv2', 737.9, 'V', data) self.assertSensor('ipv2', 0.0, 'A', data) self.assertSensor('ppv2', 981, 'W', data) self.assertSensor('vpv3', 755.4, 'V', data) self.assertSensor('ipv3', 1.3, 'A', data) self.assertSensor('ppv3', 0, 'W', data) self.assertSensor('vpv4', 755.4, 'V', data) self.assertSensor('ipv4', 0.0, 'A', data) self.assertSensor('ppv4', 0, 'W', data) self.assertSensor('ppv', 2014, 'W', data) self.assertSensor('pv4_mode', 0, '', data) self.assertSensor('pv4_mode_label', 'PV panels not connected', '', data) self.assertSensor('pv3_mode', 0, '', data) self.assertSensor('pv3_mode_label', 'PV panels not connected', '', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv1_mode', 2, '', data) self.assertSensor('pv1_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('vgrid', 230.9, 'V', data) self.assertSensor('igrid', 2.9, 'A', data) self.assertSensor('fgrid', 49.95, 'Hz', data) self.assertSensor('pgrid', 589, 'W', data) self.assertSensor('vgrid2', 231.0, 'V', data) self.assertSensor('igrid2', 2.7, 'A', data) self.assertSensor('fgrid2', 49.97, 'Hz', data) self.assertSensor('pgrid2', 553, 'W', data) self.assertSensor('vgrid3', 230.4, 'V', data) self.assertSensor('igrid3', 4.2, 'A', data) self.assertSensor('fgrid3', 49.97, 'Hz', data) self.assertSensor('pgrid3', 803, 'W', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Connected to grid', '', data) self.assertSensor('total_inverter_power', 1945, 'W', data) self.assertSensor('active_power', 1511, 'W', data) self.assertSensor('grid_in_out', 1, '', data) self.assertSensor('grid_in_out_label', 'Exporting', '', data) self.assertSensor('reactive_power', 1239, 'var', data) self.assertSensor('apparent_power', 2259, 'VA', data) self.assertSensor('backup_v1', 229.5, 'V', data) self.assertSensor('backup_i1', 3.0, 'A', data) self.assertSensor('backup_f1', 49.95, 'Hz', data) self.assertSensor('load_mode1', 0, '', data) self.assertSensor('backup_p1', 52, 'W', data) self.assertSensor('backup_v2', 230.0, 'V', data) self.assertSensor('backup_i2', 1.8, 'A', data) self.assertSensor('backup_f2', 49.97, 'Hz', data) self.assertSensor('load_mode2', 0, '', data) self.assertSensor('backup_p2', 15, 'W', data) self.assertSensor('backup_v3', 229.4, 'V', data) self.assertSensor('backup_i3', 3.2, 'A', data) self.assertSensor('backup_f3', 49.97, 'Hz', data) self.assertSensor('load_mode3', 0, '', data) self.assertSensor('backup_p3', 344, 'W', data) self.assertSensor('load_p1', 44, 'W', data) self.assertSensor('load_p2', 16, 'W', data) self.assertSensor('load_p3', 339, 'W', data) self.assertSensor('backup_ptotal', 408, 'W', data) self.assertSensor('load_ptotal', 26, 'W', data) self.assertSensor('ups_load', 7, '%', data) self.assertSensor('temperature_air', 46.2, 'C', data) self.assertSensor('temperature_module', 0.0, 'C', data) self.assertSensor('temperature', 43.0, 'C', data) self.assertSensor('function_bit', 0, '', data) self.assertSensor('bus_voltage', 773.3, 'V', data) self.assertSensor('nbus_voltage', 386.6, 'V', data) self.assertSensor('vbattery1', 215.2, 'V', data) self.assertSensor('ibattery1', 0.0, 'A', data) self.assertSensor('pbattery1', 0, 'W', data) self.assertSensor('battery_mode', 2, '', data) self.assertSensor('battery_mode_label', 'Discharge', '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('operation_mode', 0, '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('errors', '', '', data) self.assertSensor('e_total', 160.3, 'kWh', data) self.assertSensor('e_day', 14.7, 'kWh', data) self.assertSensor('e_total_exp', 137.7, 'kWh', data) self.assertSensor('h_total', 388, 'h', data) self.assertSensor('e_day_exp', 2.9, 'kWh', data) self.assertSensor('e_total_imp', 14.8, 'kWh', data) self.assertSensor('e_day_imp', 1.0, 'kWh', data) self.assertSensor('e_load_total', 17.2, 'kWh', data) self.assertSensor('e_load_day', 0.2, 'kWh', data) self.assertSensor('e_bat_charge_total', 91.3, 'kWh', data) self.assertSensor('e_bat_charge_day', 11.0, 'kWh', data) self.assertSensor('e_bat_discharge_total', 69.6, 'kWh', data) self.assertSensor('e_bat_discharge_day', 0, 'kWh', data) self.assertSensor('diagnose_result', 33816960, '', data) self.assertSensor('diagnose_result_label', 'BMS: Discharge current low, APP: Discharge current too low, BMS: Charge disabled, PF value set', '', data) self.assertSensor('house_consumption', 503, 'W', data) self.assertSensor('battery_bms', 255, '', data) self.assertSensor('battery_index', 311, '', data) self.assertSensor('battery_status', 1, '', data) self.assertSensor('battery_temperature', 23.0, 'C', data) self.assertSensor('battery_charge_limit', 0, 'A', data) self.assertSensor('battery_discharge_limit', 40, 'A', data) self.assertSensor('battery_error_l', 0, '', data) self.assertSensor('battery_soc', 100, '%', data) self.assertSensor('battery_soh', 100, '%', data) self.assertSensor('battery_modules', 4, '', data) self.assertSensor('battery_warning_l', 0, '', data) self.assertSensor('battery_protocol', 261, '', data) self.assertSensor('battery_error_h', 0, '', data) self.assertSensor('battery_error', '', '', data) self.assertSensor('battery_warning_h', 0, '', data) self.assertSensor('battery_warning', '', '', data) self.assertSensor('battery_sw_version', 790, '', data) self.assertSensor('battery_hw_version', 0, '', data) self.assertSensor('battery_max_cell_temp_id', 0, '', data) self.assertSensor('battery_min_cell_temp_id', 0, '', data) self.assertSensor('battery_max_cell_voltage_id', 0, '', data) self.assertSensor('battery_min_cell_voltage_id', 0, '', data) self.assertSensor('battery_max_cell_temp', 0.0, 'C', data) self.assertSensor('battery_min_cell_temp', 0.0, 'C', data) self.assertSensor('battery_max_cell_voltage', 0.0, 'V', data) self.assertSensor('battery_min_cell_voltage', 0.0, 'V', data) self.assertSensor('battery2_status', 515, '', data) self.assertSensor('battery2_temperature', 102.9, 'C', data) self.assertSensor('battery2_charge_limit', 1543, 'A', data) self.assertSensor('battery2_discharge_limit', 0, 'A', data) self.assertSensor('battery2_error_l', 0, '', data) self.assertSensor('battery2_soc', 0, '%', data) self.assertSensor('battery2_soh', 0, '%', data) self.assertSensor('battery2_modules', 0, '', data) self.assertSensor('battery2_warning_l', 0, '', data) self.assertSensor('battery2_protocol', 0, '', data) self.assertSensor('battery2_error_h', 0, '', data) self.assertSensor('battery2_error', '', '', data) self.assertSensor('battery2_warning_h', 0, '', data) self.assertSensor('battery2_warning', '', '', data) self.assertSensor('battery2_sw_version', 0, '', data) self.assertSensor('battery2_hw_version', 0, '', data) self.assertSensor('battery2_max_cell_temp_id', 0, '', data) self.assertSensor('battery2_min_cell_temp_id', 0, '', data) self.assertSensor('battery2_max_cell_voltage_id', 0, '', data) self.assertSensor('battery2_min_cell_voltage_id', 0, '', data) self.assertSensor('battery2_max_cell_temp', 0.0, 'C', data) self.assertSensor('battery2_min_cell_temp', 0.0, 'C', data) self.assertSensor('battery2_max_cell_voltage', 0.0, 'V', data) self.assertSensor('battery2_min_cell_voltage', 0.0, 'V', data) self.assertSensor('commode', 2, '', data) self.assertSensor('rssi', 100, '', data) self.assertSensor('manufacture_code', 10, '', data) self.assertSensor('meter_test_status', 273, '', data) self.assertSensor('meter_comm_status', 1, '', data) self.assertSensor('active_power1', -208, 'W', data) self.assertSensor('active_power2', -166, 'W', data) self.assertSensor('active_power3', -114, 'W', data) self.assertSensor('active_power_total', -489, 'W', data) self.assertSensor('reactive_power_total', 1197, 'var', data) self.assertSensor('meter_power_factor1', 24.236, '', data) self.assertSensor('meter_power_factor2', 32.336, '', data) self.assertSensor('meter_power_factor3', -28.2, '', data) self.assertSensor('meter_power_factor', 31.136, '', data) self.assertSensor('meter_freq', 49.99, 'Hz', data) self.assertSensor('meter_e_total_exp', 0.0, 'kWh', data) self.assertSensor('meter_e_total_imp', 0.0, 'kWh', data) self.assertSensor('meter_active_power1', -208, 'W', data) self.assertSensor('meter_active_power2', -166, 'W', data) self.assertSensor('meter_active_power3', -114, 'W', data) self.assertSensor('meter_active_power_total', -489, 'W', data) self.assertSensor('meter_reactive_power1', 421, 'var', data) self.assertSensor('meter_reactive_power2', 440, 'var', data) self.assertSensor('meter_reactive_power3', 335, 'var', data) self.assertSensor('meter_reactive_power_total', 1197, 'var', data) self.assertSensor('meter_apparent_power1', -511, 'VA', data) self.assertSensor('meter_apparent_power2', -509, 'VA', data) self.assertSensor('meter_apparent_power3', -448, 'VA', data) self.assertSensor('meter_apparent_power_total', -1470, 'VA', data) self.assertSensor('meter_type', 2, '', data) self.assertSensor('meter_sw_version', 5, '', data) self.assertSensor('meter2_active_power', 0, 'W', data) self.assertSensor('meter2_e_total_exp', 0.0, 'kWh', data) self.assertSensor('meter2_e_total_imp', 0.0, 'kWh', data) self.assertSensor('meter2_comm_status', 0, '', data) self.assertSensor('meter_voltage1', 229.0, 'V', data) self.assertSensor('meter_voltage2', 229.6, 'V', data) self.assertSensor('meter_voltage3', 228.8, 'V', data) self.assertSensor('meter_current1', 2.2, 'A', data) self.assertSensor('meter_current2', 2.2, 'A', data) self.assertSensor('meter_current3', 1.9, 'A', data) self.assertSensor('ppv_total', 529, 'W', data) self.assertSensor('pv_channel', 2, '', data) self.assertSensor('vpv5', 0.0, 'V', data) self.assertSensor('ipv5', 0.0, 'A', data) self.assertSensor('vpv6', 0.0, 'V', data) self.assertSensor('ipv6', 0.0, 'A', data) self.assertSensor('vpv7', 0.0, 'V', data) self.assertSensor('ipv7', 0.0, 'A', data) self.assertSensor('vpv8', 0.0, 'V', data) self.assertSensor('ipv8', 0.0, 'A', data) self.assertSensor('vpv9', 0.0, 'V', data) self.assertSensor('ipv9', 0.0, 'A', data) self.assertSensor('vpv10', 0.0, 'V', data) self.assertSensor('ipv10', 0.0, 'A', data) self.assertSensor('vpv11', 0.0, 'V', data) self.assertSensor('ipv11', 0.0, 'A', data) self.assertSensor('vpv12', 0.0, 'V', data) self.assertSensor('ipv12', 0.0, 'A', data) self.assertSensor('vpv13', 0.0, 'V', data) self.assertSensor('ipv13', 0.0, 'A', data) self.assertSensor('vpv14', 0.0, 'V', data) self.assertSensor('ipv14', 0.0, 'A', data) self.assertSensor('vpv15', 0.0, 'V', data) self.assertSensor('ipv15', 0.0, 'A', data) self.assertSensor('vpv16', 0.0, 'V', data) self.assertSensor('ipv16', 0.0, 'A', data) self.assertSensor('pmppt1', 232, 'W', data) self.assertSensor('pmppt2', 299, 'W', data) self.assertSensor('pmppt3', 0, 'W', data) self.assertSensor('pmppt4', 0, 'W', data) self.assertSensor('pmppt5', 0, 'W', data) self.assertSensor('pmppt6', 0, 'W', data) self.assertSensor('pmppt7', 0, 'W', data) self.assertSensor('pmppt8', 0, 'W', data) self.assertSensor('imppt1', 0.3, 'A', data) self.assertSensor('imppt2', 0.4, 'A', data) self.assertSensor('imppt3', 0, 'A', data) self.assertSensor('imppt4', 0, 'A', data) self.assertSensor('imppt5', 0, 'A', data) self.assertSensor('imppt6', 0, 'A', data) self.assertSensor('imppt7', 0, 'A', data) self.assertSensor('imppt8', 0, 'A', data) self.assertSensor('reactive_power1', 0, 'var', data) self.assertSensor('reactive_power2', 0, 'var', data) self.assertSensor('reactive_power3', 0, 'var', data) self.assertSensor('apparent_power1', 0, 'VA', data) self.assertSensor('apparent_power2', 0, 'VA', data) self.assertSensor('apparent_power3', 0, 'VA', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") class GW29K9_ET_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW29K9-ET_device_info.hex') self.mock_response(self._READ_RUNNING_DATA, 'GW29K9-ET_running_data.hex') self.mock_response(self._READ_METER_DATA_EXTENDED2, ILLEGAL_DATA_ADDRESS) self.mock_response(self._READ_METER_DATA_EXTENDED, 'GW29K9-ET_meter_data.hex') self.mock_response(self._READ_BATTERY_INFO, 'GW29K9-ET_battery_info.hex') self.mock_response(self._READ_BATTERY2_INFO, 'GW29K9-ET_battery2_info.hex') self.mock_response(self._READ_MPPT_DATA, 'GW29K9-ET_mppt_data.hex') def test_GW29K9_ET_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('', self.model_name) self.assertEqual('929K9ETT00CW0000', self.serial_number) self.assertEqual(29900, self.rated_power) self.assertEqual(0, self.modbus_version) self.assertEqual(1, self.ac_output_type) self.assertEqual(2, self.dsp1_version) self.assertEqual(2, self.dsp2_version) self.assertEqual(79, self.dsp_svn_version) self.assertEqual(3, self.arm_version) self.assertEqual(67, self.arm_svn_version) self.assertEqual('04062-', self.firmware) self.assertEqual('02020-03-S01', self.arm_firmware) def test_GW29K9_ET_runtime_data(self): # Reset sensors self.loop.run_until_complete(self.read_device_info()) data = self.loop.run_until_complete(self.read_runtime_data()) self.assertEqual(211, len(data)) self.sensor_map = {s.id_: s for s in self.sensors()} self.assertSensor('timestamp', datetime.strptime('2024-01-17 14:49:14', '%Y-%m-%d %H:%M:%S'), '', data) self.assertSensor('vpv1', 682.9, 'V', data) self.assertSensor('ipv1', 1.5, 'A', data) self.assertSensor('ppv1', 478, 'W', data) self.assertSensor('vpv2', 682.9, 'V', data) self.assertSensor('ipv2', 0.0, 'A', data) self.assertSensor('ppv2', 679, 'W', data) self.assertSensor('vpv3', 577.3, 'V', data) self.assertSensor('ipv3', 1.8, 'A', data) self.assertSensor('ppv3', 390, 'W', data) self.assertSensor('vpv4', 577.3, 'V', data) self.assertSensor('ipv4', 0.0, 'A', data) self.assertSensor('ppv4', 0, 'W', data) self.assertSensor('ppv', 1547, 'W', data) self.assertSensor('pv4_mode', 0, '', data) self.assertSensor('pv4_mode_label', 'PV panels not connected', '', data) self.assertSensor('pv3_mode', 2, '', data) self.assertSensor('pv3_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv2_mode', 2, '', data) self.assertSensor('pv2_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('pv1_mode', 2, '', data) self.assertSensor('pv1_mode_label', 'PV panels connected, producing power', '', data) self.assertSensor('vgrid', 231.3, 'V', data) self.assertSensor('igrid', 2.9, 'A', data) self.assertSensor('fgrid', 49.99, 'Hz', data) self.assertSensor('pgrid', 583, 'W', data) self.assertSensor('vgrid2', 232.9, 'V', data) self.assertSensor('igrid2', 2.7, 'A', data) self.assertSensor('fgrid2', 49.99, 'Hz', data) self.assertSensor('pgrid2', 565, 'W', data) self.assertSensor('vgrid3', 233.6, 'V', data) self.assertSensor('igrid3', 2.9, 'A', data) self.assertSensor('fgrid3', 49.97, 'Hz', data) self.assertSensor('pgrid3', 587, 'W', data) self.assertSensor('grid_mode', 1, '', data) self.assertSensor('grid_mode_label', 'Connected to grid', '', data) self.assertSensor('total_inverter_power', 1735, 'W', data) self.assertSensor('active_power', -5403, 'W', data) self.assertSensor('grid_in_out', 2, '', data) self.assertSensor('grid_in_out_label', 'Importing', '', data) self.assertSensor('reactive_power', 307, 'var', data) self.assertSensor('apparent_power', 1975, 'VA', data) self.assertSensor('backup_v1', 229.9, 'V', data) self.assertSensor('backup_i1', 0.7, 'A', data) self.assertSensor('backup_f1', 49.98, 'Hz', data) self.assertSensor('load_mode1', 0, '', data) self.assertSensor('backup_p1', 21, 'W', data) self.assertSensor('backup_v2', 231.5, 'V', data) self.assertSensor('backup_i2', 0.7, 'A', data) self.assertSensor('backup_f2', 50.0, 'Hz', data) self.assertSensor('load_mode2', 0, '', data) self.assertSensor('backup_p2', 5, 'W', data) self.assertSensor('backup_v3', 232.9, 'V', data) self.assertSensor('backup_i3', 0.6, 'A', data) self.assertSensor('backup_f3', 49.97, 'Hz', data) self.assertSensor('load_mode3', 0, '', data) self.assertSensor('backup_p3', 37, 'W', data) self.assertSensor('load_p1', 647, 'W', data) self.assertSensor('load_p2', 640, 'W', data) self.assertSensor('load_p3', 651, 'W', data) self.assertSensor('backup_ptotal', 66, 'W', data) self.assertSensor('load_ptotal', 7072, 'W', data) self.assertSensor('ups_load', 1, '%', data) self.assertSensor('temperature_air', 24.1, 'C', data) self.assertSensor('temperature_module', 0.0, 'C', data) self.assertSensor('temperature', 20.5, 'C', data) self.assertSensor('function_bit', 0, '', data) self.assertSensor('bus_voltage', 760.4, 'V', data) self.assertSensor('nbus_voltage', 380.2, 'V', data) self.assertSensor('vbattery1', 0.0, 'V', data) self.assertSensor('ibattery1', -0.1, 'A', data) self.assertSensor('pbattery1', 0, 'W', data) self.assertSensor('battery_mode', 0, '', data) self.assertSensor('battery_mode_label', 'No battery', '', data) self.assertSensor('warning_code', 0, '', data) self.assertSensor('safety_country', 32, '', data) self.assertSensor('safety_country_label', '50Hz 230Vac Default', '', data) self.assertSensor('work_mode', 1, '', data) self.assertSensor('work_mode_label', 'Normal (On-Grid)', '', data) self.assertSensor('operation_mode', 0, '', data) self.assertSensor('error_codes', 0, '', data) self.assertSensor('errors', '', '', data) self.assertSensor('e_total', 4562.3, 'kWh', data) self.assertSensor('e_day', 0.9, 'kWh', data) self.assertSensor('e_total_exp', 4489.7, 'kWh', data) self.assertSensor('h_total', 1175, 'h', data) self.assertSensor('e_day_exp', 1.2, 'kWh', data) self.assertSensor('e_total_imp', 8.7, 'kWh', data) self.assertSensor('e_day_imp', 0, 'kWh', data) self.assertSensor('e_load_total', 10742.2, 'kWh', data) self.assertSensor('e_load_day', 43.8, 'kWh', data) self.assertSensor('e_bat_charge_total', 0, 'kWh', data) self.assertSensor('e_bat_charge_day', 0, 'kWh', data) self.assertSensor('e_bat_discharge_total', 0, 'kWh', data) self.assertSensor('e_bat_discharge_day', 0, 'kWh', data) self.assertSensor('diagnose_result', 33816782, '', data) self.assertSensor('diagnose_result_label', 'Battery SOC low, Battery SOC in back, BMS: Discharge disabled, ' 'Discharge Driver On, BMS: Discharge current low, BMS: Charge disabled, PF value set', '', data) self.assertSensor('house_consumption', 6950, 'W', data) self.assertSensor('commode', 0, '', data) self.assertSensor('rssi', 100, '', data) self.assertSensor('manufacture_code', 10, '', data) self.assertSensor('meter_test_status', 0, '', data) self.assertSensor('meter_comm_status', 1, '', data) self.assertSensor('active_power1', -599, 'W', data) self.assertSensor('active_power2', -567, 'W', data) self.assertSensor('active_power3', -2911, 'W', data) self.assertSensor('active_power_total', -4077, 'W', data) self.assertSensor('reactive_power_total', 2831, 'var', data) self.assertSensor('meter_power_factor1', -6.38, '', data) self.assertSensor('meter_power_factor2', -4.95, '', data) self.assertSensor('meter_power_factor3', -9.17, '', data) self.assertSensor('meter_power_factor', -7.46, '', data) self.assertSensor('meter_freq', 49.99, 'Hz', data) self.assertSensor('meter_e_total_exp', 0.0, 'kWh', data) self.assertSensor('meter_e_total_imp', 0.0, 'kWh', data) self.assertSensor('meter_active_power1', -599, 'W', data) self.assertSensor('meter_active_power2', -567, 'W', data) self.assertSensor('meter_active_power3', -2911, 'W', data) self.assertSensor('meter_active_power_total', -4077, 'W', data) self.assertSensor('meter_reactive_power1', 647, 'var', data) self.assertSensor('meter_reactive_power2', 1105, 'var', data) self.assertSensor('meter_reactive_power3', 1077, 'var', data) self.assertSensor('meter_reactive_power_total', 2831, 'var', data) self.assertSensor('meter_apparent_power1', -1076, 'VA', data) self.assertSensor('meter_apparent_power2', -1414, 'VA', data) self.assertSensor('meter_apparent_power3', -3175, 'VA', data) self.assertSensor('meter_apparent_power_total', -5667, 'VA', data) self.assertSensor('meter_type', 2, '', data) self.assertSensor('meter_sw_version', 0, '', data) self.assertSensor('meter2_active_power', 0, 'W', data) self.assertSensor('meter2_e_total_exp', 0.0, 'kWh', data) self.assertSensor('meter2_e_total_imp', 0.0, 'kWh', data) self.assertSensor('meter2_comm_status', 0, '', data) self.assertSensor('meter_voltage1', 231.1, 'V', data) self.assertSensor('meter_voltage2', 233.2, 'V', data) self.assertSensor('meter_voltage3', 232.6, 'V', data) self.assertSensor('meter_current1', 4.6, 'A', data) self.assertSensor('meter_current2', 6.0, 'A', data) self.assertSensor('meter_current3', 13.6, 'A', data) self.assertSensor('battery2_status', 0, '', data) self.assertSensor('battery2_temperature', 0.0, 'C', data) self.assertSensor('battery2_charge_limit', 0, 'A', data) self.assertSensor('battery2_discharge_limit', 0, 'A', data) self.assertSensor('battery2_error_l', 4096, '', data) self.assertSensor('battery2_soc', 0, '%', data) self.assertSensor('battery2_soh', 0, '%', data) self.assertSensor('battery2_modules', 6, '', data) self.assertSensor('battery2_warning_l', 0, '', data) self.assertSensor('battery2_protocol', 288, '', data) self.assertSensor('battery2_error_h', 0, '', data) self.assertSensor('battery2_error', '', '', data) self.assertSensor('battery2_warning_h', 0, '', data) self.assertSensor('battery2_warning', '', '', data) self.assertSensor('battery2_sw_version', 0, '', data) self.assertSensor('battery2_hw_version', 0, '', data) self.assertSensor('battery2_max_cell_temp_id', 0, '', data) self.assertSensor('battery2_min_cell_temp_id', 0, '', data) self.assertSensor('battery2_max_cell_voltage_id', 0, '', data) self.assertSensor('battery2_min_cell_voltage_id', 0, '', data) self.assertSensor('battery2_max_cell_temp', 0.0, 'C', data) self.assertSensor('battery2_min_cell_temp', 0.0, 'C', data) self.assertSensor('battery2_max_cell_voltage', 0.0, 'V', data) self.assertSensor('battery2_min_cell_voltage', 0.0, 'V', data) self.assertSensor('ppv_total', 1565, 'W', data) self.assertSensor('pv_channel', 3, '', data) self.assertSensor('vpv5', 301.5, 'V', data) self.assertSensor('ipv5', 2.0, 'A', data) self.assertSensor('vpv6', 301.5, 'V', data) self.assertSensor('ipv6', 0.0, 'A', data) self.assertSensor('vpv7', 0.0, 'V', data) self.assertSensor('ipv7', 0.0, 'A', data) self.assertSensor('vpv8', 0.0, 'V', data) self.assertSensor('ipv8', 0.0, 'A', data) self.assertSensor('vpv9', 0.0, 'V', data) self.assertSensor('ipv9', 0.0, 'A', data) self.assertSensor('vpv10', 0.0, 'V', data) self.assertSensor('ipv10', 0.0, 'A', data) self.assertSensor('vpv11', 0.0, 'V', data) self.assertSensor('ipv11', 0.0, 'A', data) self.assertSensor('vpv12', 0.0, 'V', data) self.assertSensor('ipv12', 0.0, 'A', data) self.assertSensor('vpv13', 0.0, 'V', data) self.assertSensor('ipv13', 0.0, 'A', data) self.assertSensor('vpv14', 0.0, 'V', data) self.assertSensor('ipv14', 0.0, 'A', data) self.assertSensor('vpv15', 0.0, 'V', data) self.assertSensor('ipv15', 0.0, 'A', data) self.assertSensor('vpv16', 0.0, 'V', data) self.assertSensor('ipv16', 0.0, 'A', data) self.assertSensor('pmppt1', 471, 'W', data) self.assertSensor('pmppt2', 729, 'W', data) self.assertSensor('pmppt3', 365, 'W', data) self.assertSensor('pmppt4', 0, 'W', data) self.assertSensor('pmppt5', 0, 'W', data) self.assertSensor('pmppt6', 0, 'W', data) self.assertSensor('pmppt7', 0, 'W', data) self.assertSensor('pmppt8', 0, 'W', data) self.assertSensor('imppt1', 0.7, 'A', data) self.assertSensor('imppt2', 1.3, 'A', data) self.assertSensor('imppt3', 1.3, 'A', data) self.assertSensor('imppt4', 0.0, 'A', data) self.assertSensor('imppt5', 0.0, 'A', data) self.assertSensor('imppt6', 0.0, 'A', data) self.assertSensor('imppt7', 0.0, 'A', data) self.assertSensor('imppt8', 0.0, 'A', data) self.assertSensor('reactive_power1', 0, 'var', data) self.assertSensor('reactive_power2', 0, 'var', data) self.assertSensor('reactive_power3', 0, 'var', data) self.assertSensor('apparent_power1', 0, 'VA', data) self.assertSensor('apparent_power2', 0, 'VA', data) self.assertSensor('apparent_power3', 0, 'VA', data) self.assertFalse(self.sensor_map, f"Some sensors were not tested {self.sensor_map}") class GW5K_BT_Test(EtMock): def __init__(self, methodName='runTest'): EtMock.__init__(self, methodName) self.mock_response(self._READ_DEVICE_VERSION_INFO, 'GW5K-BT_device_info.hex') self.mock_response(ModbusRtuReadCommand(0xf7, 47547, 6), 'NO RESPONSE') def test_GW5K_BT_device_info(self): self.loop.run_until_complete(self.read_device_info()) self.assertEqual('GW5K-BT', self.model_name) self.assertEqual('95000BTU203W0000', self.serial_number) self.assertEqual(5000, self.rated_power) self.assertEqual(0, self.modbus_version) self.assertEqual(254, self.ac_output_type) self.assertEqual(3, self.dsp1_version) self.assertEqual(3, self.dsp2_version) self.assertEqual(124, self.dsp_svn_version) self.assertEqual(11, self.arm_version) self.assertEqual(147, self.arm_svn_version) self.assertEqual('04029-03-S10', self.firmware) self.assertEqual('02041-11-S00', self.arm_firmware) marcelblijleven-goodwe-a4b8c7f/tests/test_modbus.py000066400000000000000000000150421463365355300227150ustar00rootroot00000000000000from unittest import TestCase from goodwe.modbus import * class TestModbus(TestCase): def assert_rtu_response_ok(self, response: str, cmd: int, offset: int, value: int): self.assertTrue(validate_modbus_rtu_response(bytes.fromhex(response), cmd, offset, value)) def assert_rtu_response_fail(self, response: str, cmd: int, offset: int, value: int): self.assertFalse(validate_modbus_rtu_response(bytes.fromhex(response), cmd, offset, value)) def assert_rtu_response_partial(self, response: str, cmd: int, offset: int, value: int): self.assertRaises(PartialResponseException, lambda: validate_modbus_rtu_response(bytes.fromhex(response), cmd, offset, value)) def assert_rtu_response_rejected(self, response: str, cmd: int, offset: int, value: int): self.assertRaises(RequestRejectedException, lambda: validate_modbus_rtu_response(bytes.fromhex(response), cmd, offset, value)) def test_create_modbus_rtu_request(self): request = create_modbus_rtu_request(0x11, 0x3, 0x006b, 0x0003) self.assertEqual('1103006b00037687', request.hex()) request = create_modbus_rtu_request(0xf7, 0x3, 0x88b8, 0x0021) self.assertEqual('f70388b800213ac1', request.hex()) request = create_modbus_rtu_request(0xf7, 0x6, 0x88b8, 0x00FF) self.assertEqual('f70688b800ff7699', request.hex()) request = create_modbus_rtu_request(0xf7, 0x6, 0x88b8, -1) self.assertEqual('f70688b8ffff3769', request.hex()) def test_create_modbus_rtu_multi_request(self): request = create_modbus_rtu_multi_request(0xf7, 0x10, 0x88b8, b'\x01\x02\x03\x04\x05\x06') self.assertEqual('f71088b8000306010203040506102e', request.hex()) def test_validate_modbus_rtu_read_response(self): self.assert_rtu_response_ok('aa55f7030401020304cd33', 0x03, 0x0401, 2) # some garbage after response end self.assert_rtu_response_ok('aa55f7030401020304cd33ffffff', 0x03, 0x0401, 2) # length too short self.assert_rtu_response_partial('aa55f7030401020304', 0x03, 0x0401, 2) # wrong checksum self.assert_rtu_response_fail('aa55f70304010203043346', 0x03, 0x0401, 2) # failure code self.assert_rtu_response_rejected('aa55f783040102030405b35e', 0x03, 0x0401, 2) # unexpected message length self.assert_rtu_response_fail('aa55f70306010203040506b417', 0x03, 0x0401, 2) def test_validate_modbus_rtu_write_response(self): self.assert_rtu_response_ok('aa55f706b12c00147ba6', 0x06, 0xb12c, 0x14) self.assert_rtu_response_ok('aa55f706b12cffff7a19', 0x06, 0xb12c, -1) # some garbage after response end self.assert_rtu_response_ok('aa55f706b12c00147ba6ffffff', 0x06, 0xb12c, 0x14) # length too short self.assert_rtu_response_fail('aa55f7066b12', 0x06, 0xb12c, 0x14) # wrong checksum self.assert_rtu_response_fail('aa55f706b12c00147ba7', 0x06, 0xb12c, 0x14) # wrong value written self.assert_rtu_response_fail('aa55f706b12c0012fba4', 0x06, 0xb12c, 0x14) def test_validate_modbus_rtu_write_multi_response(self): self.assert_rtu_response_ok('aa55f71088b800033f1b', 0x10, 0x88b8, 0x03) # some garbage after response end self.assert_rtu_response_ok('aa55f71088b800033f1bffffff', 0x10, 0x88b8, 0x03) # length too short self.assert_rtu_response_fail('aa55f71088b8', 0x10, 0x88b8, 0x03) # wrong checksum self.assert_rtu_response_fail('aa55f71088b800033f1c', 0x10, 0x88b8, 0x03) # wrong value written self.assert_rtu_response_fail('aa55f71088b80001beda', 0x10, 0x88b8, 0x03) def assert_tcp_response_ok(self, response: str, cmd: int, offset: int, value: int): self.assertTrue(validate_modbus_tcp_response(bytes.fromhex(response), cmd, offset, value)) def assert_tcp_response_fail(self, response: str, cmd: int, offset: int, value: int): self.assertFalse(validate_modbus_tcp_response(bytes.fromhex(response), cmd, offset, value)) def assert_tcp_response_partial(self, response: str, cmd: int, offset: int, value: int): self.assertRaises(PartialResponseException, lambda: validate_modbus_tcp_response(bytes.fromhex(response), cmd, offset, value)) def assert_tcp_response_rejected(self, response: str, cmd: int, offset: int, value: int): self.assertRaises(RequestRejectedException, lambda: validate_modbus_tcp_response(bytes.fromhex(response), cmd, offset, value)) def test_create_modbus_tcp_request(self): request = create_modbus_tcp_request(0x11, 0x3, 0x006b, 0x0003) self.assertEqual('0001000000061103006b0003', request.hex()) request = create_modbus_tcp_request(180, 0x3, 331, 2) self.assertEqual('000100000006b403014b0002', request.hex()) request = create_modbus_tcp_request(0xf7, 0x3, 0x88b8, 0x0021) self.assertEqual('000100000006f70388b80021', request.hex()) request = create_modbus_tcp_request(0xf7, 0x6, 0x88b8, 0x00FF) self.assertEqual('000100000006f70688b800ff', request.hex()) request = create_modbus_tcp_request(0xf7, 0x6, 0x88b8, -1) self.assertEqual('000100000006f70688b8ffff', request.hex()) def test_create_modbus_tcp_multi_request(self): request = create_modbus_tcp_multi_request(0xf7, 0x10, 0x88b8, b'\x01\x02\x03\x04\x05\x06') self.assertEqual('00010000000df71088b8000306010203040506', request.hex()) def test_validate_modbus_tcp_read_response(self): self.assert_tcp_response_ok('000100000007b4030445565345', 0x3, 310, 2) self.assert_tcp_response_ok('000100000007b4030400000002', 0x3, 331, 2) # technically illegal, but work around Goodwe bug self.assert_tcp_response_ok('000100000006f703020000', 0x3, 47510, 1) # length too short self.assert_tcp_response_partial('000100000007b403040000', 0x03, 331, 2) # failure code self.assert_tcp_response_rejected('000100000007b4830400000002', 0x03, 331, 2) def test_validate_modbus_tcp_write_response(self): self.assert_tcp_response_ok('000100000006b40601364556', 0x06, 310, 0x4556) self.assert_tcp_response_ok('000100000006f70688b80021', 0x06, 0x88b8, 0x0021) # self.assert_tcp_response_ok('000100000006f70688b800ff', 0x06, 0xb12c, -1) # length too short self.assert_tcp_response_partial('000100000006f70388b8', 0x6, 0x88b8, 0x0021) # wrong value written self.assert_rtu_response_fail('aa55f706b12c0012fba4', 0x06, 0xb12c, 0x14) marcelblijleven-goodwe-a4b8c7f/tests/test_protocol.py000066400000000000000000000133421463365355300232660ustar00rootroot00000000000000from unittest import TestCase, mock from goodwe.protocol import * class TestUDPClientProtocol(TestCase): def setUp(self) -> None: self.protocol = UdpInverterProtocol('127.0.0.1', 1337, 0xf7, 1, 3) self.protocol.command = ProtocolCommand(bytes.fromhex('636f666665650d0a'), lambda x: True) self.protocol.response_future = mock.Mock() def test_datagram_received(self): data = b'this is mock data' self.protocol.datagram_received(data, ('127.0.0.1', 1337)) self.protocol.response_future.set_result.assert_called_once() # self.processor.assert_called_once_with(data) # def test_datagram_received_process_exception(self): # data = b'this is mock data' # self.protocol.processor.side_effect = TypeError # self.protocol.datagram_received(data, ('127.0.0.1', 1337)) # self.processor.assert_called_once_with(data) # self.future.set_result.assert_not_called() # self.future.set_exception.assert_called_once_with(ProcessingException) def test_error_received(self): exc = Exception('something went wrong') self.protocol.error_received(exc) self.protocol.response_future.set_exception.assert_called_once_with(exc) @mock.patch('goodwe.protocol.asyncio.get_running_loop') def test_connection_made(self, mock_get_event_loop): transport = mock.Mock() mock_loop = mock.Mock() mock_get_event_loop.return_value = mock_loop mock_timeout_mechanism = mock.Mock() self.protocol._timeout_mechanism = mock_timeout_mechanism self.protocol.connection_made(transport) self.protocol._send_request(self.protocol.command, self.protocol.response_future) transport.sendto.assert_called_with(self.protocol.command.request) mock_get_event_loop.assert_called() mock_loop.call_later.assert_called_with(1, mock_timeout_mechanism) def test_connection_lost(self): self.protocol.response_future.done.return_value = True self.protocol.connection_lost(None) self.protocol.response_future.cancel.assert_not_called() def test_connection_lost_not_done(self): self.protocol.response_future.done.return_value = False self.protocol.connection_lost(None) self.protocol.response_future.cancel.assert_called() def test_retry_mechanism(self): self.protocol._transport = mock.Mock() self.protocol._send_request = mock.Mock() self.protocol.response_future.done.return_value = True self.protocol._timeout_mechanism() # self.protocol._transport.close.assert_called() self.protocol._send_request.assert_not_called() # @mock.patch('goodwe.protocol.asyncio.get_running_loop') # def test_retry_mechanism_two_retries(self, mock_get_event_loop): # def call_later(_: int, retry_func: Callable): # retry_func() # # mock_loop = mock.Mock() # mock_get_event_loop.return_value = mock_loop # mock_loop.call_later = call_later # # self.protocol._transport = mock.Mock() # self.protocol.response_future.done.side_effect = [False, False, True, False] # self.protocol._timeout_mechanism() # # # self.protocol._transport.close.assert_called() # self.assertEqual(self.protocol._retry, 2) # @mock.patch('goodwe.protocol.asyncio.get_running_loop') # def test_retry_mechanism_max_retries(self, mock_get_event_loop): # def call_later(_: int, retry_func: Callable): # retry_func() # # mock_loop = mock.Mock() # mock_get_event_loop.return_value = mock_loop # mock_loop.call_later = call_later # # self.protocol._transport = mock.Mock() # self.protocol.response_future.done.side_effect = [False, False, False, False, False] # self.protocol._timeout_mechanism() # self.protocol.response_future.set_exception.assert_called_once_with(MaxRetriesException) # self.assertEqual(self.protocol._retry, 3) def test_modbus_rtu_read_command(self): command = ModbusRtuReadCommand(0xf7, 0x88b8, 0x0021) self.assertEqual(bytes.fromhex('f70388b800213ac1'), command.request) def test_modbus_rtu_write_command(self): command = ModbusRtuWriteCommand(0xf7, 0xb798, 0x0002) self.assertEqual(bytes.fromhex('f706b7980002bac6'), command.request) def test_modbus_rtu_write_multi_command(self): command = ModbusRtuWriteMultiCommand(0xf7, 0xb798, bytes.fromhex('08070605')) self.assertEqual(bytes.fromhex('f710b79800020408070605851b'), command.request) def test_modbus_tcp_read_command(self): command = ModbusTcpReadCommand(180, 310, 2) self.assertEqual(bytes.fromhex('000100000006b40301360002'), command.request) def test_modbus_tcp_write_command(self): command = ModbusTcpWriteCommand(180, 310, 0x4556) self.assertEqual(bytes.fromhex('000100000006B40601364556'), command.request) def test_modbus_tcp_write_multi_command(self): command = ModbusTcpWriteMultiCommand(0xf7, 0xb798, bytes.fromhex('08070605')) self.assertEqual(bytes.fromhex('00010000000bf710b79800020408070605'), command.request) def test_aa55_read_command(self): command = Aa55ReadCommand(0x0701, 16) self.assertEqual(bytes.fromhex('AA55C07F011A030701100274'), command.request) def test_aa55_write_command(self): command = Aa55WriteCommand(0x0560, 0x0002) self.assertEqual(bytes.fromhex('AA55C07F023905056001000202E6'), command.request) def test_aa55_write_multi_command(self): command = Aa55WriteMultiCommand(0x0701, bytes.fromhex('08070605')) self.assertEqual(bytes.fromhex('AA55C07F02390B0701040807060502AA'), command.request) marcelblijleven-goodwe-a4b8c7f/tests/test_sensor.py000066400000000000000000000363611463365355300227440ustar00rootroot00000000000000from unittest import TestCase from goodwe.sensor import * class MockResponse(ProtocolResponse): def __init__(self, response: str): super().__init__(bytes.fromhex(response), None) def response_data(self) -> bytes: return self.raw_data class TestUtils(TestCase): def test_byte(self): testee = Byte("", 0, "", "", None) data = MockResponse("0c") self.assertEqual(12, testee.read(data)) data = MockResponse("f0") self.assertEqual(-16, testee.read(data)) def test_byteH(self): testee = ByteH("", 0, "", "", None) data = MockResponse("2039") self.assertEqual(32, testee.read(data)) self.assertEqual("2039", testee.encode_value(32, bytes.fromhex("3039")).hex()) self.assertEqual("2039", testee.encode_value("32", bytes.fromhex("3039")).hex()) self.assertEqual("ff39", testee.encode_value(-1, bytes.fromhex("3039")).hex()) self.assertEqual("7f39", testee.encode_value(127, bytes.fromhex("3039")).hex()) self.assertEqual("20ff", testee.encode_value(32, bytes.fromhex("ffff")).hex()) def test_byteL(self): testee = ByteL("", 0, "", "", None) data = MockResponse("307f") self.assertEqual(127, testee.read(data)) self.assertEqual("3020", testee.encode_value(32, bytes.fromhex("3039")).hex()) self.assertEqual("3020", testee.encode_value("32", bytes.fromhex("3039")).hex()) self.assertEqual("30ff", testee.encode_value(-1, bytes.fromhex("3039")).hex()) self.assertEqual("307f", testee.encode_value(127, bytes.fromhex("3039")).hex()) self.assertEqual("ff20", testee.encode_value(32, bytes.fromhex("ffff")).hex()) def test_integer(self): testee = Integer("", 0, "", "", None) data = MockResponse("0031") self.assertEqual(49, testee.read(data)) self.assertEqual("0031", testee.encode_value(49).hex()) self.assertEqual("0031", testee.encode_value("49").hex()) data = MockResponse("ff9e") self.assertEqual(65438, testee.read(data)) self.assertEqual("ff9e", testee.encode_value(65438).hex()) self.assertEqual("ff9e", testee.encode_value("65438").hex()) def test_integer_signed(self): testee = IntegerS("", 0, "", "", None) data = MockResponse("0031") self.assertEqual(49, testee.read(data)) self.assertEqual("0031", testee.encode_value(49).hex()) self.assertEqual("0031", testee.encode_value("49").hex()) data = MockResponse("ff9e") self.assertEqual(-98, testee.read(data)) self.assertEqual("ff9e", testee.encode_value(-98).hex()) self.assertEqual("ff9e", testee.encode_value("-98").hex()) def test_decimal(self): testee = Decimal("", 0, 10, "", "", None) data = MockResponse("0031") self.assertEqual(4.9, testee.read(data)) self.assertEqual("0031", testee.encode_value(4.9).hex()) self.assertEqual("0031", testee.encode_value("4.9").hex()) data = MockResponse("ff9e") self.assertEqual(-9.8, testee.read(data)) self.assertEqual("ff9e", testee.encode_value(-9.8).hex()) self.assertEqual("ff9e", testee.encode_value("-9.8").hex()) def test_voltage(self): testee = Voltage("", 0, "", None) data = MockResponse("0cfe") self.assertEqual(332.6, testee.read(data)) self.assertEqual("0cfe", testee.encode_value(332.6).hex()) self.assertEqual("0cfe", testee.encode_value("332.6").hex()) data = MockResponse("1f64") self.assertEqual(803.6, testee.read(data)) self.assertEqual("1f64", testee.encode_value(803.6).hex()) self.assertEqual("1f64", testee.encode_value("803.6").hex()) data = MockResponse("a000") self.assertEqual(4096.0, testee.read(data)) data = MockResponse("ffff") self.assertEqual(0, testee.read(data)) def test_current(self): testee = Current("", 0, "", None) data = MockResponse("0031") self.assertEqual(4.9, testee.read(data)) self.assertEqual("0031", testee.encode_value(4.9).hex()) self.assertEqual("0031", testee.encode_value("4.9").hex()) data = MockResponse("ff9e") self.assertEqual(6543.8, testee.read(data)) self.assertEqual("ff9e", testee.encode_value(6543.8).hex()) self.assertEqual("ff9e", testee.encode_value("6543.8").hex()) data = MockResponse("ffff") self.assertEqual(0, testee.read(data)) def test_current_signed(self): testee = CurrentS("", 0, "", None) data = MockResponse("0031") self.assertEqual(4.9, testee.read(data)) self.assertEqual("0031", testee.encode_value(4.9).hex()) self.assertEqual("0031", testee.encode_value("4.9").hex()) data = MockResponse("ff9e") self.assertEqual(-9.8, testee.read(data)) self.assertEqual("ff9e", testee.encode_value(-9.8).hex()) self.assertEqual("ff9e", testee.encode_value("-9.8").hex()) def test_power4(self): testee = Power4("", 0, "", None) data = MockResponse("0000069f") self.assertEqual(1695, testee.read(data)) data = MockResponse("fffffffd") self.assertEqual(4294967293, testee.read(data)) data = MockResponse("ffffffff") self.assertIsNone(testee.read(data)) def test_power4_signed(self): testee = Power4S("", 0, "", None) data = MockResponse("0000069f") self.assertEqual(1695, testee.read(data)) data = MockResponse("fffffffd") self.assertEqual(-3, testee.read(data)) def test_energy(self): testee = Energy("", 0, "", None) data = MockResponse("0972") self.assertEqual(241.8, testee.read(data)) def test_energy4(self): testee = Energy4("", 0, "", None) data = MockResponse("00020972") self.assertEqual(13349.0, testee.read(data)) data = MockResponse("ffffffff") self.assertIsNone(testee.read(data)) def test_energy8(self): testee = Energy8("", 0, "", None) data = MockResponse("0000000000015b41") self.assertEqual(888.97, testee.read(data)) data = MockResponse("0000000000038E6C") self.assertEqual(2330.68, testee.read(data)) data = MockResponse("ffffffffffffffff") self.assertIsNone(testee.read(data)) def test_temp(self): testee = Temp("", 0, "", None) data = MockResponse("0177") self.assertEqual(37.5, testee.read(data)) data = MockResponse("ffff") self.assertIsNone(testee.read(data)) def test_timestamp(self): testee = Timestamp("", 0, "", None) data = MockResponse("160104121e19") self.assertEqual(datetime(2022, 1, 4, 18, 30, 25), testee.read(data)) self.assertEqual("160104121e19", testee.encode_value(datetime(2022, 1, 4, 18, 30, 25)).hex()) self.assertEqual("160104121e19", testee.encode_value("2022-01-04T18:30:25").hex()) def test_eco_mode_v1(self): testee = EcoModeV1("", 0, "") data = MockResponse("0d1e0e28ffc4ff1a") self.assertEqual("13:30-14:40 Mon,Wed,Thu -60% On", testee.read(data).__str__()) self.assertEqual(bytes.fromhex("0d1e0e28ffc4ff1a"), testee.encode_value(bytes.fromhex("0d1e0e28ffc4ff1a"))) self.assertRaises(ValueError, lambda: testee.encode_value("some string")) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) data = MockResponse(testee.encode_charge(-40).hex()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat -40% On", testee.read(data).__str__()) self.assertTrue(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat -40% (SoC 100%) On", testee.as_eco_mode_v2().__str__()) data = MockResponse(testee.encode_discharge(60).hex()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat 60% On", testee.read(data).__str__()) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertTrue(testee.read(data).is_eco_discharge_mode()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat 60% (SoC 100%) On", testee.as_eco_mode_v2().__str__()) data = MockResponse(testee.encode_off().hex()) self.assertEqual("48:0-48:0 100% Off", testee.read(data).__str__()) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) def test_schedule(self): testee = Schedule("", 0, "") data = MockResponse("0d1e0e28ff1affc4005a0000") self.assertEqual("13:30-14:40 Mon,Wed,Thu -60% (SoC 90%) On", testee.read(data).__str__()) self.assertEqual(ScheduleType.ECO_MODE, testee.schedule_type) self.assertEqual(bytes.fromhex("0d1e0e28ff1affc4005a0000"), testee.encode_value(bytes.fromhex("0d1e0e28ff1affc4005a0000"))) self.assertRaises(ValueError, lambda: testee.encode_value("some string")) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) data = MockResponse(testee.encode_charge(-40, 80).hex()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat -40% (SoC 80%) On", testee.read(data).__str__()) self.assertTrue(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat -40% On", testee.as_eco_mode_v1().__str__()) data = MockResponse(testee.encode_discharge(60).hex()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat 60% (SoC 100%) On", testee.read(data).__str__()) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertTrue(testee.read(data).is_eco_discharge_mode()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat 60% On", testee.as_eco_mode_v1().__str__()) data = MockResponse(testee.encode_off().hex()) self.assertEqual("48:0-48:0 100% (SoC 100%) Off", testee.read(data).__str__()) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) data = MockResponse("0300080006fefd12005fcfff") self.assertEqual("3:0-8:0 Mon -75% (SoC 95%) Off", testee.read(data).__str__()) data = MockResponse("0000173b5500001400640000") self.assertEqual("0:0-23:59 20% (SoC 100%) Unset", testee.read(data).__str__()) data = MockResponse("ffffffff557f000000010001") self.assertEqual("-1:-1--1:-1 Sun,Mon,Tue,Wed,Thu,Fri,Sat Jan 0% (SoC 1%) Unset", testee.read(data).__str__()) data = MockResponse("000000005500000000000000") self.assertEqual("0:0-0:0 0% (SoC 0%) Unset", testee.read(data).__str__()) def test_eco_mode_v745(self): testee = Schedule("", 0, "", ScheduleType.ECO_MODE_745) data = MockResponse("0d1e0e28f91affc4005a0000") self.assertEqual("13:30-14:40 Mon,Wed,Thu -6% (SoC 90%) On", testee.read(data).__str__()) self.assertEqual(bytes.fromhex("0d1e0e28f91affc4005a0000"), testee.encode_value(bytes.fromhex("0d1e0e28f91affc4005a0000"))) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse(testee.encode_charge(-40, 80).hex()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat -40% (SoC 80%) On", testee.read(data).__str__()) self.assertTrue(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse(testee.encode_discharge(60).hex()) self.assertEqual("0:0-23:59 Sun,Mon,Tue,Wed,Thu,Fri,Sat 60% (SoC 100%) On", testee.read(data).__str__()) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertTrue(testee.read(data).is_eco_discharge_mode()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse(testee.encode_off().hex()) self.assertEqual("48:0-48:0 100% (SoC 100%) Off", testee.read(data).__str__()) self.assertFalse(testee.read(data).is_eco_charge_mode()) self.assertFalse(testee.read(data).is_eco_discharge_mode()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) self.assertEqual(1000, testee.power) self.assertEqual(100, testee.get_power()) self.assertEqual("%", testee.get_power_unit()) data = MockResponse("10001600f97f00c800000fff") self.assertEqual("16:0-22:0 Sun,Mon,Tue,Wed,Thu,Fri,Sat 20% (SoC 0%) On", testee.read(data).__str__()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse("10001600067f00c800000fff") self.assertEqual("16:0-22:0 Sun,Mon,Tue,Wed,Thu,Fri,Sat 20% (SoC 0%) Off", testee.read(data).__str__()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse("10001600f97ffe70004b0fff") self.assertEqual("16:0-22:0 Sun,Mon,Tue,Wed,Thu,Fri,Sat -40% (SoC 75%) On", testee.read(data).__str__()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse("10001600f97fff3800320fff") self.assertEqual("16:0-22:0 Sun,Mon,Tue,Wed,Thu,Fri,Sat -20% (SoC 50%) On", testee.read(data).__str__()) self.assertEqual(ScheduleType.ECO_MODE_745, testee.schedule_type) data = MockResponse("10001600f902ff38004b0002") self.assertEqual("16:0-22:0 Mon Feb -20% (SoC 75%) On", testee.read(data).__str__()) data = MockResponse("10001600f902ff38004b0004") self.assertEqual("16:0-22:0 Mon Mar -20% (SoC 75%) On", testee.read(data).__str__()) def test_peak_shaving_mode(self): testee = PeakShavingMode("", 0, "") data = MockResponse("010a020a037f00fa00370000") self.assertEqual("1:10-2:10 Sun,Mon,Tue,Wed,Thu,Fri,Sat 2500W (SoC 55%) Off", testee.read(data).__str__()) self.assertEqual(bytes.fromhex("010a020a037f00fa00370000"), testee.encode_value(bytes.fromhex("010a020a037f00fa00370000"))) data = MockResponse("00000d08fc7f006400140000") self.assertEqual("0:0-13:8 Sun,Mon,Tue,Wed,Thu,Fri,Sat 1000W (SoC 20%) On", testee.read(data).__str__()) self.assertEqual(ScheduleType.PEAK_SHAVING, testee.schedule_type) data = MockResponse("00000d08037f000000000000") self.assertEqual("0:0-13:8 Sun,Mon,Tue,Wed,Thu,Fri,Sat 0W (SoC 0%) Off", testee.read(data).__str__()) self.assertEqual(ScheduleType.PEAK_SHAVING, testee.schedule_type) def test_decode_bitmap(self): self.assertEqual('', decode_bitmap(0, ERROR_CODES)) self.assertEqual('Utility Loss', decode_bitmap(512, ERROR_CODES)) self.assertEqual('Utility Loss', decode_bitmap(516, ERROR_CODES)) self.assertEqual('Utility Loss, Vac Failure', decode_bitmap(131584, ERROR_CODES)) self.assertEqual('err16', decode_bitmap(65536, BMS_WARNING_CODES))