Add support for Flo "pucks" (#47074)

So far the Flo integration only supports shutoff valves. Add support for Flo leak detector pucks, which measure temperature and humidity in addition to providing leak alerts.
This commit is contained in:
Adam Ernst 2021-03-08 06:36:03 -06:00 committed by GitHub
parent d37fb7d88d
commit ad86eb4be3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 389 additions and 66 deletions

View file

@ -17,7 +17,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
devices: List[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][
config_entry.entry_id
]["devices"]
entities = [FloPendingAlertsBinarySensor(device) for device in devices]
entities = []
for device in devices:
if device.device_type == "puck_oem":
# Flo "pucks" (leak detectors) *do* support pending alerts.
# However these pending alerts mix unrelated issues like
# low-battery alerts, humidity alerts, & temperature alerts
# in addition to the critical "water detected" alert.
#
# Since there are non-binary sensors for battery, humidity,
# and temperature, the binary sensor should only cover
# water detection. If this sensor trips, you really have
# a problem - vs. battery/temp/humidity which are warnings.
entities.append(FloWaterDetectedBinarySensor(device))
else:
entities.append(FloPendingAlertsBinarySensor(device))
async_add_entities(entities)
@ -48,3 +62,21 @@ class FloPendingAlertsBinarySensor(FloEntity, BinarySensorEntity):
def device_class(self):
"""Return the device class for the binary sensor."""
return DEVICE_CLASS_PROBLEM
class FloWaterDetectedBinarySensor(FloEntity, BinarySensorEntity):
"""Binary sensor that reports if water is detected (for leak detectors)."""
def __init__(self, device):
"""Initialize the pending alerts binary sensor."""
super().__init__("water_detected", "Water Detected", device)
@property
def is_on(self):
"""Return true if the Flo device is detecting water."""
return self._device.water_detected
@property
def device_class(self):
"""Return the device class for the binary sensor."""
return DEVICE_CLASS_PROBLEM

View file

@ -58,7 +58,9 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
@property
def device_name(self) -> str:
"""Return device name."""
return f"{self.manufacturer} {self.model}"
return self._device_information.get(
"nickname", f"{self.manufacturer} {self.model}"
)
@property
def manufacturer(self) -> str:
@ -120,6 +122,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
"""Return the current temperature in degrees F."""
return self._device_information["telemetry"]["current"]["tempF"]
@property
def humidity(self) -> float:
"""Return the current humidity in percent (0-100)."""
return self._device_information["telemetry"]["current"]["humidity"]
@property
def consumption_today(self) -> float:
"""Return the current consumption for today in gallons."""
@ -159,6 +166,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
or self.pending_warning_alerts_count
)
@property
def water_detected(self) -> bool:
"""Return whether water is detected, for leak detectors."""
return self._device_information["fwProperties"]["telemetry_water"]
@property
def last_known_valve_state(self) -> str:
"""Return the last known valve state for the device."""
@ -169,6 +181,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
"""Return the target valve state for the device."""
return self._device_information["valve"]["target"]
@property
def battery_level(self) -> float:
"""Return the battery level for battery-powered device, e.g. leak detectors."""
return self._device_information["battery"]["level"]
async def async_set_mode_home(self):
"""Set the Flo location to home mode."""
await self.api_client.location.set_mode_home(self._flo_location_id)

View file

@ -3,13 +3,15 @@
from typing import List, Optional
from homeassistant.const import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
PERCENTAGE,
PRESSURE_PSI,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
VOLUME_GALLONS,
)
from homeassistant.util.temperature import fahrenheit_to_celsius
from .const import DOMAIN as FLO_DOMAIN
from .device import FloDeviceDataUpdateCoordinator
@ -20,8 +22,11 @@ GAUGE_ICON = "mdi:gauge"
NAME_DAILY_USAGE = "Today's Water Usage"
NAME_CURRENT_SYSTEM_MODE = "Current System Mode"
NAME_FLOW_RATE = "Water Flow Rate"
NAME_TEMPERATURE = "Water Temperature"
NAME_WATER_TEMPERATURE = "Water Temperature"
NAME_AIR_TEMPERATURE = "Temperature"
NAME_WATER_PRESSURE = "Water Pressure"
NAME_HUMIDITY = "Humidity"
NAME_BATTERY = "Battery"
async def async_setup_entry(hass, config_entry, async_add_entities):
@ -30,11 +35,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
config_entry.entry_id
]["devices"]
entities = []
entities.extend([FloDailyUsageSensor(device) for device in devices])
entities.extend([FloSystemModeSensor(device) for device in devices])
entities.extend([FloCurrentFlowRateSensor(device) for device in devices])
entities.extend([FloTemperatureSensor(device) for device in devices])
entities.extend([FloPressureSensor(device) for device in devices])
for device in devices:
if device.device_type == "puck_oem":
entities.extend(
[
FloTemperatureSensor(NAME_AIR_TEMPERATURE, device),
FloHumiditySensor(device),
FloBatterySensor(device),
]
)
else:
entities.extend(
[
FloDailyUsageSensor(device),
FloSystemModeSensor(device),
FloCurrentFlowRateSensor(device),
FloTemperatureSensor(NAME_WATER_TEMPERATURE, device),
FloPressureSensor(device),
]
)
async_add_entities(entities)
@ -109,9 +128,9 @@ class FloCurrentFlowRateSensor(FloEntity):
class FloTemperatureSensor(FloEntity):
"""Monitors the temperature."""
def __init__(self, device):
def __init__(self, name, device):
"""Initialize the temperature sensor."""
super().__init__("temperature", NAME_TEMPERATURE, device)
super().__init__("temperature", name, device)
self._state: float = None
@property
@ -119,12 +138,12 @@ class FloTemperatureSensor(FloEntity):
"""Return the current temperature."""
if self._device.temperature is None:
return None
return round(fahrenheit_to_celsius(self._device.temperature), 1)
return round(self._device.temperature, 1)
@property
def unit_of_measurement(self) -> str:
"""Return gallons as the unit measurement for water."""
return TEMP_CELSIUS
"""Return fahrenheit as the unit measurement for temperature."""
return TEMP_FAHRENHEIT
@property
def device_class(self) -> Optional[str]:
@ -132,6 +151,32 @@ class FloTemperatureSensor(FloEntity):
return DEVICE_CLASS_TEMPERATURE
class FloHumiditySensor(FloEntity):
"""Monitors the humidity."""
def __init__(self, device):
"""Initialize the humidity sensor."""
super().__init__("humidity", NAME_HUMIDITY, device)
self._state: float = None
@property
def state(self) -> Optional[float]:
"""Return the current humidity."""
if self._device.humidity is None:
return None
return round(self._device.humidity, 1)
@property
def unit_of_measurement(self) -> str:
"""Return percent as the unit measurement for humidity."""
return PERCENTAGE
@property
def device_class(self) -> Optional[str]:
"""Return the device class for this sensor."""
return DEVICE_CLASS_HUMIDITY
class FloPressureSensor(FloEntity):
"""Monitors the water pressure."""
@ -156,3 +201,27 @@ class FloPressureSensor(FloEntity):
def device_class(self) -> Optional[str]:
"""Return the device class for this sensor."""
return DEVICE_CLASS_PRESSURE
class FloBatterySensor(FloEntity):
"""Monitors the battery level for battery-powered leak detectors."""
def __init__(self, device):
"""Initialize the battery sensor."""
super().__init__("battery", NAME_BATTERY, device)
self._state: float = None
@property
def state(self) -> Optional[float]:
"""Return the current battery level."""
return self._device.battery_level
@property
def unit_of_measurement(self) -> str:
"""Return percentage as the unit measurement for battery."""
return PERCENTAGE
@property
def device_class(self) -> Optional[str]:
"""Return the device class for this sensor."""
return DEVICE_CLASS_BATTERY

View file

@ -26,7 +26,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
devices: List[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][
config_entry.entry_id
]["devices"]
async_add_entities([FloSwitch(device) for device in devices])
entities = []
for device in devices:
if device.device_type != "puck_oem":
entities.append(FloSwitch(device))
async_add_entities(entities)
platform = entity_platform.current_platform.get()

View file

@ -43,13 +43,19 @@ def aioclient_mock_fixture(aioclient_mock):
headers={"Content-Type": CONTENT_TYPE_JSON},
status=200,
)
# Mocks the device for flo.
# Mocks the devices for flo.
aioclient_mock.get(
"https://api-gw.meetflo.com/api/v2/devices/98765",
text=load_fixture("flo/device_info_response.json"),
status=200,
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.get(
"https://api-gw.meetflo.com/api/v2/devices/32839",
text=load_fixture("flo/device_info_response_detector.json"),
status=200,
headers={"Content-Type": CONTENT_TYPE_JSON},
)
# Mocks the water consumption for flo.
aioclient_mock.get(
"https://api-gw.meetflo.com/api/v2/water/consumption",

View file

@ -4,6 +4,7 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_PASSWORD,
CONF_USERNAME,
STATE_OFF,
STATE_ON,
)
from homeassistant.setup import async_setup_component
@ -19,12 +20,14 @@ async def test_binary_sensors(hass, config_entry, aioclient_mock_fixture):
)
await hass.async_block_till_done()
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
# we should have 6 entities for the device
state = hass.states.get("binary_sensor.pending_system_alerts")
assert state.state == STATE_ON
assert state.attributes.get("info") == 0
assert state.attributes.get("warning") == 2
assert state.attributes.get("critical") == 0
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Pending System Alerts"
valve_state = hass.states.get("binary_sensor.pending_system_alerts")
assert valve_state.state == STATE_ON
assert valve_state.attributes.get("info") == 0
assert valve_state.attributes.get("warning") == 2
assert valve_state.attributes.get("critical") == 0
assert valve_state.attributes.get(ATTR_FRIENDLY_NAME) == "Pending System Alerts"
detector_state = hass.states.get("binary_sensor.water_detected")
assert detector_state.state == STATE_OFF

View file

@ -13,46 +13,64 @@ from tests.common import async_fire_time_changed
async def test_device(hass, config_entry, aioclient_mock_fixture, aioclient_mock):
"""Test Flo by Moen device."""
"""Test Flo by Moen devices."""
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][config_entry.entry_id]["devices"]) == 1
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
device: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][
valve: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][
config_entry.entry_id
]["devices"][0]
assert device.api_client is not None
assert device.available
assert device.consumption_today == 3.674
assert device.current_flow_rate == 0
assert device.current_psi == 54.20000076293945
assert device.current_system_mode == "home"
assert device.target_system_mode == "home"
assert device.firmware_version == "6.1.1"
assert device.device_type == "flo_device_v2"
assert device.id == "98765"
assert device.last_heard_from_time == "2020-07-24T12:45:00Z"
assert device.location_id == "mmnnoopp"
assert device.hass is not None
assert device.temperature == 70
assert device.mac_address == "111111111111"
assert device.model == "flo_device_075_v2"
assert device.manufacturer == "Flo by Moen"
assert device.device_name == "Flo by Moen flo_device_075_v2"
assert device.rssi == -47
assert device.pending_info_alerts_count == 0
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"
assert valve.api_client is not None
assert valve.available
assert valve.consumption_today == 3.674
assert valve.current_flow_rate == 0
assert valve.current_psi == 54.20000076293945
assert valve.current_system_mode == "home"
assert valve.target_system_mode == "home"
assert valve.firmware_version == "6.1.1"
assert valve.device_type == "flo_device_v2"
assert valve.id == "98765"
assert valve.last_heard_from_time == "2020-07-24T12:45:00Z"
assert valve.location_id == "mmnnoopp"
assert valve.hass is not None
assert valve.temperature == 70
assert valve.mac_address == "111111111111"
assert valve.model == "flo_device_075_v2"
assert valve.manufacturer == "Flo by Moen"
assert valve.device_name == "Smart Water Shutoff"
assert valve.rssi == -47
assert valve.pending_info_alerts_count == 0
assert valve.pending_critical_alerts_count == 0
assert valve.pending_warning_alerts_count == 2
assert valve.has_alerts is True
assert valve.last_known_valve_state == "open"
assert valve.target_valve_state == "open"
detector: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][
config_entry.entry_id
]["devices"][1]
assert detector.api_client is not None
assert detector.available
assert detector.device_type == "puck_oem"
assert detector.id == "32839"
assert detector.last_heard_from_time == "2021-03-07T14:05:00Z"
assert detector.location_id == "mmnnoopp"
assert detector.hass is not None
assert detector.temperature == 61
assert detector.humidity == 43
assert detector.water_detected is False
assert detector.mac_address == "1a2b3c4d5e6f"
assert detector.model == "puck_v1"
assert detector.manufacturer == "Flo by Moen"
assert detector.device_name == "Kitchen Sink"
call_count = aioclient_mock.call_count
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=90))
await hass.async_block_till_done()
assert aioclient_mock.call_count == call_count + 2
assert aioclient_mock.call_count == call_count + 4

View file

@ -13,6 +13,6 @@ async def test_setup_entry(hass, config_entry, aioclient_mock_fixture):
hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
)
await hass.async_block_till_done()
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
assert await hass.config_entries.async_unload(config_entry.entry_id)

View file

@ -14,14 +14,19 @@ async def test_sensors(hass, config_entry, aioclient_mock_fixture):
)
await hass.async_block_till_done()
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
# we should have 5 entities for the device
# we should have 5 entities for the valve
assert hass.states.get("sensor.current_system_mode").state == "home"
assert hass.states.get("sensor.today_s_water_usage").state == "3.7"
assert hass.states.get("sensor.water_flow_rate").state == "0"
assert hass.states.get("sensor.water_pressure").state == "54.2"
assert hass.states.get("sensor.water_temperature").state == "21.1"
assert hass.states.get("sensor.water_temperature").state == "21"
# and 3 entities for the detector
assert hass.states.get("sensor.temperature").state == "16"
assert hass.states.get("sensor.humidity").state == "43"
assert hass.states.get("sensor.battery").state == "100"
async def test_manual_update_entity(
@ -34,7 +39,7 @@ async def test_manual_update_entity(
)
await hass.async_block_till_done()
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
await async_setup_component(hass, "homeassistant", {})

View file

@ -25,8 +25,8 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo
)
await hass.async_block_till_done()
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
assert aioclient_mock.call_count == 4
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
assert aioclient_mock.call_count == 6
await hass.services.async_call(
FLO_DOMAIN,
@ -35,7 +35,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo
blocking=True,
)
await hass.async_block_till_done()
assert aioclient_mock.call_count == 5
assert aioclient_mock.call_count == 7
await hass.services.async_call(
FLO_DOMAIN,
@ -44,7 +44,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo
blocking=True,
)
await hass.async_block_till_done()
assert aioclient_mock.call_count == 6
assert aioclient_mock.call_count == 8
await hass.services.async_call(
FLO_DOMAIN,
@ -53,7 +53,7 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo
blocking=True,
)
await hass.async_block_till_done()
assert aioclient_mock.call_count == 7
assert aioclient_mock.call_count == 9
await hass.services.async_call(
FLO_DOMAIN,
@ -66,4 +66,4 @@ async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mo
blocking=True,
)
await hass.async_block_till_done()
assert aioclient_mock.call_count == 8
assert aioclient_mock.call_count == 10

View file

@ -15,7 +15,7 @@ async def test_valve_switches(hass, config_entry, aioclient_mock_fixture):
)
await hass.async_block_till_done()
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2
entity_id = "switch.shutoff_valve"
assert hass.states.get(entity_id).state == STATE_ON

View file

@ -0,0 +1,161 @@
{
"actionRules": [],
"battery": {
"level": 100,
"updated": "2021-03-01T12:05:00Z"
},
"connectivity": {
"ssid": "SOMESSID"
},
"deviceModel": "puck_v1",
"deviceType": "puck_oem",
"fwProperties": {
"alert_battery_active": false,
"alert_humidity_high_active": false,
"alert_humidity_high_count": 0,
"alert_humidity_low_active": false,
"alert_humidity_low_count": 1,
"alert_state": "inactive",
"alert_temperature_high_active": false,
"alert_temperature_high_count": 0,
"alert_temperature_low_active": false,
"alert_temperature_low_count": 0,
"alert_water_active": false,
"alert_water_count": 0,
"ap_mode_count": 1,
"beep_pattern": "off",
"button_click_count": 1,
"date": "2021-03-07T14:00:05.054Z",
"deep_sleep_count": 8229,
"device_boot_count": 25,
"device_boot_reason": "wakeup_timer",
"device_count": 8230,
"device_failed_count": 36,
"device_id": "1a2b3c4d5e6f",
"device_time_total": 405336,
"device_time_up": 1502,
"device_uuid": "32839",
"device_wakeup_count": 8254,
"flosense_shut_off_level": 2,
"fw_name": "1.1.15",
"fw_version": 10115,
"led_pattern": "led_blue_solid",
"limit_ota_battery_min": 30,
"pairing_state": "configured",
"reason": "heartbeat",
"serial_number": "111111111112",
"telemetry_battery_percent": 100,
"telemetry_battery_voltage": 2.9896278381347656,
"telemetry_count": 8224,
"telemetry_failed_count": 27,
"telemetry_humidity": 43.21965408325195,
"telemetry_rssi": 100,
"telemetry_temperature": 61.43144607543945,
"telemetry_water": false,
"timer_alarm_active": 10,
"timer_heartbeat_battery_low": 3600,
"timer_heartbeat_battery_ok": 1740,
"timer_heartbeat_last": 1740,
"timer_heartbeat_not_configured": 10,
"timer_heartbeat_retry_attempts": 3,
"timer_heartbeat_retry_delay": 600,
"timer_water_debounce": 2000,
"timer_wifi_ap_timeout": 600000,
"wifi_ap_ssid": "FloDetector-a123",
"wifi_sta_enc": "wpa2-psk",
"wifi_sta_failed_count": 21,
"wifi_sta_mac": "50:01:01:01:01:44",
"wifi_sta_ssid": "SOMESSID"
},
"fwVersion": "1.1.15",
"hardwareThresholds": {
"battery": {
"maxValue": 100,
"minValue": 0,
"okMax": 100,
"okMin": 20
},
"batteryEnabled": true,
"humidity": {
"maxValue": 100,
"minValue": 0,
"okMax": 85,
"okMin": 15
},
"humidityEnabled": true,
"tempC": {
"maxValue": 60,
"minValue": -17.77777777777778,
"okMax": 37.77777777777778,
"okMin": 0
},
"tempEnabled": true,
"tempF": {
"maxValue": 140,
"minValue": 0,
"okMax": 100,
"okMin": 32
}
},
"id": "32839",
"installStatus": {
"isInstalled": false
},
"isConnected": true,
"isPaired": true,
"lastHeardFromTime": "2021-03-07T14:05:00Z",
"location": {
"id": "mmnnoopp"
},
"macAddress": "1a2b3c4d5e6f",
"nickname": "Kitchen Sink",
"notifications": {
"pending": {
"alarmCount": [],
"critical": {
"count": 0,
"devices": {
"absolute": 0,
"count": 0
}
},
"criticalCount": 0,
"info": {
"count": 0,
"devices": {
"absolute": 0,
"count": 0
}
},
"infoCount": 0,
"warning": {
"count": 0,
"devices": {
"absolute": 0,
"count": 0
}
},
"warningCount": 0
}
},
"puckConfig": {
"configuredAt": "2020-09-01T18:15:12.216Z",
"isConfigured": true
},
"serialNumber": "111111111112",
"shutoff": {
"scheduledAt": "1970-01-01T00:00:00.000Z"
},
"systemMode": {
"isLocked": false,
"shouldInherit": true
},
"telemetry": {
"current": {
"humidity": 43,
"tempF": 61,
"updated": "2021-03-07T14:05:00Z"
}
},
"valve": {}
}

View file

@ -9,6 +9,10 @@
{
"id": "98765",
"macAddress": "123456abcdef"
},
{
"id": "32839",
"macAddress": "1a2b3c4d5e6f"
}
],
"userRoles": [

View file

@ -19,6 +19,10 @@
{
"id": "98765",
"macAddress": "606405c11e10"
},
{
"id": "32839",
"macAddress": "1a2b3c4d5e6f"
}
],
"userRoles": [