summaryrefslogtreecommitdiff
path: root/include/py/homekit/inverter
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2023-09-27 00:54:57 +0300
committerEvgeny Zinoviev <me@ch1p.io>2023-09-27 00:54:57 +0300
commitd3a295872c49defb55fc8e4e43e55550991e0927 (patch)
treeb9dca15454f9027d5a9dad0d4443a20de04dbc5d /include/py/homekit/inverter
parentb7cbc2571c1870b4582ead45277d0aa7f961bec8 (diff)
parentbdbb296697f55f4c3a07af43c9aaf7a9ea86f3d0 (diff)
Merge branch 'master' of ch1p.io:homekit
Diffstat (limited to 'include/py/homekit/inverter')
-rw-r--r--include/py/homekit/inverter/__init__.py3
-rw-r--r--include/py/homekit/inverter/config.py13
-rw-r--r--include/py/homekit/inverter/emulator.py556
-rw-r--r--include/py/homekit/inverter/inverter_wrapper.py48
-rw-r--r--include/py/homekit/inverter/monitor.py499
-rw-r--r--include/py/homekit/inverter/types.py64
-rw-r--r--include/py/homekit/inverter/util.py8
7 files changed, 1191 insertions, 0 deletions
diff --git a/include/py/homekit/inverter/__init__.py b/include/py/homekit/inverter/__init__.py
new file mode 100644
index 0000000..8831ef3
--- /dev/null
+++ b/include/py/homekit/inverter/__init__.py
@@ -0,0 +1,3 @@
+from .monitor import InverterMonitor
+from .inverter_wrapper import wrapper_instance
+from .util import beautify_table
diff --git a/include/py/homekit/inverter/config.py b/include/py/homekit/inverter/config.py
new file mode 100644
index 0000000..e284dfe
--- /dev/null
+++ b/include/py/homekit/inverter/config.py
@@ -0,0 +1,13 @@
+from ..config import ConfigUnit
+from typing import Optional
+
+
+class InverterdConfig(ConfigUnit):
+ NAME = 'inverterd'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'remote_addr': {'type': 'string'},
+ 'local_addr': {'type': 'string'},
+ } \ No newline at end of file
diff --git a/include/py/homekit/inverter/emulator.py b/include/py/homekit/inverter/emulator.py
new file mode 100644
index 0000000..e86b8bb
--- /dev/null
+++ b/include/py/homekit/inverter/emulator.py
@@ -0,0 +1,556 @@
+import asyncio
+import logging
+
+from inverterd import Format
+
+from typing import Union
+from enum import Enum
+from ..util import Addr, stringify
+
+
+class InverterEnum(Enum):
+ def as_text(self) -> str:
+ raise RuntimeError('abstract method')
+
+
+class BatteryType(InverterEnum):
+ AGM = 0
+ Flooded = 1
+ User = 2
+
+ def as_text(self) -> str:
+ return ('AGM', 'Flooded', 'User')[self.value]
+
+
+class InputVoltageRange(InverterEnum):
+ Appliance = 0
+ USP = 1
+
+ def as_text(self) -> str:
+ return ('Appliance', 'USP')[self.value]
+
+
+class OutputSourcePriority(InverterEnum):
+ SolarUtilityBattery = 0
+ SolarBatteryUtility = 1
+
+ def as_text(self) -> str:
+ return ('Solar-Utility-Battery', 'Solar-Battery-Utility')[self.value]
+
+
+class ChargeSourcePriority(InverterEnum):
+ SolarFirst = 0
+ SolarAndUtility = 1
+ SolarOnly = 2
+
+ def as_text(self) -> str:
+ return ('Solar-First', 'Solar-and-Utility', 'Solar-only')[self.value]
+
+
+class MachineType(InverterEnum):
+ OffGridTie = 0
+ GridTie = 1
+
+ def as_text(self) -> str:
+ return ('Off-Grid-Tie', 'Grid-Tie')[self.value]
+
+
+class Topology(InverterEnum):
+ TransformerLess = 0
+ Transformer = 1
+
+ def as_text(self) -> str:
+ return ('Transformer-less', 'Transformer')[self.value]
+
+
+class OutputMode(InverterEnum):
+ SingleOutput = 0
+ ParallelOutput = 1
+ Phase_1_of_3 = 2
+ Phase_2_of_3 = 3
+ Phase_3_of_3 = 4
+
+ def as_text(self) -> str:
+ return (
+ 'Single output',
+ 'Parallel output',
+ 'Phase 1 of 3-phase output',
+ 'Phase 2 of 3-phase output',
+ 'Phase 3 of 3-phase'
+ )[self.value]
+
+
+class SolarPowerPriority(InverterEnum):
+ BatteryLoadUtility = 0
+ LoadBatteryUtility = 1
+
+ def as_text(self) -> str:
+ return ('Battery-Load-Utility', 'Load-Battery-Utility')[self.value]
+
+
+class MPPTChargerStatus(InverterEnum):
+ Abnormal = 0
+ NotCharging = 1
+ Charging = 2
+
+ def as_text(self) -> str:
+ return ('Abnormal', 'Not charging', 'Charging')[self.value]
+
+
+class BatteryPowerDirection(InverterEnum):
+ DoNothing = 0
+ Charge = 1
+ Discharge = 2
+
+ def as_text(self) -> str:
+ return ('Do nothing', 'Charge', 'Discharge')[self.value]
+
+
+class DC_AC_PowerDirection(InverterEnum):
+ DoNothing = 0
+ AC_DC = 1
+ DC_AC = 2
+
+ def as_text(self) -> str:
+ return ('Do nothing', 'AC/DC', 'DC/AC')[self.value]
+
+
+class LinePowerDirection(InverterEnum):
+ DoNothing = 0
+ Input = 1
+ Output = 2
+
+ def as_text(self) -> str:
+ return ('Do nothing', 'Input', 'Output')[self.value]
+
+
+class WorkingMode(InverterEnum):
+ PowerOnMode = 0
+ StandbyMode = 1
+ BypassMode = 2
+ BatteryMode = 3
+ FaultMode = 4
+ HybridMode = 5
+
+ def as_text(self) -> str:
+ return (
+ 'Power on mode',
+ 'Standby mode',
+ 'Bypass mode',
+ 'Battery mode',
+ 'Fault mode',
+ 'Hybrid mode'
+ )[self.value]
+
+
+class ParallelConnectionStatus(InverterEnum):
+ NotExistent = 0
+ Existent = 1
+
+ def as_text(self) -> str:
+ return ('Non-existent', 'Existent')[self.value]
+
+
+class LoadConnectionStatus(InverterEnum):
+ Disconnected = 0
+ Connected = 1
+
+ def as_text(self) -> str:
+ return ('Disconnected', 'Connected')[self.value]
+
+
+class ConfigurationStatus(InverterEnum):
+ Default = 0
+ Changed = 1
+
+ def as_text(self) -> str:
+ return ('Default', 'Changed')[self.value]
+
+
+_g_human_readable = {"grid_voltage": "Grid voltage",
+ "grid_freq": "Grid frequency",
+ "ac_output_voltage": "AC output voltage",
+ "ac_output_freq": "AC output frequency",
+ "ac_output_apparent_power": "AC output apparent power",
+ "ac_output_active_power": "AC output active power",
+ "output_load_percent": "Output load percent",
+ "battery_voltage": "Battery voltage",
+ "battery_voltage_scc": "Battery voltage from SCC",
+ "battery_voltage_scc2": "Battery voltage from SCC2",
+ "battery_discharge_current": "Battery discharge current",
+ "battery_charge_current": "Battery charge current",
+ "battery_capacity": "Battery capacity",
+ "inverter_heat_sink_temp": "Inverter heat sink temperature",
+ "mppt1_charger_temp": "MPPT1 charger temperature",
+ "mppt2_charger_temp": "MPPT2 charger temperature",
+ "pv1_input_power": "PV1 input power",
+ "pv2_input_power": "PV2 input power",
+ "pv1_input_voltage": "PV1 input voltage",
+ "pv2_input_voltage": "PV2 input voltage",
+ "configuration_status": "Configuration state",
+ "mppt1_charger_status": "MPPT1 charger status",
+ "mppt2_charger_status": "MPPT2 charger status",
+ "load_connected": "Load connection",
+ "battery_power_direction": "Battery power direction",
+ "dc_ac_power_direction": "DC/AC power direction",
+ "line_power_direction": "Line power direction",
+ "local_parallel_id": "Local parallel ID",
+ "ac_input_rating_voltage": "AC input rating voltage",
+ "ac_input_rating_current": "AC input rating current",
+ "ac_output_rating_voltage": "AC output rating voltage",
+ "ac_output_rating_freq": "AC output rating frequency",
+ "ac_output_rating_current": "AC output rating current",
+ "ac_output_rating_apparent_power": "AC output rating apparent power",
+ "ac_output_rating_active_power": "AC output rating active power",
+ "battery_rating_voltage": "Battery rating voltage",
+ "battery_recharge_voltage": "Battery re-charge voltage",
+ "battery_redischarge_voltage": "Battery re-discharge voltage",
+ "battery_under_voltage": "Battery under voltage",
+ "battery_bulk_voltage": "Battery bulk voltage",
+ "battery_float_voltage": "Battery float voltage",
+ "battery_type": "Battery type",
+ "max_charge_current": "Max charge current",
+ "max_ac_charge_current": "Max AC charge current",
+ "input_voltage_range": "Input voltage range",
+ "output_source_priority": "Output source priority",
+ "charge_source_priority": "Charge source priority",
+ "parallel_max_num": "Parallel max num",
+ "machine_type": "Machine type",
+ "topology": "Topology",
+ "output_mode": "Output mode",
+ "solar_power_priority": "Solar power priority",
+ "mppt": "MPPT string",
+ "fault_code": "Fault code",
+ "line_fail": "Line fail",
+ "output_circuit_short": "Output circuit short",
+ "inverter_over_temperature": "Inverter over temperature",
+ "fan_lock": "Fan lock",
+ "battery_voltage_high": "Battery voltage high",
+ "battery_low": "Battery low",
+ "battery_under": "Battery under",
+ "over_load": "Over load",
+ "eeprom_fail": "EEPROM fail",
+ "power_limit": "Power limit",
+ "pv1_voltage_high": "PV1 voltage high",
+ "pv2_voltage_high": "PV2 voltage high",
+ "mppt1_overload_warning": "MPPT1 overload warning",
+ "mppt2_overload_warning": "MPPT2 overload warning",
+ "battery_too_low_to_charge_for_scc1": "Battery too low to charge for SCC1",
+ "battery_too_low_to_charge_for_scc2": "Battery too low to charge for SCC2",
+ "buzzer": "Buzzer",
+ "overload_bypass": "Overload bypass function",
+ "escape_to_default_screen_after_1min_timeout": "Escape to default screen after 1min timeout",
+ "overload_restart": "Overload restart",
+ "over_temp_restart": "Over temperature restart",
+ "backlight_on": "Backlight on",
+ "alarm_on_on_primary_source_interrupt": "Alarm on on primary source interrupt",
+ "fault_code_record": "Fault code record",
+ "wh": "Wh"}
+
+
+class InverterEmulator:
+ def __init__(self, addr: Addr, wait=True):
+ self.status = {"grid_voltage": {"unit": "V", "value": 236.3},
+ "grid_freq": {"unit": "Hz", "value": 50.0},
+ "ac_output_voltage": {"unit": "V", "value": 229.9},
+ "ac_output_freq": {"unit": "Hz", "value": 50.0},
+ "ac_output_apparent_power": {"unit": "VA", "value": 207},
+ "ac_output_active_power": {"unit": "Wh", "value": 146},
+ "output_load_percent": {"unit": "%", "value": 4},
+ "battery_voltage": {"unit": "V", "value": 49.1},
+ "battery_voltage_scc": {"unit": "V", "value": 0.0},
+ "battery_voltage_scc2": {"unit": "V", "value": 0.0},
+ "battery_discharge_current": {"unit": "A", "value": 3},
+ "battery_charge_current": {"unit": "A", "value": 0},
+ "battery_capacity": {"unit": "%", "value": 69},
+ "inverter_heat_sink_temp": {"unit": "°C", "value": 17},
+ "mppt1_charger_temp": {"unit": "°C", "value": 0},
+ "mppt2_charger_temp": {"unit": "°C", "value": 0},
+ "pv1_input_power": {"unit": "Wh", "value": 0},
+ "pv2_input_power": {"unit": "Wh", "value": 0},
+ "pv1_input_voltage": {"unit": "V", "value": 0.0},
+ "pv2_input_voltage": {"unit": "V", "value": 0.0},
+ "configuration_status": ConfigurationStatus.Default,
+ "mppt1_charger_status": MPPTChargerStatus.Abnormal,
+ "mppt2_charger_status": MPPTChargerStatus.Abnormal,
+ "load_connected": LoadConnectionStatus.Connected,
+ "battery_power_direction": BatteryPowerDirection.Discharge,
+ "dc_ac_power_direction": DC_AC_PowerDirection.DC_AC,
+ "line_power_direction": LinePowerDirection.DoNothing,
+ "local_parallel_id": 0}
+
+ self.rated = {"ac_input_rating_voltage": {"unit": "V", "value": 230.0},
+ "ac_input_rating_current": {"unit": "A", "value": 21.7},
+ "ac_output_rating_voltage": {"unit": "V", "value": 230.0},
+ "ac_output_rating_freq": {"unit": "Hz", "value": 50.0},
+ "ac_output_rating_current": {"unit": "A", "value": 21.7},
+ "ac_output_rating_apparent_power": {"unit": "VA", "value": 5000},
+ "ac_output_rating_active_power": {"unit": "Wh", "value": 5000},
+ "battery_rating_voltage": {"unit": "V", "value": 48.0},
+ "battery_recharge_voltage": {"unit": "V", "value": 48.0},
+ "battery_redischarge_voltage": {"unit": "V", "value": 55.0},
+ "battery_under_voltage": {"unit": "V", "value": 42.0},
+ "battery_bulk_voltage": {"unit": "V", "value": 57.6},
+ "battery_float_voltage": {"unit": "V", "value": 54.0},
+ "battery_type": BatteryType.User,
+ "max_charge_current": {"unit": "A", "value": 60},
+ "max_ac_charge_current": {"unit": "A", "value": 30},
+ "input_voltage_range": InputVoltageRange.Appliance,
+ "output_source_priority": OutputSourcePriority.SolarBatteryUtility,
+ "charge_source_priority": ChargeSourcePriority.SolarAndUtility,
+ "parallel_max_num": 6,
+ "machine_type": MachineType.OffGridTie,
+ "topology": Topology.TransformerLess,
+ "output_mode": OutputMode.SingleOutput,
+ "solar_power_priority": SolarPowerPriority.LoadBatteryUtility,
+ "mppt": "2"}
+
+ self.errors = {"fault_code": 0,
+ "line_fail": False,
+ "output_circuit_short": False,
+ "inverter_over_temperature": False,
+ "fan_lock": False,
+ "battery_voltage_high": False,
+ "battery_low": False,
+ "battery_under": False,
+ "over_load": False,
+ "eeprom_fail": False,
+ "power_limit": False,
+ "pv1_voltage_high": False,
+ "pv2_voltage_high": False,
+ "mppt1_overload_warning": False,
+ "mppt2_overload_warning": False,
+ "battery_too_low_to_charge_for_scc1": False,
+ "battery_too_low_to_charge_for_scc2": False}
+
+ self.flags = {"buzzer": False,
+ "overload_bypass": True,
+ "escape_to_default_screen_after_1min_timeout": False,
+ "overload_restart": True,
+ "over_temp_restart": True,
+ "backlight_on": False,
+ "alarm_on_on_primary_source_interrupt": True,
+ "fault_code_record": False}
+
+ self.day_generated = 1000
+
+ self.logger = logging.getLogger(self.__class__.__name__)
+
+ host, port = addr
+ asyncio.run(self.run_server(host, port, wait))
+ # self.max_ac_charge_current = 30
+ # self.max_charge_current = 60
+ # self.charge_thresholds = [48, 54]
+
+ async def run_server(self, host, port, wait: bool):
+ server = await asyncio.start_server(self.client_handler, host, port)
+ async with server:
+ self.logger.info(f'listening on {host}:{port}')
+ if wait:
+ await server.serve_forever()
+ else:
+ asyncio.ensure_future(server.serve_forever())
+
+ async def client_handler(self, reader, writer):
+ client_fmt = Format.JSON
+
+ def w(s: str):
+ writer.write(s.encode('utf-8'))
+
+ def return_error(message=None):
+ w('err\r\n')
+ if message:
+ if client_fmt in (Format.JSON, Format.SIMPLE_JSON):
+ w(stringify({
+ 'result': 'error',
+ 'message': message
+ }))
+ elif client_fmt in (Format.TABLE, Format.SIMPLE_TABLE):
+ w(f'error: {message}')
+ w('\r\n')
+ w('\r\n')
+
+ def return_ok(data=None):
+ w('ok\r\n')
+ if client_fmt in (Format.JSON, Format.SIMPLE_JSON):
+ jdata = {
+ 'result': 'ok'
+ }
+ if data:
+ jdata['data'] = data
+ w(stringify(jdata))
+ w('\r\n')
+ elif data:
+ w(data)
+ w('\r\n')
+ w('\r\n')
+
+ request = None
+ while request != 'quit':
+ try:
+ request = await reader.read(255)
+ if request == b'\x04':
+ break
+ request = request.decode('utf-8').strip()
+ except Exception:
+ break
+
+ if request.startswith('format '):
+ requested_format = request[7:]
+ try:
+ client_fmt = Format(requested_format)
+ except ValueError:
+ return_error('invalid format')
+
+ return_ok()
+
+ elif request.startswith('exec '):
+ buf = request[5:].split(' ')
+ command = buf[0]
+ args = buf[1:]
+
+ try:
+ return_ok(self.process_command(client_fmt, command, *args))
+ except ValueError as e:
+ return_error(str(e))
+
+ else:
+ return_error(f'invalid token: {request}')
+
+ try:
+ await writer.drain()
+ except ConnectionResetError as e:
+ # self.logger.exception(e)
+ pass
+
+ writer.close()
+
+ def process_command(self, fmt: Format, c: str, *args) -> Union[dict, str, list[int], None]:
+ ac_charge_currents = [2, 10, 20, 30, 40, 50, 60]
+
+ if c == 'get-status':
+ return self.format_dict(self.status, fmt)
+
+ elif c == 'get-rated':
+ return self.format_dict(self.rated, fmt)
+
+ elif c == 'get-errors':
+ return self.format_dict(self.errors, fmt)
+
+ elif c == 'get-flags':
+ return self.format_dict(self.flags, fmt)
+
+ elif c == 'get-day-generated':
+ return self.format_dict({'wh': 1000}, fmt)
+
+ elif c == 'get-allowed-ac-charge-currents':
+ return self.format_list(ac_charge_currents, fmt)
+
+ elif c == 'set-max-ac-charge-current':
+ if int(args[0]) != 0:
+ raise ValueError(f'invalid machine id: {args[0]}')
+ amps = int(args[1])
+ if amps not in ac_charge_currents:
+ raise ValueError(f'invalid value: {amps}')
+ self.rated['max_ac_charge_current']['value'] = amps
+
+ elif c == 'set-charge-thresholds':
+ self.rated['battery_recharge_voltage']['value'] = float(args[0])
+ self.rated['battery_redischarge_voltage']['value'] = float(args[1])
+
+ elif c == 'set-output-source-priority':
+ self.rated['output_source_priority'] = OutputSourcePriority.SolarBatteryUtility if args[0] == 'SBU' else OutputSourcePriority.SolarUtilityBattery
+
+ elif c == 'set-battery-cutoff-voltage':
+ self.rated['battery_under_voltage']['value'] = float(args[0])
+
+ elif c == 'set-flag':
+ flag = args[0]
+ val = bool(int(args[1]))
+
+ if flag == 'BUZZ':
+ k = 'buzzer'
+ elif flag == 'OLBP':
+ k = 'overload_bypass'
+ elif flag == 'LCDE':
+ k = 'escape_to_default_screen_after_1min_timeout'
+ elif flag == 'OLRS':
+ k = 'overload_restart'
+ elif flag == 'OTRS':
+ k = 'over_temp_restart'
+ elif flag == 'BLON':
+ k = 'backlight_on'
+ elif flag == 'ALRM':
+ k = 'alarm_on_on_primary_source_interrupt'
+ elif flag == 'FTCR':
+ k = 'fault_code_record'
+ else:
+ raise ValueError('invalid flag')
+
+ self.flags[k] = val
+
+ else:
+ raise ValueError(f'{c}: unsupported command')
+
+ @staticmethod
+ def format_list(values: list, fmt: Format) -> Union[str, list]:
+ if fmt in (Format.JSON, Format.SIMPLE_JSON):
+ return values
+ return '\n'.join(map(lambda v: str(v), values))
+
+ @staticmethod
+ def format_dict(data: dict, fmt: Format) -> Union[str, dict]:
+ new_data = {}
+ for k, v in data.items():
+ new_val = None
+ if fmt in (Format.JSON, Format.TABLE, Format.SIMPLE_TABLE):
+ if isinstance(v, dict):
+ new_val = v
+ elif isinstance(v, InverterEnum):
+ new_val = v.as_text()
+ else:
+ new_val = v
+ elif fmt == Format.SIMPLE_JSON:
+ if isinstance(v, dict):
+ new_val = v['value']
+ elif isinstance(v, InverterEnum):
+ new_val = v.value
+ else:
+ new_val = str(v)
+ new_data[k] = new_val
+
+ if fmt in (Format.JSON, Format.SIMPLE_JSON):
+ return new_data
+
+ lines = []
+
+ if fmt == Format.SIMPLE_TABLE:
+ for k, v in new_data.items():
+ buf = k
+ if isinstance(v, dict):
+ buf += ' ' + str(v['value']) + ' ' + v['unit']
+ elif isinstance(v, InverterEnum):
+ buf += ' ' + v.as_text()
+ else:
+ buf += ' ' + str(v)
+ lines.append(buf)
+
+ elif fmt == Format.TABLE:
+ max_k_len = 0
+ for k in new_data.keys():
+ if len(_g_human_readable[k]) > max_k_len:
+ max_k_len = len(_g_human_readable[k])
+ for k, v in new_data.items():
+ buf = _g_human_readable[k] + ':'
+ buf += ' ' * (max_k_len - len(_g_human_readable[k]) + 1)
+ if isinstance(v, dict):
+ buf += str(v['value']) + ' ' + v['unit']
+ elif isinstance(v, InverterEnum):
+ buf += v.as_text()
+ elif isinstance(v, bool):
+ buf += str(int(v))
+ else:
+ buf += str(v)
+ lines.append(buf)
+
+ return '\n'.join(lines)
diff --git a/include/py/homekit/inverter/inverter_wrapper.py b/include/py/homekit/inverter/inverter_wrapper.py
new file mode 100644
index 0000000..df2c2fc
--- /dev/null
+++ b/include/py/homekit/inverter/inverter_wrapper.py
@@ -0,0 +1,48 @@
+import json
+
+from threading import Lock
+from inverterd import (
+ Format,
+ Client as InverterClient,
+ InverterError
+)
+
+_lock = Lock()
+
+
+class InverterClientWrapper:
+ def __init__(self):
+ self._inverter = None
+ self._host = None
+ self._port = None
+
+ def init(self, host: str, port: int):
+ self._host = host
+ self._port = port
+ self.create()
+
+ def create(self):
+ self._inverter = InverterClient(host=self._host, port=self._port)
+ self._inverter.connect()
+
+ def exec(self, command: str, arguments: tuple = (), format=Format.JSON):
+ with _lock:
+ try:
+ self._inverter.format(format)
+ response = self._inverter.exec(command, arguments)
+ if format == Format.JSON:
+ response = json.loads(response)
+ return response
+ except InverterError as e:
+ raise e
+ except Exception as e:
+ # silently try to reconnect
+ try:
+ self.create()
+ except Exception:
+ pass
+ raise e
+
+
+wrapper_instance = InverterClientWrapper()
+
diff --git a/include/py/homekit/inverter/monitor.py b/include/py/homekit/inverter/monitor.py
new file mode 100644
index 0000000..5955d92
--- /dev/null
+++ b/include/py/homekit/inverter/monitor.py
@@ -0,0 +1,499 @@
+import logging
+import time
+
+from .types import *
+from threading import Thread
+from typing import Callable, Optional
+from .inverter_wrapper import wrapper_instance as inverter
+from inverterd import InverterError
+from ..util import Stopwatch, StopwatchError
+from ..config import config
+
+logger = logging.getLogger(__name__)
+
+
+def _pd_from_string(pd: str) -> BatteryPowerDirection:
+ if pd == 'Discharge':
+ return BatteryPowerDirection.DISCHARGING
+ elif pd == 'Charge':
+ return BatteryPowerDirection.CHARGING
+ elif pd == 'Do nothing':
+ return BatteryPowerDirection.DO_NOTHING
+ else:
+ raise ValueError(f'invalid power direction: {pd}')
+
+
+class MonitorConfig:
+ def __getattr__(self, item):
+ return config.app_config['monitor'][item]
+
+
+cfg = MonitorConfig()
+
+
+"""
+TODO:
+- поддержать возможность ручного (через бота) переключения тока заряда вверх и вниз
+- поддержать возможность бесшовного перезапуска бота, когда монитор понимает, что зарядка уже идет, и он
+ не запускает программу с начала, а продолжает с уже существующей позиции. Уведомления при этом можно не
+ присылать совсем, либо прислать какое-то одно приложение, в духе "программа была перезапущена"
+"""
+
+
+class InverterMonitor(Thread):
+ charging_event_handler: Optional[Callable]
+ battery_event_handler: Optional[Callable]
+ util_event_handler: Optional[Callable]
+ error_handler: Optional[Callable]
+ osp_change_cb: Optional[Callable]
+ osp: Optional[OutputSourcePriority]
+
+ def __init__(self):
+ super().__init__()
+ self.setName('InverterMonitor')
+
+ self.interrupted = False
+ self.min_allowed_current = 0
+ self.ac_mode = None
+ self.osp = None
+
+ # Event handlers for the bot.
+ self.charging_event_handler = None
+ self.battery_event_handler = None
+ self.util_event_handler = None
+ self.error_handler = None
+ self.osp_change_cb = None
+
+ # Currents list, defined in the bot config.
+ self.currents = cfg.gen_currents
+ self.currents.sort()
+
+ # We start charging at lowest possible current, then increase it once per minute (or so) to the maximum level.
+ # This is done so that the load on the generator increases smoothly, not abruptly. Generator will thank us.
+ self.current_change_direction = CurrentChangeDirection.UP
+ self.next_current_enter_time = 0
+ self.active_current_idx = -1
+
+ self.battery_state = BatteryState.NORMAL
+ self.charging_state = ChargingState.NOT_CHARGING
+
+ # 'Mostly-charged' means that we've already lowered the charging current to the level
+ # at which batteries are charging pretty slow. So instead of burning gasoline and shaking the air,
+ # we can just turn the generator off at this point.
+ self.mostly_charged = False
+
+ # The stopwatch is used to measure how long does the battery voltage exceeds the float voltage level.
+ # We don't want to damage our batteries, right?
+ self.floating_stopwatch = Stopwatch()
+
+ # State variables for utilities charging program
+ self.util_ac_present = None
+ self.util_pd = None
+ self.util_solar = None
+
+ @property
+ def active_current(self) -> Optional[int]:
+ try:
+ if self.active_current_idx < 0:
+ return None
+ return self.currents[self.active_current_idx]
+ except IndexError:
+ return None
+
+ def run(self):
+ # Check allowed currents and validate the config.
+ allowed_currents = list(inverter.exec('get-allowed-ac-charge-currents')['data'])
+ allowed_currents.sort()
+
+ for a in self.currents:
+ if a not in allowed_currents:
+ raise ValueError(f'invalid value {a} in gen_currents list')
+
+ self.min_allowed_current = min(allowed_currents)
+
+ # Reading rated configuration
+ rated = inverter.exec('get-rated')['data']
+ self.osp = OutputSourcePriority.from_text(rated['output_source_priority'])
+
+ # Read data and run implemented programs every 2 seconds.
+ while not self.interrupted:
+ try:
+ response = inverter.exec('get-status')
+ if response['result'] != 'ok':
+ logger.error('get-status failed:', response)
+ else:
+ gs = response['data']
+
+ ac = gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0
+ solar = gs['pv1_input_voltage']['value'] > 0 or gs['pv2_input_voltage']['value'] > 0
+ solar_input = gs['pv1_input_power']['value']
+ v = float(gs['battery_voltage']['value'])
+ load_watts = int(gs['ac_output_active_power']['value'])
+ pd = _pd_from_string(gs['battery_power_direction'])
+
+ logger.debug(f'got status: ac={ac}, solar={solar}, v={v}, pd={pd}')
+
+ if self.ac_mode == ACMode.GENERATOR:
+ self.gen_charging_program(ac, solar, v, pd)
+
+ elif self.ac_mode == ACMode.UTILITIES:
+ self.utilities_monitoring_program(ac, solar, v, load_watts, solar_input, pd)
+
+ if not ac or pd != BatteryPowerDirection.CHARGING:
+ # if AC is disconnected or not charging, run the low voltage checking program
+ self.low_voltage_program(v, load_watts)
+
+ elif self.battery_state != BatteryState.NORMAL:
+ # AC is connected and the battery is charging, assume battery level is normal
+ self.battery_state = BatteryState.NORMAL
+
+ except InverterError as e:
+ logger.exception(e)
+
+ time.sleep(2)
+
+ def utilities_monitoring_program(self,
+ ac: bool, # whether AC is connected
+ solar: bool, # whether MPPT is active
+ v: float, # battery voltage
+ load_watts: int, # load, wh
+ solar_input: int, # input from solar panels, wh
+ pd: BatteryPowerDirection # current power direction
+ ):
+ pd_event_send = False
+ if self.util_solar is None or solar != self.util_solar:
+ self.util_solar = solar
+ if solar and self.util_ac_present and self.util_pd == BatteryPowerDirection.CHARGING:
+ self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR)
+ pd_event_send = True
+
+ if solar:
+ if v <= 48 and self.osp == OutputSourcePriority.SolarBatteryUtility:
+ self.osp_change_cb(OutputSourcePriority.SolarUtilityBattery, solar_input=solar_input, v=v)
+ self.osp = OutputSourcePriority.SolarUtilityBattery
+
+ if self.osp == OutputSourcePriority.SolarUtilityBattery and solar_input >= 900:
+ self.osp_change_cb(OutputSourcePriority.SolarBatteryUtility, solar_input=solar_input, v=v)
+ self.osp = OutputSourcePriority.SolarBatteryUtility
+
+ if self.util_ac_present is None or ac != self.util_ac_present:
+ self.util_event_handler(ACPresentEvent.CONNECTED if ac else ACPresentEvent.DISCONNECTED)
+ self.util_ac_present = ac
+
+ if self.util_pd is None or self.util_pd != pd:
+ self.util_pd = pd
+ if not pd_event_send and not solar:
+ if pd == BatteryPowerDirection.CHARGING:
+ self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STARTED)
+
+ elif pd == BatteryPowerDirection.DISCHARGING:
+ self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED)
+
+ def gen_charging_program(self,
+ ac: bool, # whether AC is connected
+ solar: bool, # whether MPPT is active
+ v: float, # current battery voltage
+ pd: BatteryPowerDirection # current power direction
+ ):
+ if self.charging_state == ChargingState.NOT_CHARGING:
+ if ac and solar:
+ # Not charging because MPPT is active (solar line is connected).
+ # Notify users about it and change the current state.
+ self.charging_state = ChargingState.AC_BUT_SOLAR
+ self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR)
+ logger.info('entering AC_BUT_SOLAR state')
+ elif ac:
+ # Not charging, but AC is connected and ready to use.
+ # Start the charging program.
+ self.gen_start(pd)
+
+ elif self.charging_state == ChargingState.AC_BUT_SOLAR:
+ if not ac:
+ # AC charger has been disconnected. Since the state is AC_BUT_SOLAR,
+ # charging probably never even started. Stop the charging program.
+ self.gen_stop(ChargingState.NOT_CHARGING)
+ elif not solar:
+ # MPPT has been disconnected, and, since AC is still connected, we can
+ # try to start the charging program.
+ self.gen_start(pd)
+
+ elif self.charging_state in (ChargingState.AC_OK, ChargingState.AC_WAITING):
+ if not ac:
+ # Charging was in progress, but AC has been suddenly disconnected.
+ # Sad, but what can we do? Stop the charging program and return.
+ self.gen_stop(ChargingState.NOT_CHARGING)
+ return
+
+ if solar:
+ # Charging was in progress, but MPPT has been detected. Inverter doesn't charge
+ # batteries from AC when MPPT is active, so we have to pause our program.
+ self.charging_state = ChargingState.AC_BUT_SOLAR
+ self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR)
+ try:
+ self.floating_stopwatch.pause()
+ except StopwatchError:
+ msg = 'gen_charging_program: floating_stopwatch.pause() failed at (1)'
+ logger.warning(msg)
+ # self.error_handler(msg)
+ logger.info('solar power connected during charging, entering AC_BUT_SOLAR state')
+ return
+
+ # No surprises at this point, just check the values and make decisions based on them.
+ # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+ # We've reached the 'mostly-charged' point, the voltage level is not float,
+ # but inverter decided to stop charging (or somebody used a kettle, lol).
+ # Anyway, assume that charging is complete, stop the program, notify users and return.
+ if self.mostly_charged and v > (cfg.gen_floating_v - 1) and pd != BatteryPowerDirection.CHARGING:
+ self.gen_stop(ChargingState.AC_DONE)
+ return
+
+ # Monitor inverter power direction and notify users when it changes.
+ state = ChargingState.AC_OK if pd == BatteryPowerDirection.CHARGING else ChargingState.AC_WAITING
+ if state != self.charging_state:
+ self.charging_state = state
+
+ evt = ChargingEvent.AC_CHARGING_STARTED if state == ChargingState.AC_OK else ChargingEvent.AC_NOT_CHARGING
+ self.charging_event_handler(evt)
+
+ if self.floating_stopwatch.get_elapsed_time() >= cfg.gen_floating_time_max:
+ # We've been at a bulk voltage level too long, so we have to stop charging.
+ # Set the minimum current possible.
+
+ if self.current_change_direction == CurrentChangeDirection.UP:
+ # This shouldn't happen, obviously an error.
+ msg = 'gen_charging_program:'
+ msg += ' been at bulk voltage level too long, but current change direction is still \'up\'!'
+ msg += ' This is obviously an error, please fix it'
+ logger.warning(msg)
+ self.error_handler(msg)
+
+ self.gen_next_current(current=self.min_allowed_current)
+
+ elif self.active_current is not None:
+ # If voltage is greater than float voltage, keep the stopwatch ticking
+ if v > cfg.gen_floating_v and self.floating_stopwatch.is_paused():
+ try:
+ self.floating_stopwatch.go()
+ except StopwatchError:
+ msg = 'gen_charging_program: floating_stopwatch.go() failed at (2)'
+ logger.warning(msg)
+ self.error_handler(msg)
+ # Otherwise, pause it
+ elif v <= cfg.gen_floating_v and not self.floating_stopwatch.is_paused():
+ try:
+ self.floating_stopwatch.pause()
+ except StopwatchError:
+ msg = 'gen_charging_program: floating_stopwatch.pause() failed at (3)'
+ logger.warning(msg)
+ self.error_handler(msg)
+
+ # Charging current monitoring
+ if self.current_change_direction == CurrentChangeDirection.UP:
+ # Generator is warming up in this code path
+
+ if self.next_current_enter_time != 0 and pd != BatteryPowerDirection.CHARGING:
+ # Generator was warming up and charging, but stopped (pd has changed).
+ # Resetting to the minimum possible current
+ logger.info(f'gen_charging_program (warming path): was charging but power direction suddeny changed. resetting to minimum current')
+ self.next_current_enter_time = 0
+ self.gen_next_current(current=self.min_allowed_current)
+
+ elif self.next_current_enter_time == 0 and pd == BatteryPowerDirection.CHARGING:
+ self.next_current_enter_time = time.time() + cfg.gen_raise_intervals[self.active_current_idx]
+ logger.info(f'gen_charging_program (warming path): set next_current_enter_time to {self.next_current_enter_time}')
+
+ elif self.next_current_enter_time != 0 and time.time() >= self.next_current_enter_time:
+ logger.info('gen_charging_program (warming path): hit next_current_enter_time, calling gen_next_current()')
+ self.gen_next_current()
+ else:
+ # Gradually lower the current level, based on how close
+ # battery voltage has come to the bulk level.
+ if self.active_current >= 30:
+ upper_bound = cfg.gen_cur30_v_limit
+ elif self.active_current == 20:
+ upper_bound = cfg.gen_cur20_v_limit
+ else:
+ upper_bound = cfg.gen_cur10_v_limit
+
+ # Voltage is high enough already and it's close to bulk level; we hit the upper bound,
+ # so let's lower the current
+ if v >= upper_bound:
+ self.gen_next_current()
+
+ elif self.charging_state == ChargingState.AC_DONE:
+ # We've already finished charging, but AC was connected. Not that it's disconnected,
+ # set the appropriate state and notify users.
+ if not ac:
+ self.gen_stop(ChargingState.NOT_CHARGING)
+
+ def gen_start(self, pd: BatteryPowerDirection):
+ if pd == BatteryPowerDirection.CHARGING:
+ self.charging_state = ChargingState.AC_OK
+ self.charging_event_handler(ChargingEvent.AC_CHARGING_STARTED)
+ logger.info('AC line connected and charging, entering AC_OK state')
+
+ # Continue the stopwatch, if needed
+ try:
+ self.floating_stopwatch.go()
+ except StopwatchError:
+ msg = 'floating_stopwatch.go() failed at ac_charging_start(), AC_OK path'
+ logger.warning(msg)
+ self.error_handler(msg)
+ else:
+ self.charging_state = ChargingState.AC_WAITING
+ self.charging_event_handler(ChargingEvent.AC_NOT_CHARGING)
+ logger.info('AC line connected but not charging yet, entering AC_WAITING state')
+
+ # Pause the stopwatch, if needed
+ try:
+ if not self.floating_stopwatch.is_paused():
+ self.floating_stopwatch.pause()
+ except StopwatchError:
+ msg = 'floating_stopwatch.pause() failed at ac_charging_start(), AC_WAITING path'
+ logger.warning(msg)
+ self.error_handler(msg)
+
+ # idx == -1 means haven't started our program yet.
+ if self.active_current_idx == -1:
+ self.gen_next_current()
+ # self.set_hw_charging_current(self.min_allowed_current)
+
+ def gen_stop(self, reason: ChargingState):
+ self.charging_state = reason
+
+ if reason == ChargingState.AC_DONE:
+ event = ChargingEvent.AC_CHARGING_FINISHED
+ elif reason == ChargingState.NOT_CHARGING:
+ event = ChargingEvent.AC_DISCONNECTED
+ else:
+ raise ValueError(f'ac_charging_stop: unexpected reason {reason}')
+
+ logger.info(f'charging is finished, entering {reason} state')
+ self.charging_event_handler(event)
+
+ self.next_current_enter_time = 0
+ self.mostly_charged = False
+ self.active_current_idx = -1
+ self.floating_stopwatch.reset()
+ self.current_change_direction = CurrentChangeDirection.UP
+
+ self.set_hw_charging_current(self.min_allowed_current)
+
+ def gen_next_current(self, current=None):
+ if current is None:
+ try:
+ current = self._next_current()
+ logger.debug(f'gen_next_current: ready to change charging current to {current} A')
+ except IndexError:
+ logger.debug('gen_next_current: was going to change charging current, but no currents left; finishing charging program')
+ self.gen_stop(ChargingState.AC_DONE)
+ return
+
+ else:
+ try:
+ idx = self.currents.index(current)
+ except ValueError:
+ msg = f'gen_next_current: got current={current} but it\'s not in the currents list'
+ logger.error(msg)
+ self.error_handler(msg)
+ return
+ self.active_current_idx = idx
+
+ if self.current_change_direction == CurrentChangeDirection.DOWN:
+ if current == self.currents[0]:
+ self.mostly_charged = True
+ self.gen_stop(ChargingState.AC_DONE)
+
+ elif current == self.currents[1] and not self.mostly_charged:
+ self.mostly_charged = True
+ self.charging_event_handler(ChargingEvent.AC_MOSTLY_CHARGED)
+
+ self.set_hw_charging_current(current)
+
+ def set_hw_charging_current(self, current: int):
+ try:
+ response = inverter.exec('set-max-ac-charge-current', (0, current))
+ if response['result'] != 'ok':
+ logger.error(f'failed to change AC charging current to {current} A')
+ raise InverterError('set-max-ac-charge-current: inverterd reported error')
+ else:
+ self.charging_event_handler(ChargingEvent.AC_CURRENT_CHANGED, current=current)
+ logger.info(f'changed AC charging current to {current} A')
+ except InverterError as e:
+ self.error_handler(f'failed to set charging current to {current} A (caught InverterError)')
+ logger.exception(e)
+
+ def _next_current(self):
+ if self.current_change_direction == CurrentChangeDirection.UP:
+ self.active_current_idx += 1
+ if self.active_current_idx == len(self.currents)-1:
+ logger.info('_next_current: charging current power direction to DOWN')
+ self.current_change_direction = CurrentChangeDirection.DOWN
+ self.next_current_enter_time = 0
+ else:
+ if self.active_current_idx == 0:
+ raise IndexError('can\'t go lower')
+ self.active_current_idx -= 1
+
+ logger.info(f'_next_current: active_current_idx set to {self.active_current_idx}, returning current of {self.currents[self.active_current_idx]} A')
+ return self.currents[self.active_current_idx]
+
+ def low_voltage_program(self, v: float, load_watts: int):
+ crit_level = cfg.vcrit
+ low_level = cfg.vlow
+
+ if v <= crit_level:
+ state = BatteryState.CRITICAL
+ elif v <= low_level:
+ state = BatteryState.LOW
+ else:
+ state = BatteryState.NORMAL
+
+ if state != self.battery_state:
+ self.battery_state = state
+ self.battery_event_handler(state, v, load_watts)
+
+ def set_charging_event_handler(self, handler: Callable):
+ self.charging_event_handler = handler
+
+ def set_battery_event_handler(self, handler: Callable):
+ self.battery_event_handler = handler
+
+ def set_util_event_handler(self, handler: Callable):
+ self.util_event_handler = handler
+
+ def set_error_handler(self, handler: Callable):
+ self.error_handler = handler
+
+ def set_osp_need_change_callback(self, cb: Callable):
+ self.osp_change_cb = cb
+
+ def set_ac_mode(self, mode: ACMode):
+ self.ac_mode = mode
+
+ def notify_osp(self, osp: OutputSourcePriority):
+ self.osp = osp
+
+ def stop(self):
+ self.interrupted = True
+
+ def dump_status(self) -> dict:
+ return {
+ 'interrupted': self.interrupted,
+ 'currents': self.currents,
+ 'active_current': self.active_current,
+ 'current_change_direction': self.current_change_direction.name,
+ 'battery_state': self.battery_state.name,
+ 'charging_state': self.charging_state.name,
+ 'mostly_charged': self.mostly_charged,
+ 'floating_stopwatch_paused': self.floating_stopwatch.is_paused(),
+ 'floating_stopwatch_elapsed': self.floating_stopwatch.get_elapsed_time(),
+ 'time_now': time.time(),
+ 'next_current_enter_time': self.next_current_enter_time,
+ 'ac_mode': self.ac_mode,
+ 'osp': self.osp,
+ 'util_ac_present': self.util_ac_present,
+ 'util_pd': self.util_pd.name,
+ 'util_solar': self.util_solar
+ }
diff --git a/include/py/homekit/inverter/types.py b/include/py/homekit/inverter/types.py
new file mode 100644
index 0000000..57021f1
--- /dev/null
+++ b/include/py/homekit/inverter/types.py
@@ -0,0 +1,64 @@
+from enum import Enum, auto
+
+
+class BatteryPowerDirection(Enum):
+ DISCHARGING = auto()
+ CHARGING = auto()
+ DO_NOTHING = auto()
+
+
+class ChargingEvent(Enum):
+ AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR = auto()
+ AC_NOT_CHARGING = auto()
+ AC_CHARGING_STARTED = auto()
+ AC_DISCONNECTED = auto()
+ AC_CURRENT_CHANGED = auto()
+ AC_MOSTLY_CHARGED = auto()
+ AC_CHARGING_FINISHED = auto()
+
+ UTIL_CHARGING_STARTED = auto()
+ UTIL_CHARGING_STOPPED = auto()
+ UTIL_CHARGING_STOPPED_SOLAR = auto()
+
+
+class ACPresentEvent(Enum):
+ CONNECTED = auto()
+ DISCONNECTED = auto()
+
+
+class ChargingState(Enum):
+ NOT_CHARGING = auto()
+ AC_BUT_SOLAR = auto()
+ AC_WAITING = auto()
+ AC_OK = auto()
+ AC_DONE = auto()
+
+
+class CurrentChangeDirection(Enum):
+ UP = auto()
+ DOWN = auto()
+
+
+class BatteryState(Enum):
+ NORMAL = auto()
+ LOW = auto()
+ CRITICAL = auto()
+
+
+class ACMode(Enum):
+ GENERATOR = 'generator'
+ UTILITIES = 'utilities'
+
+
+class OutputSourcePriority(Enum):
+ SolarUtilityBattery = 'SUB'
+ SolarBatteryUtility = 'SBU'
+
+ @classmethod
+ def from_text(cls, s: str):
+ if s == 'Solar-Battery-Utility':
+ return cls.SolarBatteryUtility
+ elif s == 'Solar-Utility-Battery':
+ return cls.SolarUtilityBattery
+ else:
+ raise ValueError(f'unknown value: {s}') \ No newline at end of file
diff --git a/include/py/homekit/inverter/util.py b/include/py/homekit/inverter/util.py
new file mode 100644
index 0000000..a577e6a
--- /dev/null
+++ b/include/py/homekit/inverter/util.py
@@ -0,0 +1,8 @@
+import re
+
+
+def beautify_table(s):
+ lines = s.split('\n')
+ lines = list(map(lambda line: re.sub(r'\s+', ' ', line), lines))
+ lines = list(map(lambda line: re.sub(r'(.*?): (.*)', r'<b>\1:</b> \2', line), lines))
+ return '\n'.join(lines)