diff options
Diffstat (limited to 'src/home/inverter')
-rw-r--r-- | src/home/inverter/__init__.py | 3 | ||||
-rw-r--r-- | src/home/inverter/emulator.py | 556 | ||||
-rw-r--r-- | src/home/inverter/inverter_wrapper.py | 48 | ||||
-rw-r--r-- | src/home/inverter/monitor.py | 499 | ||||
-rw-r--r-- | src/home/inverter/types.py | 64 | ||||
-rw-r--r-- | src/home/inverter/util.py | 8 |
6 files changed, 0 insertions, 1178 deletions
diff --git a/src/home/inverter/__init__.py b/src/home/inverter/__init__.py deleted file mode 100644 index 8831ef3..0000000 --- a/src/home/inverter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .monitor import InverterMonitor -from .inverter_wrapper import wrapper_instance -from .util import beautify_table diff --git a/src/home/inverter/emulator.py b/src/home/inverter/emulator.py deleted file mode 100644 index e86b8bb..0000000 --- a/src/home/inverter/emulator.py +++ /dev/null @@ -1,556 +0,0 @@ -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/src/home/inverter/inverter_wrapper.py b/src/home/inverter/inverter_wrapper.py deleted file mode 100644 index df2c2fc..0000000 --- a/src/home/inverter/inverter_wrapper.py +++ /dev/null @@ -1,48 +0,0 @@ -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/src/home/inverter/monitor.py b/src/home/inverter/monitor.py deleted file mode 100644 index 86f75ac..0000000 --- a/src/home/inverter/monitor.py +++ /dev/null @@ -1,499 +0,0 @@ -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['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/src/home/inverter/types.py b/src/home/inverter/types.py deleted file mode 100644 index 57021f1..0000000 --- a/src/home/inverter/types.py +++ /dev/null @@ -1,64 +0,0 @@ -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/src/home/inverter/util.py b/src/home/inverter/util.py deleted file mode 100644 index a577e6a..0000000 --- a/src/home/inverter/util.py +++ /dev/null @@ -1,8 +0,0 @@ -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) |