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
38 changes: 33 additions & 5 deletions apps/predbat/config/apps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pred_bat:

# If you are using Predbat outside of HA then set the HA URL and Key (long lived access token here)
#ha_url: 'http://homeassistant.local:8123'
#ha_key: 'xxx'
#ha_key: !secret ha_key

# Set to auto-match with a GivEnergy serial number, but you can override the serial or the sensor names
# if it doesn't work or if you have more than one inverter you will need to list both
Expand Down Expand Up @@ -67,7 +67,12 @@ pred_bat:
#
#ge_cloud_data: False
#ge_cloud_serial: '{geserial}'
#ge_cloud_key: 'xxxx'
#ge_cloud_key: !secret ge_cloud_key

# Fox direct cloud interface
#fox_key: !secret fox_key
#fox_automatic: True

#
# Controls/status - must by 1 per inverter
#
Expand Down Expand Up @@ -265,9 +270,17 @@ pred_bat:

# Solcast cloud interface, set this or the local interface below
#solcast_host: 'https://api.solcast.com.au/'
#solcast_api_key: 'xxxx'
#solcast_api_key: !secret solcast_api_key
#solcast_poll_hours: 8

# Forecast Solar interface, set this or the local interface below
#forecast_solar:
#- postcode: 'SW1A 1AA'
# kwp: 4.0
# azimuth: 180
# declination: 10
# api_key: !secret forecast_solar_api_key

# Set these to match solcast sensor names if not using the cloud interface
# The regular expression (re:) makes the solcast bit optional
# If these don't match find your own names in Home Assistant
Expand All @@ -276,6 +289,11 @@ pred_bat:
pv_forecast_d3: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_3|d3))
pv_forecast_d4: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_4|d4))

# Ohme EV charger cloud direct integration
#ohme_login: !secret ohme_login
#ohme_password: !secret ohme_password
#ohme_automatic_octopus_intelligent: True

# car_charging_energy defines an incrementing sensor which measures the charge added to your car
# is used for car_charging_hold feature to filter out car charging from the previous load data
# Automatically set to detect Wallbox and Zappi, if it doesn't match manually enter your sensor name
Expand Down Expand Up @@ -338,6 +356,12 @@ pred_bat:
#car_charging_exclusive:
# - True

# Octopus Energy direct integration
#octopus_api_key: !secret octopus_api_key
#octopus_api_account: !secret octopus_api_account
#octopus_automatic: True

# Or if using bottle cap dave Home Assistant Octopus Energy plugin set the entities here
# If you have Octopus intelligent, enable the intelligent slot information to add to pricing
# Will automatically disable if not found, or comment out to disable fully
# When enabled it overrides the 'car_charging_planned' feature and predict the car charging based on the intelligent plan (unless octopus intelligent charging is False)
Expand All @@ -354,7 +378,11 @@ pred_bat:
# Set this to False if you use Octopus Intelligent slot for car planning but when on another tariff e.g. Agile
#octopus_slot_low_rate: False

# Carbon Intensity data from National grid
# Carbon Intensity data direct from National grid
#carbon_postcode: 'SW1A 1AA'
#carbon_automatic: true

# Or using Home Assistant sensor
carbon_intensity: 're:(sensor.carbon_intensity_uk)'

# Octopus saving session points to the saving session Sensor in the Octopus plugin, when enabled saving sessions will be at the assumed
Expand All @@ -370,7 +398,7 @@ pred_bat:
# octopus_free_url: 'http://octopus.energy/free-electricity'

# Enter your Axle VPP API key if you have signed up to the Axle service in the UK
# axle_api_key: "xxxxxxx"
# axle_api_key: !secret axle_api_key

# Energy rates
# Please set one of these three, if multiple are set then Octopus is used first, second rates_import/rates_export and latest basic metric
Expand Down
53 changes: 53 additions & 0 deletions apps/predbat/config/secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# -----------------------------------------------------------------------------
# Predbat secrets.yaml template
# Copyright Trefor Southwell 2025 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
#
# This file stores sensitive information like API keys, passwords, and tokens
# that should not be stored directly in apps.yaml.
#
# Store this file in one of these locations (checked in priority order):
# 1. Path specified in PREDBAT_SECRETS_FILE environment variable
# 2. secrets.yaml in the same directory as your apps.yaml
# 3. /config/secrets.yaml (standard Home Assistant location)
#
# To use secrets in apps.yaml, reference them with the !secret tag:
# octopus_api_key: !secret octopus_api_key
#
# This will replace !secret octopus_api_key with the actual value below.
#
# DO NOT commit this file to version control or share it publicly!
#
# -----------------------------------------------------------------------------

# Enable debug logging to show where secrets are loaded from
# (does NOT print actual secret values)
# logger: debug

# Octopus Energy API key
# Get your API key from: https://octopus.energy/dashboard/new/accounts/personal-details/api-access
#octopus_api_key: "sk_live_YOUR_API_KEY_HERE"

# Solcast API key
# Get your API key from: https://toolkit.solcast.com.au/
#solcast_api_key: "YOUR_SOLCAST_API_KEY_HERE"

# Get your Forecast.Solar API key from: https://forecast.solar/
#forecast_solar_api_key: "YOUR_FORECAST_SOLAR_API_KEY_HERE"

# GivEnergy API key (if using GE Cloud)
# Get your API key from: https://givenergy.cloud/
#ge_cloud_key: "YOUR_GIVENERGY_API_KEY_HERE"

# Fox ESS API key and username (if using Fox Cloud)
# Get your API key from: https://fox-ess.com/en/fox-cloud/
#fox_key: "YOUR_FOX_API_KEY_HERE"

# Axle API key (if using Axle VPP)
# Get your API key from: https://axle.energy/
#axle_api_key: "YOUR_AXLE_API_KEY_HERE"

# Home Assistant Long-Lived Access Token
# Create a long-lived access token in Home Assistant under your user profile
#ha_key: "YOUR_HOME_ASSISTANT_LONG_LIVED_ACCESS_TOKEN"
54 changes: 54 additions & 0 deletions apps/predbat/hass.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,54 @@ async def stop_all(self):
t.join(5 * 60)
self.logfile.close()

def load_secrets(self):
"""
Load secrets from secrets.yaml file
Priority: PREDBAT_SECRETS_FILE env var, ./secrets.yaml, /config/secrets.yaml
"""
secrets = {}
secrets_file = None

# Try loading from different locations in priority order
possible_locations = [
os.getenv("PREDBAT_SECRETS_FILE"),
"secrets.yaml",
"/config/secrets.yaml",
]

for location in possible_locations:
if location and os.path.exists(location):
secrets_file = location
break

if secrets_file:
self.log(f"Loading secrets from {secrets_file}", quiet=False)
try:
with io.open(secrets_file, "r") as stream:
secrets = yaml.safe_load(stream) or {}
# Check for debug logging option
if secrets.get("logger") == "debug":
self.log(f"Info: Secrets loaded from {secrets_file}", quiet=False)
except yaml.YAMLError as exc:
self.log(f"Error: Failed to load secrets from {secrets_file}: {exc}", quiet=False)
except Exception as exc:
self.log(f"Error: Failed to open secrets file {secrets_file}: {exc}", quiet=False)
else:
self.log("Info: No secrets.yaml file found", quiet=False)

return secrets

def secret_constructor(self, loader, node):
"""
YAML constructor for !secret tag
"""
secret_key = loader.construct_scalar(node)
if secret_key in self.secrets:
return self.secrets[secret_key]
else:
self.log(f"Warn: Secret '{secret_key}' not found in secrets.yaml")
return None

def __init__(self):
"""
Start Predbat
Expand All @@ -161,6 +209,12 @@ def __init__(self):

self.logfile = open("predbat.log", "a")

# Load secrets first
self.secrets = self.load_secrets()

# Register custom YAML constructor for !secret tag
yaml.add_constructor("!secret", self.secret_constructor, Loader=yaml.SafeLoader)
Comment on lines +215 to +216
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The yaml.add_constructor is being called globally for yaml.SafeLoader in the init method. This modifies a global YAML loader state and could cause issues if multiple Hass instances are created (as happens in the test suite). Each new instance will re-register the constructor, potentially causing side effects.

While this may work in practice since the constructor does the same thing, it's better practice to either:

  1. Only add the constructor once using a class-level flag to check if it's already registered
  2. Use a custom loader subclass instead of modifying the global SafeLoader

This is particularly important given that test_secrets.py creates 5 separate Hass() instances, each re-registering the global constructor.

Suggested change
# Register custom YAML constructor for !secret tag
yaml.add_constructor("!secret", self.secret_constructor, Loader=yaml.SafeLoader)
# Register custom YAML constructor for !secret tag only once per class
if not getattr(self.__class__, "_secret_constructor_registered", False):
yaml.add_constructor("!secret", self.secret_constructor, Loader=yaml.SafeLoader)
self.__class__._secret_constructor_registered = True

Copilot uses AI. Check for mistakes.

# Open YAML file apps.yaml and read it
apps_file = os.getenv("PREDBAT_APPS_FILE", "apps.yaml")
self.log(f"Loading {apps_file}", quiet=False)
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import requests
import asyncio

THIS_VERSION = "v8.30.9"
THIS_VERSION = "v8.31.0"

# fmt: off
PREDBAT_FILES = ["predbat.py", "hass.py", "config.py", "prediction.py", "gecloud.py", "utils.py", "inverter.py", "ha.py", "download.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py", "execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py", "carbon.py", "web_mcp.py", "component_base.py", "axle.py", "unit_test.py"]
Expand Down
113 changes: 113 additions & 0 deletions apps/predbat/tests/test_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2025 - 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

import os
import yaml
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'io' module is imported but never used in this file. All file operations use the built-in 'open()' function directly. This unused import should be removed.

Copilot uses AI. Check for mistakes.
import tempfile
from hass import Hass


def test_secrets_loading():
"""
Test secrets loading mechanism
"""
print("**** Running test_secrets_loading ****")

# Test 1: No secrets file - should work without error
print(" Test 1: No secrets file")
if os.path.exists("secrets.yaml"):
os.remove("secrets.yaml")
if os.path.exists("/config/secrets.yaml"):
os.remove("/config/secrets.yaml")
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 1 attempts to remove /config/secrets.yaml but doesn't verify the test has write permissions to that directory. If the directory doesn't exist or is not writable, the os.remove call could raise an exception. The test should either:

  1. Check if the directory exists and is writable before attempting removal
  2. Wrap the removal in a try/except block
  3. Or use a more controlled test environment that doesn't rely on system paths

This is particularly important since /config/ is a system-level path that may have restricted permissions depending on where tests are run.

Suggested change
os.remove("/config/secrets.yaml")
try:
os.remove("/config/secrets.yaml")
except OSError:
# Ignore errors if /config is not writable or file cannot be removed
pass

Copilot uses AI. Check for mistakes.

h = Hass()
assert h.secrets == {}, "Expected empty secrets dict"
print(" PASS - No secrets file handled correctly")

# Test 2: Secrets file in current directory
print(" Test 2: Secrets file in current directory")
secrets_data = {"api_key": "test_api_key_123", "password": "test_password_456"}
with open("secrets.yaml", "w") as f:
yaml.dump(secrets_data, f)

h = Hass()
assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
os.remove("secrets.yaml")
print(" PASS - Secrets loaded from current directory")

# Test 3: Secrets file from PREDBAT_SECRETS_FILE env var
print(" Test 3: Secrets file from PREDBAT_SECRETS_FILE")
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
Comment on lines +41 to +47
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 2 doesn't clean up the secrets.yaml file if the assertion fails. If the assertion on line 45 fails, line 46 (os.remove) won't execute, leaving secrets.yaml in place for subsequent tests.

Consider using a try/finally block:

with open("secrets.yaml", "w") as f:
    yaml.dump(secrets_data, f)

try:
    h = Hass()
    assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
    print("    PASS - Secrets loaded from current directory")
finally:
    if os.path.exists("secrets.yaml"):
        os.remove("secrets.yaml")

Copilot uses AI. Check for mistakes.
temp_secrets_file = f.name
yaml.dump(secrets_data, f)

os.environ["PREDBAT_SECRETS_FILE"] = temp_secrets_file
h = Hass()
assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
del os.environ["PREDBAT_SECRETS_FILE"]
os.remove(temp_secrets_file)
print(" PASS - Secrets loaded from PREDBAT_SECRETS_FILE")

# Test 4: Test !secret tag in apps.yaml
print(" Test 4: Test !secret tag resolution")
secrets_data = {"test_api_key": "secret_value_789", "test_username": "secret_user"}
Comment on lines +55 to +60
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 3 doesn't clean up the environment variable and temp file if the assertion fails. If the assertion on line 57 fails, lines 58-59 won't execute, potentially affecting subsequent tests or leaving temporary files on the system.

Consider using a try/finally block:

os.environ["PREDBAT_SECRETS_FILE"] = temp_secrets_file
try:
    h = Hass()
    assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
    print("    PASS - Secrets loaded from PREDBAT_SECRETS_FILE")
finally:
    if "PREDBAT_SECRETS_FILE" in os.environ:
        del os.environ["PREDBAT_SECRETS_FILE"]
    if os.path.exists(temp_secrets_file):
        os.remove(temp_secrets_file)

Copilot uses AI. Check for mistakes.
with open("secrets.yaml", "w") as f:
yaml.dump(secrets_data, f)

# Create a test apps.yaml with !secret tags
test_config = {"pred_bat": {"module": "predbat", "class": "PredBat", "api_key": "!secret test_api_key", "username": "!secret test_username"}}

# Write YAML with !secret tags (manually to preserve the tag)
with open("test_apps.yaml", "w") as f:
f.write("pred_bat:\n")
f.write(" module: predbat\n")
f.write(" class: PredBat\n")
f.write(" api_key: !secret test_api_key\n")
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable test_config is not used.

Copilot uses AI. Check for mistakes.
f.write(" username: !secret test_username\n")

os.environ["PREDBAT_APPS_FILE"] = "test_apps.yaml"
h = Hass()
assert h.args.get("api_key") == "secret_value_789", f"Expected 'secret_value_789', got {h.args.get('api_key')}"
assert h.args.get("username") == "secret_user", f"Expected 'secret_user', got {h.args.get('username')}"

del os.environ["PREDBAT_APPS_FILE"]
os.remove("test_apps.yaml")
os.remove("secrets.yaml")
print(" PASS - !secret tags resolved correctly")

# Test 5: Missing secret key should return None and warn
print(" Test 5: Missing secret key handling")
secrets_data = {"existing_key": "value"}
with open("secrets.yaml", "w") as f:
yaml.dump(secrets_data, f)

with open("test_apps.yaml", "w") as f:
f.write("pred_bat:\n")
f.write(" module: predbat\n")
f.write(" class: PredBat\n")
f.write(" missing_key: !secret non_existent_key\n")

os.environ["PREDBAT_APPS_FILE"] = "test_apps.yaml"
h = Hass()
assert h.args.get("missing_key") is None, f"Expected None for missing secret, got {h.args.get('missing_key')}"
print(" PASS - Missing secret key returns None and warns correctly")
del os.environ["PREDBAT_APPS_FILE"]
os.remove("test_apps.yaml")
os.remove("secrets.yaml")

print("**** test_secrets_loading PASSED ****")
return False # False = success in Predbat test framework


def run_secrets_tests(my_predbat=None):
"""
Run all secrets tests
"""
return test_secrets_loading()
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from tests.test_alert_feed import test_alert_feed
from tests.test_single_debug import run_single_debug
from tests.test_saving_session import test_saving_session, test_saving_session_null_octopoints
from tests.test_secrets import run_secrets_tests
from tests.test_ge_cloud import (
run_test_ge_cloud,
test_async_get_inverter_data_success,
Expand Down Expand Up @@ -359,6 +360,7 @@ def main():
# Test registry - table of all available tests
# Format: (name, function, description, slow)
TEST_REGISTRY = [
("secrets", run_secrets_tests, "Secrets loading tests", False),
("perf", run_perf_test, "Performance tests", False),
("model", run_model_tests, "Model tests", False),
("inverter", run_inverter_tests, "Inverter tests", False),
Expand Down
1 change: 1 addition & 0 deletions coverage/standalone_ha
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ cd predbat_standalone_ha
ln -s ../../apps/predbat/*.py .
cd ..
cp /Volumes/addon_configs/6adb4f0d_predbat/apps.yaml predbat_standalone_ha/apps.yaml
cp /Volumes/addon_configs/6adb4f0d_predbat/secrets.yaml predbat_standalone_ha/secrets.yaml
cd predbat_standalone_ha
python3 hass.py
Loading