From 6d2d4b171c5dc890915e2794938f000a5004c92d Mon Sep 17 00:00:00 2001 From: rgc99 Date: Mon, 23 Oct 2023 03:12:15 +0000 Subject: [PATCH] Add entity_id to check_back --- .../irrigation_unlimited.py | 50 ++++-- .../irrigation_unlimited/schema.py | 1 + tests/configs/test_check_back.yaml | 98 ++++++++-- tests/test_check_back.py | 169 ++++++++++++++---- 4 files changed, 250 insertions(+), 68 deletions(-) diff --git a/custom_components/irrigation_unlimited/irrigation_unlimited.py b/custom_components/irrigation_unlimited/irrigation_unlimited.py index e94ca7c..b246d65 100644 --- a/custom_components/irrigation_unlimited/irrigation_unlimited.py +++ b/custom_components/irrigation_unlimited/irrigation_unlimited.py @@ -1352,6 +1352,7 @@ def __init__( self._check_back_resync: bool = True self._state_on = STATE_ON self._state_off = STATE_OFF + self._check_back_entity_id: str = None # private variables self._state: bool = None # This parameter should mirror IUZone._is_on self._check_back_time: timedelta = None @@ -1413,6 +1414,9 @@ def load_params(config: OrderedDict) -> None: self._state_off = config.get(CONF_STATE_OFF, self._state_off) delay = config.get(CONF_DELAY, self._check_back_delay.total_seconds()) self._check_back_delay = wash_td(timedelta(seconds=delay)) + self._check_back_entity_id = config.get( + CONF_ENTITY_ID, self._check_back_entity_id + ) self.clear() self._switch_entity_id = config.get(CONF_ENTITY_ID) @@ -1429,26 +1433,36 @@ def muster(self, stime: datetime) -> int: def check_switch(self, stime: datetime, resync: bool, log: bool) -> list[str]: """Check the linked entity is in sync. Returns a list of entities that are not in sync""" + result: list[str] = [] + + def _check_entity(entity_id: str, expected: str) -> bool: + is_valid = self._hass.states.is_state(entity_id, expected) + if not is_valid: + result.append(entity_id) + if log: + self._coordinator.logger.log_sync_error(stime, expected, entity_id) + self._coordinator.notify_switch( + EVENT_SYNC_ERROR, + expected, + [entity_id], + self._controller, + self._zone, + ) + + return is_valid + if self._switch_entity_id is not None: - for entity_id in self._switch_entity_id: - expected = self._state_on if self._state else self._state_off - is_valid = self._hass.states.is_state(entity_id, expected) - if not is_valid: - result.append(entity_id) - if log: - self._coordinator.logger.log_sync_error( - stime, expected, entity_id - ) - self._coordinator.notify_switch( - EVENT_SYNC_ERROR, - expected, - [entity_id], - self._controller, - self._zone, - ) - if resync: - self._set_switch(entity_id, self._state) + expected = self._state_on if self._state else self._state_off + if self._check_back_entity_id is None: + for entity_id in self._switch_entity_id: + if not _check_entity(entity_id, expected): + if resync: + self._set_switch(entity_id, self._state) + else: + if not _check_entity(self._check_back_entity_id, expected): + if resync and len(self._switch_entity_id) == 1: + self._set_switch(self._switch_entity_id, self._state) return result def call_switch(self, state: bool, stime: datetime = None) -> None: diff --git a/custom_components/irrigation_unlimited/schema.py b/custom_components/irrigation_unlimited/schema.py index b101e01..c0b6aea 100644 --- a/custom_components/irrigation_unlimited/schema.py +++ b/custom_components/irrigation_unlimited/schema.py @@ -196,6 +196,7 @@ def _parse_dd_mmm(value: str) -> date | None: vol.Optional(CONF_RESYNC): cv.boolean, vol.Optional(CONF_STATE_ON): cv.string, vol.Optional(CONF_STATE_OFF): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_id, } ) diff --git a/tests/configs/test_check_back.yaml b/tests/configs/test_check_back.yaml index b9a3a96..626f51f 100644 --- a/tests/configs/test_check_back.yaml +++ b/tests/configs/test_check_back.yaml @@ -2,31 +2,35 @@ default_config: # Dummy switches input_boolean: - dummy_switch_c1_m: + dummy_switch_m: name: Dummy Master Switch initial: false - dummy_switch_c1_z1: + dummy_switch_z1: name: Dummy Zone Switch 1 initial: false - dummy_switch_c1_z2: + dummy_switch_z2: name: Dummy Zone Switch 2 initial: false - dummy_switch_c1_z3: + dummy_switch_z3: name: Dummy Zone Switch 3 initial: false - dummy_switch_c1_z4: + dummy_switch_z4: name: Dummy Zone Switch 4 initial: false + dummy_check_back_switch: + name: Dummy Check Back Switch + initial: false + irrigation_unlimited: refresh_interval: 2000 controllers: - name: "Test controller 1" - entity_id: input_boolean.dummy_switch_c1_m + entity_id: input_boolean.dummy_switch_m all_zones_config: check_back: states: all @@ -37,9 +41,9 @@ irrigation_unlimited: state_off: "off" zones: - name: "Zone 1" - entity_id: input_boolean.dummy_switch_c1_z1 + entity_id: input_boolean.dummy_switch_z1 - name: "Zone 2" - entity_id: input_boolean.dummy_switch_c1_z2,input_boolean.dummy_switch_c1_z3,input_boolean.dummy_switch_c1_z4 + entity_id: input_boolean.dummy_switch_z2,input_boolean.dummy_switch_z3,input_boolean.dummy_switch_z4 check_back: states: all delay: 30 @@ -58,12 +62,12 @@ irrigation_unlimited: - zone_id: 2 duration: "0:12:00" - name: "Test controller 2" - entity_id: input_boolean.dummy_switch_c1_m + entity_id: input_boolean.dummy_switch_m zones: - name: "Zone 1" - entity_id: input_boolean.dummy_switch_c1_z1 + entity_id: input_boolean.dummy_switch_z1 - name: "Zone 2" - entity_id: input_boolean.dummy_switch_c1_z2,input_boolean.dummy_switch_c1_z3,input_boolean.dummy_switch_c1_z4 + entity_id: input_boolean.dummy_switch_z2,input_boolean.dummy_switch_z3,input_boolean.dummy_switch_z4 sequences: - name: "Sequence 1" delay: "0:01:00" @@ -74,6 +78,34 @@ irrigation_unlimited: duration: "0:00:15" - zone_id: 2 duration: "0:00:15" + - name: "Test controller 3" + entity_id: input_boolean.dummy_switch_m + check_back: + states: none + all_zones_config: + check_back: + entity_id: input_boolean.dummy_check_back_switch + states: all + delay: 15 + retries: 5 + resync: true + state_on: "on" + state_off: "off" + zones: + - name: "Zone 1" + entity_id: input_boolean.dummy_switch_z1 + - name: "Zone 2" + entity_id: input_boolean.dummy_switch_z2,input_boolean.dummy_switch_z3,input_boolean.dummy_switch_z4 + sequences: + - name: "Sequence 1" + delay: "0:01:00" + schedules: + - time: "20:05" + zones: + - zone_id: 1 + duration: "0:06:00" + - zone_id: 2 + duration: "0:12:00" testing: enabled: true speed: 600.0 @@ -93,7 +125,19 @@ irrigation_unlimited: - {t: '2021-01-04 07:12:00', c: 1, z: 2, s: 1} - {t: '2021-01-04 07:24:00', c: 1, z: 2, s: 0} - {t: '2021-01-04 07:24:00', c: 1, z: 0, s: 0} - - name: "2-Faux partial communications error on zone 2" + - name: "2-Check entity is corrected" + start: "2021-01-04 07:00" + end: "2021-01-04 08:00" + results: + - {t: '2021-01-04 07:05:00', c: 1, z: 0, s: 1} + - {t: '2021-01-04 07:05:00', c: 1, z: 1, s: 1} + - {t: '2021-01-04 07:11:00', c: 1, z: 1, s: 0} + - {t: '2021-01-04 07:11:00', c: 1, z: 0, s: 0} + - {t: '2021-01-04 07:12:00', c: 1, z: 0, s: 1} + - {t: '2021-01-04 07:12:00', c: 1, z: 2, s: 1} + - {t: '2021-01-04 07:24:00', c: 1, z: 2, s: 0} + - {t: '2021-01-04 07:24:00', c: 1, z: 0, s: 0} + - name: "3-Faux partial communications error on zone 2" start: "2021-01-04 07:00" end: "2021-01-04 08:00" results: @@ -105,7 +149,7 @@ irrigation_unlimited: - {t: '2021-01-04 07:12:00', c: 1, z: 2, s: 1} - {t: '2021-01-04 07:24:00', c: 1, z: 2, s: 0} - {t: '2021-01-04 07:24:00', c: 1, z: 0, s: 0} - - name: "3-Faux full communications error on zone 2" + - name: "4-Faux full communications error on zone 2" start: "2021-01-04 07:00" end: "2021-01-04 08:00" results: @@ -117,7 +161,7 @@ irrigation_unlimited: - {t: '2021-01-04 07:12:00', c: 1, z: 2, s: 1} - {t: '2021-01-04 07:24:00', c: 1, z: 2, s: 0} - {t: '2021-01-04 07:24:00', c: 1, z: 0, s: 0} - - name: "4-Faux full communications error" + - name: "5-Faux full communications error" start: "2021-01-04 07:00" end: "2021-01-04 08:00" results: @@ -129,7 +173,7 @@ irrigation_unlimited: - {t: '2021-01-04 07:12:00', c: 1, z: 2, s: 1} - {t: '2021-01-04 07:24:00', c: 1, z: 2, s: 0} - {t: '2021-01-04 07:24:00', c: 1, z: 0, s: 0} - - name: "5-State change before chack back" + - name: "6-State change before check back" start: "2021-01-04 19:00" end: "2021-01-04 20:00" results: @@ -141,3 +185,27 @@ irrigation_unlimited: - {t: '2021-01-04 19:06:15', c: 2, z: 2, s: 1} - {t: '2021-01-04 19:06:30', c: 2, z: 2, s: 0} - {t: '2021-01-04 19:06:30', c: 2, z: 0, s: 0} + - name: "7-Check back from other entity, no errors" + start: "2021-01-04 20:00" + end: "2021-01-04 21:00" + results: + - {t: '2021-01-04 20:05:00', c: 3, z: 0, s: 1} + - {t: '2021-01-04 20:05:00', c: 3, z: 1, s: 1} + - {t: '2021-01-04 20:11:00', c: 3, z: 1, s: 0} + - {t: '2021-01-04 20:11:00', c: 3, z: 0, s: 0} + - {t: '2021-01-04 20:12:00', c: 3, z: 0, s: 1} + - {t: '2021-01-04 20:12:00', c: 3, z: 2, s: 1} + - {t: '2021-01-04 20:24:00', c: 3, z: 2, s: 0} + - {t: '2021-01-04 20:24:00', c: 3, z: 0, s: 0} + - name: "8-Check back from other entity - check restore" + start: "2021-01-04 20:00" + end: "2021-01-04 21:00" + results: + - {t: '2021-01-04 20:05:00', c: 3, z: 0, s: 1} + - {t: '2021-01-04 20:05:00', c: 3, z: 1, s: 1} + - {t: '2021-01-04 20:11:00', c: 3, z: 1, s: 0} + - {t: '2021-01-04 20:11:00', c: 3, z: 0, s: 0} + - {t: '2021-01-04 20:12:00', c: 3, z: 0, s: 1} + - {t: '2021-01-04 20:12:00', c: 3, z: 2, s: 1} + - {t: '2021-01-04 20:24:00', c: 3, z: 2, s: 0} + - {t: '2021-01-04 20:24:00', c: 3, z: 0, s: 0} diff --git a/tests/test_check_back.py b/tests/test_check_back.py index 8335ae7..bb35937 100644 --- a/tests/test_check_back.py +++ b/tests/test_check_back.py @@ -1,5 +1,5 @@ """irrigation_unlimited check_back tester""" -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import patch import homeassistant.core as ha from custom_components.irrigation_unlimited.const import ( @@ -10,7 +10,7 @@ from custom_components.irrigation_unlimited.irrigation_unlimited import ( IULogger, ) -from tests.iu_test_support import IUExam, mk_utc, parse_utc +from tests.iu_test_support import IUExam, parse_utc IUExam.quiet_mode() @@ -21,7 +21,6 @@ async def test_check_back(hass: ha.HomeAssistant, skip_dependencies, skip_histor # pylint: disable=too-many-statements async with IUExam(hass, "test_check_back.yaml") as exam: - await exam.load_component("homeassistant") await exam.load_component("input_boolean") @@ -43,47 +42,91 @@ def handle_switch_events(event: ha.Event) -> None: hass.bus.async_listen(f"{DOMAIN}_{EVENT_SYNC_ERROR}", handle_sync_events) hass.bus.async_listen(f"{DOMAIN}_{EVENT_SWITCH_ERROR}", handle_switch_events) - async def kill_c1_m(atime: datetime) -> None: - """Turn off dummy_switch_c1_m at the specified time""" + async def kill_m(atime: datetime) -> None: + """Turn off dummy_switch_m at the specified time""" + await exam.run_until(atime) + await hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": "input_boolean.dummy_switch_m"}, + True, + ) + + async def kill_z1(atime: datetime) -> None: + """Turn off dummy_switch_z1 at the specified time""" await exam.run_until(atime) await hass.services.async_call( "homeassistant", "turn_off", - {"entity_id": "input_boolean.dummy_switch_c1_m"}, + {"entity_id": "input_boolean.dummy_switch_z1"}, True, ) - async def kill_c1_z3(atime: datetime) -> None: - """Turn off dummy_switch_c1_z3 at the specified time""" + async def kill_z3(atime: datetime) -> None: + """Turn off dummy_switch_z3 at the specified time""" await exam.run_until(atime) await hass.services.async_call( "homeassistant", "turn_off", - {"entity_id": "input_boolean.dummy_switch_c1_z3"}, + {"entity_id": "input_boolean.dummy_switch_z3"}, + True, + ) + + async def change_check_back_switch(state: bool) -> None: + """Turn on/off dummy_switch_call_back_switch""" + await hass.services.async_call( + "homeassistant", + "turn_on" if state else "turn_off", + {"entity_id": "input_boolean.dummy_check_back_switch"}, True, ) + # Basic check + # pylint: disable=protected-access + assert exam.coordinator.controllers[2].zones[ + 0 + ]._switch._check_back_delay == timedelta(seconds=15) + assert exam.coordinator.controllers[2].zones[0]._switch._check_back_retries == 5 + # Regular run - no errors sync_event_errors.clear() switch_event_errors.clear() with patch.object(IULogger, "log_sync_error") as mock_sync_logger: with patch.object(IULogger, "log_switch_error") as mock_switch_logger: - await exam.run_test(1) assert mock_sync_logger.call_count == 0 assert mock_switch_logger.call_count == 0 assert len(sync_event_errors) == 0 assert len(switch_event_errors) == 0 - # Partial comms error on c1_z3 + # Check switch gets turned back on sync_event_errors.clear() switch_event_errors.clear() with patch.object(IULogger, "log_sync_error") as mock_sync_logger: with patch.object(IULogger, "log_switch_error") as mock_switch_logger: - await exam.begin_test(2) - await kill_c1_z3(mk_utc("2021-01-04 07:12:15")) - await kill_c1_z3(mk_utc("2021-01-04 07:12:45")) + await kill_m("2021-01-04 07:05:15") + assert hass.states.get("input_boolean.dummy_switch_m").state == "off" + await exam.run_until("2021-01-04 07:05:45") + assert hass.states.get("input_boolean.dummy_switch_m").state == "on" + await kill_z3("2021-01-04 07:12:15") + assert hass.states.get("input_boolean.dummy_switch_z3").state == "off" + await exam.run_until("2021-01-04 07:12:45") + assert hass.states.get("input_boolean.dummy_switch_z3").state == "on" + await exam.finish_test() + assert mock_sync_logger.call_count == 2 + assert mock_switch_logger.call_count == 0 + assert len(sync_event_errors) == 2 + assert len(switch_event_errors) == 0 + + # Partial comms error on c1_z3 + sync_event_errors.clear() + switch_event_errors.clear() + with patch.object(IULogger, "log_sync_error") as mock_sync_logger: + with patch.object(IULogger, "log_switch_error") as mock_switch_logger: + await exam.begin_test(3) + await kill_z3("2021-01-04 07:12:15") + await kill_z3("2021-01-04 07:12:45") await exam.finish_test() assert mock_sync_logger.call_count == 2 assert mock_switch_logger.call_count == 0 @@ -92,14 +135,14 @@ async def kill_c1_z3(atime: datetime) -> None: { "vtime": parse_utc("2021-01-04 15:12:00"), "expected": "on", - "entity_id": "input_boolean.dummy_switch_c1_z3", + "entity_id": "input_boolean.dummy_switch_z3", "controller": {"index": 0, "name": "Test controller 1"}, "zone": {"index": 1, "name": "Zone 2"}, }, { "vtime": parse_utc("2021-01-04 15:12:30"), "expected": "on", - "entity_id": "input_boolean.dummy_switch_c1_z3", + "entity_id": "input_boolean.dummy_switch_z3", "controller": {"index": 0, "name": "Test controller 1"}, "zone": {"index": 1, "name": "Zone 2"}, }, @@ -110,12 +153,11 @@ async def kill_c1_z3(atime: datetime) -> None: switch_event_errors.clear() with patch.object(IULogger, "log_sync_error") as mock_sync_logger: with patch.object(IULogger, "log_switch_error") as mock_switch_logger: - - await exam.begin_test(3) - await kill_c1_z3(mk_utc("2021-01-04 07:12:15")) - await kill_c1_z3(mk_utc("2021-01-04 07:12:45")) - await kill_c1_z3(mk_utc("2021-01-04 07:13:15")) - await kill_c1_z3(mk_utc("2021-01-04 07:13:45")) + await exam.begin_test(4) + await kill_z3("2021-01-04 07:12:15") + await kill_z3("2021-01-04 07:12:45") + await kill_z3("2021-01-04 07:13:15") + await kill_z3("2021-01-04 07:13:45") await exam.finish_test() assert mock_sync_logger.call_count == 3 assert mock_switch_logger.call_count == 1 @@ -123,21 +165,21 @@ async def kill_c1_z3(atime: datetime) -> None: { "vtime": parse_utc("2021-01-04 15:12:00"), "expected": "on", - "entity_id": "input_boolean.dummy_switch_c1_z3", + "entity_id": "input_boolean.dummy_switch_z3", "controller": {"index": 0, "name": "Test controller 1"}, "zone": {"index": 1, "name": "Zone 2"}, }, { "vtime": parse_utc("2021-01-04 15:12:30"), "expected": "on", - "entity_id": "input_boolean.dummy_switch_c1_z3", + "entity_id": "input_boolean.dummy_switch_z3", "controller": {"index": 0, "name": "Test controller 1"}, "zone": {"index": 1, "name": "Zone 2"}, }, { "vtime": parse_utc("2021-01-04 15:13:00"), "expected": "on", - "entity_id": "input_boolean.dummy_switch_c1_z3", + "entity_id": "input_boolean.dummy_switch_z3", "controller": {"index": 0, "name": "Test controller 1"}, "zone": {"index": 1, "name": "Zone 2"}, }, @@ -146,7 +188,7 @@ async def kill_c1_z3(atime: datetime) -> None: { "vtime": parse_utc("2021-01-04 15:13:30"), "expected": "on", - "entity_id": "input_boolean.dummy_switch_c1_z3", + "entity_id": "input_boolean.dummy_switch_z3", "controller": {"index": 0, "name": "Test controller 1"}, "zone": {"index": 1, "name": "Zone 2"}, } @@ -156,12 +198,11 @@ async def kill_c1_z3(atime: datetime) -> None: sync_event_errors.clear() switch_event_errors.clear() with patch.object(IULogger, "_output") as mock_logger: - - await exam.begin_test(4) - await kill_c1_m(mk_utc("2021-01-04 07:12:15")) - await kill_c1_m(mk_utc("2021-01-04 07:12:45")) - await kill_c1_m(mk_utc("2021-01-04 07:13:15")) - await kill_c1_m(mk_utc("2021-01-04 07:13:45")) + await exam.begin_test(5) + await kill_m("2021-01-04 07:12:15") + await kill_m("2021-01-04 07:12:45") + await kill_m("2021-01-04 07:13:15") + await kill_m("2021-01-04 07:13:45") await exam.finish_test() assert ( mock_logger.call_count == 14 @@ -172,12 +213,70 @@ async def kill_c1_z3(atime: datetime) -> None: switch_event_errors.clear() with patch.object(IULogger, "log_sync_error") as mock_sync_logger: with patch.object(IULogger, "log_switch_error") as mock_switch_logger: - - await exam.begin_test(5) - await kill_c1_z3(mk_utc("2021-01-04 19:06:20")) + await exam.begin_test(6) + await kill_z3("2021-01-04 19:06:20") await exam.finish_test() assert len(sync_event_errors) == 1 assert len(switch_event_errors) == 0 + # Read state from other entity + sync_event_errors.clear() + switch_event_errors.clear() + with patch.object(IULogger, "log_sync_error") as mock_sync_logger: + with patch.object(IULogger, "log_switch_error") as mock_switch_logger: + await change_check_back_switch(False) + await exam.begin_test(7) + await kill_m("2021-01-04 20:05:05") + await kill_z1("2021-01-04 20:05:05") + await change_check_back_switch(True) + await exam.run_until("2021-01-04 20:11:05") + await change_check_back_switch(False) + await kill_z3("2021-01-04 20:12:05") + await change_check_back_switch(True) + await exam.run_until("2021-01-04 20:24:05") + await change_check_back_switch(False) + await exam.finish_test() + assert mock_sync_logger.call_count == 0 + assert mock_switch_logger.call_count == 0 + assert len(sync_event_errors) == 0 + assert len(switch_event_errors) == 0 + + sync_event_errors.clear() + switch_event_errors.clear() + with patch.object(IULogger, "log_sync_error") as mock_sync_logger: + with patch.object(IULogger, "log_switch_error") as mock_switch_logger: + await change_check_back_switch(False) + await exam.begin_test(8) + await kill_z1("2021-01-04 20:05:05") + assert hass.states.get("input_boolean.dummy_switch_z1").state == "off" + assert ( + hass.states.get("input_boolean.dummy_check_back_switch").state + == "off" + ) + await exam.run_until("2021-01-04 20:05:20") + assert hass.states.get("input_boolean.dummy_switch_z1").state == "on" + assert ( + hass.states.get("input_boolean.dummy_check_back_switch").state + == "off" + ) + await kill_z3("2021-01-04 20:12:05") + assert hass.states.get("input_boolean.dummy_switch_z3").state == "off" + assert ( + hass.states.get("input_boolean.dummy_check_back_switch").state + == "off" + ) + await exam.run_until("2021-01-04 20:24:20") + # Check no attempt to resync on group switch + assert hass.states.get("input_boolean.dummy_switch_z3").state == "off" + assert ( + hass.states.get("input_boolean.dummy_check_back_switch").state + == "off" + ) + await exam.finish_test() + assert mock_sync_logger.call_count == 10 + assert mock_switch_logger.call_count == 2 + assert len(sync_event_errors) == 10 + assert len(switch_event_errors) == 2 + # Check the exam results exam.check_summary()