From 63b3d4c48ec0ff073d32c1df10d8eb44a581d4e2 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 27 Dec 2025 19:28:40 +0000 Subject: [PATCH 1/4] New feature, added support for secrets.yaml --- apps/predbat/config/apps.yaml | 38 +++++++-- apps/predbat/config/secrets.yaml | 53 ++++++++++++ apps/predbat/hass.py | 54 ++++++++++++ apps/predbat/predbat.py | 2 +- apps/predbat/tests/test_secrets.py | 129 +++++++++++++++++++++++++++++ apps/predbat/unit_test.py | 2 + coverage/standalone_ha | 1 + docs/apps-yaml.md | 55 +++++++++++- 8 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 apps/predbat/config/secrets.yaml create mode 100644 apps/predbat/tests/test_secrets.py diff --git a/apps/predbat/config/apps.yaml b/apps/predbat/config/apps.yaml index e95db7f2e..5fb4ac3f8 100644 --- a/apps/predbat/config/apps.yaml +++ b/apps/predbat/config/apps.yaml @@ -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 @@ -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 # @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 diff --git a/apps/predbat/config/secrets.yaml b/apps/predbat/config/secrets.yaml new file mode 100644 index 000000000..dc2796a05 --- /dev/null +++ b/apps/predbat/config/secrets.yaml @@ -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. /config/secrets.yaml (standard Home Assistant location) +# 2. Path specified in PREDBAT_SECRETS_FILE environment variable +# 3. secrets.yaml in the same directory as your apps.yaml +# +# 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" \ No newline at end of file diff --git a/apps/predbat/hass.py b/apps/predbat/hass.py index 953abdbfb..04e40f1ec 100644 --- a/apps/predbat/hass.py +++ b/apps/predbat/hass.py @@ -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: /config/secrets.yaml, PREDBAT_SECRETS_FILE env var, ./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 @@ -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) + # 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) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index b48a3d5fc..895f86155 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -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"] diff --git a/apps/predbat/tests/test_secrets.py b/apps/predbat/tests/test_secrets.py new file mode 100644 index 000000000..72fab81ec --- /dev/null +++ b/apps/predbat/tests/test_secrets.py @@ -0,0 +1,129 @@ +# ----------------------------------------------------------------------------- +# 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 io +import yaml +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") + + 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: + 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" + } + 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") + 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() diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 1ca0d7eb9..961b88f3a 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -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, @@ -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), diff --git a/coverage/standalone_ha b/coverage/standalone_ha index 203a6e631..df03f6c9c 100755 --- a/coverage/standalone_ha +++ b/coverage/standalone_ha @@ -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 diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 8710c4591..1296438e9 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -87,6 +87,49 @@ the Predbat internal Solcast rather than the external integration: ![image](https://github.com/user-attachments/assets/0eda352c-c6fc-459c-abda-5c0de0b2372b) +## Storing secrets + +Predbat supports the Home Assistant [secrets mechanism](https://www.home-assistant.io/docs/configuration/secrets/) for storing sensitive information like API keys, passwords, and tokens. + +### Using secrets.yaml + +Create a `secrets.yaml` file in one of these locations (checked in order, only the first one is read): + +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) + +The `secrets.yaml` file contains key-value pairs of your secrets: + +```yaml +octopus_api_key: "sk_live_abc123xyz..." +solcast_api_key: "def456uvw..." +``` + +### Referencing secrets in apps.yaml + +Use the `!secret` tag followed by the secret key name in your `apps.yaml`: + +```yaml +pred_bat: + module: predbat + class: PredBat + + octopus_api_key: !secret octopus_api_key + solcast_api_key: !secret solcast_api_key +``` + +When Predbat loads, it will automatically replace `!secret octopus_api_key` with the actual value from `secrets.yaml`. + +If a secret is referenced in `apps.yaml` but not found in `secrets.yaml`, Predbat will log a warning and the configuration item will be set to `None`. + +### Benefits of using secrets + +- Keeps sensitive information separate from configuration files +- Makes it safer to share your `apps.yaml` for troubleshooting +- All secrets stored in one centralized location +- Compatible with Home Assistant's secrets system + ## Basics Basic configuration items @@ -153,6 +196,8 @@ In future versions of Predbat, AppDaemon will be removed. ha_key: 'xxxxxxxxxxx' ``` +**NOTE:** It's recommended to store `ha_key` in `secrets.yaml` and reference it as `ha_key: !secret ha_key` - see [Storing secrets](#storing-secrets). + *TIP:* You can replace *homeassistant.local* with the IP address of your Home Assistant server if you have it set to a fixed IP address. This will remove the need for a DNS lookup of the IP address every time Predbat talks to Home Assistant and may improve reliability as a result. @@ -280,6 +325,8 @@ you will need to wait until you have a few days of history established (at least ge_cloud_data: True ``` +**NOTE:** It's recommended to store `ge_cloud_key` in `secrets.yaml` and reference it as `ge_cloud_key: !secret givenergy_api_key` - see [Storing secrets](#storing-secrets). + ### num_inverters The number of inverters you have. If you increase this above 1 you must provide multiple of each of the inverter entities @@ -775,6 +822,8 @@ Uncomment the following Solcast cloud interface settings in `apps.yaml` and set solcast_poll_hours: 8 ``` +**NOTE:** It's recommended to store `solcast_api_key` in `secrets.yaml` and reference it as `solcast_api_key: !secret solcast_api_key` - see [Storing secrets](#storing-secrets). + Note that by default the Solcast API will be used to download all sites (up to 2 for hobby accounts), if you want to override this set your sites manually using **solcast_sites** as an array of site IDs: @@ -888,9 +937,9 @@ These are described in detail in [Energy Rates](energy-rates.md) and are listed - **futurerate_adjust_import** and **futurerate_adjust_export** - Whether tomorrow's predicted import or export prices should be adjusted based on market prices or not - **futurerate_peak_start** and **futurerate_peak_end** - start/end times for peak-rate adjustment - **carbon_intensity** - Carbon intensity of the grid in half-hour slots from an integration. -- **octopus_api_key** - Sets API key to communicate directly with octopus +- **octopus_api_key** - Sets API key to communicate directly with octopus. *Recommended: store in `secrets.yaml` and use `!secret octopus_api_key`* - **octopus_account** - Sets Octopus account number -- **axle_api_key** - API key to communicate with Axle Energy VPP (Virtual Power Plant) service +- **axle_api_key** - API key to communicate with Axle Energy VPP (Virtual Power Plant) service. *Recommended: store in `secrets.yaml` and use `!secret axle_api_key`* - **axle_pence_per_kwh** - Payment rate in pence per kWh for Axle Energy VPP events (default: 100) - **axle_automatic** - Optional, whether to use the default entity name **binary_sensor.predbat_axle_event** for axle event details (default True, use the default entity name) - **axle_session** - Optional, enables manual override of the Axle event entity name @@ -1069,6 +1118,8 @@ configured to take Octopus Intelligent car charging slots from Ohme (rather than ohme_automatic_octopus_intelligent: true ``` +**NOTE:** It's recommended to store `ohme_password` in `secrets.yaml` and reference it as `ohme_password: !secret ohme_password` - see [Storing secrets](#storing-secrets). + ## Watch List - automatically start Predbat execution By default Predbat will run automatically every 5 minute and to execute the plan, and re-evaluate the plan automatically every 10 minutes. From 963f8a0c71188f6f9535c4dd093e6e85c53df5c0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:31:56 +0000 Subject: [PATCH 2/4] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/config/secrets.yaml | 2 +- apps/predbat/tests/test_secrets.py | 56 +++++++++++------------------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/apps/predbat/config/secrets.yaml b/apps/predbat/config/secrets.yaml index dc2796a05..a2f88410c 100644 --- a/apps/predbat/config/secrets.yaml +++ b/apps/predbat/config/secrets.yaml @@ -50,4 +50,4 @@ # 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" \ No newline at end of file +#ha_key: "YOUR_HOME_ASSISTANT_LONG_LIVED_ACCESS_TOKEN" diff --git a/apps/predbat/tests/test_secrets.py b/apps/predbat/tests/test_secrets.py index 72fab81ec..d0e86c4f5 100644 --- a/apps/predbat/tests/test_secrets.py +++ b/apps/predbat/tests/test_secrets.py @@ -9,7 +9,6 @@ # pylint: disable=attribute-defined-outside-init import os -import io import yaml import tempfile from hass import Hass @@ -20,64 +19,51 @@ 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") - + 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" - } + 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: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: 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" - } + secrets_data = {"test_api_key": "secret_value_789", "test_username": "secret_user"} 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" - } - } - + 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") @@ -85,31 +71,29 @@ def test_secrets_loading(): f.write(" class: PredBat\n") f.write(" api_key: !secret test_api_key\n") 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" - } + 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')}" @@ -117,7 +101,7 @@ def test_secrets_loading(): 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 From 5e39ff3da288f4ac96142e4cb43d9c7e281c055f Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:36:17 +0000 Subject: [PATCH 3/4] Update apps/predbat/config/secrets.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/predbat/config/secrets.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/predbat/config/secrets.yaml b/apps/predbat/config/secrets.yaml index a2f88410c..5e774c21b 100644 --- a/apps/predbat/config/secrets.yaml +++ b/apps/predbat/config/secrets.yaml @@ -8,9 +8,9 @@ # that should not be stored directly in apps.yaml. # # Store this file in one of these locations (checked in priority order): -# 1. /config/secrets.yaml (standard Home Assistant location) -# 2. Path specified in PREDBAT_SECRETS_FILE environment variable -# 3. secrets.yaml in the same directory as your apps.yaml +# 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 From 6ec4ef527ab9662904fb4a3d1f1ce5c25267d98d Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:37:03 +0000 Subject: [PATCH 4/4] Update apps/predbat/hass.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/predbat/hass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/hass.py b/apps/predbat/hass.py index 04e40f1ec..9e4b85119 100644 --- a/apps/predbat/hass.py +++ b/apps/predbat/hass.py @@ -152,7 +152,7 @@ async def stop_all(self): def load_secrets(self): """ Load secrets from secrets.yaml file - Priority: /config/secrets.yaml, PREDBAT_SECRETS_FILE env var, ./secrets.yaml + Priority: PREDBAT_SECRETS_FILE env var, ./secrets.yaml, /config/secrets.yaml """ secrets = {} secrets_file = None