Skip to content

Commit 5f17e4b

Browse files
committed
added config_flow and test cases
1 parent 7c6c395 commit 5f17e4b

File tree

16 files changed

+434
-60
lines changed

16 files changed

+434
-60
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
venv
22
.venv
33
.vscode
4+
*.pyc

README.md

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This is a _Custom Integration_ for [Home Assistant](https://www.home-assistant.io/). It uses the unofficial [Redfin](https://www.redfin.com) API to get property value estimates.
44

5-
![GitHub release](https://img.shields.io/badge/release-v1.0.2-blue)
5+
![GitHub release](https://img.shields.io/badge/release-v1.1.0-blue)
66
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs)
77

88
There is currently support for the Sensor device type within Home Assistant.
@@ -22,7 +22,11 @@ To manually add Redfin to your installation, create this folder structure in you
2222
Then drop the following files into that folder:
2323

2424
```yaml
25+
translations/en.json
2526
__init__.py
27+
config_flow.py
28+
const.py
29+
hacs.json
2630
sensor.py
2731
manifest.json
2832
```
@@ -33,15 +37,7 @@ You will need the Redfin property ID for each property you’d like to track. Th
3337

3438
For example, given this Redfin URL: https://www.redfin.com/DC/Washington/1745-Q-St-NW-20009/unit-3/home/9860590 the property ID is 9860590.
3539

36-
To enable this sensor, add the following lines to your `configuration.yaml`.
37-
38-
```yaml
39-
sensor:
40-
- platform: redfin
41-
property_ids:
42-
- "12345678"
43-
- "34567890"
44-
```
40+
To enable this sensor, add new Redfin integration component in the Home Assistant UI and follow the prompts to add your properties.
4541

4642
The sensor provides the following attributes:
4743

custom_components/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
""""""
Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,52 @@
1-
"""The Redfin component."""
1+
"""Redfin Component."""
2+
import logging
3+
4+
from homeassistant import config_entries, core
5+
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers.typing import ConfigType
8+
9+
from .const import DOMAIN, CONF_PROPERTIES
10+
from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
15+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
16+
# We allow setup only through config flow type of config
17+
return True
18+
19+
20+
async def async_setup_entry(
21+
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
22+
) -> bool:
23+
"""Set up Redfin component from a ConfigEntry."""
24+
hass.data.setdefault(DOMAIN, {})
25+
hass_data = dict()
26+
hass_data[CONF_NAME] = entry.options[CONF_NAME]
27+
hass_data[CONF_SCAN_INTERVAL] = entry.options[CONF_SCAN_INTERVAL]
28+
hass_data[CONF_PROPERTIES] = entry.options[CONF_PROPERTIES]
29+
# Registers update listener to update config entry when options are updated.
30+
unsub_options_update_listener = entry.add_update_listener(options_update_listener)
31+
# Store a reference to the unsubscribe function to cleanup if an entry is unloaded.
32+
hass_data["unsub_options_update_listener"] = unsub_options_update_listener
33+
hass.data[DOMAIN][entry.entry_id] = hass_data
34+
35+
# Forward the setup to the sensor component.
36+
hass.async_create_task(
37+
hass.config_entries.async_forward_entry_setup(entry, "sensor")
38+
)
39+
return True
40+
41+
42+
async def options_update_listener(
43+
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
44+
):
45+
_LOGGER.debug("%s options updated: %s", DOMAIN, config_entry.as_dict()["options"])
46+
"""Handle options update."""
47+
try:
48+
result = await hass.config_entries.async_reload(config_entry.entry_id)
49+
except config_entries.OperationNotAllowed:
50+
_LOGGER.error("Entry cannot be reloaded. ID = %s Restart is required.", config_entry.entry_id)
51+
except config_entries.UnknownEntry:
52+
_LOGGER.error("Invalid entry specified. ID = %s", config_entry.entry_id)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from copy import deepcopy
2+
import logging
3+
from typing import Any, Dict, Optional
4+
5+
from homeassistant import config_entries, core
6+
from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL
7+
from homeassistant.core import callback
8+
import homeassistant.helpers.config_validation as cv
9+
import voluptuous as vol
10+
from homeassistant.helpers.entity_registry import (
11+
async_entries_for_config_entry,
12+
async_get_registry,
13+
)
14+
15+
from .const import (CONF_PROPERTIES, DOMAIN, DEFAULT_SCAN_INTERVAL, DEFAULT_NAME,
16+
CONF_PROPERTY_ID, CONF_SCAN_INTERVAL_MIN, CONF_SCAN_INTERVAL_MAX)
17+
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
CONF_SCHEMA = vol.Schema(
21+
{
22+
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
23+
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
24+
): vol.All(vol.Coerce(int), vol.Range(min=CONF_SCAN_INTERVAL_MIN, max=CONF_SCAN_INTERVAL_MAX))
25+
}
26+
)
27+
PROPERTY_SCHEMA = vol.Schema(
28+
{
29+
vol.Optional(CONF_PROPERTY_ID): cv.string,
30+
vol.Optional("add_another"): cv.boolean,
31+
}
32+
)
33+
34+
35+
class RedfinConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
36+
"""Redfin config flow."""
37+
38+
VERSION = 1
39+
40+
data: Optional[Dict[str, Any]]
41+
options: Optional[Dict[str, Any]] = {}
42+
43+
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None):
44+
"""Invoked when a user initiates a flow via the user interface."""
45+
errors: Dict[str, str] = {}
46+
47+
if self._async_current_entries():
48+
return self.async_abort(reason="single_instance_allowed")
49+
if self.hass.data.get(DOMAIN):
50+
return self.async_abort(reason="single_instance_allowed")
51+
52+
if user_input is not None:
53+
self.data = dict() # user_input
54+
self.options = user_input
55+
self.options[CONF_PROPERTIES] = []
56+
# Return the form of the next step.
57+
return await self.async_step_property()
58+
59+
return self.async_show_form(
60+
step_id="user", data_schema=CONF_SCHEMA, errors=errors
61+
)
62+
63+
async def async_step_property(self, user_input: Optional[Dict[str, Any]] = None):
64+
"""Second step in config flow to add a property id's."""
65+
errors: Dict[str, str] = {}
66+
67+
if user_input is not None:
68+
69+
# check for duplicate property id's
70+
is_dup = False
71+
for params in self.options[CONF_PROPERTIES]:
72+
if user_input[CONF_PROPERTY_ID] == params[CONF_PROPERTY_ID]:
73+
is_dup = True
74+
if is_dup == True:
75+
errors["base"] = "duplicate_property_id"
76+
else:
77+
self.options[CONF_PROPERTIES].append(
78+
{"property_id": user_input[CONF_PROPERTY_ID]}
79+
)
80+
81+
if not errors:
82+
# If user ticked the box show this form again so they can add
83+
if user_input.get("add_another", False):
84+
return await self.async_step_property()
85+
86+
# User is done adding properties, create the config entry.
87+
_LOGGER.debug("%s component added a new config entry: %s", DOMAIN, self.options)
88+
return self.async_create_entry(title=self.options["name"], data=self.data, options=self.options)
89+
90+
return self.async_show_form(
91+
step_id="property", data_schema=PROPERTY_SCHEMA, errors=errors
92+
)
93+
94+
@staticmethod
95+
@callback
96+
def async_get_options_flow(config_entry):
97+
return OptionsFlowHandler(config_entry)
98+
99+
100+
class OptionsFlowHandler(config_entries.OptionsFlow):
101+
"""Handles options flow for the component."""
102+
103+
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
104+
self.config_entry = config_entry
105+
106+
async def async_step_init(
107+
self, user_input: Dict[str, Any] = None
108+
) -> Dict[str, Any]:
109+
"""Manage the options for the component."""
110+
errors: Dict[str, str] = {}
111+
# Grab all configured propert id's from the entity registry so we can populate the
112+
# multi-select dropdown that will allow a user to remove a property.
113+
entity_registry = await async_get_registry(self.hass)
114+
entries = async_entries_for_config_entry(
115+
entity_registry, self.config_entry.entry_id
116+
)
117+
# Default value for our multi-select.
118+
all_properties = {e.entity_id: e.original_name for e in entries}
119+
property_map = {e.entity_id: e for e in entries}
120+
121+
if user_input is not None:
122+
updated_properties = deepcopy(self.config_entry.options[CONF_PROPERTIES])
123+
124+
# Remove any unchecked properties.
125+
removed_entities = [
126+
entity_id
127+
for entity_id in property_map.keys()
128+
if entity_id not in user_input["properties"]
129+
]
130+
for entity_id in removed_entities:
131+
# Unregister from HA
132+
entity_registry.async_remove(entity_id)
133+
# Remove from our configured properties.
134+
entry = property_map[entity_id]
135+
property_id = entry.unique_id
136+
updated_properties = [e for e in updated_properties if e[CONF_PROPERTY_ID] != property_id]
137+
138+
if user_input.get(CONF_PROPERTY_ID):
139+
140+
# check for duplicate property id's
141+
is_dup = False
142+
for params in updated_properties:
143+
if user_input[CONF_PROPERTY_ID] == params[CONF_PROPERTY_ID]:
144+
is_dup = True
145+
if is_dup == True:
146+
errors["base"] = "duplicate_property_id"
147+
else:
148+
# Add the new property.
149+
updated_properties.append(
150+
{CONF_PROPERTY_ID: user_input[CONF_PROPERTY_ID]}
151+
)
152+
153+
if not errors:
154+
# Value of data will be set on the options property of the config_entry
155+
# instance.
156+
return self.async_create_entry(
157+
title="",
158+
data={
159+
CONF_NAME: user_input[CONF_NAME],
160+
CONF_SCAN_INTERVAL: user_input[CONF_SCAN_INTERVAL],
161+
CONF_PROPERTIES: updated_properties
162+
},
163+
)
164+
165+
options_schema = vol.Schema(
166+
{
167+
vol.Optional("properties", default=list(all_properties.keys())): cv.multi_select(
168+
all_properties
169+
),
170+
vol.Optional(CONF_NAME, default=self.config_entry.options[CONF_NAME]): str,
171+
vol.Optional(CONF_SCAN_INTERVAL, default=self.config_entry.options[CONF_SCAN_INTERVAL]
172+
): vol.All(vol.Coerce(int), vol.Range(min=CONF_SCAN_INTERVAL_MIN, max=CONF_SCAN_INTERVAL_MAX)),
173+
vol.Optional(CONF_PROPERTY_ID): cv.string,
174+
}
175+
)
176+
177+
return self.async_show_form(
178+
step_id="init", data_schema=options_schema, errors=errors
179+
)

custom_components/redfin/const.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
DOMAIN = "redfin"
2+
3+
DEFAULT_NAME = "Redfin"
4+
5+
ATTRIBUTION = "Data provided by Redfin.com"
6+
ATTR_AMOUNT = "amount"
7+
ATTR_AMOUNT_FORMATTED = "amount_formatted"
8+
ATTR_ADDRESS = "address"
9+
ATTR_FULL_ADDRESS = "full_address"
10+
ATTR_CURRENCY = "amount_currency"
11+
ATTR_STREET_VIEW = "street_view"
12+
ATTR_REDFIN_URL = "redfin_url"
13+
14+
CONF_PROPERTIES = "properties"
15+
CONF_PROPERTY_ID = "property_id"
16+
CONF_PROPERTY_IDS = "property_ids"
17+
DEFAULT_SCAN_INTERVAL = 60
18+
CONF_SCAN_INTERVAL_MIN = 5
19+
CONF_SCAN_INTERVAL_MAX = 600
20+
21+
ICON = "mdi:home-variant"

custom_components/redfin/manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
"codeowners": ["@dreed47"],
88
"requirements": ["redfin==0.1.1"],
99
"iot_class": "cloud_polling",
10-
"version": "1.0.2"
10+
"version": "1.1.0",
11+
"config_flow": true
1112
}

0 commit comments

Comments
 (0)