diff --git a/.strict-typing b/.strict-typing index c8f28c84f8a..07aed7b4ca1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -295,6 +295,7 @@ homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.madvr.* homeassistant.components.mailbox.* +homeassistant.components.manual.* homeassistant.components.map.* homeassistant.components.mastodon.* homeassistant.components.matrix.* diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index d1b558842b6..f9b791668e8 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -30,7 +30,7 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - ManualAlarm( # type:ignore[no-untyped-call] + ManualAlarm( hass, "Security", "demo_alarm_control_panel", diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 055e79867ab..c1910d0dfa1 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -3,12 +3,12 @@ from __future__ import annotations import datetime -import logging from typing import Any import voluptuous as vol from homeassistant.components.alarm_control_panel import ( + PLATFORM_SCHEMA as ALARM_CONTROL_PANEL_PLATFORM_SCHEMA, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, CodeFormat, @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_DELAY_TIME, CONF_DISARM_AFTER_TRIGGER, CONF_NAME, - CONF_PLATFORM, CONF_TRIGGER_TIME, CONF_UNIQUE_ID, STATE_ALARM_ARMED_AWAY, @@ -33,15 +32,16 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +DOMAIN = "manual" CONF_ARMING_STATES = "arming_states" CONF_CODE_TEMPLATE = "code_template" @@ -85,7 +85,7 @@ ATTR_PREVIOUS_STATE = "previous_state" ATTR_NEXT_STATE = "next_state" -def _state_validator(config): +def _state_validator(config: dict[str, Any]) -> dict[str, Any]: """Validate the state.""" for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: @@ -101,7 +101,7 @@ def _state_validator(config): return config -def _state_schema(state): +def _state_schema(state: str) -> vol.Schema: """Validate the state.""" schema = {} if state in SUPPORTED_PRETRIGGER_STATES: @@ -120,63 +120,64 @@ def _state_schema(state): PLATFORM_SCHEMA = vol.Schema( vol.All( - { - vol.Required(CONF_PLATFORM): "manual", - vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Exclusive(CONF_CODE, "code validation"): cv.string, - vol.Exclusive(CONF_CODE_TEMPLATE, "code validation"): cv.template, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_ARMING_TIME, default=DEFAULT_ARMING_TIME): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional( - CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER - ): cv.boolean, - vol.Optional(CONF_ARMING_STATES, default=SUPPORTED_ARMING_STATES): vol.All( - cv.ensure_list, [vol.In(SUPPORTED_ARMING_STATES)] - ), - vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): _state_schema( - STATE_ALARM_ARMED_AWAY - ), - vol.Optional(STATE_ALARM_ARMED_HOME, default={}): _state_schema( - STATE_ALARM_ARMED_HOME - ), - vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): _state_schema( - STATE_ALARM_ARMED_NIGHT - ), - vol.Optional(STATE_ALARM_ARMED_VACATION, default={}): _state_schema( - STATE_ALARM_ARMED_VACATION - ), - vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, default={}): _state_schema( - STATE_ALARM_ARMED_CUSTOM_BYPASS - ), - vol.Optional(STATE_ALARM_DISARMED, default={}): _state_schema( - STATE_ALARM_DISARMED - ), - vol.Optional(STATE_ALARM_TRIGGERED, default={}): _state_schema( - STATE_ALARM_TRIGGERED - ), - }, + ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Exclusive(CONF_CODE, "code validation"): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, "code validation"): cv.template, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_ARMING_TIME, default=DEFAULT_ARMING_TIME): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional( + CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER + ): cv.boolean, + vol.Optional( + CONF_ARMING_STATES, default=SUPPORTED_ARMING_STATES + ): vol.All(cv.ensure_list, [vol.In(SUPPORTED_ARMING_STATES)]), + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): _state_schema( + STATE_ALARM_ARMED_AWAY + ), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): _state_schema( + STATE_ALARM_ARMED_HOME + ), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): _state_schema( + STATE_ALARM_ARMED_NIGHT + ), + vol.Optional(STATE_ALARM_ARMED_VACATION, default={}): _state_schema( + STATE_ALARM_ARMED_VACATION + ), + vol.Optional( + STATE_ALARM_ARMED_CUSTOM_BYPASS, default={} + ): _state_schema(STATE_ALARM_ARMED_CUSTOM_BYPASS), + vol.Optional(STATE_ALARM_DISARMED, default={}): _state_schema( + STATE_ALARM_DISARMED + ), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): _state_schema( + STATE_ALARM_TRIGGERED + ), + }, + ), _state_validator, ) ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the manual alarm platform.""" - add_entities( + async_add_entities( [ ManualAlarm( hass, @@ -184,8 +185,8 @@ def setup_platform( config.get(CONF_UNIQUE_ID), config.get(CONF_CODE), config.get(CONF_CODE_TEMPLATE), - config.get(CONF_CODE_ARM_REQUIRED), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config[CONF_CODE_ARM_REQUIRED], + config[CONF_DISARM_AFTER_TRIGGER], config, ) ] @@ -206,28 +207,25 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): def __init__( self, - hass, - name, - unique_id, - code, - code_template, - code_arm_required, - disarm_after_trigger, - config, - ): + hass: HomeAssistant, + name: str, + unique_id: str | None, + code: str | None, + code_template: Template | None, + code_arm_required: bool, + disarm_after_trigger: bool, + config: dict[str, Any], + ) -> None: """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._attr_name = name self._attr_unique_id = unique_id - if code_template: - self._code = code_template - else: - self._code = code or None + self._code = code_template or code or None self._attr_code_arm_required = code_arm_required self._disarm_after_trigger = disarm_after_trigger self._previous_state = self._state - self._state_ts = None + self._state_ts: datetime.datetime = dt_util.utcnow() self._delay_time_by_state = { state: config[state][CONF_DELAY_TIME] @@ -253,7 +251,9 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): return STATE_ALARM_PENDING - trigger_time = self._trigger_time_by_state[self._previous_state] + trigger_time: datetime.timedelta = self._trigger_time_by_state[ + self._previous_state + ] if ( self._state_ts + self._pending_time(self._state) + trigger_time ) < dt_util.utcnow(): @@ -270,25 +270,27 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): return self._state @property - def _active_state(self): + def _active_state(self) -> str: """Get the current state.""" if self.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING): return self._previous_state return self._state - def _arming_time(self, state): + def _arming_time(self, state: str) -> datetime.timedelta: """Get the arming time.""" - return self._arming_time_by_state[state] + arming_time: datetime.timedelta = self._arming_time_by_state[state] + return arming_time - def _pending_time(self, state): + def _pending_time(self, state: str) -> datetime.timedelta: """Get the pending time.""" - return self._delay_time_by_state[self._previous_state] + delay_time: datetime.timedelta = self._delay_time_by_state[self._previous_state] + return delay_time - def _within_arming_time(self, state): + def _within_arming_time(self, state: str) -> bool: """Get if the action is in the arming time window.""" return self._state_ts + self._arming_time(state) > dt_util.utcnow() - def _within_pending_time(self, state): + def _within_pending_time(self, state: str) -> bool: """Get if the action is in the pending time window.""" return self._state_ts + self._pending_time(state) > dt_util.utcnow() @@ -377,7 +379,7 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): self._state_ts + arming_time, ) - def _async_validate_code(self, code, state): + def _async_validate_code(self, code: str | None, state: str) -> None: """Validate given code.""" if ( state != STATE_ALARM_DISARMED and not self.code_arm_required @@ -394,24 +396,28 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): if not alarm_code or code == alarm_code: return - raise HomeAssistantError("Invalid alarm code provided") + raise ServiceValidationError( + "Invalid alarm code provided", + translation_domain=DOMAIN, + translation_key="invalid_code", + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING): - return { - ATTR_PREVIOUS_STATE: self._previous_state, - ATTR_NEXT_STATE: self._state, - } - if self.state == STATE_ALARM_TRIGGERED: - return { - ATTR_PREVIOUS_STATE: self._previous_state, - } - return {} + prev_state: str | None = self._previous_state + state: str | None = self._state + elif self.state == STATE_ALARM_TRIGGERED: + prev_state = self._previous_state + state = None + else: + prev_state = None + state = None + return {ATTR_PREVIOUS_STATE: prev_state, ATTR_NEXT_STATE: state} @callback - def async_scheduled_update(self, now): + def async_scheduled_update(self, now: datetime.datetime) -> None: """Update state at a scheduled point in time.""" self.async_write_ha_state() @@ -420,13 +426,13 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): self._state_ts = state.last_updated - if hasattr(state, "attributes") and ATTR_NEXT_STATE in state.attributes: + if next_state := state.attributes.get(ATTR_NEXT_STATE): # If in arming or pending state we record the transition, # not the current state - self._state = state.attributes[ATTR_NEXT_STATE] + self._state = next_state else: self._state = state.state - if hasattr(state, "attributes") and ATTR_PREVIOUS_STATE in state.attributes: - self._previous_state = state.attributes[ATTR_PREVIOUS_STATE] + if prev_state := state.attributes.get(ATTR_PREVIOUS_STATE): + self._previous_state = prev_state self._async_set_state_update_events() diff --git a/homeassistant/components/manual/strings.json b/homeassistant/components/manual/strings.json new file mode 100644 index 00000000000..f26a1570d05 --- /dev/null +++ b/homeassistant/components/manual/strings.json @@ -0,0 +1,7 @@ +{ + "exceptions": { + "invalid_code": { + "message": "Invalid alarm code provided" + } + } +} diff --git a/mypy.ini b/mypy.ini index ca10d05af86..2a361f56397 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2706,6 +2706,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.manual.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.map.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 6c9ba9ee9a0..7900dfd1c91 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -9,6 +9,10 @@ import pytest from homeassistant.components import alarm_control_panel from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.demo import alarm_control_panel as demo +from homeassistant.components.manual.alarm_control_panel import ( + ATTR_NEXT_STATE, + ATTR_PREVIOUS_STATE, +) from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -28,7 +32,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -227,7 +231,7 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): await hass.services.async_call( alarm_control_panel.DOMAIN, service, @@ -1089,7 +1093,7 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1133,7 +1137,7 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + with pytest.raises(ServiceValidationError, match=r"^Invalid alarm code provided$"): await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) @@ -1411,8 +1415,8 @@ async def test_restore_state_triggered(hass: HomeAssistant, previous_state) -> N state = hass.states.get(entity_id) assert state - assert state.attributes["previous_state"] == previous_state - assert "next_state" not in state.attributes + assert state.attributes[ATTR_PREVIOUS_STATE] == previous_state + assert state.attributes[ATTR_NEXT_STATE] is None assert state.state == STATE_ALARM_TRIGGERED future = time + timedelta(seconds=121)