From fba3b4cd11f1ffc2133b72a5aa56c837dbda2630 Mon Sep 17 00:00:00 2001 From: Willem Stuursma-Ruwen Date: Mon, 19 Jan 2026 08:45:52 +0100 Subject: [PATCH 1/6] Fix missing translation key --- custom_components/target_timeframes/translations/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/target_timeframes/translations/en.json b/custom_components/target_timeframes/translations/en.json index 2567376..9bd1e82 100644 --- a/custom_components/target_timeframes/translations/en.json +++ b/custom_components/target_timeframes/translations/en.json @@ -120,6 +120,7 @@ }, "error": { "value_greater_than_zero": "Value must be greater or equal to 1", + "invalid_data_source_data": "Invalid source data provided: {error}", "invalid_target_hours": "Hours must be in half hour increments (e.g. 0.5 = 30 minutes; 1 = 60 minutes).", "invalid_target_name": "Name must only include lower case alpha characters and underscore (e.g. my_target)", "invalid_target_time": "Must be in the format HH:MM", From 42521334dbaeaa042603b08fd5a6bbd3e8a9c42c Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sat, 28 Feb 2026 21:15:54 +0000 Subject: [PATCH 2/6] chore: Fixed hacs validation issue around urls --- .../target_timeframes/config_flow.py | 24 ++++++++++++++----- .../target_timeframes/translations/en.json | 12 +++++----- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/custom_components/target_timeframes/config_flow.py b/custom_components/target_timeframes/config_flow.py index 2a94a83..77bd074 100644 --- a/custom_components/target_timeframes/config_flow.py +++ b/custom_components/target_timeframes/config_flow.py @@ -22,6 +22,12 @@ from .config.data_source import validate_source_config +description_placeholders = { + "setup_data_source_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/data_source", + "setup_target_timeframe_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/target_timeframe", + "setup_rolling_target_timeframe_docs_url": "https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/rolling_target_timeframe", +} + class TargetTimeframesConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow.""" @@ -48,7 +54,8 @@ async def async_step_user(self, user_input): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA_SOURCE, - errors=errors + errors=errors, + description_placeholders=description_placeholders ) async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): @@ -76,7 +83,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) DATA_SCHEMA_SOURCE, config ), - errors=errors + errors=errors, + description_placeholders=description_placeholders ) @classmethod @@ -112,7 +120,8 @@ async def async_step_user( DATA_SCHEMA_TARGET_TIME_PERIOD, user_input if user_input is not None else {} ), - errors=errors + errors=errors, + description_placeholders=description_placeholders ) async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): @@ -132,7 +141,8 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) DATA_SCHEMA_TARGET_TIME_PERIOD, config ), - errors=errors + errors=errors, + description_placeholders=description_placeholders ) class RollingTargetTimePeriodSubentryFlowHandler(ConfigSubentryFlow): @@ -157,7 +167,8 @@ async def async_step_user( DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD, user_input if user_input is not None else {} ), - errors=errors + errors=errors, + description_placeholders=description_placeholders ) async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): @@ -177,5 +188,6 @@ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD, config ), - errors=errors + errors=errors, + description_placeholders=description_placeholders ) \ No newline at end of file diff --git a/custom_components/target_timeframes/translations/en.json b/custom_components/target_timeframes/translations/en.json index 9bd1e82..7fd484b 100644 --- a/custom_components/target_timeframes/translations/en.json +++ b/custom_components/target_timeframes/translations/en.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Add Data Source", - "description": "Setup a data source for your target timeframes. Full documentation can be found at https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/data_source", + "description": "Setup a data source for your target timeframes. Full documentation can be found at {setup_data_source_docs_url}", "data": { "source_name": "The name of the source", "source_id": "The id of the source" @@ -15,7 +15,7 @@ }, "reconfigure": { "title": "Reconfigure Data Source", - "description": "Setup a data source for your target timeframes. Full documentation can be found at https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/data_source", + "description": "Setup a data source for your target timeframes. Full documentation can be found at {setup_data_source_docs_url}", "data": { "source_name": "The name of the source", "source_id": "The id of the source" @@ -38,7 +38,7 @@ "step": { "user": { "title": "Add Target Timeframe", - "description": "Setup a target time. Full documentation can be found at https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/target_timeframe", + "description": "Setup a target time. Full documentation can be found at {setup_target_timeframe_docs_url}", "data": { "name": "The name of your target", "hours": "The hours you require in decimal format.", @@ -77,7 +77,7 @@ }, "reconfigure": { "title": "Reconfigure Target Timeframe", - "description": "Setup a target time. Full documentation can be found at https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/target_timeframe", + "description": "Setup a target time. Full documentation can be found at {setup_target_timeframe_docs_url}", "data": { "name": "The name of your target", "hours": "The hours you require in decimal format.", @@ -146,7 +146,7 @@ "step": { "user": { "title": "Rolling Target Timeframe", - "description": "Setup a rolling target time. Full documentation can be found at https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/rolling_target_timeframe", + "description": "Setup a rolling target time. Full documentation can be found at {setup_rolling_target_timeframe_docs_url}", "data": { "name": "The name of your target", "hours": "The hours you require in decimal format.", @@ -172,7 +172,7 @@ }, "reconfigure": { "title": "Reconfigure Rolling Target Timeframe", - "description": "Setup a rolling target time. Full documentation can be found at https://bottlecapdave.github.io/HomeAssistant-TargetTimeframes/setup/rolling_target_timeframe", + "description": "Setup a rolling target time. Full documentation can be found at {setup_rolling_target_timeframe_docs_url}", "data": { "name": "The name of your target", "hours": "The hours you require in decimal format.", From a421c9b4d7f1d075443525059a24e4c1d8ef5f47 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sat, 28 Feb 2026 21:16:11 +0000 Subject: [PATCH 3/6] docs: Added other integrations section to docs --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index fe8f5cc..086b8ee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ nav: - blueprints.md - faq.md - sponsorship.md + - Other Integrations: https://bottlecapdave.github.io/HomeAssistant-Integrations # extra: # version: From 18c891a408dfb3acb738256f4b3a0a4a7ff738ea Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sun, 1 Mar 2026 08:48:14 +0000 Subject: [PATCH 4/6] chore: Added missing translation --- custom_components/target_timeframes/translations/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/target_timeframes/translations/en.json b/custom_components/target_timeframes/translations/en.json index 7fd484b..6ca7b37 100644 --- a/custom_components/target_timeframes/translations/en.json +++ b/custom_components/target_timeframes/translations/en.json @@ -31,6 +31,7 @@ }, "config_subentries": { "target_time_period": { + "entry_type": "Target Timeframe", "initiate_flow": { "user": "Add Target Timeframe", "reconfigure": "Reconfigure Target Timeframe" @@ -139,6 +140,7 @@ } }, "rolling_target_time_period": { + "entry_type": "Rolling Target Timeframe", "initiate_flow": { "user": "Add Rolling Target Timeframe", "reconfigure": "Reconfigure Rolling Target Timeframe" From f039ed76ab8f745a3b5f55ac3d5e07b2b892ba95 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Wed, 4 Mar 2026 13:41:06 +0000 Subject: [PATCH 5/6] feat: Added the ability to update data source data updates via raised events (2 hours dev time) --- _docs/events.md | 22 +- _docs/setup/data_source.md | 8 +- custom_components/target_timeframes/const.py | 3 +- .../target_timeframes/entities/data_source.py | 11 +- .../utils/data_source_data.py | 21 +- .../utils/test_validate_data_source_data.py | 523 ++++++++++++++++++ 6 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 tests/unit/utils/test_validate_data_source_data.py diff --git a/_docs/events.md b/_docs/events.md index 27a48d1..f9bbd6f 100644 --- a/_docs/events.md +++ b/_docs/events.md @@ -1,3 +1,23 @@ # Events -The following events are raised by the integration. These events power various entities and can also be used within automations. \ No newline at end of file +The following events are either raised or received by the integration. These events power various entities and can also be used within automations. + +## Update Data Source + +`target_timeframe_update_data_source` + +Data sources listen for this event and then update the data based on the provided data if the `data_source_id` matches the data source's id. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `data_source_id` | `string` | The id of the data source the data belongs to | +| `data` | `array` | The data to update the data source with | + +For each item in `data`, the following attributes should be present + +| Attribute | Type | Description | +|-----------|------|-------------| +| `start` | `datetime` | The start timestamp the value is effective from | +| `end` | `datetime` | The end timestamp the value is effective to | +| `value` | `float` | The value that is applicable for the timeframe. This could be something like an electricity rate | +| `metadata` | `object` | Additional metadata that might describe how the value was created | \ No newline at end of file diff --git a/_docs/setup/data_source.md b/_docs/setup/data_source.md index b7dac21..a3963f4 100644 --- a/_docs/setup/data_source.md +++ b/_docs/setup/data_source.md @@ -10,12 +10,16 @@ The name of the data source. This is for informative purposes. ### Id -The unique identifier of the data source. This is used by internal events to ensure sensors use the correct data as well as part of the entity name of all related entities. For example, if you were using data provided by Octopus Energy, then this might be `octopus_energy`. +The unique identifier of the data source. This is used by internal events to ensure sensors use the correct data as well as part of the entity name of all related entities. -Once the data source has been created, you'll have the following entities created. You'll then need to use the [available service](../services.md#target_timeframesupdate_target_timeframe_data_source) to configure the underlying data. There are also a collection of [blueprints](../blueprints.md#data-sources) available for loading popular data sources. +The data source will listen for the [update data source event](../events.md#update-data-source) where the `data source id` matches this id. This event might be raised by other integrations. If this data source is being used for this purpose, then the id will need to be set to a certain value which should be highlighted in that integrations guide. + +Alternatively, you can use the [available service](../services.md#target_timeframesupdate_target_timeframe_data_source) to configure the underlying data. There is a collection of [blueprints](../blueprints.md#data-sources) available for loading data from popular data sources. ## Entities +Once the data source has been created, you'll have the following entities created. + ### Data Source Last Updated `sensor.target_timeframes_{{DATA_SOURCE_ID}}_data_source_last_updated` diff --git a/custom_components/target_timeframes/const.py b/custom_components/target_timeframes/const.py index 3e20ef2..9fa5248 100644 --- a/custom_components/target_timeframes/const.py +++ b/custom_components/target_timeframes/const.py @@ -185,4 +185,5 @@ ), }) -EVENT_DATA_SOURCE = "target_time_period_data_source_updated" \ No newline at end of file +EVENT_DATA_SOURCE = "target_time_period_data_source_updated" +EVENT_UPDATE_DATA_SOURCE = "target_timeframe_update_data_source" \ No newline at end of file diff --git a/custom_components/target_timeframes/entities/data_source.py b/custom_components/target_timeframes/entities/data_source.py index 2bdaeed..3faec1d 100644 --- a/custom_components/target_timeframes/entities/data_source.py +++ b/custom_components/target_timeframes/entities/data_source.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import generate_entity_id from ..utils.attributes import dict_to_typed_dict -from ..const import DOMAIN, EVENT_DATA_SOURCE +from ..const import DOMAIN, EVENT_DATA_SOURCE, EVENT_UPDATE_DATA_SOURCE from ..storage.data_source_data import async_save_cached_data_source_data from ..utils.data_source_data import DataSourceItem, merge_data_source_data, validate_data_source_data @@ -67,6 +67,11 @@ def extra_state_attributes(self): @property def native_value(self): return self._state + + @callback + async def _async_handle_event(self, event) -> None: + if event.data.get("data_source_id", '').lower() == self._source_id.lower(): + await self.async_update_target_timeframe_data_source(event.data.get("data", [])) async def async_added_to_hass(self): """Call when entity about to be added to hass.""" @@ -81,6 +86,10 @@ async def async_added_to_hass(self): _LOGGER.debug(f'Restored state: {self._state}') + self.async_on_remove( + self._hass.bus.async_listen(EVENT_UPDATE_DATA_SOURCE, self._async_handle_event) + ) + @callback async def async_update_target_timeframe_data_source(self, data, replace_all_existing_data = False): """Update target timeframe data source""" diff --git a/custom_components/target_timeframes/utils/data_source_data.py b/custom_components/target_timeframes/utils/data_source_data.py index aa4784d..a8212b7 100644 --- a/custom_components/target_timeframes/utils/data_source_data.py +++ b/custom_components/target_timeframes/utils/data_source_data.py @@ -1,3 +1,4 @@ +from copy import Error from datetime import datetime, timedelta from typing import Any @@ -19,7 +20,7 @@ def __init__(self, success: bool, data_source_id: str, data: list[DataSourceItem def validate_data_source_data(items: list[dict], data_source_id: str): if items is None or len(items) < 1: - return ValidateDataSourceDataResult(True, []) + return ValidateDataSourceDataResult(True, data_source_id, []) processed_data_source = [] for index in range(len(items)): @@ -28,7 +29,14 @@ def validate_data_source_data(items: list[dict], data_source_id: str): start = None try: - start = datetime.fromisoformat(item["start"]) + if "start" not in item: + error = f"start is missing at index {index}" + break + + if isinstance(item["start"], datetime): + start = item["start"] + else: + start = datetime.fromisoformat(item["start"]) except: error = f"start was not a valid ISO datetime in string format at index {index}" break @@ -39,7 +47,14 @@ def validate_data_source_data(items: list[dict], data_source_id: str): end = None try: - end = datetime.fromisoformat(item["end"]) + if "end" not in item: + error = f"end is missing at index {index}" + break + + if isinstance(item["end"], datetime): + end = item["end"] + else: + end = datetime.fromisoformat(item["end"]) except: error = f"end was not a valid ISO datetime in string format at index {index}" break diff --git a/tests/unit/utils/test_validate_data_source_data.py b/tests/unit/utils/test_validate_data_source_data.py new file mode 100644 index 0000000..2831b0c --- /dev/null +++ b/tests/unit/utils/test_validate_data_source_data.py @@ -0,0 +1,523 @@ +from datetime import datetime, timedelta, timezone +import pytest + +from custom_components.target_timeframes.utils.data_source_data import validate_data_source_data, DataSourceItem + +@pytest.mark.asyncio +async def test_when_none_items_are_provided_then_success_with_empty_data_is_returned(): + # Arrange + items = None + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message is None + +@pytest.mark.asyncio +async def test_when_empty_list_is_provided_then_success_with_empty_data_is_returned(): + # Arrange + items = [] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message is None + +@pytest.mark.asyncio +async def test_when_valid_data_with_datetime_objects_is_provided_then_success_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + items = [ + { + "start": start, + "end": end, + "value": 10.5, + "metadata": {"test": "data"} + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert len(result.data) == 1 + assert result.data[0].start == start + assert result.data[0].end == end + assert result.data[0].value == 10.5 + assert result.data[0].metadata == {"test": "data"} + assert result.data_source_id == data_source_id + assert result.error_message is None + +@pytest.mark.asyncio +async def test_when_valid_data_with_iso_string_format_is_provided_then_success_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + items = [ + { + "start": start.isoformat(), + "end": end.isoformat(), + "value": 10.5, + "metadata": {"test": "data"} + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert len(result.data) == 1 + assert result.data[0].start == start + assert result.data[0].end == end + assert result.data[0].value == 10.5 + assert result.data[0].metadata == {"test": "data"} + assert result.data_source_id == data_source_id + assert result.error_message is None + +@pytest.mark.asyncio +async def test_when_valid_data_without_metadata_is_provided_then_success_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert len(result.data) == 1 + assert result.data[0].start == start + assert result.data[0].end == end + assert result.data[0].value == 10.5 + assert result.data[0].metadata is None + assert result.data_source_id == data_source_id + assert result.error_message is None + +@pytest.mark.asyncio +async def test_when_multiple_valid_items_are_provided_then_success_is_returned(): + # Arrange + start1 = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end1 = start1 + timedelta(minutes=30) + start2 = datetime(2024, 1, 1, 12, 30, 0, 0, tzinfo=timezone.utc) + end2 = start2 + timedelta(minutes=30) + items = [ + { + "start": start1, + "end": end1, + "value": 10.5, + "metadata": {"test": "data1"} + }, + { + "start": start2, + "end": end2, + "value": 20.5, + "metadata": {"test": "data2"} + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert len(result.data) == 2 + assert result.data[0].start == start1 + assert result.data[1].start == start2 + assert result.data_source_id == data_source_id + assert result.error_message is None + +@pytest.mark.asyncio +async def test_when_start_is_missing_then_error_is_returned(): + # Arrange + end = datetime(2024, 1, 1, 12, 30, 0, 0, tzinfo=timezone.utc) + items = [ + { + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start is missing at index 0" + +@pytest.mark.asyncio +async def test_when_end_is_missing_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + items = [ + { + "start": start, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "end is missing at index 0" + +@pytest.mark.asyncio +async def test_when_start_is_invalid_iso_format_then_error_is_returned(): + # Arrange + end = datetime(2024, 1, 1, 12, 30, 0, 0, tzinfo=timezone.utc) + items = [ + { + "start": "invalid-date", + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start was not a valid ISO datetime in string format at index 0" + +@pytest.mark.asyncio +async def test_when_end_is_invalid_iso_format_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + items = [ + { + "start": start, + "end": "invalid-date", + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "end was not a valid ISO datetime in string format at index 0" + +@pytest.mark.asyncio +async def test_when_start_has_no_timezone_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0) # No timezone + end = datetime(2024, 1, 1, 12, 30, 0, 0, tzinfo=timezone.utc) + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start must include timezone at index 0" + +@pytest.mark.asyncio +async def test_when_end_has_no_timezone_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = datetime(2024, 1, 1, 12, 30, 0, 0) # No timezone + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "end must include timezone at index 0" + +@pytest.mark.asyncio +async def test_when_start_equals_end_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = start + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start must be before end at index 0" + +@pytest.mark.asyncio +async def test_when_start_is_after_end_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 30, 0, 0, tzinfo=timezone.utc) + end = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start must be before end at index 0" + +@pytest.mark.asyncio +async def test_when_time_period_is_not_30_minutes_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=60) # 60 minutes instead of 30 + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "time period must be equal to 30 minutes at index 0" + +@pytest.mark.asyncio +async def test_when_start_minute_is_not_0_or_30_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 15, 0, 0, tzinfo=timezone.utc) # 15 minutes + end = start + timedelta(minutes=30) + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start minute must equal 0 or 30 at index 0" + +@pytest.mark.asyncio +async def test_when_start_second_is_not_0_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 5, 0, tzinfo=timezone.utc) # 5 seconds + end = start + timedelta(minutes=30) + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start second and microsecond must equal 0 at index 0" + +@pytest.mark.asyncio +async def test_when_start_microsecond_is_not_0_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc) # 1000 microseconds + end = start + timedelta(minutes=30) + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start second and microsecond must equal 0 at index 0" + +@pytest.mark.asyncio +async def test_when_end_second_is_not_0_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 5, 0, tzinfo=timezone.utc) + end = datetime(2024, 1, 1, 12, 30, 5, 0, tzinfo=timezone.utc) # 5 seconds + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start second and microsecond must equal 0 at index 0" + +@pytest.mark.asyncio +async def test_when_end_microsecond_is_not_0_then_error_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end = datetime(2024, 1, 1, 12, 30, 0, 1000, tzinfo=timezone.utc) # 1000 microseconds + items = [ + { + "start": start, + "end": end, + "value": 10.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "end second and microsecond must equal 0 at index 0" + +@pytest.mark.asyncio +async def test_when_error_occurs_at_second_item_then_correct_index_is_reported(): + # Arrange + start1 = datetime(2024, 1, 1, 12, 0, 0, 0, tzinfo=timezone.utc) + end1 = start1 + timedelta(minutes=30) + start2 = datetime(2024, 1, 1, 12, 15, 0, 0, tzinfo=timezone.utc) # Invalid minute: 15 + end2 = start2 + timedelta(minutes=30) + items = [ + { + "start": start1, + "end": end1, + "value": 10.5 + }, + { + "start": start2, + "end": end2, + "value": 20.5 + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == False + assert result.data == [] + assert result.data_source_id == data_source_id + assert result.error_message == "start minute must equal 0 or 30 at index 1" + +@pytest.mark.asyncio +async def test_when_valid_data_with_30_minute_start_is_provided_then_success_is_returned(): + # Arrange + start = datetime(2024, 1, 1, 12, 30, 0, 0, tzinfo=timezone.utc) # 30 minutes + end = datetime(2024, 1, 1, 13, 0, 0, 0, tzinfo=timezone.utc) # 0 minutes + items = [ + { + "start": start, + "end": end, + "value": 10.5, + "metadata": {"test": "data"} + } + ] + data_source_id = "test_source" + + # Act + result = validate_data_source_data(items, data_source_id) + + # Assert + assert result.success == True + assert len(result.data) == 1 + assert result.data[0].start == start + assert result.data[0].end == end + assert result.data_source_id == data_source_id + assert result.error_message is None From 2133b5febeaf18491e163aefbdc78ca597941659 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Thu, 5 Mar 2026 17:15:21 +0000 Subject: [PATCH 6/6] feat: Added support for weighting for target timeframes not in minimum and maximum mode (3 hours dev time) --- _docs/setup/rolling_target_timeframe.md | 2 +- _docs/setup/target_timeframe.md | 2 +- .../config/rolling_target_timeframe.py | 16 ++++-- .../config/target_timeframe.py | 16 ++++-- .../target_timeframes/entities/__init__.py | 45 +++++++++-------- .../entities/rolling_target_timeframe.py | 3 +- .../entities/target_timeframe.py | 3 +- .../target_timeframes/translations/en.json | 8 ++- ...alidate_rolling_target_timeframe_config.py | 50 +++++++++++++++++-- .../test_validate_target_timeframe_config.py | 50 +++++++++++++++++-- .../test_calculate_continuous_times.py | 24 ++++----- .../target_rates/test_create_weighting.py | 2 + 12 files changed, 164 insertions(+), 57 deletions(-) diff --git a/_docs/setup/rolling_target_timeframe.md b/_docs/setup/rolling_target_timeframe.md index e49fd55..ab5c773 100644 --- a/_docs/setup/rolling_target_timeframe.md +++ b/_docs/setup/rolling_target_timeframe.md @@ -100,7 +100,7 @@ There may be times that you want the target timeframe sensors to not take into a !!! info - This is only available for **continuous** target value sensors in **exact** hours mode. + This is only available for **continuous** target value sensors. There may be times when the device you're wanting the target value sensor to turn on doesn't have a consistent power draw. You can specify a weighting/multiplier which can be applied to the value of each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting/multiplier for a required 2 hours. diff --git a/_docs/setup/target_timeframe.md b/_docs/setup/target_timeframe.md index d3600e3..d254302 100644 --- a/_docs/setup/target_timeframe.md +++ b/_docs/setup/target_timeframe.md @@ -128,7 +128,7 @@ There may be times that you want the target timeframe sensors to not take into a !!! info - This is only available for **continuous** target value sensors in **exact** hours mode. + This is only available for **continuous** target value sensors. There may be times when the device you're wanting the target value sensor to turn on doesn't have a consistent power draw. You can specify a weighting/multiplier which can be applied to the value of each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting/multiplier for a required 2 hours. diff --git a/custom_components/target_timeframes/config/rolling_target_timeframe.py b/custom_components/target_timeframes/config/rolling_target_timeframe.py index 7fdfa0a..3c797ed 100644 --- a/custom_components/target_timeframes/config/rolling_target_timeframe.py +++ b/custom_components/target_timeframes/config/rolling_target_timeframe.py @@ -6,6 +6,7 @@ CONFIG_TARGET_HOURS, CONFIG_TARGET_HOURS_MODE, CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_HOURS_MODE_MAXIMUM, CONFIG_TARGET_HOURS_MODE_MINIMUM, CONFIG_TARGET_MAX_VALUE, CONFIG_TARGET_MIN_VALUE, @@ -120,14 +121,19 @@ def validate_rolling_target_timeframe_config(data): number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2) weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots) - if (len(weighting) != number_of_slots): - errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots" + if (weighting is None or len(weighting) != number_of_slots): + if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots" + elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots" + else: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots" + + if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT and "*" not in data[CONFIG_TARGET_WEIGHTING]: + errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode" if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS: errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type" - - if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT: - errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode" if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM: if (CONFIG_TARGET_MIN_VALUE not in data or data[CONFIG_TARGET_MIN_VALUE] is None) and (CONFIG_TARGET_MAX_VALUE not in data or data[CONFIG_TARGET_MAX_VALUE] is None): diff --git a/custom_components/target_timeframes/config/target_timeframe.py b/custom_components/target_timeframes/config/target_timeframe.py index 36846cc..16b9d3d 100644 --- a/custom_components/target_timeframes/config/target_timeframe.py +++ b/custom_components/target_timeframes/config/target_timeframe.py @@ -9,6 +9,7 @@ CONFIG_TARGET_HOURS, CONFIG_TARGET_HOURS_MODE, CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_HOURS_MODE_MAXIMUM, CONFIG_TARGET_HOURS_MODE_MINIMUM, CONFIG_TARGET_MAX_VALUE, CONFIG_TARGET_MIN_VALUE, @@ -146,14 +147,19 @@ def validate_target_timeframe_config(data): number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2) weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots) - if (len(weighting) != number_of_slots): - errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots" + if (weighting is None or len(weighting) != number_of_slots): + if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots" + elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots" + else: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots" + + if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT and "*" not in data[CONFIG_TARGET_WEIGHTING]: + errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode" if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS: errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type" - - if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT: - errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode" if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM: if (CONFIG_TARGET_MIN_VALUE not in data or data[CONFIG_TARGET_MIN_VALUE] is None) and (CONFIG_TARGET_MAX_VALUE not in data or data[CONFIG_TARGET_MAX_VALUE] is None): diff --git a/custom_components/target_timeframes/entities/__init__.py b/custom_components/target_timeframes/entities/__init__.py index 0df3641..6c8a725 100644 --- a/custom_components/target_timeframes/entities/__init__.py +++ b/custom_components/target_timeframes/entities/__init__.py @@ -141,7 +141,7 @@ def calculate_continuous_times( find_latest_values = False, min_value = None, max_value = None, - weighting: list = None, + weighting: str = None, hours_mode = CONFIG_TARGET_HOURS_MODE_EXACT, context: str = None ): @@ -151,9 +151,6 @@ def calculate_continuous_times( applicable_time_periods_count = len(applicable_time_periods) total_required_time_periods = math.ceil(target_hours * 2) - if weighting is not None and len(weighting) != total_required_time_periods: - raise ValueError(f"{context} - Weighting does not match target hours") - best_continuous_time_periods = None best_continuous_time_periods_total = None @@ -169,8 +166,6 @@ def calculate_continuous_times( continue continuous_time_periods = [time_period] - value_weight = Decimal(time_period["weighting"]) if "weighting" in time_period else 1 - continuous_rates_total = Decimal(time_period["value"]) * value_weight * (weighting[0] if weighting is not None and len(weighting) > 0 else 1) for offset in range(1, total_required_time_periods if hours_mode != CONFIG_TARGET_HOURS_MODE_MINIMUM else applicable_time_periods_count): if (index + offset) < applicable_time_periods_count: @@ -183,21 +178,12 @@ def calculate_continuous_times( break continuous_time_periods.append(offset_time_period) - value_weight = Decimal(offset_time_period["weighting"]) if "weighting" in offset_time_period else 1 - continuous_rates_total += Decimal(offset_time_period["value"]) * value_weight * (weighting[offset] if weighting is not None else 1) else: break current_continuous_time_periods_length = len(continuous_time_periods) best_continuous_time_periods_length = len(best_continuous_time_periods) if best_continuous_time_periods is not None else 0 - is_best_continuous_rates = False - if best_continuous_time_periods is not None: - if search_for_highest_value: - is_best_continuous_rates = (continuous_rates_total >= best_continuous_time_periods_total if find_latest_values else continuous_rates_total > best_continuous_time_periods_total) - else: - is_best_continuous_rates = (continuous_rates_total <= best_continuous_time_periods_total if find_latest_values else continuous_rates_total < best_continuous_time_periods_total) - has_required_hours = False if hours_mode == CONFIG_TARGET_HOURS_MODE_EXACT: has_required_hours = current_continuous_time_periods_length == total_required_time_periods @@ -206,12 +192,26 @@ def calculate_continuous_times( elif hours_mode == CONFIG_TARGET_HOURS_MODE_MAXIMUM: has_required_hours = current_continuous_time_periods_length <= total_required_time_periods and current_continuous_time_periods_length >= best_continuous_time_periods_length - if ((best_continuous_time_periods is None or is_best_continuous_rates) and has_required_hours): - best_continuous_time_periods = continuous_time_periods - best_continuous_time_periods_total = continuous_rates_total - _LOGGER.debug(f'{context} - New best block discovered {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None})') - else: - _LOGGER.debug(f'{context} - Total rates for current block {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None}). Total rates for best block {best_continuous_time_periods_total}') + if has_required_hours: + weighting_values = create_weighting(weighting, len(continuous_time_periods)) + if weighting_values is not None: + continuous_rates_total = sum([Decimal(rate["value"]) * weighting_values[index] for index, rate in enumerate(continuous_time_periods)]) + else: + continuous_rates_total = sum([Decimal(rate["value"]) for rate in continuous_time_periods]) + + is_best_continuous_rates = False + if best_continuous_time_periods is not None: + if search_for_highest_value: + is_best_continuous_rates = (continuous_rates_total >= best_continuous_time_periods_total if find_latest_values else continuous_rates_total > best_continuous_time_periods_total) + else: + is_best_continuous_rates = (continuous_rates_total <= best_continuous_time_periods_total if find_latest_values else continuous_rates_total < best_continuous_time_periods_total) + + if is_best_continuous_rates or best_continuous_time_periods is None: + best_continuous_time_periods = continuous_time_periods + best_continuous_time_periods_total = continuous_rates_total + _LOGGER.debug(f'{context} - New best block discovered {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None})') + else: + _LOGGER.debug(f'{context} - Total rates for current block {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None}). Total rates for best block {best_continuous_time_periods_total}') if best_continuous_time_periods is not None: # Make sure our rates are in ascending order before returning @@ -409,6 +409,9 @@ def create_weighting(config: str, number_of_slots: int): parts = config.split(',') parts_length = len(parts) + if parts_length > number_of_slots: + return None + weighting = [] for index in range(parts_length): if (parts[index] == "*"): diff --git a/custom_components/target_timeframes/entities/rolling_target_timeframe.py b/custom_components/target_timeframes/entities/rolling_target_timeframe.py index 847b2f5..ee48e1d 100644 --- a/custom_components/target_timeframes/entities/rolling_target_timeframe.py +++ b/custom_components/target_timeframes/entities/rolling_target_timeframe.py @@ -163,8 +163,7 @@ async def async_update(self): ) if applicable_time_periods is not None: - number_of_slots = math.ceil(target_hours * 2) - weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots) + weighting = self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS): self._target_timeframes = calculate_continuous_times( diff --git a/custom_components/target_timeframes/entities/target_timeframe.py b/custom_components/target_timeframes/entities/target_timeframe.py index 52fefed..d90d58b 100644 --- a/custom_components/target_timeframes/entities/target_timeframe.py +++ b/custom_components/target_timeframes/entities/target_timeframe.py @@ -195,8 +195,7 @@ async def async_update(self): ) if applicable_time_periods is not None and is_target_timeframe_complete == False: - number_of_slots = math.ceil(target_hours * 2) - weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots) + weighting = self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None proposed_target_timeframes = None if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS): diff --git a/custom_components/target_timeframes/translations/en.json b/custom_components/target_timeframes/translations/en.json index 6ca7b37..d61453c 100644 --- a/custom_components/target_timeframes/translations/en.json +++ b/custom_components/target_timeframes/translations/en.json @@ -130,8 +130,10 @@ "invalid_value": "Value must be in decimal format (e.g. 0.10)", "invalid_weighting": "The weighting format is not supported. Please consult documentation for more information.", "invalid_weighting_slots": "The number of weighting blocks does not equal the specified number of hours.", + "invalid_minimum_weighting_slots": "The number of weighting blocks is higher than the specified number of minimum hours.", + "invalid_maximum_weighting_slots": "The number of weighting blocks is higher than the specified number of maximum hours.", + "weighting_not_varied_for_hour_mode": "The weighting must include a variable block (*) if the hour mode is not exact", "weighting_not_supported_for_type": "Weighting is only supported for continuous target values", - "weighting_not_supported_for_hour_mode": "Weighting is not supported for this hour mode", "minimum_or_maximum_value_not_specified": "Either minimum and/or maximum value must be specified for minimum hours mode", "minimum_value_not_less_than_maximum_value": "Minimum value must be less or equal to the maximum value if both are specified", "invalid_integer": "Value must be a number with no decimal places", @@ -212,8 +214,10 @@ "invalid_value": "Value must be in decimal format (e.g. 0.10)", "invalid_weighting": "The weighting format is not supported. Please consult documentation for more information.", "invalid_weighting_slots": "The number of weighting blocks does not equal the specified number of hours.", + "invalid_minimum_weighting_slots": "The number of weighting blocks is higher than the specified number of minimum hours.", + "invalid_maximum_weighting_slots": "The number of weighting blocks is higher than the specified number of maximum hours.", + "weighting_not_varied_for_hour_mode": "The weighting must include a variable block (*) if the hour mode is not exact", "weighting_not_supported_for_type": "Weighting is only supported for continuous target values", - "weighting_not_supported_for_hour_mode": "Weighting is not supported for this hour mode", "minimum_or_maximum_value_not_specified": "Either minimum and/or maximum value must be specified for minimum hours mode", "minimum_value_not_less_than_maximum_value": "Minimum value must be less or equal to the maximum value if both are specified", "invalid_integer": "Value must be a number with no decimal places", diff --git a/tests/unit/config/test_validate_rolling_target_timeframe_config.py b/tests/unit/config/test_validate_rolling_target_timeframe_config.py index 15c681a..1bd70e4 100644 --- a/tests/unit/config/test_validate_rolling_target_timeframe_config.py +++ b/tests/unit/config/test_validate_rolling_target_timeframe_config.py @@ -321,7 +321,7 @@ async def test_when_minimum_value_greater_to_maximum_value_is_specified_then_err (CONFIG_TARGET_HOURS_MODE_MINIMUM), (CONFIG_TARGET_HOURS_MODE_MAXIMUM), ]) -async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_returned(hour_mode: str): +async def test_when_hour_mode_is_not_exact_and_weighting_is_not_varied_then_error_returned(hour_mode: str): # Arrange data = { CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, @@ -329,7 +329,7 @@ async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_re CONFIG_TARGET_HOURS: "1.5", CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD: "2", CONFIG_TARGET_OFFSET: "-00:30:00", - CONFIG_TARGET_WEIGHTING: "2,*,2", + CONFIG_TARGET_WEIGHTING: "2,1,2,2", CONFIG_TARGET_MIN_VALUE: "0.18", CONFIG_TARGET_HOURS_MODE: hour_mode } @@ -339,7 +339,51 @@ async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_re # Assert assert CONFIG_TARGET_WEIGHTING in errors - assert errors[CONFIG_TARGET_WEIGHTING] == "weighting_not_supported_for_hour_mode" + assert errors[CONFIG_TARGET_WEIGHTING] == "weighting_not_varied_for_hour_mode" + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING) + +@pytest.mark.asyncio +async def test_when_hour_mode_is_minimum_and_weighting_too_small_then_error_returned(): + # Arrange + data = { + CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_NAME: "test", + CONFIG_TARGET_HOURS: "1.5", + CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD: "2", + CONFIG_TARGET_OFFSET: "-00:30:00", + CONFIG_TARGET_WEIGHTING: "2,*,2,2", + CONFIG_TARGET_MIN_VALUE: "0.18", + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MINIMUM + } + + # Act + errors = validate_rolling_target_timeframe_config(data) + + # Assert + assert CONFIG_TARGET_WEIGHTING in errors + assert errors[CONFIG_TARGET_WEIGHTING] == "invalid_minimum_weighting_slots" + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING) + +@pytest.mark.asyncio +async def test_when_hour_mode_is_maximum_and_weighting_too_large_then_error_returned(): + # Arrange + data = { + CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_NAME: "test", + CONFIG_TARGET_HOURS: "1.5", + CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD: "2", + CONFIG_TARGET_OFFSET: "-00:30:00", + CONFIG_TARGET_WEIGHTING: "2,1,*,2,2", + CONFIG_TARGET_MIN_VALUE: "0.18", + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MAXIMUM + } + + # Act + errors = validate_rolling_target_timeframe_config(data) + + # Assert + assert CONFIG_TARGET_WEIGHTING in errors + assert errors[CONFIG_TARGET_WEIGHTING] == "invalid_maximum_weighting_slots" assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING) @pytest.mark.asyncio diff --git a/tests/unit/config/test_validate_target_timeframe_config.py b/tests/unit/config/test_validate_target_timeframe_config.py index e046285..7e044e6 100644 --- a/tests/unit/config/test_validate_target_timeframe_config.py +++ b/tests/unit/config/test_validate_target_timeframe_config.py @@ -447,7 +447,7 @@ async def test_when_minimum_value_greater_to_maximum_value_is_specified_then_err (CONFIG_TARGET_HOURS_MODE_MINIMUM), (CONFIG_TARGET_HOURS_MODE_MAXIMUM), ]) -async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_returned(hour_mode: str): +async def test_when_hour_mode_is_not_exact_and_weighting_is_not_varied_then_error_returned(hour_mode: str): # Arrange data = { CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, @@ -456,7 +456,7 @@ async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_re CONFIG_TARGET_START_TIME: "00:00", CONFIG_TARGET_END_TIME: "00:00", CONFIG_TARGET_OFFSET: "-00:30:00", - CONFIG_TARGET_WEIGHTING: "2,*,2", + CONFIG_TARGET_WEIGHTING: "2,1,2", CONFIG_TARGET_MIN_VALUE: "0.18", CONFIG_TARGET_HOURS_MODE: hour_mode, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST @@ -467,7 +467,51 @@ async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_re # Assert assert CONFIG_TARGET_WEIGHTING in errors - assert errors[CONFIG_TARGET_WEIGHTING] == "weighting_not_supported_for_hour_mode" + assert errors[CONFIG_TARGET_WEIGHTING] == "weighting_not_varied_for_hour_mode" + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING) + +@pytest.mark.asyncio +async def test_when_hour_mode_is_minimum_and_weighting_too_small_then_error_returned(): + # Arrange + data = { + CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_NAME: "test", + CONFIG_TARGET_HOURS: "1.5", + CONFIG_TARGET_OFFSET: "-00:30:00", + CONFIG_TARGET_WEIGHTING: "2,*,2,2", + CONFIG_TARGET_MIN_VALUE: "0.18", + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MINIMUM, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST + } + + # Act + errors = validate_target_timeframe_config(data) + + # Assert + assert CONFIG_TARGET_WEIGHTING in errors + assert errors[CONFIG_TARGET_WEIGHTING] == "invalid_minimum_weighting_slots" + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING) + +@pytest.mark.asyncio +async def test_when_hour_mode_is_maximum_and_weighting_too_large_then_error_returned(): + # Arrange + data = { + CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_NAME: "test", + CONFIG_TARGET_HOURS: "1.5", + CONFIG_TARGET_OFFSET: "-00:30:00", + CONFIG_TARGET_WEIGHTING: "2,1,*,2,2", + CONFIG_TARGET_MIN_VALUE: "0.18", + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MAXIMUM, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST + } + + # Act + errors = validate_target_timeframe_config(data) + + # Assert + assert CONFIG_TARGET_WEIGHTING in errors + assert errors[CONFIG_TARGET_WEIGHTING] == "invalid_maximum_weighting_slots" assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING) @pytest.mark.asyncio diff --git a/tests/unit/target_rates/test_calculate_continuous_times.py b/tests/unit/target_rates/test_calculate_continuous_times.py index caad802..8b7b586 100644 --- a/tests/unit/target_rates/test_calculate_continuous_times.py +++ b/tests/unit/target_rates/test_calculate_continuous_times.py @@ -476,23 +476,23 @@ async def test_when_max_value_is_provided_then_result_does_not_include_any_rate_ @pytest.mark.asyncio @pytest.mark.parametrize("weighting,possible_values,expected_first_valid_from,expected_values",[ - ([1, 2, 1], [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [19.1, 15.1, 20]), - ([1, 2, 2], [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [18.9, 19.1, 15.1]), - ([1, 0, 0], [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [15.1, 20, 19.1]), + ("1,2,1", [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [19.1, 15.1, 20]), + ("1,2,2", [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [18.9, 19.1, 15.1]), + ("1,0,0", [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [15.1, 20, 19.1]), - ([Decimal('1.1'), Decimal('2.2'), Decimal('1.1')], [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [19.1, 15.1, 20]), - ([Decimal('1.1'), Decimal('2.2'), Decimal('2.2')], [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [18.9, 19.1, 15.1]), - ([Decimal('1.1'), Decimal('0.0'), Decimal('0.0')], [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [15.1, 20, 19.1]), + ("1.1,2.2,1.1", [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [19.1, 15.1, 20]), + ("1.1,2.2,2.2", [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [18.9, 19.1, 15.1]), + ("1.1,0.0,0.0", [19.1, 18.9, 19.1, 15.1, 20], datetime.strptime("2022-10-22T11:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [15.1, 20, 19.1]), # Examples defined in https://github.com/BottlecapDave/homeassistant-targettimeframes/issues/807 (None, [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T09:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [14, 10, 7]), - ([1, 1, 2], [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T09:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [14, 10, 7]), - ([5, 1, 1], [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [7, 15, 21]), + ("1,1,2", [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T09:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [14, 10, 7]), + ("5,1,1", [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [7, 15, 21]), - ([Decimal('1.1'), Decimal('1.1'), Decimal('2.2')], [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T09:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [14, 10, 7]), - ([Decimal('5.5'), Decimal('1.1'), Decimal('1.1')], [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [7, 15, 21]), + ("1.1,1.1,2.2", [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T09:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [14, 10, 7]), + ("5.5,1.1,1.1", [14, 14, 10, 7, 15, 21], datetime.strptime("2022-10-22T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), [7, 15, 21]), ]) -async def test_when_weighting_specified_then_result_is_adjusted(weighting: list, possible_values: list, expected_first_valid_from: datetime, expected_values: list): +async def test_when_weighting_specified_then_result_is_adjusted(weighting: str, possible_values: list, expected_first_valid_from: datetime, expected_values: list): # Arrange current_date = datetime.strptime("2022-10-22T09:10:00+00:00", "%Y-%m-%dT%H:%M:%S%z") target_start_time = "09:00" @@ -1016,7 +1016,7 @@ def test_when_weighting_present_with_find_latest_rate_then_latest_time_is_picked 7.5, False, True, - weighting=[2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + weighting="2,*" ) assert result is not None diff --git a/tests/unit/target_rates/test_create_weighting.py b/tests/unit/target_rates/test_create_weighting.py index 24b347e..2d04d39 100644 --- a/tests/unit/target_rates/test_create_weighting.py +++ b/tests/unit/target_rates/test_create_weighting.py @@ -50,6 +50,8 @@ ("*,220", 4, [Decimal('1'), Decimal('1'), Decimal('1'), Decimal('220')]), ("220,30.3,*", 4, [Decimal('220'), Decimal('30.3'), Decimal('1'), Decimal('1')]), ("220,*", 4, [Decimal('220'), Decimal('1'), Decimal('1'), Decimal('1')]), + + ("2,*,3,4", 1, None), ]) async def test_when_create_weighting_called_then_valid_weighting_returned(config, number_of_slots, expected_weighting): actual_weighting = create_weighting(config, number_of_slots)