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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"ignorePaths": [
"**/*.json",
"**/*.yaml",
".gitignore",
],
"import": [
"@cspell/dict-en-gb/cspell-ext.json"
Expand Down
86 changes: 86 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,88 @@
batpred.code-workspace
.vscode/settings.json

# System files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

# Python cache files
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Test and coverage files
coverage/
.coverage
.pytest_cache/
.tox/
.nox/
.coverage
htmlcov/
.mypy_cache/
.dmypy.json
dmypy.json

# Generated test files
test_*.html
test_*.py
*_test.html
*_test.py

# Debug and log files
*.log
*.txt.actual.json
*.yaml.actual.json
*.bin
profile.txt
simulation_state.bin

# Generated HTML files
plan_*.html
*.html_10.html

# Generated PNG files
*.png

# Build artifacts
a.out
prediction_server
standalone_ha

# Temporary and backup files
*.tmp
*.bak
*.swp
*.swo
*~
*.mark

# Development artifacts
apps.mod.yaml
apps.yaml.mark
comparisons.yaml
it
deploy
;
183 changes: 183 additions & 0 deletions apps/predbat/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2024 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init


from gecloud import GECloudDirect
from ohme import OhmeAPI
from octopus import OctopusAPI
from web import WebInterface
from ha import HAInterface
from db_manager import DatabaseManager
import os

COMPONENT_LIST = {
"db": {
"class": DatabaseManager,
"name": "Database Manager",
"args": {
"db_enable": {"required": True, "config": "db_enable"},
"db_days": {"required": False, "config": "db_days", "default": 30},
},
},
"ha": {
"class": HAInterface,
"name": "Home Assistant Interface",
"args": {
"ha_url": {"required": False, "config": "ha_url", "default": "http://supervisor/core"},
"ha_key": {"required": False, "config": "ha_key", "default": os.environ.get("SUPERVISOR_TOKEN", None)},
"db_enable": {"required": False, "config": "db_enable", "default": False},
"db_mirror_ha": {"required": False, "config": "db_mirror_ha", "default": False},
"db_primary": {"required": False, "config": "db_primary", "default": False},
},
},
"web": {"class": WebInterface, "name": "Web Interface", "args": {"port": {"required": False, "config": "web_port", "default": 5052}}},
"gecloud": {
"class": GECloudDirect,
"name": "GivEnergy Cloud Direct",
"event_filter": "predbat_gecloud_",
"args": {
"api_key": {
"required": True,
"config": "ge_cloud_key",
},
"automatic": {
"required": False,
"default": False,
"config": "ge_cloud_automatic",
},
},
},
"octopus": {
"class": OctopusAPI,
"name": "Octopus Energy Direct",
"event_filter": "predbat_octopus_",
"args": {
"key": {
"required": True,
"config": "octopus_api_key",
},
"account_id": {
"required": True,
"config": "octopus_api_account",
},
},
},
"ohme": {
"class": OhmeAPI,
"name": "Ohme Charger",
"event_filter": "predbat_ohme_",
"args": {
"email": {
"required": True,
"config": "ohme_login",
},
"password": {
"required": True,
"config": "ohme_password",
},
"ohme_automatic_octopus_intelligent": {
"required": False,
"config": "ohme_automatic_octopus_intelligent",
},
},
},
}


class Components:
def __init__(self, base):
self.components = {}
self.component_tasks = {}
self.base = base
self.log = base.log

def start(self):
for component_name, component_info in COMPONENT_LIST.items():
have_all_args = True
self.components[component_name] = None
self.component_tasks[component_name] = None

# Check required arguments
arg_dict = {}
for arg, arg_info in component_info["args"].items():
required = arg_info.get("required", False)
default = arg_info.get("default", None)
if required and self.base.get_arg(arg_info["config"], None, indirect=False) is None:
have_all_args = False
else:
arg_dict[arg] = self.base.get_arg(arg_info["config"], default, indirect=False)
if have_all_args:
self.log(f"Starting {component_info['name']} interface")
self.components[component_name] = component_info["class"](*arg_dict.values(), self.base)
self.component_tasks[component_name] = self.base.create_task(self.components[component_name].start())
if not self.components[component_name].wait_api_started():
self.log(f"Error: {component_info['name']} API failed to start")
self.record_status(f"Error: {component_info['name']} API failed to start")
raise ValueError(f"{component_info['name']} API failed to start")

async def stop(self):
for component_name, component_info in reversed(list(self.components.items())):
component = self.components[component_name]
if component:
self.log(f"Stopping {component_name} interface")
print(component_name, component)
await component.stop()
self.component_tasks[component_name] = None
self.components[component_name] = None

"""
Pass through events to the appropriate component
"""

async def select_event(self, entity_id, value):
for component_name, component in self.components.items():
event_filter = COMPONENT_LIST[component_name].get("event_filter", None)
if component and event_filter and (event_filter in entity_id):
await component.select_event(entity_id, value)

async def switch_event(self, entity_id, service):
for component_name, component in self.components.items():
event_filter = COMPONENT_LIST[component_name].get("event_filter", None)
if component and event_filter and (event_filter in entity_id):
await component.switch_event(entity_id, service)

async def number_event(self, entity_id, value):
for component_name, component in self.components.items():
event_filter = COMPONENT_LIST[component_name].get("event_filter", None)
if component and event_filter and (event_filter in entity_id):
await component.number_event(entity_id, value)

def is_all_alive(self):
"""Check if a component is alive, or check if all are alive"""
return all(self.is_alive(name) for name in self.components.keys())

def is_alive(self, name):
"""Check if a single component is alive"""
if name not in self.components:
return False
if not self.components[name]:
# Disabled components can be ignored
return True
if not self.component_tasks[name] or not self.component_tasks[name].is_alive():
return False
if not self.components[name].is_alive():
return False
return True

def get_active(self):
active_components = [name for name, comp in self.components.items() if comp]
return active_components

def get_component(self, name):
return self.components.get(name, None)

def get_all(self):
all_components = [name for name in self.components.keys()]
return all_components
29 changes: 25 additions & 4 deletions apps/predbat/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


class DatabaseManager:
def __init__(self, base, db_days):
def __init__(self, db_enable, db_days, base):
self.base = base
self.log = base.log
self.db_days = db_days
Expand All @@ -27,6 +27,7 @@ def __init__(self, base, db_days):
self.sync_event = threading.Event()
self.async_event = asyncio.Event()
self.return_event = threading.Event()
self.api_started = False

def bridge_event(self, loop):
"""
Expand All @@ -39,6 +40,24 @@ def bridge_event(self, loop):
self.sync_event.clear() # Clear the event to allow the loop to continue
loop.call_soon_threadsafe(self.async_event.set)

def is_alive(self):
"""Check if the database manager is alive"""
return self.api_started

def wait_api_started(self):
"""
Wait for the API to start
"""
self.log("DBManager: Waiting for API to start")
count = 0
while not self.api_started and count < 240:
time.sleep(1)
count += 1
if not self.api_started:
self.log("Warn: DBManager: Failed to start")
return False
return True

async def start(self):
"""
Initialize the database and clean up old data
Expand All @@ -52,6 +71,7 @@ async def start(self):
threading.Thread(target=self.bridge_event, args=(loop,), daemon=True).start()

self.log("db_manager: Started")
self.api_started = True

while not self.stop_thread:
if not self.db_queue:
Expand Down Expand Up @@ -87,6 +107,7 @@ async def start(self):

self.db_engine._close()
self.log("db_manager: Stopped")
self.api_started = False

def send_via_ipc(self, command, info, expect_response=False):
"""
Expand Down Expand Up @@ -114,14 +135,14 @@ def send_via_ipc(self, command, info, expect_response=False):
return None
return None

def stop(self):
async def stop(self):
"""
Close the database connection
"""
self.stop_thread = True
queue_id = self.queue_id
self.queue_id += 1
self.send_via_ipc("stop", {}, expect_response=False)
self.api_started = False
self.log("db_manager: stop command sent")

def get_state_db(self, entity_id):
"""
Expand Down
Loading