goodwe 0.4.0__py3-none-any.whl → 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goodwe/dt.py +40 -13
- goodwe/es.py +7 -6
- goodwe/et.py +103 -38
- goodwe/exceptions.py +14 -0
- goodwe/inverter.py +13 -9
- goodwe/modbus.py +10 -9
- goodwe/protocol.py +115 -49
- goodwe/sensor.py +28 -4
- {goodwe-0.4.0.dist-info → goodwe-0.4.2.dist-info}/METADATA +1 -1
- goodwe-0.4.2.dist-info/RECORD +16 -0
- goodwe-0.4.0.dist-info/RECORD +0 -16
- {goodwe-0.4.0.dist-info → goodwe-0.4.2.dist-info}/LICENSE +0 -0
- {goodwe-0.4.0.dist-info → goodwe-0.4.2.dist-info}/WHEEL +0 -0
- {goodwe-0.4.0.dist-info → goodwe-0.4.2.dist-info}/top_level.txt +0 -0
goodwe/dt.py
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from typing import Tuple
|
|
4
5
|
|
|
5
|
-
from .exceptions import InverterError
|
|
6
|
+
from .exceptions import InverterError, RequestRejectedException
|
|
6
7
|
from .inverter import Inverter
|
|
7
8
|
from .inverter import OperationMode
|
|
8
9
|
from .inverter import SensorKind as Kind
|
|
10
|
+
from .modbus import ILLEGAL_DATA_ADDRESS
|
|
9
11
|
from .model import is_3_mppt, is_single_phase
|
|
10
12
|
from .protocol import ProtocolCommand
|
|
11
13
|
from .sensor import *
|
|
12
14
|
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
class DT(Inverter):
|
|
15
19
|
"""Class representing inverter of DT/MS/D-NS/XS or GE's GEP(PSB/PSC) families"""
|
|
@@ -123,10 +127,7 @@ class DT(Inverter):
|
|
|
123
127
|
)
|
|
124
128
|
|
|
125
129
|
def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
|
|
126
|
-
super().__init__(host, port, comm_addr, timeout, retries)
|
|
127
|
-
if not self.comm_addr:
|
|
128
|
-
# Set the default inverter address
|
|
129
|
-
self.comm_addr = 0x7f
|
|
130
|
+
super().__init__(host, port, comm_addr if comm_addr else 0x7f, timeout, retries)
|
|
130
131
|
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x7531, 0x0028)
|
|
131
132
|
self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = self._read_command(0x7594, 0x0049)
|
|
132
133
|
self._sensors = self.__all_sensors
|
|
@@ -177,17 +178,43 @@ class DT(Inverter):
|
|
|
177
178
|
|
|
178
179
|
async def read_setting(self, setting_id: str) -> Any:
|
|
179
180
|
setting = self._settings.get(setting_id)
|
|
180
|
-
if
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
if setting:
|
|
182
|
+
return await self._read_setting(setting)
|
|
183
|
+
else:
|
|
184
|
+
if setting_id.startswith("modbus"):
|
|
185
|
+
response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1))
|
|
186
|
+
return int.from_bytes(response.read(2), byteorder="big", signed=True)
|
|
187
|
+
else:
|
|
188
|
+
raise ValueError(f'Unknown setting "{setting_id}"')
|
|
189
|
+
|
|
190
|
+
async def _read_setting(self, setting: Sensor) -> Any:
|
|
191
|
+
try:
|
|
192
|
+
count = (setting.size_ + (setting.size_ % 2)) // 2
|
|
193
|
+
response = await self._read_from_socket(self._read_command(setting.offset, count))
|
|
194
|
+
return setting.read_value(response)
|
|
195
|
+
except RequestRejectedException as ex:
|
|
196
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
197
|
+
logger.debug("Unsupported setting %s", setting.id_)
|
|
198
|
+
self._settings.pop(setting.id_, None)
|
|
199
|
+
return None
|
|
185
200
|
|
|
186
201
|
async def write_setting(self, setting_id: str, value: Any):
|
|
187
202
|
setting = self._settings.get(setting_id)
|
|
188
|
-
if
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
if setting:
|
|
204
|
+
await self._write_setting(setting, value)
|
|
205
|
+
else:
|
|
206
|
+
if setting_id.startswith("modbus"):
|
|
207
|
+
await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value)))
|
|
208
|
+
else:
|
|
209
|
+
raise ValueError(f'Unknown setting "{setting_id}"')
|
|
210
|
+
|
|
211
|
+
async def _write_setting(self, setting: Sensor, value: Any):
|
|
212
|
+
if setting.size_ == 1:
|
|
213
|
+
# modbus can address/store only 16 bit values, read the other 8 bytes
|
|
214
|
+
response = await self._read_from_socket(self._read_command(setting.offset, 1))
|
|
215
|
+
raw_value = setting.encode_value(value, response.response_data()[0:2])
|
|
216
|
+
else:
|
|
217
|
+
raw_value = setting.encode_value(value)
|
|
191
218
|
if len(raw_value) <= 2:
|
|
192
219
|
value = int.from_bytes(raw_value, byteorder="big", signed=True)
|
|
193
220
|
await self._read_from_socket(self._write_command(setting.offset, value))
|
goodwe/es.py
CHANGED
|
@@ -168,10 +168,7 @@ class ES(Inverter):
|
|
|
168
168
|
)
|
|
169
169
|
|
|
170
170
|
def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
|
|
171
|
-
super().__init__(host, port, comm_addr, timeout, retries)
|
|
172
|
-
if not self.comm_addr:
|
|
173
|
-
# Set the default inverter address
|
|
174
|
-
self.comm_addr = 0xf7
|
|
171
|
+
super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries)
|
|
175
172
|
self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}
|
|
176
173
|
|
|
177
174
|
def _supports_eco_mode_v2(self) -> bool:
|
|
@@ -220,6 +217,9 @@ class ES(Inverter):
|
|
|
220
217
|
if not setting:
|
|
221
218
|
raise ValueError(f'Unknown setting "{setting_id}"')
|
|
222
219
|
return await self._read_setting(setting)
|
|
220
|
+
elif setting_id.startswith("modbus"):
|
|
221
|
+
response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1))
|
|
222
|
+
return int.from_bytes(response.read(2), byteorder="big", signed=True)
|
|
223
223
|
else:
|
|
224
224
|
all_settings = await self.read_settings_data()
|
|
225
225
|
return all_settings.get(setting_id)
|
|
@@ -238,6 +238,8 @@ class ES(Inverter):
|
|
|
238
238
|
await self._read_from_socket(
|
|
239
239
|
Aa55ProtocolCommand("030206" + Timestamp("time", 0, "").encode_value(value).hex(), "0382")
|
|
240
240
|
)
|
|
241
|
+
elif setting_id.startswith("modbus"):
|
|
242
|
+
await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value)))
|
|
241
243
|
else:
|
|
242
244
|
setting: Sensor | None = self._settings.get(setting_id)
|
|
243
245
|
if not setting:
|
|
@@ -249,10 +251,9 @@ class ES(Inverter):
|
|
|
249
251
|
# modbus can address/store only 16 bit values, read the other 8 bytes
|
|
250
252
|
if self._is_modbus_setting(setting):
|
|
251
253
|
response = await self._read_from_socket(self._read_command(setting.offset, 1))
|
|
252
|
-
raw_value = setting.encode_value(value, response.response_data()[0:2])
|
|
253
254
|
else:
|
|
254
255
|
response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1))
|
|
255
|
-
|
|
256
|
+
raw_value = setting.encode_value(value, response.response_data()[0:2])
|
|
256
257
|
else:
|
|
257
258
|
raw_value = setting.encode_value(value)
|
|
258
259
|
if len(raw_value) <= 2:
|
goodwe/et.py
CHANGED
|
@@ -3,10 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Tuple
|
|
5
5
|
|
|
6
|
-
from .exceptions import RequestRejectedException
|
|
6
|
+
from .exceptions import RequestFailedException, RequestRejectedException
|
|
7
7
|
from .inverter import Inverter
|
|
8
8
|
from .inverter import OperationMode
|
|
9
9
|
from .inverter import SensorKind as Kind
|
|
10
|
+
from .modbus import ILLEGAL_DATA_ADDRESS
|
|
10
11
|
from .model import is_2_battery, is_4_mppt, is_745_platform, is_single_phase
|
|
11
12
|
from .protocol import ProtocolCommand
|
|
12
13
|
from .sensor import *
|
|
@@ -331,7 +332,7 @@ class ET(Inverter):
|
|
|
331
332
|
# Modbus registers of inverter settings, offsets are modbus register addresses
|
|
332
333
|
__all_settings: Tuple[Sensor, ...] = (
|
|
333
334
|
Integer("comm_address", 45127, "Communication Address", ""),
|
|
334
|
-
|
|
335
|
+
Integer("modbus_baud_rate", 45132, "Modbus Baud rate", ""),
|
|
335
336
|
Timestamp("time", 45200, "Inverter time"),
|
|
336
337
|
|
|
337
338
|
Integer("sensitivity_check", 45246, "Sensitivity Check Mode", "", Kind.AC),
|
|
@@ -371,6 +372,51 @@ class ET(Inverter):
|
|
|
371
372
|
ByteH("eco_mode_3_switch", 47526, "Eco Mode Group 3 Switch"),
|
|
372
373
|
EcoModeV1("eco_mode_4", 47527, "Eco Mode Group 4"),
|
|
373
374
|
ByteH("eco_mode_4_switch", 47530, "Eco Mode Group 4 Switch"),
|
|
375
|
+
|
|
376
|
+
# Direct BMS communication for EMS Control
|
|
377
|
+
Integer("bms_version", 47900, "BMS Version"),
|
|
378
|
+
Integer("bms_bat_modules", 47901, "BMS Battery Modules"),
|
|
379
|
+
# Real time read from BMS
|
|
380
|
+
Voltage("bms_bat_charge_v_max", 47902, "BMS Battery Charge Voltage (max)", Kind.BMS),
|
|
381
|
+
Current("bms_bat_charge_i_max", 47903, "BMS Battery Charge Current (max)", Kind.BMS),
|
|
382
|
+
Voltage("bms_bat_discharge_v_min", 47904, "BMS min. Battery Discharge Voltage (min)", Kind.BMS),
|
|
383
|
+
Current("bms_bat_discharge_i_max", 47905, "BMS max. Battery Discharge Current (max)", Kind.BMS),
|
|
384
|
+
Voltage("bms_bat_voltage", 47906, "BMS Battery Voltage", Kind.BMS),
|
|
385
|
+
Current("bms_bat_current", 47907, "BMS Battery Current", Kind.BMS),
|
|
386
|
+
#
|
|
387
|
+
Integer("bms_bat_soc", 47908, "BMS Battery State of Charge", "%", Kind.BMS),
|
|
388
|
+
Integer("bms_bat_soh", 47909, "BMS Battery State of Health", "%", Kind.BMS),
|
|
389
|
+
Temp("bms_bat_temperature", 47910, "BMS Battery Temperature", Kind.BMS),
|
|
390
|
+
Long("bms_bat_warning-code", 47911, "BMS Battery Warning Code"),
|
|
391
|
+
# Reserved
|
|
392
|
+
Long("bms_bat_alarm-code", 47913, "BMS Battery Alarm Code"),
|
|
393
|
+
Integer("bms_status", 47915, "BMS Status"),
|
|
394
|
+
Integer("bms_comm_loss_disable", 47916, "BMS Communication Loss Disable"),
|
|
395
|
+
# RW settings of BMS voltage rate
|
|
396
|
+
Integer("bms_battery_string_rate_v", 47917, "BMS Battery String Rate Voltage"),
|
|
397
|
+
|
|
398
|
+
# Direct BMS communication for EMS Control
|
|
399
|
+
Integer("bms2_version", 47918, "BMS2 Version"),
|
|
400
|
+
Integer("bms2_bat_modules", 47919, "BMS2 Battery Modules"),
|
|
401
|
+
# Real time read from BMS
|
|
402
|
+
Voltage("bms2_bat_charge_v_max", 47920, "BMS2 Battery Charge Voltage (max)", Kind.BMS),
|
|
403
|
+
Current("bms2_bat_charge_i_max", 47921, "BMS2 Battery Charge Current (max)", Kind.BMS),
|
|
404
|
+
Voltage("bms2_bat_discharge_v_min", 47922, "BMS2 min. Battery Discharge Voltage (min)", Kind.BMS),
|
|
405
|
+
Current("bms2_bat_discharge_i_max", 47923, "BMS2 max. Battery Discharge Current (max)", Kind.BMS),
|
|
406
|
+
Voltage("bms2_bat_voltage", 47924, "BMS2 Battery Voltage", Kind.BMS),
|
|
407
|
+
Current("bms2_bat_current", 47925, "BMS2 Battery Current", Kind.BMS),
|
|
408
|
+
#
|
|
409
|
+
Integer("bms2_bat_soc", 47926, "BMS2 Battery State of Charge", "%", Kind.BMS),
|
|
410
|
+
Integer("bms2_bat_soh", 47927, "BMS2 Battery State of Health", "%", Kind.BMS),
|
|
411
|
+
Temp("bms2_bat_temperature", 47928, "BMS2 Battery Temperature", Kind.BMS),
|
|
412
|
+
Long("bms2_bat_warning-code", 47929, "BMS2 Battery Warning Code"),
|
|
413
|
+
# Reserved
|
|
414
|
+
Long("bms2_bat_alarm-code", 47931, "BMS2 Battery Alarm Code"),
|
|
415
|
+
Integer("bms2_status", 47933, "BMS2 Status"),
|
|
416
|
+
Integer("bms2_comm_loss_disable", 47934, "BMS2 Communication Loss Disable"),
|
|
417
|
+
# RW settings of BMS voltage rate
|
|
418
|
+
Integer("bms2_battery_string_rate_v", 47935, "BMS2 Battery String Rate Voltage"),
|
|
419
|
+
|
|
374
420
|
)
|
|
375
421
|
|
|
376
422
|
# Settings added in ARM firmware 19
|
|
@@ -389,6 +435,7 @@ class ET(Inverter):
|
|
|
389
435
|
Integer("load_control_mode", 47595, "Load Control Mode", "", Kind.AC),
|
|
390
436
|
Integer("load_control_switch", 47596, "Load Control Switch", "", Kind.AC),
|
|
391
437
|
Integer("load_control_soc", 47597, "Load Control SoC", "", Kind.AC),
|
|
438
|
+
Integer("hardware_feed_power", 47599, "Hardware Feed Power"),
|
|
392
439
|
|
|
393
440
|
Integer("fast_charging_power", 47603, "Fast Charging Power", "%", Kind.BAT),
|
|
394
441
|
)
|
|
@@ -410,10 +457,7 @@ class ET(Inverter):
|
|
|
410
457
|
)
|
|
411
458
|
|
|
412
459
|
def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
|
|
413
|
-
super().__init__(host, port, comm_addr, timeout, retries)
|
|
414
|
-
if not self.comm_addr:
|
|
415
|
-
# Set the default inverter address
|
|
416
|
-
self.comm_addr = 0xf7
|
|
460
|
+
super().__init__(host, port, comm_addr if comm_addr else 0xf7, timeout, retries)
|
|
417
461
|
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x88b8, 0x0021)
|
|
418
462
|
self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x891c, 0x007d)
|
|
419
463
|
self._READ_METER_DATA: ProtocolCommand = self._read_command(0x8ca0, 0x2d)
|
|
@@ -447,19 +491,19 @@ class ET(Inverter):
|
|
|
447
491
|
async def read_device_info(self):
|
|
448
492
|
response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO)
|
|
449
493
|
response = response.response_data()
|
|
450
|
-
# Modbus registers from
|
|
494
|
+
# Modbus registers from 35000 - 35032
|
|
451
495
|
self.modbus_version = read_unsigned_int(response, 0)
|
|
452
496
|
self.rated_power = read_unsigned_int(response, 2)
|
|
453
497
|
self.ac_output_type = read_unsigned_int(response, 4) # 0: 1-phase, 1: 3-phase (4 wire), 2: 3-phase (3 wire)
|
|
454
|
-
self.serial_number = self._decode(response[6:22])
|
|
455
|
-
self.model_name = self._decode(response[22:32])
|
|
456
|
-
self.dsp1_version = read_unsigned_int(response, 32)
|
|
457
|
-
self.dsp2_version = read_unsigned_int(response, 34)
|
|
458
|
-
self.dsp_svn_version = read_unsigned_int(response, 36)
|
|
459
|
-
self.arm_version = read_unsigned_int(response, 38)
|
|
460
|
-
self.arm_svn_version = read_unsigned_int(response, 40)
|
|
461
|
-
self.firmware = self._decode(response[42:54])
|
|
462
|
-
self.arm_firmware = self._decode(response[54:66])
|
|
498
|
+
self.serial_number = self._decode(response[6:22]) # 35003 - 350010
|
|
499
|
+
self.model_name = self._decode(response[22:32]) # 35011 - 35015
|
|
500
|
+
self.dsp1_version = read_unsigned_int(response, 32) # 35016
|
|
501
|
+
self.dsp2_version = read_unsigned_int(response, 34) # 35017
|
|
502
|
+
self.dsp_svn_version = read_unsigned_int(response, 36) # 35018
|
|
503
|
+
self.arm_version = read_unsigned_int(response, 38) # 35019
|
|
504
|
+
self.arm_svn_version = read_unsigned_int(response, 40) # 35020
|
|
505
|
+
self.firmware = self._decode(response[42:54]) # 35021 - 35027
|
|
506
|
+
self.arm_firmware = self._decode(response[54:66]) # 35027 - 35032
|
|
463
507
|
|
|
464
508
|
if not is_4_mppt(self) and self.rated_power < 15000:
|
|
465
509
|
# This inverter does not have 4 MPPTs or PV strings
|
|
@@ -485,18 +529,24 @@ class ET(Inverter):
|
|
|
485
529
|
await self._read_from_socket(self._read_command(47547, 6))
|
|
486
530
|
self._settings.update({s.id_: s for s in self.__settings_arm_fw_19})
|
|
487
531
|
except RequestRejectedException as ex:
|
|
488
|
-
if ex.message ==
|
|
489
|
-
logger.debug("
|
|
532
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
533
|
+
logger.debug("EcoModeV2 settings not supported, switching to EcoModeV1.")
|
|
490
534
|
self._has_eco_mode_v2 = False
|
|
535
|
+
except RequestFailedException:
|
|
536
|
+
logger.debug("Cannot read EcoModeV2 settings, switching to EcoModeV1.")
|
|
537
|
+
self._has_eco_mode_v2 = False
|
|
491
538
|
|
|
492
539
|
# Check and add Peak Shaving settings added in (ETU fw 22)
|
|
493
540
|
try:
|
|
494
541
|
await self._read_from_socket(self._read_command(47589, 6))
|
|
495
542
|
self._settings.update({s.id_: s for s in self.__settings_arm_fw_22})
|
|
496
543
|
except RequestRejectedException as ex:
|
|
497
|
-
if ex.message ==
|
|
498
|
-
logger.debug("
|
|
544
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
545
|
+
logger.debug("PeakShaving setting not supported, disabling it.")
|
|
499
546
|
self._has_peak_shaving = False
|
|
547
|
+
except RequestFailedException:
|
|
548
|
+
logger.debug("Cannot read _has_peak_shaving settings, disabling it.")
|
|
549
|
+
self._has_peak_shaving = False
|
|
500
550
|
|
|
501
551
|
async def read_runtime_data(self) -> Dict[str, Any]:
|
|
502
552
|
response = await self._read_from_socket(self._READ_RUNNING_DATA)
|
|
@@ -508,8 +558,8 @@ class ET(Inverter):
|
|
|
508
558
|
response = await self._read_from_socket(self._READ_BATTERY_INFO)
|
|
509
559
|
data.update(self._map_response(response, self._sensors_battery))
|
|
510
560
|
except RequestRejectedException as ex:
|
|
511
|
-
if ex.message ==
|
|
512
|
-
logger.
|
|
561
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
562
|
+
logger.info("Battery values not supported, disabling further attempts.")
|
|
513
563
|
self._has_battery = False
|
|
514
564
|
else:
|
|
515
565
|
raise ex
|
|
@@ -519,8 +569,8 @@ class ET(Inverter):
|
|
|
519
569
|
data.update(
|
|
520
570
|
self._map_response(response, self._sensors_battery2))
|
|
521
571
|
except RequestRejectedException as ex:
|
|
522
|
-
if ex.message ==
|
|
523
|
-
logger.
|
|
572
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
573
|
+
logger.info("Battery 2 values not supported, disabling further attempts.")
|
|
524
574
|
self._has_battery2 = False
|
|
525
575
|
else:
|
|
526
576
|
raise ex
|
|
@@ -530,8 +580,8 @@ class ET(Inverter):
|
|
|
530
580
|
response = await self._read_from_socket(self._READ_METER_DATA_EXTENDED)
|
|
531
581
|
data.update(self._map_response(response, self._sensors_meter))
|
|
532
582
|
except RequestRejectedException as ex:
|
|
533
|
-
if ex.message ==
|
|
534
|
-
logger.
|
|
583
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
584
|
+
logger.info("Extended meter values not supported, disabling further attempts.")
|
|
535
585
|
self._has_meter_extended = False
|
|
536
586
|
self._sensors_meter = tuple(filter(self._not_extended_meter, self._sensors_meter))
|
|
537
587
|
response = await self._read_from_socket(self._READ_METER_DATA)
|
|
@@ -548,8 +598,8 @@ class ET(Inverter):
|
|
|
548
598
|
response = await self._read_from_socket(self._READ_MPPT_DATA)
|
|
549
599
|
data.update(self._map_response(response, self._sensors_mppt))
|
|
550
600
|
except RequestRejectedException as ex:
|
|
551
|
-
if ex.message ==
|
|
552
|
-
logger.
|
|
601
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
602
|
+
logger.info("MPPT values not supported, disabling further attempts.")
|
|
553
603
|
self._has_mppt = False
|
|
554
604
|
else:
|
|
555
605
|
raise ex
|
|
@@ -558,20 +608,35 @@ class ET(Inverter):
|
|
|
558
608
|
|
|
559
609
|
async def read_setting(self, setting_id: str) -> Any:
|
|
560
610
|
setting = self._settings.get(setting_id)
|
|
561
|
-
if
|
|
562
|
-
|
|
563
|
-
|
|
611
|
+
if setting:
|
|
612
|
+
return await self._read_setting(setting)
|
|
613
|
+
else:
|
|
614
|
+
if setting_id.startswith("modbus"):
|
|
615
|
+
response = await self._read_from_socket(self._read_command(int(setting_id[7:]), 1))
|
|
616
|
+
return int.from_bytes(response.read(2), byteorder="big", signed=True)
|
|
617
|
+
else:
|
|
618
|
+
raise ValueError(f'Unknown setting "{setting_id}"')
|
|
564
619
|
|
|
565
620
|
async def _read_setting(self, setting: Sensor) -> Any:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
621
|
+
try:
|
|
622
|
+
count = (setting.size_ + (setting.size_ % 2)) // 2
|
|
623
|
+
response = await self._read_from_socket(self._read_command(setting.offset, count))
|
|
624
|
+
return setting.read_value(response)
|
|
625
|
+
except RequestRejectedException as ex:
|
|
626
|
+
if ex.message == ILLEGAL_DATA_ADDRESS:
|
|
627
|
+
logger.debug("Unsupported setting %s", setting.id_)
|
|
628
|
+
self._settings.pop(setting.id_, None)
|
|
629
|
+
return None
|
|
569
630
|
|
|
570
631
|
async def write_setting(self, setting_id: str, value: Any):
|
|
571
632
|
setting = self._settings.get(setting_id)
|
|
572
|
-
if
|
|
573
|
-
|
|
574
|
-
|
|
633
|
+
if setting:
|
|
634
|
+
await self._write_setting(setting, value)
|
|
635
|
+
else:
|
|
636
|
+
if setting_id.startswith("modbus"):
|
|
637
|
+
await self._read_from_socket(self._write_command(int(setting_id[7:]), int(value)))
|
|
638
|
+
else:
|
|
639
|
+
raise ValueError(f'Unknown setting "{setting_id}"')
|
|
575
640
|
|
|
576
641
|
async def _write_setting(self, setting: Sensor, value: Any):
|
|
577
642
|
if setting.size_ == 1:
|
|
@@ -592,7 +657,7 @@ class ET(Inverter):
|
|
|
592
657
|
try:
|
|
593
658
|
value = await self.read_setting(setting.id_)
|
|
594
659
|
data[setting.id_] = value
|
|
595
|
-
except ValueError:
|
|
660
|
+
except (ValueError, RequestFailedException):
|
|
596
661
|
logger.exception("Error reading setting %s.", setting.id_)
|
|
597
662
|
data[setting.id_] = None
|
|
598
663
|
return data
|
goodwe/exceptions.py
CHANGED
|
@@ -29,5 +29,19 @@ class RequestRejectedException(InverterError):
|
|
|
29
29
|
self.message: str = message
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
class PartialResponseException(InverterError):
|
|
33
|
+
"""
|
|
34
|
+
Indicates the received response data are incomplete and is probably fragmented to multiple packets.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
length -- received data length
|
|
38
|
+
expected -- expected data lenght
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, lenght: int, expected: int):
|
|
42
|
+
self.length: int = lenght
|
|
43
|
+
self.expected: int = expected
|
|
44
|
+
|
|
45
|
+
|
|
32
46
|
class MaxRetriesException(InverterError):
|
|
33
47
|
"""Indicates the maximum number of retries has been reached"""
|
goodwe/inverter.py
CHANGED
|
@@ -22,6 +22,7 @@ class SensorKind(Enum):
|
|
|
22
22
|
UPS - inverter ups/eps/backup output (e.g. ac voltage of backup/off-grid connected output)
|
|
23
23
|
BAT - battery (e.g. dc voltage of connected battery pack)
|
|
24
24
|
GRID - power grid/smart meter (e.g. active power exported to grid)
|
|
25
|
+
BMS - BMS direct data (e.g. dc voltage of)
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
28
|
PV = 1
|
|
@@ -29,6 +30,7 @@ class SensorKind(Enum):
|
|
|
29
30
|
UPS = 3
|
|
30
31
|
BAT = 4
|
|
31
32
|
GRID = 5
|
|
33
|
+
BMS = 6
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
@dataclass
|
|
@@ -87,10 +89,9 @@ class Inverter(ABC):
|
|
|
87
89
|
"""
|
|
88
90
|
|
|
89
91
|
def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
|
|
90
|
-
self._protocol: InverterProtocol = self._create_protocol(host, port, timeout, retries)
|
|
92
|
+
self._protocol: InverterProtocol = self._create_protocol(host, port, comm_addr, timeout, retries)
|
|
91
93
|
self._consecutive_failures_count: int = 0
|
|
92
|
-
|
|
93
|
-
self.comm_addr: int = comm_addr
|
|
94
|
+
self.keep_alive: bool = True
|
|
94
95
|
|
|
95
96
|
self.model_name: str | None = None
|
|
96
97
|
self.serial_number: str | None = None
|
|
@@ -107,15 +108,15 @@ class Inverter(ABC):
|
|
|
107
108
|
|
|
108
109
|
def _read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
109
110
|
"""Create read protocol command."""
|
|
110
|
-
return self._protocol.read_command(
|
|
111
|
+
return self._protocol.read_command(offset, count)
|
|
111
112
|
|
|
112
113
|
def _write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
113
114
|
"""Create write protocol command."""
|
|
114
|
-
return self._protocol.write_command(
|
|
115
|
+
return self._protocol.write_command(register, value)
|
|
115
116
|
|
|
116
117
|
def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
117
118
|
"""Create write multiple protocol command."""
|
|
118
|
-
return self._protocol.write_multi_command(
|
|
119
|
+
return self._protocol.write_multi_command(offset, values)
|
|
119
120
|
|
|
120
121
|
async def _read_from_socket(self, command: ProtocolCommand) -> ProtocolResponse:
|
|
121
122
|
try:
|
|
@@ -129,6 +130,9 @@ class Inverter(ABC):
|
|
|
129
130
|
except RequestFailedException as ex:
|
|
130
131
|
self._consecutive_failures_count += 1
|
|
131
132
|
raise RequestFailedException(ex.message, self._consecutive_failures_count) from None
|
|
133
|
+
finally:
|
|
134
|
+
if not self.keep_alive:
|
|
135
|
+
self._protocol.close_transport()
|
|
132
136
|
|
|
133
137
|
@abstractmethod
|
|
134
138
|
async def read_device_info(self):
|
|
@@ -268,11 +272,11 @@ class Inverter(ABC):
|
|
|
268
272
|
raise NotImplementedError()
|
|
269
273
|
|
|
270
274
|
@staticmethod
|
|
271
|
-
def _create_protocol(host: str, port: int, timeout: int, retries: int) -> InverterProtocol:
|
|
275
|
+
def _create_protocol(host: str, port: int, comm_addr: int, timeout: int, retries: int) -> InverterProtocol:
|
|
272
276
|
if port == 502:
|
|
273
|
-
return TcpInverterProtocol(host, port, timeout, retries)
|
|
277
|
+
return TcpInverterProtocol(host, port, comm_addr, timeout, retries)
|
|
274
278
|
else:
|
|
275
|
-
return UdpInverterProtocol(host, port, timeout, retries)
|
|
279
|
+
return UdpInverterProtocol(host, port, comm_addr, timeout, retries)
|
|
276
280
|
|
|
277
281
|
@staticmethod
|
|
278
282
|
def _map_response(response: ProtocolResponse, sensors: Tuple[Sensor, ...]) -> Dict[str, Any]:
|
goodwe/modbus.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from typing import Union
|
|
3
3
|
|
|
4
|
-
from .exceptions import RequestRejectedException
|
|
4
|
+
from .exceptions import PartialResponseException, RequestRejectedException
|
|
5
5
|
|
|
6
6
|
logger = logging.getLogger(__name__)
|
|
7
7
|
|
|
@@ -9,9 +9,11 @@ MODBUS_READ_CMD: int = 0x3
|
|
|
9
9
|
MODBUS_WRITE_CMD: int = 0x6
|
|
10
10
|
MODBUS_WRITE_MULTI_CMD: int = 0x10
|
|
11
11
|
|
|
12
|
+
ILLEGAL_DATA_ADDRESS: str = 'ILLEGAL DATA ADDRESS'
|
|
13
|
+
|
|
12
14
|
FAILURE_CODES = {
|
|
13
15
|
1: "ILLEGAL FUNCTION",
|
|
14
|
-
2:
|
|
16
|
+
2: ILLEGAL_DATA_ADDRESS,
|
|
15
17
|
3: "ILLEGAL DATA VALUE",
|
|
16
18
|
4: "SLAVE DEVICE FAILURE",
|
|
17
19
|
5: "ACKNOWLEDGE",
|
|
@@ -176,8 +178,7 @@ def validate_modbus_rtu_response(data: bytes, cmd: int, offset: int, value: int)
|
|
|
176
178
|
return False
|
|
177
179
|
expected_length = data[4] + 7
|
|
178
180
|
if len(data) < expected_length:
|
|
179
|
-
|
|
180
|
-
return False
|
|
181
|
+
raise PartialResponseException(len(data), expected_length)
|
|
181
182
|
elif data[3] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
|
|
182
183
|
if len(data) < 10:
|
|
183
184
|
logger.debug("Response has unexpected length: %d, expected %d.", len(data), 10)
|
|
@@ -220,18 +221,18 @@ def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int)
|
|
|
220
221
|
if len(data) <= 8:
|
|
221
222
|
logger.debug("Response is too short.")
|
|
222
223
|
return False
|
|
224
|
+
expected_length = int.from_bytes(data[4:6], byteorder='big', signed=False) + 6
|
|
225
|
+
if len(data) < expected_length:
|
|
226
|
+
raise PartialResponseException(len(data), expected_length)
|
|
227
|
+
|
|
223
228
|
if data[7] == MODBUS_READ_CMD:
|
|
224
229
|
if data[8] != value * 2:
|
|
225
230
|
logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
|
|
226
231
|
return False
|
|
227
|
-
expected_length = data[8] + 9
|
|
228
|
-
if len(data) < expected_length:
|
|
229
|
-
logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
|
|
230
|
-
return False
|
|
231
232
|
elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
|
|
232
233
|
if len(data) < 12:
|
|
233
234
|
logger.debug("Response has unexpected length: %d, expected %d.", len(data), 14)
|
|
234
|
-
|
|
235
|
+
raise PartialResponseException(len(data), expected_length)
|
|
235
236
|
response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False)
|
|
236
237
|
if response_offset != offset:
|
|
237
238
|
logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)
|
goodwe/protocol.py
CHANGED
|
@@ -3,22 +3,35 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import io
|
|
5
5
|
import logging
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
6
8
|
from asyncio.futures import Future
|
|
7
9
|
from typing import Tuple, Optional, Callable
|
|
8
10
|
|
|
9
|
-
from .exceptions import MaxRetriesException, RequestFailedException, RequestRejectedException
|
|
11
|
+
from .exceptions import MaxRetriesException, PartialResponseException, RequestFailedException, RequestRejectedException
|
|
10
12
|
from .modbus import create_modbus_rtu_request, create_modbus_rtu_multi_request, create_modbus_tcp_request, \
|
|
11
13
|
create_modbus_tcp_multi_request, validate_modbus_rtu_response, validate_modbus_tcp_response, MODBUS_READ_CMD, \
|
|
12
14
|
MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD
|
|
13
15
|
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
18
|
+
_modbus_tcp_tx = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _next_tx() -> bytes:
|
|
22
|
+
global _modbus_tcp_tx
|
|
23
|
+
_modbus_tcp_tx += 1
|
|
24
|
+
if _modbus_tcp_tx == 0xFFFF:
|
|
25
|
+
_modbus_tcp_tx = 1
|
|
26
|
+
return int.to_bytes(_modbus_tcp_tx, length=2, byteorder="big", signed=False)
|
|
27
|
+
|
|
16
28
|
|
|
17
29
|
class InverterProtocol:
|
|
18
30
|
|
|
19
|
-
def __init__(self, host: str, port: int, timeout: int, retries: int):
|
|
31
|
+
def __init__(self, host: str, port: int, comm_addr: int, timeout: int, retries: int):
|
|
20
32
|
self._host: str = host
|
|
21
33
|
self._port: int = port
|
|
34
|
+
self._comm_addr: int = comm_addr
|
|
22
35
|
self._running_loop: asyncio.AbstractEventLoop | None = None
|
|
23
36
|
self._lock: asyncio.Lock | None = None
|
|
24
37
|
self._timer: asyncio.TimerHandle | None = None
|
|
@@ -27,6 +40,7 @@ class InverterProtocol:
|
|
|
27
40
|
self.protocol: asyncio.Protocol | None = None
|
|
28
41
|
self.response_future: Future | None = None
|
|
29
42
|
self.command: ProtocolCommand | None = None
|
|
43
|
+
self._partial_data: bytes | None = None
|
|
30
44
|
|
|
31
45
|
def _ensure_lock(self) -> asyncio.Lock:
|
|
32
46
|
"""Validate (or create) asyncio Lock.
|
|
@@ -43,45 +57,47 @@ class InverterProtocol:
|
|
|
43
57
|
logger.debug("Creating lock instance for current event loop.")
|
|
44
58
|
self._lock = asyncio.Lock()
|
|
45
59
|
self._running_loop = asyncio.get_event_loop()
|
|
46
|
-
self.
|
|
60
|
+
self.close_transport()
|
|
47
61
|
return self._lock
|
|
48
62
|
|
|
49
|
-
def
|
|
63
|
+
def close_transport(self) -> None:
|
|
64
|
+
"""Close the underlying transport/connection."""
|
|
50
65
|
raise NotImplementedError()
|
|
51
66
|
|
|
52
67
|
async def send_request(self, command: ProtocolCommand) -> Future:
|
|
68
|
+
"""Convert command to request and send it to inverter."""
|
|
53
69
|
raise NotImplementedError()
|
|
54
70
|
|
|
55
|
-
def read_command(self,
|
|
71
|
+
def read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
56
72
|
"""Create read protocol command."""
|
|
57
73
|
raise NotImplementedError()
|
|
58
74
|
|
|
59
|
-
def write_command(self,
|
|
75
|
+
def write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
60
76
|
"""Create write protocol command."""
|
|
61
77
|
raise NotImplementedError()
|
|
62
78
|
|
|
63
|
-
def write_multi_command(self,
|
|
79
|
+
def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
64
80
|
"""Create write multiple protocol command."""
|
|
65
81
|
raise NotImplementedError()
|
|
66
82
|
|
|
67
83
|
|
|
68
84
|
class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
69
|
-
def __init__(self, host: str, port: int, timeout: int = 1, retries: int = 3):
|
|
70
|
-
super().__init__(host, port, timeout, retries)
|
|
85
|
+
def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 3):
|
|
86
|
+
super().__init__(host, port, comm_addr, timeout, retries)
|
|
71
87
|
self._transport: asyncio.transports.DatagramTransport | None = None
|
|
72
88
|
self._retry: int = 0
|
|
73
89
|
|
|
74
|
-
def read_command(self,
|
|
90
|
+
def read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
75
91
|
"""Create read protocol command."""
|
|
76
|
-
return ModbusRtuReadCommand(
|
|
92
|
+
return ModbusRtuReadCommand(self._comm_addr, offset, count)
|
|
77
93
|
|
|
78
|
-
def write_command(self,
|
|
94
|
+
def write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
79
95
|
"""Create write protocol command."""
|
|
80
|
-
return ModbusRtuWriteCommand(
|
|
96
|
+
return ModbusRtuWriteCommand(self._comm_addr, register, value)
|
|
81
97
|
|
|
82
|
-
def write_multi_command(self,
|
|
98
|
+
def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
83
99
|
"""Create write multiple protocol command."""
|
|
84
|
-
return ModbusRtuWriteMultiCommand(
|
|
100
|
+
return ModbusRtuWriteMultiCommand(self._comm_addr, offset, values)
|
|
85
101
|
|
|
86
102
|
async def _connect(self) -> None:
|
|
87
103
|
if not self._transport or self._transport.is_closing():
|
|
@@ -100,7 +116,7 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
100
116
|
logger.debug("Socket closed with error: %s.", exc)
|
|
101
117
|
else:
|
|
102
118
|
logger.debug("Socket closed.")
|
|
103
|
-
self.
|
|
119
|
+
self.close_transport()
|
|
104
120
|
|
|
105
121
|
def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
|
|
106
122
|
"""On datagram received"""
|
|
@@ -108,22 +124,35 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
108
124
|
self._timer.cancel()
|
|
109
125
|
self._timer = None
|
|
110
126
|
try:
|
|
127
|
+
if self._partial_data:
|
|
128
|
+
logger.debug("Received another response fragment: %s.", data.hex())
|
|
129
|
+
data = self._partial_data + data
|
|
111
130
|
if self.command.validator(data):
|
|
112
|
-
|
|
131
|
+
if self._partial_data:
|
|
132
|
+
logger.debug("Composed fragmented response: %s", data.hex())
|
|
133
|
+
else:
|
|
134
|
+
logger.debug("Received: %s", data.hex())
|
|
135
|
+
self._partial_data = None
|
|
113
136
|
self.response_future.set_result(data)
|
|
114
137
|
else:
|
|
115
138
|
logger.debug("Received invalid response: %s", data.hex())
|
|
116
139
|
asyncio.get_running_loop().call_soon(self._retry_mechanism)
|
|
140
|
+
except PartialResponseException:
|
|
141
|
+
logger.debug("Received response fragment: %s", data.hex())
|
|
142
|
+
self._partial_data = data
|
|
143
|
+
return
|
|
144
|
+
except asyncio.InvalidStateError:
|
|
145
|
+
logger.debug("Response already handled: %s", data.hex())
|
|
117
146
|
except RequestRejectedException as ex:
|
|
118
147
|
logger.debug("Received exception response: %s", data.hex())
|
|
119
148
|
self.response_future.set_exception(ex)
|
|
120
|
-
self.
|
|
149
|
+
self.close_transport()
|
|
121
150
|
|
|
122
151
|
def error_received(self, exc: Exception) -> None:
|
|
123
152
|
"""On error received"""
|
|
124
153
|
logger.debug("Received error: %s", exc)
|
|
125
154
|
self.response_future.set_exception(exc)
|
|
126
|
-
self.
|
|
155
|
+
self.close_transport()
|
|
127
156
|
|
|
128
157
|
async def send_request(self, command: ProtocolCommand) -> Future:
|
|
129
158
|
"""Send message via transport"""
|
|
@@ -139,9 +168,12 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
139
168
|
"""Send message via transport"""
|
|
140
169
|
self.command = command
|
|
141
170
|
self.response_future = response_future
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
171
|
+
payload = command.request_bytes()
|
|
172
|
+
if self._retry > 0:
|
|
173
|
+
logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries)
|
|
174
|
+
else:
|
|
175
|
+
logger.debug("Sending: %s", self.command)
|
|
176
|
+
self._transport.sendto(payload)
|
|
145
177
|
self._timer = asyncio.get_running_loop().call_later(self.timeout, self._retry_mechanism)
|
|
146
178
|
|
|
147
179
|
def _retry_mechanism(self) -> None:
|
|
@@ -156,9 +188,9 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
156
188
|
else:
|
|
157
189
|
logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
|
|
158
190
|
self.response_future.set_exception(MaxRetriesException)
|
|
159
|
-
self.
|
|
191
|
+
self.close_transport()
|
|
160
192
|
|
|
161
|
-
def
|
|
193
|
+
def close_transport(self) -> None:
|
|
162
194
|
if self._transport:
|
|
163
195
|
try:
|
|
164
196
|
self._transport.close()
|
|
@@ -171,22 +203,22 @@ class UdpInverterProtocol(InverterProtocol, asyncio.DatagramProtocol):
|
|
|
171
203
|
|
|
172
204
|
|
|
173
205
|
class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
174
|
-
def __init__(self, host: str, port: int, timeout: int = 1, retries: int = 0):
|
|
175
|
-
super().__init__(host, port, timeout, retries)
|
|
206
|
+
def __init__(self, host: str, port: int, comm_addr: int, timeout: int = 1, retries: int = 0):
|
|
207
|
+
super().__init__(host, port, comm_addr, timeout, retries)
|
|
176
208
|
self._transport: asyncio.transports.Transport | None = None
|
|
177
209
|
self._retry: int = 0
|
|
178
210
|
|
|
179
|
-
def read_command(self,
|
|
211
|
+
def read_command(self, offset: int, count: int) -> ProtocolCommand:
|
|
180
212
|
"""Create read protocol command."""
|
|
181
|
-
return ModbusTcpReadCommand(
|
|
213
|
+
return ModbusTcpReadCommand(self._comm_addr, offset, count)
|
|
182
214
|
|
|
183
|
-
def write_command(self,
|
|
215
|
+
def write_command(self, register: int, value: int) -> ProtocolCommand:
|
|
184
216
|
"""Create write protocol command."""
|
|
185
|
-
return ModbusTcpWriteCommand(
|
|
217
|
+
return ModbusTcpWriteCommand(self._comm_addr, register, value)
|
|
186
218
|
|
|
187
|
-
def write_multi_command(self,
|
|
219
|
+
def write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
|
|
188
220
|
"""Create write multiple protocol command."""
|
|
189
|
-
return ModbusTcpWriteMultiCommand(
|
|
221
|
+
return ModbusTcpWriteMultiCommand(self._comm_addr, offset, values)
|
|
190
222
|
|
|
191
223
|
async def _connect(self) -> None:
|
|
192
224
|
if not self._transport or self._transport.is_closing():
|
|
@@ -195,6 +227,14 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
195
227
|
lambda: self,
|
|
196
228
|
host=self._host, port=self._port,
|
|
197
229
|
)
|
|
230
|
+
sock = self._transport.get_extra_info('socket')
|
|
231
|
+
if sock is not None:
|
|
232
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
233
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
|
|
234
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
|
|
235
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
|
|
236
|
+
if platform.system() == 'Windows':
|
|
237
|
+
sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 10000, 10000))
|
|
198
238
|
|
|
199
239
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
200
240
|
"""On connection made"""
|
|
@@ -203,7 +243,7 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
203
243
|
|
|
204
244
|
def eof_received(self) -> None:
|
|
205
245
|
logger.debug("EOF received.")
|
|
206
|
-
self.
|
|
246
|
+
self.close_transport()
|
|
207
247
|
|
|
208
248
|
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
209
249
|
"""On connection lost"""
|
|
@@ -211,37 +251,50 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
211
251
|
logger.debug("Connection closed with error: %s.", exc)
|
|
212
252
|
else:
|
|
213
253
|
logger.debug("Connection closed.")
|
|
214
|
-
self.
|
|
254
|
+
self.close_transport()
|
|
215
255
|
|
|
216
256
|
def data_received(self, data: bytes) -> None:
|
|
217
257
|
"""On data received"""
|
|
218
258
|
if self._timer:
|
|
219
259
|
self._timer.cancel()
|
|
220
260
|
try:
|
|
261
|
+
if self._partial_data:
|
|
262
|
+
logger.debug("Received another response fragment: %s.", data.hex())
|
|
263
|
+
data = self._partial_data + data
|
|
221
264
|
if self.command.validator(data):
|
|
222
|
-
|
|
265
|
+
if self._partial_data:
|
|
266
|
+
logger.debug("Composed fragmented response: %s", data.hex())
|
|
267
|
+
else:
|
|
268
|
+
logger.debug("Received: %s", data.hex())
|
|
223
269
|
self._retry = 0
|
|
270
|
+
self._partial_data = None
|
|
224
271
|
self.response_future.set_result(data)
|
|
225
272
|
else:
|
|
226
273
|
logger.debug("Received invalid response: %s", data.hex())
|
|
227
274
|
self.response_future.set_exception(RequestRejectedException())
|
|
228
|
-
self.
|
|
275
|
+
self.close_transport()
|
|
276
|
+
except PartialResponseException:
|
|
277
|
+
logger.debug("Received response fragment: %s", data.hex())
|
|
278
|
+
self._partial_data = data
|
|
279
|
+
return
|
|
280
|
+
except asyncio.InvalidStateError:
|
|
281
|
+
logger.debug("Response already handled: %s", data.hex())
|
|
229
282
|
except RequestRejectedException as ex:
|
|
230
283
|
logger.debug("Received exception response: %s", data.hex())
|
|
231
284
|
self.response_future.set_exception(ex)
|
|
232
|
-
# self.
|
|
285
|
+
# self.close_transport()
|
|
233
286
|
|
|
234
287
|
def error_received(self, exc: Exception) -> None:
|
|
235
288
|
"""On error received"""
|
|
236
289
|
logger.debug("Received error: %s", exc)
|
|
237
290
|
self.response_future.set_exception(exc)
|
|
238
|
-
self.
|
|
291
|
+
self.close_transport()
|
|
239
292
|
|
|
240
293
|
async def send_request(self, command: ProtocolCommand) -> Future:
|
|
241
294
|
"""Send message via transport"""
|
|
242
295
|
await self._ensure_lock().acquire()
|
|
243
296
|
try:
|
|
244
|
-
await self._connect()
|
|
297
|
+
await asyncio.wait_for(self._connect(), timeout=5)
|
|
245
298
|
response_future = asyncio.get_running_loop().create_future()
|
|
246
299
|
self._send_request(command, response_future)
|
|
247
300
|
await response_future
|
|
@@ -249,17 +302,17 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
249
302
|
except asyncio.CancelledError:
|
|
250
303
|
if self._retry < self.retries:
|
|
251
304
|
if self._timer:
|
|
252
|
-
logger.debug("Connection broken error")
|
|
305
|
+
logger.debug("Connection broken error.")
|
|
253
306
|
self._retry += 1
|
|
254
307
|
if self._lock and self._lock.locked():
|
|
255
308
|
self._lock.release()
|
|
256
|
-
self.
|
|
309
|
+
self.close_transport()
|
|
257
310
|
return await self.send_request(command)
|
|
258
311
|
else:
|
|
259
312
|
return self._max_retries_reached()
|
|
260
|
-
except (ConnectionRefusedError, TimeoutError
|
|
313
|
+
except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError):
|
|
261
314
|
if self._retry < self.retries:
|
|
262
|
-
logger.debug("Connection refused error
|
|
315
|
+
logger.debug("Connection refused error.")
|
|
263
316
|
self._retry += 1
|
|
264
317
|
if self._lock and self._lock.locked():
|
|
265
318
|
self._lock.release()
|
|
@@ -274,9 +327,12 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
274
327
|
"""Send message via transport"""
|
|
275
328
|
self.command = command
|
|
276
329
|
self.response_future = response_future
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
330
|
+
payload = command.request_bytes()
|
|
331
|
+
if self._retry > 0:
|
|
332
|
+
logger.debug("Sending: %s - retry #%s/%s", self.command, self._retry, self.retries)
|
|
333
|
+
else:
|
|
334
|
+
logger.debug("Sending: %s", self.command)
|
|
335
|
+
self._transport.write(payload)
|
|
280
336
|
self._timer = asyncio.get_running_loop().call_later(self.timeout, self._timeout_mechanism)
|
|
281
337
|
|
|
282
338
|
def _timeout_mechanism(self) -> None:
|
|
@@ -287,16 +343,16 @@ class TcpInverterProtocol(InverterProtocol, asyncio.Protocol):
|
|
|
287
343
|
if self._timer:
|
|
288
344
|
logger.debug("Failed to receive response to %s in time (%ds).", self.command, self.timeout)
|
|
289
345
|
self._timer = None
|
|
290
|
-
self.
|
|
346
|
+
self.close_transport()
|
|
291
347
|
|
|
292
348
|
def _max_retries_reached(self) -> Future:
|
|
293
349
|
logger.debug("Max number of retries (%d) reached, request %s failed.", self.retries, self.command)
|
|
294
|
-
self.
|
|
350
|
+
self.close_transport()
|
|
295
351
|
self.response_future = asyncio.get_running_loop().create_future()
|
|
296
352
|
self.response_future.set_exception(MaxRetriesException)
|
|
297
353
|
return self.response_future
|
|
298
354
|
|
|
299
|
-
def
|
|
355
|
+
def close_transport(self) -> None:
|
|
300
356
|
if self._transport:
|
|
301
357
|
try:
|
|
302
358
|
self._transport.close()
|
|
@@ -354,6 +410,10 @@ class ProtocolCommand:
|
|
|
354
410
|
def __repr__(self):
|
|
355
411
|
return self.request.hex()
|
|
356
412
|
|
|
413
|
+
def request_bytes(self) -> bytes:
|
|
414
|
+
"""Return raw bytes payload, optionally pre-processed"""
|
|
415
|
+
return self.request
|
|
416
|
+
|
|
357
417
|
def trim_response(self, raw_response: bytes):
|
|
358
418
|
"""Trim raw response from header and checksum data"""
|
|
359
419
|
return raw_response
|
|
@@ -567,6 +627,12 @@ class ModbusTcpProtocolCommand(ProtocolCommand):
|
|
|
567
627
|
self.first_address: int = offset
|
|
568
628
|
self.value = value
|
|
569
629
|
|
|
630
|
+
def request_bytes(self) -> bytes:
|
|
631
|
+
"""Return raw bytes payload, optionally pre-processed"""
|
|
632
|
+
# Apply sequential Modbus/TCP transaction identifier
|
|
633
|
+
self.request = _next_tx() + self.request[2:]
|
|
634
|
+
return self.request
|
|
635
|
+
|
|
570
636
|
def trim_response(self, raw_response: bytes):
|
|
571
637
|
"""Trim raw response from header and checksum data"""
|
|
572
638
|
return raw_response[9:]
|
goodwe/sensor.py
CHANGED
|
@@ -183,7 +183,7 @@ class Energy(Sensor):
|
|
|
183
183
|
|
|
184
184
|
def read_value(self, data: ProtocolResponse):
|
|
185
185
|
value = read_bytes2(data)
|
|
186
|
-
return float(value) / 10 if value else None
|
|
186
|
+
return float(value) / 10 if value is not None else None
|
|
187
187
|
|
|
188
188
|
|
|
189
189
|
class Energy4(Sensor):
|
|
@@ -194,7 +194,7 @@ class Energy4(Sensor):
|
|
|
194
194
|
|
|
195
195
|
def read_value(self, data: ProtocolResponse):
|
|
196
196
|
value = read_bytes4(data)
|
|
197
|
-
return float(value) / 10 if value else None
|
|
197
|
+
return float(value) / 10 if value is not None else None
|
|
198
198
|
|
|
199
199
|
|
|
200
200
|
class Apparent(Sensor):
|
|
@@ -515,6 +515,14 @@ class EcoMode(ABC):
|
|
|
515
515
|
def set_schedule_type(self, schedule_type: ScheduleType, is745: bool):
|
|
516
516
|
"""Set the schedule type"""
|
|
517
517
|
|
|
518
|
+
@abstractmethod
|
|
519
|
+
def get_power(self) -> int:
|
|
520
|
+
"""Answer the power value"""
|
|
521
|
+
|
|
522
|
+
@abstractmethod
|
|
523
|
+
def get_power_unit(self) -> str:
|
|
524
|
+
"""Answer the power unit"""
|
|
525
|
+
|
|
518
526
|
|
|
519
527
|
class EcoModeV1(Sensor, EcoMode):
|
|
520
528
|
"""Sensor representing Eco Mode Battery Power Group encoded in 8 bytes"""
|
|
@@ -606,6 +614,14 @@ class EcoModeV1(Sensor, EcoMode):
|
|
|
606
614
|
"""Set the schedule type"""
|
|
607
615
|
pass
|
|
608
616
|
|
|
617
|
+
def get_power(self) -> int:
|
|
618
|
+
"""Answer the power value"""
|
|
619
|
+
return self.power
|
|
620
|
+
|
|
621
|
+
def get_power_unit(self) -> str:
|
|
622
|
+
"""Answer the power unit"""
|
|
623
|
+
return "%"
|
|
624
|
+
|
|
609
625
|
def as_eco_mode_v2(self) -> EcoModeV2:
|
|
610
626
|
"""Convert V1 to V2 EcoMode"""
|
|
611
627
|
result = EcoModeV2(self.id_, self.offset, self.name)
|
|
@@ -642,7 +658,7 @@ class Schedule(Sensor, EcoMode):
|
|
|
642
658
|
def __str__(self):
|
|
643
659
|
return f"{self.start_h}:{self.start_m}-{self.end_h}:{self.end_m} {self.days} " \
|
|
644
660
|
f"{self.months + ' ' if self.months else ''}" \
|
|
645
|
-
f"{self.
|
|
661
|
+
f"{self.get_power()}{self.get_power_unit()} (SoC {self.soc}%) " \
|
|
646
662
|
f"{'On' if -10 < self.on_off < 0 else 'Off' if 10 > self.on_off >= 0 else 'Unset'}"
|
|
647
663
|
|
|
648
664
|
def read_value(self, data: ProtocolResponse):
|
|
@@ -736,6 +752,14 @@ class Schedule(Sensor, EcoMode):
|
|
|
736
752
|
else:
|
|
737
753
|
self.schedule_type = schedule_type
|
|
738
754
|
|
|
755
|
+
def get_power(self) -> int:
|
|
756
|
+
"""Answer the power value"""
|
|
757
|
+
return self.schedule_type.decode_power(self.power)
|
|
758
|
+
|
|
759
|
+
def get_power_unit(self) -> str:
|
|
760
|
+
"""Answer the power unit"""
|
|
761
|
+
return self.schedule_type.power_unit()
|
|
762
|
+
|
|
739
763
|
def as_eco_mode_v1(self) -> EcoModeV1:
|
|
740
764
|
"""Convert V2 to V1 EcoMode"""
|
|
741
765
|
result = EcoModeV1(self.id_, self.offset, self.name)
|
|
@@ -886,7 +910,7 @@ def read_temp(buffer: ProtocolResponse, offset: int = None) -> float | None:
|
|
|
886
910
|
if offset is not None:
|
|
887
911
|
buffer.seek(offset)
|
|
888
912
|
value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
|
|
889
|
-
if value == 32767:
|
|
913
|
+
if value == -1 or value == 32767:
|
|
890
914
|
return None
|
|
891
915
|
else:
|
|
892
916
|
return float(value) / 10
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
goodwe/__init__.py,sha256=0Zwuri1cbJ2Qe24R2rEjDMTZeVtsh21YIx3KlRaXgWg,5742
|
|
2
|
+
goodwe/const.py,sha256=yhWk56YV7k7-MbgfmWEMYNlqeRNLOfOpfTqEfRj6Hp8,7934
|
|
3
|
+
goodwe/dt.py,sha256=oGbkdVHP51KnlwQraKeebmiP6AtJ1S67aLB7euNRIoE,11743
|
|
4
|
+
goodwe/es.py,sha256=iVK8EMCaAJJFihZLntJZ_Eu4sQWoZTVtTROp9mHFG6o,22730
|
|
5
|
+
goodwe/et.py,sha256=CiX-PE7wouDnj1RnPnOyqiNE4FELhOGdyPUOm9VCzUw,43890
|
|
6
|
+
goodwe/exceptions.py,sha256=dKMLxotjoR1ic8OVlw1joIJ4mKWD6oFtUMZ86fNM5ZE,1403
|
|
7
|
+
goodwe/inverter.py,sha256=-eRq6ND-BpLmj6vgYW0K0Oq3WvNcjjScbkalAzPH5ew,10494
|
|
8
|
+
goodwe/modbus.py,sha256=zT3W9ByANPaZd7T0XTqYGBaVo9PEwyg8jus12mRxCPU,8211
|
|
9
|
+
goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
|
|
10
|
+
goodwe/protocol.py,sha256=m4n1VAonXLBswFEjUcvKXEPV2WcOv_-MDMAefpsQ_-g,27703
|
|
11
|
+
goodwe/sensor.py,sha256=buPG8BcgZmRDqaMrLQUACLHB85U134qG6qo_ggsu48A,37679
|
|
12
|
+
goodwe-0.4.2.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
|
|
13
|
+
goodwe-0.4.2.dist-info/METADATA,sha256=gOkkNodwpHtUf_743Nc7jCKpdxjwX_L5DSr2POJDjs8,3376
|
|
14
|
+
goodwe-0.4.2.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
15
|
+
goodwe-0.4.2.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
|
|
16
|
+
goodwe-0.4.2.dist-info/RECORD,,
|
goodwe-0.4.0.dist-info/RECORD
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
goodwe/__init__.py,sha256=0Zwuri1cbJ2Qe24R2rEjDMTZeVtsh21YIx3KlRaXgWg,5742
|
|
2
|
-
goodwe/const.py,sha256=yhWk56YV7k7-MbgfmWEMYNlqeRNLOfOpfTqEfRj6Hp8,7934
|
|
3
|
-
goodwe/dt.py,sha256=q8PRs0nVqN4mEhH8243NTbkkBtrGx-n8icwE-BkTN5Q,10460
|
|
4
|
-
goodwe/es.py,sha256=gnSla5SGXK3cJag45o9Z2Wd7rwLkjm3xmS-JN1lf5Ck,22545
|
|
5
|
-
goodwe/et.py,sha256=VJxCl54DILBRFQTmm-9K2yqS_QbBVMDvPFNv8dr0z7Y,39676
|
|
6
|
-
goodwe/exceptions.py,sha256=I6PHG0GTWgxNrDVZwJZBnyzItRq5eiM6ci23-EEsn1I,1012
|
|
7
|
-
goodwe/inverter.py,sha256=JIKYcOLihxCG1_m7HGMoFgVR1dyO8F0OXP5q1ClQJ-w,10336
|
|
8
|
-
goodwe/modbus.py,sha256=sFmkBgylwJkZd64a52fOUst6Rde5Vm3JsAm3Nh3s6e8,8151
|
|
9
|
-
goodwe/model.py,sha256=dWBjMFJMnhZoUdDd9fGT54DERDANz4TirK0Wy8kWMbk,2068
|
|
10
|
-
goodwe/protocol.py,sha256=_jPwIlKE5ou2X3_3PDTUzsgBLLD1dzAdyt5DJOsWTWA,24873
|
|
11
|
-
goodwe/sensor.py,sha256=fWMYyr3Vw02axfGvL7y7YUH2LmfE4A_lsIulX0Zpy5c,37054
|
|
12
|
-
goodwe-0.4.0.dist-info/LICENSE,sha256=aZAhk3lRdYT1YZV-IKRHISEcc_KNUmgfuNO3QhRamNM,1073
|
|
13
|
-
goodwe-0.4.0.dist-info/METADATA,sha256=CRS4h7iSlxExGN2ZjLungFGwuI7d85g-TlVRbnPx6vU,3376
|
|
14
|
-
goodwe-0.4.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
15
|
-
goodwe-0.4.0.dist-info/top_level.txt,sha256=kKoiqiVvAxDaDJYMZZQLgHQj9cuWT1MXLfXElTDuf8s,7
|
|
16
|
-
goodwe-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|