diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index b02a848de7c..6bbadf0e89d 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -19,7 +19,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = ["binary_sensor", "sensor", "switch"] async def async_setup(hass: HomeAssistant, config: dict): diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index abba045693d..179a293ba20 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -162,6 +162,16 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): or self.pending_warning_alerts_count ) + @property + def last_known_valve_state(self) -> str: + """Return the last known valve state for the device.""" + return self._device_information["valve"]["lastKnown"] + + @property + def target_valve_state(self) -> str: + """Return the target valve state for the device.""" + return self._device_information["valve"]["target"] + async def _update_device(self, *_) -> None: """Update the device information from the API.""" self._device_information = await self.api_client.device.get_info( diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py new file mode 100644 index 00000000000..cabf8135ad9 --- /dev/null +++ b/homeassistant/components/flo/switch.py @@ -0,0 +1,59 @@ +"""Switch representing the shutoff valve for the Flo by Moen integration.""" + +from typing import List + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import callback + +from .const import DOMAIN as FLO_DOMAIN +from .device import FloDeviceDataUpdateCoordinator +from .entity import FloEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Flo switches from config entry.""" + devices: List[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN]["devices"] + async_add_entities([FloSwitch(device) for device in devices]) + + +class FloSwitch(FloEntity, SwitchEntity): + """Switch class for the Flo by Moen valve.""" + + def __init__(self, device: FloDeviceDataUpdateCoordinator): + """Initialize the Flo switch.""" + super().__init__("shutoff_valve", "Shutoff Valve", device) + self._state = self._device.last_known_valve_state == "open" + + @property + def is_on(self) -> bool: + """Return True if the valve is open.""" + return self._state + + @property + def icon(self): + """Return the icon to use for the valve.""" + if self.is_on: + return "mdi:valve-open" + return "mdi:valve-closed" + + async def async_turn_on(self, **kwargs) -> None: + """Open the valve.""" + await self._device.api_client.device.open_valve(self._device.id) + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Close the valve.""" + await self._device.api_client.device.close_valve(self._device.id) + self._state = False + self.async_write_ha_state() + + @callback + def async_update_state(self) -> None: + """Retrieve the latest valve state and update the state machine.""" + self._state = self._device.last_known_valve_state == "open" + self.async_write_ha_state() + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove(self._device.async_add_listener(self.async_update_state)) diff --git a/tests/components/flo/conftest.py b/tests/components/flo/conftest.py index 69167b58a02..982bdbdec0d 100644 --- a/tests/components/flo/conftest.py +++ b/tests/components/flo/conftest.py @@ -79,3 +79,19 @@ def aioclient_mock_fixture(aioclient_mock): status=200, headers={"Content-Type": "application/json"}, ) + # Mocks the valve open call for flo. + aioclient_mock.post( + "https://api-gw.meetflo.com/api/v2/devices/98765", + text=load_fixture("flo/device_info_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + json={"valve": {"target": "open"}}, + ) + # Mocks the valve close call for flo. + aioclient_mock.post( + "https://api-gw.meetflo.com/api/v2/devices/98765", + text=load_fixture("flo/device_info_response_closed.json"), + status=200, + headers={"Content-Type": "application/json"}, + json={"valve": {"target": "closed"}}, + ) diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 5f5a035ecbd..db5c8cd5c9e 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -45,6 +45,8 @@ async def test_device(hass, config_entry, aioclient_mock_fixture, aioclient_mock assert device.pending_critical_alerts_count == 0 assert device.pending_warning_alerts_count == 2 assert device.has_alerts is True + assert device.last_known_valve_state == "open" + assert device.target_valve_state == "open" call_count = aioclient_mock.call_count diff --git a/tests/components/flo/test_switch.py b/tests/components/flo/test_switch.py new file mode 100644 index 00000000000..f821e5f57d9 --- /dev/null +++ b/tests/components/flo/test_switch.py @@ -0,0 +1,31 @@ +"""Tests for the switch domain for Flo by Moen.""" +from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN +from homeassistant.components.switch import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + +from .common import TEST_PASSWORD, TEST_USER_ID + + +async def test_valve_switches(hass, config_entry, aioclient_mock_fixture): + """Test Flo by Moen valve switches.""" + config_entry.add_to_hass(hass) + assert await async_setup_component( + hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} + ) + await hass.async_block_till_done() + + assert len(hass.data[FLO_DOMAIN]["devices"]) == 1 + + entity_id = "switch.shutoff_valve" + assert hass.states.get(entity_id).state == STATE_ON + + await hass.services.async_call( + DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + + await hass.services.async_call( + DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/fixtures/flo/device_info_response_closed.json b/tests/fixtures/flo/device_info_response_closed.json new file mode 100644 index 00000000000..28ae3d8154c --- /dev/null +++ b/tests/fixtures/flo/device_info_response_closed.json @@ -0,0 +1,238 @@ +{ + "isConnected": true, + "fwVersion": "6.1.1", + "lastHeardFromTime": "2020-07-24T12:45:00Z", + "fwProperties": { + "alarm_away_high_flow_rate_shut_off_enabled": true, + "alarm_away_high_water_use_shut_off_enabled": true, + "alarm_away_long_flow_event_shut_off_enabled": true, + "alarm_away_v2_shut_off_enabled": true, + "alarm_home_high_flow_rate_shut_off_deferment": 300, + "alarm_home_high_flow_rate_shut_off_enabled": true, + "alarm_home_high_water_use_shut_off_deferment": 300, + "alarm_home_high_water_use_shut_off_enabled": true, + "alarm_home_long_flow_event_shut_off_deferment": 300, + "alarm_home_long_flow_event_shut_off_enabled": true, + "alarm_shut_off_enabled": true, + "alarm_shutoff_id": "", + "alarm_shutoff_time_epoch_sec": -1, + "alarm_snooze_enabled": true, + "alarm_suppress_duplicate_duration": 300, + "alarm_suppress_until_event_end": false, + "data_flosense_force_retrain": 1, + "data_flosense_min_flodetect_sec": 0, + "data_flosense_min_irr_sec": 180, + "data_flosense_status_interval": 1200, + "data_flosense_verbosity": 1, + "device_data_free_mb": 1465, + "device_installed": true, + "device_mem_available_kb": 339456, + "device_rootfs_free_kb": 711504, + "device_uptime_sec": 867190, + "feature_mode": "default", + "flodetect_post_enabled": true, + "flodetect_post_frequency": 0, + "flodetect_storage_days": 60, + "flosense_action": "", + "flosense_deployment_result": "success", + "flosense_link": "", + "flosense_shut_off_enabled": true, + "flosense_shut_off_level": 3, + "flosense_state": "active", + "flosense_version_app": "2.5.3", + "flosense_version_model": "2.5.0", + "fw_ver": "6.1.1", + "fw_ver_a": "6.1.1", + "fw_ver_b": "6.0.3", + "heartbeat_frequency": 1800, + "ht_attempt_interval": 60000, + "ht_check_window_max_pressure_decay_limit": 0.1, + "ht_check_window_width": 30000, + "ht_controller": "ultima", + "ht_max_open_closed_pressure_decay_pct_limit": 2, + "ht_max_pressure_growth_limit": 3, + "ht_max_pressure_growth_pct_limit": 3, + "ht_max_valve_closures_per_24h": 0, + "ht_min_computable_point_limit": 3, + "ht_min_pressure_limit": 10, + "ht_min_r_squared_limit": 0.9, + "ht_min_slope_limit": -0.6, + "ht_phase_1_max_pressure_decay_limit": 6, + "ht_phase_1_max_pressure_decay_pct_limit": 10, + "ht_phase_1_time_index": 12000, + "ht_phase_2_max_pressure_decay_limit": 6, + "ht_phase_2_max_pressure_decay_pct_limit": 10, + "ht_phase_2_time_index": 30000, + "ht_phase_3_max_pressure_decay_limit": 3, + "ht_phase_3_max_pressure_decay_pct_limit": 5, + "ht_phase_3_time_index": 240000, + "ht_phase_4_max_pressure_decay_limit": 1.5, + "ht_phase_4_max_pressure_decay_pct_limit": 5, + "ht_phase_4_time_index": 480000, + "ht_pre_delay": 0, + "ht_recent_flow_event_cool_down": 1000, + "ht_retry_on_fail_interval": 900000, + "ht_scheduler": "flosense", + "ht_scheduler_end": "08:00", + "ht_scheduler_start": "06:00", + "ht_scheduler_ultima_allotted_time_1": "06:00", + "ht_scheduler_ultima_allotted_time_2": "07:00", + "ht_scheduler_ultima_allotted_time_3": "", + "ht_times_per_day": 1, + "log_bytes_sent": 0, + "log_enabled": true, + "log_frequency": 3600, + "log_send": false, + "mender_check": false, + "mender_host": "https://mender.flotech.co", + "mender_parts_link": "", + "mender_ping_delay": 300, + "mender_signature": "20200610", + "motor_delay_close": 175, + "motor_delay_open": 0, + "motor_retry_count": 2, + "motor_timeout": 5000, + "mqtt_host": "mqtt.flosecurecloud.com", + "mqtt_port": 8884, + "pes_away_max_duration": 1505, + "pes_away_max_pressure": 150, + "pes_away_max_temperature": 226, + "pes_away_max_volume": 91.8913240498193, + "pes_away_min_pressure": 20, + "pes_away_min_pressure_duration": 5, + "pes_away_min_temperature": 36, + "pes_away_min_temperature_duration": 10, + "pes_away_v1_high_flow_rate": 7.825131772346, + "pes_away_v1_high_flow_rate_duration": 5, + "pes_away_v2_high_flow_rate": 0.5, + "pes_away_v2_high_flow_rate_duration": 5, + "pes_home_high_flow_rate": 1000, + "pes_home_high_flow_rate_duration": 20, + "pes_home_max_duration": 7431, + "pes_home_max_pressure": 150, + "pes_home_max_temperature": 226, + "pes_home_max_volume": 185.56459045410156, + "pes_home_min_pressure": 20, + "pes_home_min_pressure_duration": 5, + "pes_home_min_temperature": 36, + "pes_home_min_temperature_duration": 10, + "pes_moderately_high_pressure": 80, + "pes_moderately_high_pressure_count": 43200, + "pes_moderately_high_pressure_delay": 300, + "pes_moderately_high_pressure_period": 10, + "player_action": "disabled", + "player_flow": 0, + "player_min_pressure": 40, + "player_pressure": 60, + "player_temperature": 50, + "power_downtime_last_24h": 91, + "power_downtime_last_7days": 91, + "power_downtime_last_reboot": 91, + "pt_state": "ok", + "reboot_count": 26, + "reboot_count_7days": 1, + "reboot_reason": "power_cycle", + "s3_bucket_host": "api-bulk.meetflo.com", + "serial_number": "111111111111", + "system_mode": 2, + "tag": "", + "telemetry_batched_enabled": true, + "telemetry_batched_hf_enabled": true, + "telemetry_batched_hf_interval": 10800, + "telemetry_batched_hf_poll_rate": 100, + "telemetry_batched_interval": 300, + "telemetry_batched_pending_storage": 30, + "telemetry_batched_sent_storage": 30, + "telemetry_flow_rate": 0, + "telemetry_pressure": 42.4, + "telemetry_realtime_change_gpm": 0, + "telemetry_realtime_change_psi": 0, + "telemetry_realtime_enabled": true, + "telemetry_realtime_interval": 1, + "telemetry_realtime_packet_uptime": 0, + "telemetry_realtime_session_last_epoch": 1595555701518, + "telemetry_realtime_sessions_7days": 25, + "telemetry_realtime_storage": 7, + "telemetry_realtime_timeout": 300, + "telemetry_temperature": 68, + "valve_actuation_count": 906, + "valve_actuation_timeout_count": 0, + "valve_state": 1, + "vpn_enabled": false, + "vpn_ip": "", + "water_event_enabled": false, + "water_event_min_duration": 2, + "water_event_min_gallons": 0.1, + "wifi_bytes_received": 24164, + "wifi_bytes_sent": 18319, + "wifi_disconnections": 76, + "wifi_rssi": -50, + "wifi_sta_enc": "psk2", + "wifi_sta_ip": "192.168.1.1", + "wifi_sta_ssid": "SOMESSID", + "zit_auto_count": 2363, + "zit_manual_count": 0 + }, + "id": "98765", + "macAddress": "111111111111", + "nickname": "Smart Water Shutoff", + "isPaired": true, + "deviceModel": "flo_device_075_v2", + "deviceType": "flo_device_v2", + "irrigationType": "sprinklers", + "systemMode": { + "isLocked": false, + "shouldInherit": true, + "lastKnown": "home", + "target": "home" + }, + "valve": { "target": "closed", "lastKnown": "closed" }, + "installStatus": { + "isInstalled": true, + "installDate": "2019-05-04T13:50:04.758Z" + }, + "learning": { "outOfLearningDate": "2019-05-10T21:45:48.916Z" }, + "notifications": { + "pending": { + "infoCount": 0, + "warningCount": 2, + "criticalCount": 0, + "alarmCount": [ + { "id": 30, "severity": "warning", "count": 1 }, + { "id": 31, "severity": "warning", "count": 1 } + ], + "info": { "count": 0, "devices": { "count": 0, "absolute": 0 } }, + "warning": { "count": 2, "devices": { "count": 1, "absolute": 1 } }, + "critical": { "count": 0, "devices": { "count": 0, "absolute": 0 } } + } + }, + "hardwareThresholds": { + "gpm": { "okMin": 0, "okMax": 29, "minValue": 0, "maxValue": 35 }, + "psi": { "okMin": 30, "okMax": 80, "minValue": 0, "maxValue": 100 }, + "lpm": { "okMin": 0, "okMax": 110, "minValue": 0, "maxValue": 130 }, + "kPa": { "okMin": 210, "okMax": 550, "minValue": 0, "maxValue": 700 }, + "tempF": { "okMin": 50, "okMax": 80, "minValue": 0, "maxValue": 100 }, + "tempC": { "okMin": 10, "okMax": 30, "minValue": 0, "maxValue": 40 } + }, + "serialNumber": "111111111111", + "connectivity": { "rssi": -47, "ssid": "SOMESSID" }, + "telemetry": { + "current": { + "gpm": 0, + "psi": 54.20000076293945, + "tempF": 70, + "updated": "2020-07-24T12:20:58Z" + } + }, + "healthTest": { + "config": { + "enabled": true, + "timesPerDay": 1, + "start": "02:00", + "end": "04:00" + } + }, + "shutoff": { "scheduledAt": "1970-01-01T00:00:00.000Z" }, + "actionRules": [], + "location": { "id": "12345abcde" } +}