diff --git a/_docs/blueprints.md b/_docs/blueprints.md index 0ddb9f3..5a85807 100644 --- a/_docs/blueprints.md +++ b/_docs/blueprints.md @@ -47,3 +47,9 @@ This blueprint will provide the data source for Octopus Energy rates as provided !!! warning This automation will run when any of the underlying entities update. This make take a while initially. If you want the data available immediately, then you'll need to run the automation manually. + +### Weather + +[Install blueprint](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fbottlecapdave.github.io%2FHomeAssistant-TargetTimeframes%2Fblueprints%2Ftarget_timeframes_weather.yaml) | [Source](./blueprints/target_timeframes_weather.yaml) + +This blueprint will provide the data source based on the forecast of the provide weather sensor and will use the specified `Forecast attribute` as the value for the calculation of the target timeframes. The forecast will be refreshed every 30 minutes. \ No newline at end of file diff --git a/_docs/blueprints/target_timeframes_weather.yaml b/_docs/blueprints/target_timeframes_weather.yaml new file mode 100644 index 0000000..0260070 --- /dev/null +++ b/_docs/blueprints/target_timeframes_weather.yaml @@ -0,0 +1,78 @@ +blueprint: + name: Target Timeframes - Weather source + description: Configures a target timeframe data source from Weather + domain: automation + author: BottlecapDave + input: + target_timeframe_data_source_sensor: + name: Target timeframe data source sensor + description: The data source sensor which represents the data source to update + selector: + entity: + filter: + - domain: + - sensor + integration: target_timeframes + multiple: false + weather_sensor: + name: Weather sensor + description: The weather sensor to get the forecast for. + selector: + entity: + filter: + - domain: + - weather + multiple: false + forecast_attribute: + name: Forecast attribute + description: Type in the name of the desired forecast attribute to use as the value (e.g. "precipitation", "uv_index" or "temperature"). See the metadata of the result for possible options + default: "condition" + selector: + text: +variables: + target_timeframe_data_source_sensor: !input target_timeframe_data_source_sensor + weather_sensor: !input weather_sensor + forecast_attribute: !input forecast_attribute + millisecond_jitter: > + {{ range(1, 1000) | random }} +mode: queued +max: 4 +triggers: +- trigger: time_pattern + minutes: '/30' +condition: [] +action: +# Add a bit of jitter so the API isn't hit at once +- delay: + milliseconds: > + {{ millisecond_jitter }} +- action: weather.get_forecasts + target: + entity_id: !input weather_sensor + data: + type: hourly + response_variable: weather_forecast +- variables: + data_source_data: > + {%- set forecast_items = weather_forecast[weather_sensor]["forecast"] -%} + {%- set data = namespace(items=[]) -%} + {%- for forecast in forecast_items -%} + {%- set start = forecast["datetime"] | as_timestamp | timestamp_utc -%} + {%- set end = ((start | as_datetime) + timedelta(minutes=30)) | as_timestamp | timestamp_utc -%} + {%- set value = forecast[forecast_attribute] | float -%} + {%- set new_item = [{ 'start': start , 'end': end, 'value': value, 'metadata': forecast }] -%} + + {%- set data.items = data.items + new_item -%} + + {%- set start = end -%} + {%- set end = ((start | as_datetime) + timedelta(minutes=30)) | as_timestamp | timestamp_utc -%} + {%- set new_item = [{ 'start': start , 'end': end, 'value': value, 'metadata': forecast }] -%} + + {%- set data.items = data.items + new_item -%} + {%- endfor -%} + {{ data.items }} +- action: target_timeframes.update_target_timeframe_data_source + data: > + {{ { 'data': data_source_data } }} + target: + entity_id: !input target_timeframe_data_source_sensor diff --git a/_docs/setup/target_timeframe.md b/_docs/setup/target_timeframe.md index ade5ff0..d3600e3 100644 --- a/_docs/setup/target_timeframe.md +++ b/_docs/setup/target_timeframe.md @@ -64,9 +64,27 @@ This will only evaluate target times if no target times have been calculated or For example, lets say we have a continuous target which looks between `00:00` and `08:00` has existing target times from `2023-01-02T01:00` to `2023-01-02T02:00`. -* If the current time is `2023-01-02T00:59`, then the target times will be re-evaluated and might change if the target period (i.e. `2023-01-02T00:30` to `2023-01-02T08:30`) has better values than the existing target times (e.g. the external weightings have changed). +* If the current time is `2023-01-02T00:59`, then the target times will be re-evaluated and might change if the target period (i.e. `2023-01-02T00:30` to `2023-01-02T08:00`) has better values than the existing target times (e.g. the external weightings have changed). * If the current time is `2023-01-02T01:00`, the the target times will not be re-evaluated because we've entered our current target times, even if the evaluation period has cheaper times. -* If the current time is `2023-01-02T02:01`, the the target times will be re-evaluated because our existing target times are in the past and will find the best times in the new rolling target period (i.e. `2023-01-02T02:00` to `2023-01-02T10:00`). +* If the current time is `2023-01-02T02:01`, the the target times will be re-evaluated because our existing target times are in the past and will find the best times in the new target period (i.e. `2023-01-02T02:00` to `2023-01-02T08:00`). + +#### Always + +This will always evaluate the best target times for the target period, even if the sensor is in the middle of an existing target time period. + +For example, lets say we have a continuous target which looks between `00:00` and `08:00` and has existing target times from `2023-01-02T01:00` to `2023-01-02T02:00`. + +* If the current time is `2023-01-02T00:59`, then the target times will be re-evaluated and might change if the new target period (i.e. `2023-01-02T00:30` to `2023-01-02T08:30`) has better times than the existing target times. +* If the current time is `2023-01-02T01:31`, then the target times will be re-evaluated and might change if the new target period (i.e. `2023-01-02T01:30` to `2023-01-02T08:30`) has better times than the existing target times. +* If the current time is `2023-01-02T02:01`, the the target times will be re-evaluated because our existing target times are in the past and will find the best times in the new target period (i.e. `2023-01-02T02:00` to `2023-01-02T08:00`). + +!!! note + + This is only supported when [Re-evaluate within time frame](#re-evaluate-within-time-frame) is enabled, otherwise it will behave the same as the other options. + +!!! warning + + This setting means that you could end up with the sensor not turning on for the fully requested hours as the target times might be moved ahead half way through the picked times. It also could mean that the sensor doesn't come on at all during the requested look ahead hours (e.g. 8) because the lowest period kept moving back. ### Offset diff --git a/custom_components/target_timeframes/config/target_timeframe.py b/custom_components/target_timeframes/config/target_timeframe.py index 379c65c..36846cc 100644 --- a/custom_components/target_timeframes/config/target_timeframe.py +++ b/custom_components/target_timeframes/config/target_timeframe.py @@ -15,7 +15,10 @@ CONFIG_TARGET_MINIMUM_REQUIRED_MINUTES_IN_SLOT, CONFIG_TARGET_NAME, CONFIG_TARGET_OFFSET, + CONFIG_TARGET_ROLLING_TARGET, CONFIG_TARGET_START_TIME, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, CONFIG_TARGET_TYPE, CONFIG_TARGET_TYPE_CONTINUOUS, CONFIG_TARGET_WEIGHTING, @@ -186,4 +189,8 @@ def validate_target_timeframe_config(data): if minimum_required_minutes_in_slot is not None and (minimum_required_minutes_in_slot < 1 or minimum_required_minutes_in_slot > 30): errors[CONFIG_TARGET_DANGEROUS_SETTINGS] = "invalid_minimum_required_minutes_in_slot" + if (data[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE] == CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS and + (CONFIG_TARGET_ROLLING_TARGET not in data or data[CONFIG_TARGET_ROLLING_TARGET] == False)): + errors[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE] = "always_evaluation_not_supported" + return errors \ No newline at end of file diff --git a/custom_components/target_timeframes/const.py b/custom_components/target_timeframes/const.py index 4468dc7..eaae0f0 100644 --- a/custom_components/target_timeframes/const.py +++ b/custom_components/target_timeframes/const.py @@ -112,6 +112,7 @@ options=[ selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, label="All existing target rates are in the past"), selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, label="Existing target rates haven't started or finished"), + selector.SelectOptionDict(value=CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, label="Always"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) diff --git a/custom_components/target_timeframes/entities/target_timeframe.py b/custom_components/target_timeframes/entities/target_timeframe.py index 9d7c11a..0c9aa45 100644 --- a/custom_components/target_timeframes/entities/target_timeframe.py +++ b/custom_components/target_timeframes/entities/target_timeframe.py @@ -190,7 +190,8 @@ async def async_update(self): is_target_timeframe_complete = is_rolling_target == False and is_target_timeframe_complete_in_period( current_local_date, applicable_target_start, - applicable_target_end, self._target_timeframes, + applicable_target_end, + self._target_timeframes, self._config[CONFIG_TARGET_NAME] ) diff --git a/custom_components/target_timeframes/translations/en.json b/custom_components/target_timeframes/translations/en.json index e41edeb..2567376 100644 --- a/custom_components/target_timeframes/translations/en.json +++ b/custom_components/target_timeframes/translations/en.json @@ -133,7 +133,8 @@ "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", - "invalid_minimum_required_minutes_in_slot": "Value must be between 1 and 30" + "invalid_minimum_required_minutes_in_slot": "Value must be between 1 and 30", + "always_evaluation_not_supported": "Always evaluation mode is only supported when \"Re-evaluate multiple times a day\" is enabled" } }, "rolling_target_time_period": { diff --git a/tests/unit/config/test_validate_target_timeframe_config.py b/tests/unit/config/test_validate_target_timeframe_config.py index 2426430..e046285 100644 --- a/tests/unit/config/test_validate_target_timeframe_config.py +++ b/tests/unit/config/test_validate_target_timeframe_config.py @@ -2,7 +2,7 @@ from homeassistant.util.dt import (as_utc, parse_datetime) from custom_components.target_timeframes.config.target_timeframe import validate_target_timeframe_config -from custom_components.target_timeframes.const import CONFIG_TARGET_DANGEROUS_SETTINGS, CONFIG_TARGET_END_TIME, 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, CONFIG_TARGET_MINIMUM_REQUIRED_MINUTES_IN_SLOT, CONFIG_TARGET_NAME, CONFIG_TARGET_OFFSET, CONFIG_TARGET_START_TIME, CONFIG_TARGET_TYPE, CONFIG_TARGET_TYPE_CONTINUOUS, CONFIG_TARGET_TYPE_INTERMITTENT, CONFIG_TARGET_WEIGHTING, DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD, DATA_SCHEMA_TARGET_TIME_PERIOD +from custom_components.target_timeframes.const import CONFIG_TARGET_DANGEROUS_SETTINGS, CONFIG_TARGET_END_TIME, 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, CONFIG_TARGET_MINIMUM_REQUIRED_MINUTES_IN_SLOT, CONFIG_TARGET_NAME, CONFIG_TARGET_OFFSET, CONFIG_TARGET_ROLLING_TARGET, CONFIG_TARGET_START_TIME, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, CONFIG_TARGET_TYPE, CONFIG_TARGET_TYPE_CONTINUOUS, CONFIG_TARGET_TYPE_INTERMITTENT, CONFIG_TARGET_WEIGHTING, DATA_SCHEMA_ROLLING_TARGET_TIME_PERIOD, DATA_SCHEMA_TARGET_TIME_PERIOD from ..config import assert_errors_not_present, get_schema_keys now = as_utc(parse_datetime("2023-08-20T10:00:00Z")) @@ -22,7 +22,8 @@ async def test_when_config_is_valid_no_errors_returned(): CONFIG_TARGET_MIN_VALUE: "0", CONFIG_TARGET_MAX_VALUE: "10", CONFIG_TARGET_WEIGHTING: "2,*,2", - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -44,7 +45,8 @@ async def test_when_optional_config_is_valid_no_errors_returned(): CONFIG_TARGET_MIN_VALUE: None, CONFIG_TARGET_MAX_VALUE: None, CONFIG_TARGET_WEIGHTING: None, - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -68,7 +70,8 @@ async def test_when_config_has_invalid_name_then_errors_returned(name): CONFIG_TARGET_START_TIME: "00:00", CONFIG_TARGET_END_TIME: "16:00", CONFIG_TARGET_OFFSET: "-00:00:00", - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -89,7 +92,8 @@ async def test_when_config_has_valid_hours_then_no_errors_returned(): CONFIG_TARGET_START_TIME: "00:00", CONFIG_TARGET_END_TIME: "16:00", CONFIG_TARGET_OFFSET: "-00:00:00", - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -117,7 +121,8 @@ async def test_when_config_has_invalid_hours_then_errors_returned(hours): CONFIG_TARGET_START_TIME: "00:00", CONFIG_TARGET_END_TIME: "16:00", CONFIG_TARGET_OFFSET: "-00:00:00", - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -147,7 +152,8 @@ async def test_when_config_has_invalid_start_time_then_errors_returned(start_tim CONFIG_TARGET_START_TIME: start_time, CONFIG_TARGET_END_TIME: "16:00", CONFIG_TARGET_OFFSET: "-00:00:00", - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -178,6 +184,7 @@ async def test_when_config_has_invalid_end_time_then_errors_returned(end_time): CONFIG_TARGET_END_TIME: end_time, CONFIG_TARGET_OFFSET: "-00:00:00", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } # Act @@ -213,6 +220,7 @@ async def test_when_config_has_invalid_offset_then_errors_returned(offset): CONFIG_TARGET_END_TIME: "16:00", CONFIG_TARGET_OFFSET: offset, CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } # Act @@ -238,6 +246,7 @@ async def test_when_hours_exceed_selected_time_frame_then_errors_returned(start_ CONFIG_TARGET_END_TIME: end_time, CONFIG_TARGET_OFFSET: "-00:00:00", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } # Act @@ -260,6 +269,7 @@ async def test_when_config_is_valid_and_not_agile_then_no_errors_returned(start_ CONFIG_TARGET_NAME: "test", CONFIG_TARGET_HOURS: "1.5", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } if start_time is not None: @@ -296,6 +306,7 @@ async def test_when_config_is_valid_and_agile_then_no_errors_returned(start_time CONFIG_TARGET_NAME: "test", CONFIG_TARGET_HOURS: "1.5", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } if start_time is not None: @@ -331,6 +342,7 @@ async def test_when_weighting_is_invalid_then_weighting_error_returned(weighting CONFIG_TARGET_HOURS: "1.5", CONFIG_TARGET_WEIGHTING: weighting, CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } # Act @@ -353,6 +365,7 @@ async def test_when_weighting_set_and_type_invalid_then_weighting_error_returned CONFIG_TARGET_HOURS: "1.5", CONFIG_TARGET_WEIGHTING: "1,2,3", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, } # Act @@ -389,7 +402,8 @@ async def test_when_hour_mode_is_minimum_and_minimum_or_maximum_value_is_specifi CONFIG_TARGET_OFFSET: "-00:30:00", CONFIG_TARGET_MIN_VALUE: min_value, CONFIG_TARGET_MAX_VALUE: max_value, - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MINIMUM + 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 @@ -416,7 +430,8 @@ async def test_when_minimum_value_greater_to_maximum_value_is_specified_then_err CONFIG_TARGET_OFFSET: "-00:30:00", CONFIG_TARGET_MIN_VALUE: min_value, CONFIG_TARGET_MAX_VALUE: max_value, - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MINIMUM + 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 @@ -443,7 +458,8 @@ async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_re CONFIG_TARGET_OFFSET: "-00:30:00", CONFIG_TARGET_WEIGHTING: "2,*,2", CONFIG_TARGET_MIN_VALUE: "0.18", - CONFIG_TARGET_HOURS_MODE: hour_mode + CONFIG_TARGET_HOURS_MODE: hour_mode, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST } # Act @@ -464,7 +480,8 @@ async def test_when_hour_mode_is_minimum_and_minimum_and_maximum_value_is_not_sp CONFIG_TARGET_START_TIME: "00:00", CONFIG_TARGET_END_TIME: "00:00", CONFIG_TARGET_OFFSET: "-00:30:00", - CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MINIMUM + 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 @@ -494,6 +511,7 @@ async def test_when_minimum_required_minutes_set_to_valid_integer_then_no_error_ CONFIG_TARGET_MAX_VALUE: "10", CONFIG_TARGET_WEIGHTING: "2,*,2", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_DANGEROUS_SETTINGS: { CONFIG_TARGET_MINIMUM_REQUIRED_MINUTES_IN_SLOT: minimum_required_minutes } @@ -524,6 +542,7 @@ async def test_when_minimum_required_minutes_set_to_invalid_integer_then_error_r CONFIG_TARGET_MAX_VALUE: "10", CONFIG_TARGET_WEIGHTING: "2,*,2", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_DANGEROUS_SETTINGS: { CONFIG_TARGET_MINIMUM_REQUIRED_MINUTES_IN_SLOT: minimum_required_minutes } @@ -557,6 +576,7 @@ async def test_when_minimum_required_minutes_set_to_invalid_value_then_error_ret CONFIG_TARGET_MAX_VALUE: "10", CONFIG_TARGET_WEIGHTING: "2,*,2", CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST, CONFIG_TARGET_DANGEROUS_SETTINGS: { CONFIG_TARGET_MINIMUM_REQUIRED_MINUTES_IN_SLOT: minimum_required_minutes } @@ -568,4 +588,62 @@ async def test_when_minimum_required_minutes_set_to_invalid_value_then_error_ret # Assert assert CONFIG_TARGET_DANGEROUS_SETTINGS in errors assert errors[CONFIG_TARGET_DANGEROUS_SETTINGS] == "invalid_minimum_required_minutes_in_slot" - assert_errors_not_present(errors, default_keys, CONFIG_TARGET_DANGEROUS_SETTINGS) \ No newline at end of file + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_DANGEROUS_SETTINGS) + +@pytest.mark.asyncio +async def test_when_evaluation_mode_is_always_and_rolling_turned_off_then_error_returned(): + # Arrange + data = { + CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_NAME: "test", + CONFIG_TARGET_HOURS: "1.5", + CONFIG_TARGET_START_TIME: "00:00", + CONFIG_TARGET_END_TIME: "00:00", + CONFIG_TARGET_OFFSET: "-00:30:00", + CONFIG_TARGET_MIN_VALUE: "0", + CONFIG_TARGET_MAX_VALUE: "10", + CONFIG_TARGET_WEIGHTING: "2,*,2", + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS, + + } + + # Act + errors = validate_target_timeframe_config(data) + + # Assert + assert CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE in errors + assert errors[CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE] == "always_evaluation_not_supported" + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE) + +@pytest.mark.asyncio +@pytest.mark.parametrize("rolling_target,evaluation_mode",[ + (True, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALWAYS), + (True, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST), + (False, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_PAST), + (True, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST), + (False, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST), +]) +async def test_when_evaluation_mode_is_not_always_and_rolling_turned_off_then_no_errors_returned(rolling_target: bool, evaluation_mode: str): + # Arrange + data = { + CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_NAME: "test", + CONFIG_TARGET_HOURS: "1.5", + CONFIG_TARGET_START_TIME: "00:00", + CONFIG_TARGET_END_TIME: "00:00", + CONFIG_TARGET_OFFSET: "-00:30:00", + CONFIG_TARGET_MIN_VALUE: "0", + CONFIG_TARGET_MAX_VALUE: "10", + CONFIG_TARGET_WEIGHTING: "2,*,2", + CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_EXACT, + CONFIG_TARGET_ROLLING_TARGET: rolling_target, + CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE: evaluation_mode, + + } + + # Act + errors = validate_target_timeframe_config(data) + + # Assert + assert_errors_not_present(errors, default_keys, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE) \ No newline at end of file