Skip to content

Commit 7967a3d

Browse files
committed
Add sensor as entity platform on MQTT subentries
1 parent 16cf96e commit 7967a3d

File tree

8 files changed

+598
-106
lines changed

8 files changed

+598
-106
lines changed

homeassistant/components/mqtt/config_flow.py

+207-15
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727

2828
from homeassistant.components.file_upload import process_uploaded_file
2929
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
30+
from homeassistant.components.sensor import (
31+
CONF_STATE_CLASS,
32+
DEVICE_CLASS_UNITS,
33+
SensorDeviceClass,
34+
SensorStateClass,
35+
)
3036
from homeassistant.config_entries import (
3137
SOURCE_RECONFIGURE,
3238
ConfigEntry,
@@ -45,6 +51,7 @@
4551
ATTR_SW_VERSION,
4652
CONF_CLIENT_ID,
4753
CONF_DEVICE,
54+
CONF_DEVICE_CLASS,
4855
CONF_DISCOVERY,
4956
CONF_HOST,
5057
CONF_NAME,
@@ -53,7 +60,9 @@
5360
CONF_PLATFORM,
5461
CONF_PORT,
5562
CONF_PROTOCOL,
63+
CONF_UNIT_OF_MEASUREMENT,
5664
CONF_USERNAME,
65+
CONF_VALUE_TEMPLATE,
5766
)
5867
from homeassistant.core import HomeAssistant, callback
5968
from homeassistant.data_entry_flow import AbortFlow
@@ -99,11 +108,16 @@
99108
CONF_COMMAND_TOPIC,
100109
CONF_DISCOVERY_PREFIX,
101110
CONF_ENTITY_PICTURE,
111+
CONF_EXPIRE_AFTER,
102112
CONF_KEEPALIVE,
113+
CONF_LAST_RESET_VALUE_TEMPLATE,
114+
CONF_OPTIONS,
103115
CONF_PAYLOAD_AVAILABLE,
104116
CONF_PAYLOAD_NOT_AVAILABLE,
105117
CONF_QOS,
106118
CONF_RETAIN,
119+
CONF_STATE_TOPIC,
120+
CONF_SUGGESTED_DISPLAY_PRECISION,
107121
CONF_TLS_INSECURE,
108122
CONF_TRANSPORT,
109123
CONF_WILL_MESSAGE,
@@ -133,11 +147,13 @@
133147
from .util import (
134148
async_create_certificate_temp_files,
135149
get_file_path,
150+
learn_more_url,
136151
valid_birth_will,
137152
valid_publish_topic,
138153
valid_qos_schema,
139154
valid_subscribe_topic,
140155
valid_subscribe_topic_template,
156+
validate_sensor_state_and_device_class_config,
141157
)
142158

143159
_LOGGER = logging.getLogger(__name__)
@@ -217,7 +233,8 @@
217233
)
218234

219235
# Subentry selectors
220-
SUBENTRY_PLATFORMS = [Platform.NOTIFY]
236+
RESET_IF_EMPTY = {CONF_OPTIONS}
237+
SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
221238
SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
222239
SelectSelectorConfig(
223240
options=[platform.value for platform in SUBENTRY_PLATFORMS],
@@ -241,17 +258,64 @@
241258
}
242259
)
243260

261+
# Sensor specific selectors
262+
SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
263+
SelectSelectorConfig(
264+
options=[device_class.value for device_class in SensorDeviceClass],
265+
mode=SelectSelectorMode.DROPDOWN,
266+
translation_key=CONF_DEVICE_CLASS,
267+
)
268+
)
269+
SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
270+
SelectSelectorConfig(
271+
options=[device_class.value for device_class in SensorStateClass],
272+
mode=SelectSelectorMode.DROPDOWN,
273+
translation_key=CONF_STATE_CLASS,
274+
)
275+
)
276+
OPTIONS_SELECTOR = SelectSelector(
277+
SelectSelectorConfig(
278+
options=[],
279+
custom_value=True,
280+
multiple=True,
281+
)
282+
)
283+
SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector(
284+
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9)
285+
)
286+
EXIRE_AFTER_SELECTOR = NumberSelector(
287+
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0)
288+
)
289+
244290

245291
@dataclass(frozen=True)
246292
class PlatformField:
247293
"""Stores a platform config field schema, required flag and validator."""
248294

249-
selector: Selector
295+
selector: Selector[Any] | Callable[..., Selector[Any]]
250296
required: bool
251297
validator: Callable[..., Any]
252298
error: str | None = None
253299
default: str | int | vol.Undefined = vol.UNDEFINED
254300
exclude_from_reconfig: bool = False
301+
custom_filtering: bool = False
302+
303+
304+
@callback
305+
def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
306+
"""Return a context based unit of measurement selector."""
307+
if (
308+
user_data is None
309+
or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None
310+
or device_class not in DEVICE_CLASS_UNITS
311+
):
312+
return TEXT_SELECTOR
313+
return SelectSelector(
314+
SelectSelectorConfig(
315+
options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]],
316+
custom_value=True,
317+
)
318+
)
255319

256320

257321
COMMON_ENTITY_FIELDS = {
@@ -264,7 +328,20 @@ class PlatformField:
264328

265329
COMMON_MQTT_FIELDS = {
266330
CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0),
267-
CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool),
331+
}
332+
PLATFORM_ENTITY_FIELDS = {
333+
Platform.NOTIFY.value: {},
334+
Platform.SENSOR.value: {
335+
CONF_DEVICE_CLASS: PlatformField(SENSOR_DEVICE_CLASS_SELECTOR, False, str),
336+
CONF_STATE_CLASS: PlatformField(SENSOR_STATE_CLASS_SELECTOR, False, str),
337+
CONF_UNIT_OF_MEASUREMENT: PlatformField(
338+
unit_of_measurement_selector, False, str, custom_filtering=True
339+
),
340+
CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField(
341+
SUGGESTED_DISPLAY_PRECISION_SELECTOR, False, cv.positive_int
342+
),
343+
CONF_OPTIONS: PlatformField(OPTIONS_SELECTOR, False, cv.ensure_list),
344+
},
268345
}
269346
PLATFORM_MQTT_FIELDS = {
270347
Platform.NOTIFY.value: {
@@ -274,8 +351,27 @@ class PlatformField:
274351
CONF_COMMAND_TEMPLATE: PlatformField(
275352
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
276353
),
354+
CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool),
355+
},
356+
Platform.SENSOR.value: {
357+
CONF_STATE_TOPIC: PlatformField(
358+
TEXT_SELECTOR, True, valid_subscribe_topic, "invalid_subscribe_topic"
359+
),
360+
CONF_VALUE_TEMPLATE: PlatformField(
361+
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
362+
),
363+
CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField(
364+
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
365+
),
366+
CONF_EXPIRE_AFTER: PlatformField(EXIRE_AFTER_SELECTOR, False, cv.positive_int),
277367
},
278368
}
369+
ENTITY_CONFIG_VALIDATOR: dict[
370+
str, Callable[[dict[str, Any], dict[str, str]], dict[str, Any]] | None
371+
] = {
372+
Platform.NOTIFY.value: None,
373+
Platform.SENSOR.value: validate_sensor_state_and_device_class_config,
374+
}
279375

280376
MQTT_DEVICE_SCHEMA = vol.Schema(
281377
{
@@ -342,6 +438,9 @@ def validate_user_input(
342438
user_input: dict[str, Any],
343439
data_schema_fields: dict[str, PlatformField],
344440
errors: dict[str, str],
441+
config_validator: Callable[[dict[str, Any], dict[str, str]], dict[str, str]]
442+
| None = None,
443+
component_data: dict[str, Any] | None = None,
345444
) -> None:
346445
"""Validate user input."""
347446
for field, value in user_input.items():
@@ -351,20 +450,33 @@ def validate_user_input(
351450
except (ValueError, vol.Invalid):
352451
errors[field] = data_schema_fields[field].error or "invalid_input"
353452

453+
if config_validator is not None:
454+
config = user_input
455+
if component_data is not None:
456+
config |= component_data
457+
config_validator(config, errors)
458+
354459

355460
@callback
356461
def data_schema_from_fields(
357462
data_schema_fields: dict[str, PlatformField],
358463
reconfig: bool,
464+
component: dict[str, Any] | None = None,
465+
user_input: dict[str, Any] | None = None,
359466
) -> vol.Schema:
360-
"""Generate data schema from platform fields."""
467+
"""Generate custom data schema from platform fields."""
468+
user_data = component
469+
if user_data is not None and user_input is not None:
470+
user_data |= user_input
361471
return vol.Schema(
362472
{
363473
vol.Required(field_name, default=field_details.default)
364474
if field_details.required
365475
else vol.Optional(
366476
field_name, default=field_details.default
367-
): field_details.selector
477+
): field_details.selector(user_data) # type: ignore[operator]
478+
if field_details.custom_filtering
479+
else field_details.selector
368480
for field_name, field_details in data_schema_fields.items()
369481
if not field_details.exclude_from_reconfig or not reconfig
370482
}
@@ -908,6 +1020,34 @@ def update_component_fields(
9081020
component_data.pop(field)
9091021
component_data.update(user_input)
9101022

1023+
@callback
1024+
def reset_if_empty(self, user_input: dict[str, Any]) -> None:
1025+
"""Reset fields in componment config that are not in the user_input."""
1026+
if TYPE_CHECKING:
1027+
assert self._component_id is not None
1028+
for field in [
1029+
form_field
1030+
for form_field in user_input
1031+
if form_field in RESET_IF_EMPTY
1032+
and form_field in RESET_IF_EMPTY
1033+
and not user_input[form_field]
1034+
]:
1035+
user_input.pop(field)
1036+
1037+
@callback
1038+
def generate_names(self) -> tuple[str, str]:
1039+
"""Generate the device and full entity name."""
1040+
if TYPE_CHECKING:
1041+
assert self._component_id is not None
1042+
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
1043+
if entity_name := self._subentry_data["components"][self._component_id].get(
1044+
CONF_NAME
1045+
):
1046+
full_entity_name: str = f"{device_name} {entity_name}"
1047+
else:
1048+
full_entity_name = device_name
1049+
return device_name, full_entity_name
1050+
9111051
async def async_step_user(
9121052
self, user_input: dict[str, Any] | None = None
9131053
) -> SubentryFlowResult:
@@ -970,7 +1110,7 @@ async def async_step_entity(
9701110
self._component_id = uuid4().hex
9711111
self._subentry_data["components"].setdefault(self._component_id, {})
9721112
self.update_component_fields(data_schema, user_input)
973-
return await self.async_step_mqtt_platform_config()
1113+
return await self.async_step_entity_platform_config()
9741114
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
9751115
elif self.source == SOURCE_RECONFIGURE and self._component_id is not None:
9761116
data_schema = self.add_suggested_values_to_schema(
@@ -1034,6 +1174,57 @@ async def async_step_delete_entity(
10341174
return await self.async_step_summary_menu()
10351175
return self._show_update_or_delete_form("delete_entity")
10361176

1177+
async def async_step_entity_platform_config(
1178+
self, user_input: dict[str, Any] | None = None
1179+
) -> SubentryFlowResult:
1180+
"""Configure platform entity details."""
1181+
if TYPE_CHECKING:
1182+
assert self._component_id is not None
1183+
component = self._subentry_data["components"][self._component_id]
1184+
platform = component[CONF_PLATFORM]
1185+
if not (data_schema_fields := PLATFORM_ENTITY_FIELDS[platform]):
1186+
return await self.async_step_mqtt_platform_config()
1187+
errors: dict[str, str] = {}
1188+
1189+
data_schema = data_schema_from_fields(
1190+
data_schema_fields,
1191+
reconfig=True,
1192+
component=component,
1193+
user_input=user_input,
1194+
)
1195+
if user_input is not None:
1196+
# Test entity fields against the validator
1197+
self.reset_if_empty(user_input)
1198+
validate_user_input(
1199+
user_input,
1200+
data_schema_fields,
1201+
errors,
1202+
ENTITY_CONFIG_VALIDATOR[platform],
1203+
)
1204+
if not errors:
1205+
self.update_component_fields(data_schema, user_input)
1206+
return await self.async_step_mqtt_platform_config()
1207+
1208+
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
1209+
else:
1210+
data_schema = self.add_suggested_values_to_schema(
1211+
data_schema, self._subentry_data["components"][self._component_id]
1212+
)
1213+
1214+
device_name, full_entity_name = self.generate_names()
1215+
return self.async_show_form(
1216+
step_id="entity_platform_config",
1217+
data_schema=data_schema,
1218+
description_placeholders={
1219+
"mqtt_device": device_name,
1220+
CONF_PLATFORM: platform,
1221+
"entity": full_entity_name,
1222+
"url": learn_more_url(platform),
1223+
},
1224+
errors=errors,
1225+
last_step=False,
1226+
)
1227+
10371228
async def async_step_mqtt_platform_config(
10381229
self, user_input: dict[str, Any] | None = None
10391230
) -> SubentryFlowResult:
@@ -1048,7 +1239,14 @@ async def async_step_mqtt_platform_config(
10481239
)
10491240
if user_input is not None:
10501241
# Test entity fields against the validator
1051-
validate_user_input(user_input, data_schema_fields, errors)
1242+
self.reset_if_empty(user_input)
1243+
validate_user_input(
1244+
user_input,
1245+
data_schema_fields,
1246+
errors,
1247+
ENTITY_CONFIG_VALIDATOR[platform],
1248+
self._subentry_data["components"][self._component_id],
1249+
)
10521250
if not errors:
10531251
self.update_component_fields(data_schema, user_input)
10541252
self._component_id = None
@@ -1061,21 +1259,15 @@ async def async_step_mqtt_platform_config(
10611259
data_schema = self.add_suggested_values_to_schema(
10621260
data_schema, self._subentry_data["components"][self._component_id]
10631261
)
1064-
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
1065-
entity_name: str | None
1066-
if entity_name := self._subentry_data["components"][self._component_id].get(
1067-
CONF_NAME
1068-
):
1069-
full_entity_name: str = f"{device_name} {entity_name}"
1070-
else:
1071-
full_entity_name = device_name
1262+
device_name, full_entity_name = self.generate_names()
10721263
return self.async_show_form(
10731264
step_id="mqtt_platform_config",
10741265
data_schema=data_schema,
10751266
description_placeholders={
10761267
"mqtt_device": device_name,
10771268
CONF_PLATFORM: platform,
10781269
"entity": full_entity_name,
1270+
"url": learn_more_url(platform),
10791271
},
10801272
errors=errors,
10811273
last_step=False,

homeassistant/components/mqtt/const.py

+3
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic"
6464
CONF_ENABLED_BY_DEFAULT = "enabled_by_default"
6565
CONF_ENTITY_PICTURE = "entity_picture"
66+
CONF_EXPIRE_AFTER = "expire_after"
67+
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
6668
CONF_MAX_KELVIN = "max_kelvin"
6769
CONF_MIN_KELVIN = "min_kelvin"
6870
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
@@ -82,6 +84,7 @@
8284
CONF_STATE_CLOSING = "state_closing"
8385
CONF_STATE_OPEN = "state_open"
8486
CONF_STATE_OPENING = "state_opening"
87+
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
8588
CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template"
8689
CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic"
8790
CONF_TEMP_STATE_TEMPLATE = "temperature_state_template"

0 commit comments

Comments
 (0)