Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# GitHub CODEOWNERS file
# This file specifies code ownership and automatically requests reviews from owners when PRs modify their files
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners

# Victron inverter implementation - maintained by contributor
src/interfaces/inverters/victron.py @Awienert
src\interfaces\inverters\victron_old.py @Awienert
src/interfaces/inverters/ccgx_registers_all.py @Awienert
src/interfaces/inverters/ccgx_registers.py @Awienert
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ Guidelines
- Document new config keys / API / MQTT topics
- Prefer clarity over cleverness

## Code Ownership

Some components have designated owners who maintain and review changes to those areas. This is documented in the [CODEOWNERS](/.github/CODEOWNERS) file.

**Victron Inverter Implementation** (`src/interfaces/inverters/victron.py`, `src/interfaces/inverters/ccgx_registers*.py`)
- **Owner:** @Awienert
- These files are maintained by the original contributor. PRs modifying Victron-related code will automatically request review from @Awienert to ensure compatibility and correctness with Victron hardware.

## Supporting the Project

If you find EOS Connect useful but don't have the time to contribute code, you can also support the project by [becoming a sponsor](https://github.com/sponsors/ohAnd). Your support helps keep the project active and maintained.
Expand Down
3 changes: 3 additions & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import sys

sys.path.append(".")
90 changes: 27 additions & 63 deletions src/eos_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@
from interfaces.base_control import BaseControl
from interfaces.load_interface import LoadInterface
from interfaces.battery_interface import BatteryInterface
from interfaces.inverter_fronius import FroniusWR
from interfaces.inverter_fronius_v2 import FroniusWRV2
from interfaces.evcc_interface import EvccInterface
from interfaces.optimization_interface import OptimizationInterface
from interfaces.price_interface import PriceInterface
from interfaces.mqtt_interface import MqttInterface
from interfaces.pv_interface import PvInterface
from interfaces.port_interface import PortInterface
from interfaces.update_checker import UpdateChecker
from interfaces.inverters import create_inverter
from interfaces.inverters.null_inverter import NullInverter
from interfaces.inverters.evcc_inverter import EvccInverter

# Check Python version early
if sys.version_info < (3, 11):
Expand Down Expand Up @@ -141,56 +142,13 @@ def formatTime(self, record, datefmt=None):
# initialize the inverter interface
inverter_interface = None

# Handle backward compatibility for old interface names
inverter_type = config_manager.config["inverter"]["type"]
if inverter_type == "fronius_gen24_v2":
logger.warning(
"[Config] Interface name 'fronius_gen24_v2' is deprecated. "
"Please update your config.yaml to use 'fronius_gen24' instead. "
"Using enhanced interface for compatibility."
)
inverter_type = "fronius_gen24" # Auto-migrate to new name

if inverter_type == "fronius_gen24":
# Enhanced V2 interface (default for existing users)
logger.info(
"[Inverter] Using enhanced Fronius GEN24 interface with firmware-based authentication"
)
inverter_config = {
"address": config_manager.config["inverter"]["address"],
"max_grid_charge_rate": config_manager.config["inverter"][
"max_grid_charge_rate"
],
"max_pv_charge_rate": config_manager.config["inverter"]["max_pv_charge_rate"],
"user": config_manager.config["inverter"]["user"],
"password": config_manager.config["inverter"]["password"],
}
inverter_interface = FroniusWRV2(inverter_config)
elif inverter_type == "fronius_gen24_legacy":
# Legacy V1 interface (for corner cases)
logger.info(
"[Inverter] Using legacy Fronius GEN24 interface (V1) for compatibility"
)
inverter_config = {
"address": config_manager.config["inverter"]["address"],
"max_grid_charge_rate": config_manager.config["inverter"][
"max_grid_charge_rate"
],
"max_pv_charge_rate": config_manager.config["inverter"]["max_pv_charge_rate"],
"user": config_manager.config["inverter"]["user"],
"password": config_manager.config["inverter"]["password"],
}
inverter_interface = FroniusWR(inverter_config)
elif inverter_type == "evcc":
logger.info(
"[Inverter] Inverter type %s - using the universal evcc external battery control.",
inverter_type,
)
# Call factory via config dict
inverter_interface = create_inverter(config_manager.config["inverter"])
if inverter_interface is not None:
inverter_interface.initialize()
else:
logger.info(
"[Inverter] Inverter type %s - no external connection."
+ " Changing to show only mode.",
config_manager.config["inverter"]["type"],
logger.error(
"[Main] Failed to initialize inverter interface - check inverter configuration"
)


Expand Down Expand Up @@ -1149,7 +1107,10 @@ def __update_state_loop_data_loop(self):
self.__start_update_service_data_loop()

def __run_data_loop(self):
if inverter_type in ["fronius_gen24", "fronius_gen24_legacy"]:
if (
inverter_interface is not None
and inverter_interface.supports_extended_monitoring
):
inverter_interface.fetch_inverter_data()
mqtt_interface.update_publish_topics(
{
Expand Down Expand Up @@ -1238,10 +1199,15 @@ def change_control_state():
"""
inverter_fronius_en = False
inverter_evcc_en = False
if inverter_type in ["fronius_gen24", "fronius_gen24_legacy"]:
inverter_fronius_en = True
elif config_manager.config["inverter"]["type"] == "evcc":
inverter_evcc_en = True
# Check if we have an active inverter (Fronius) or if EVCC/display-only mode is enabled
if inverter_interface is not None:
if isinstance(inverter_interface, EvccInverter):
inverter_evcc_en = True
elif isinstance(inverter_interface, NullInverter):
inverter_evcc_en = True
else:
# Real inverter (Fronius, Victron, etc.)
inverter_fronius_en = True

current_overall_state = base_control.get_current_overall_state_number()
current_overall_state_text = base_control.get_current_overall_state()
Expand Down Expand Up @@ -1609,8 +1575,8 @@ def get_controls():
"inverter": {
"inverter_special_data": (
inverter_interface.get_inverter_current_data()
if inverter_type in ["fronius_gen24", "fronius_gen24_legacy"]
and inverter_interface is not None
if inverter_interface is not None
and inverter_interface.supports_extended_monitoring
else None
)
},
Expand Down Expand Up @@ -2068,11 +2034,9 @@ def get_update_status():
http_server.stop()
logger.info("[Main] HTTP server stopped")

# restore the old config
if (
config_manager.config["inverter"]["type"]
in ["fronius_gen24", "fronius_gen24_v2"]
and inverter_interface is not None
# Shutdown real inverter if it exists (not NullInverter/display-only or EvccInverter)
if inverter_interface is not None and not isinstance(
inverter_interface, (NullInverter, EvccInverter)
):
inverter_interface.shutdown()
pv_interface.shutdown()
Expand Down
121 changes: 121 additions & 0 deletions src/interfaces/base_inverter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from abc import ABC, abstractmethod
import logging

logger = logging.getLogger("__main__").getChild("BaseInverter")
logger.setLevel(logging.INFO)


class BaseInverter(ABC):
"""Abstrakte Basisklasse für verschiedene Wechselrichter-Typen."""

# Default value for supports_extended_monitoring (can be overridden by subclasses)
supports_extended_monitoring_default = False

def __init__(self, config: dict):
# ✔ komplette Config speichern (für Tests & spätere Erweiterungen)
self.config = config

# ✔ weiterhin einzelne Werte extrahieren
self.address = config.get("address")
self.user = config.get("user", "customer").lower()
self.password = config.get("password", "")
self.max_grid_charge_rate = config.get("max_grid_charge_rate")
self.max_pv_charge_rate = config.get("max_pv_charge_rate")

self.is_authenticated = False
self.inverter_type = self.__class__.__name__

# Set supports_extended_monitoring from class attribute
self.supports_extended_monitoring = (
self.__class__.supports_extended_monitoring_default
)

logger.info(f"[{self.inverter_type}] Initialized for {self.address}")

# --- Optionale Authentifizierung ---

@abstractmethod
def initialize(self):
"""Heavy initialization (API calls)."""
pass

def authenticate(self) -> bool:
"""
Optionale Authentifizierung.
Standardmäßig tut diese Methode nichts und gibt True zurück.
Subklassen können sie überschreiben, wenn sie Auth benötigen.
"""
logger.debug(f"[{self.inverter_type}] No authentication required")
self.is_authenticated = True
return True

# --- Pflichtmethoden für alle Inverter ---

@abstractmethod
def set_battery_mode(self, mode: str) -> bool:
"""Setzt den Batteriemodus (z. B. normal, hold, charge)."""
pass

# --- EOS Connect Helfer ---

@abstractmethod
def set_mode_avoid_discharge(self) -> bool:
"""Vermeidet Entladung (Hold Mode)"""
return self.set_battery_mode("hold")

@abstractmethod
def set_mode_allow_discharge(self) -> bool:
"""Erlaubt Entladung (Normal Mode)"""
return self.set_battery_mode("normal")

@abstractmethod
def set_allow_grid_charging(self, value: bool):
pass

@abstractmethod
def get_battery_info(self) -> dict:
"""Liest aktuelle Batterieinformationen."""
pass

@abstractmethod
def fetch_inverter_data(self) -> dict:
"""Liest aktuelle Inverterdaten."""
pass

@abstractmethod
def set_mode_force_charge(self, charge_power_w: int) -> bool:
"""
Force charge mode with specific power.
Jede Subklasse muss diese Methode implementieren.
"""
pass

@abstractmethod
def connect_inverter(self) -> bool:
"""
Establishes a connection to the inverter.

This method is required to be implemented by all subclasses.
It should return True if the connection was successful, False otherwise.
"""
pass

@abstractmethod
def disconnect_inverter(self) -> bool:
"""
Disconnect from the inverter.

This method is required to be implemented by all subclasses.
It should return True if the disconnection was successful, False otherwise.
"""
pass

# --- Gemeinsame Utility-Methoden ---

def disconnect(self):
"""Session schließt sich selbst."""
logger.info(f"[{self.inverter_type}] Session closed")

def shutdown(self):
"""Standard-Shutdown (kann überschrieben werden)."""
self.disconnect()
Loading
Loading