From 405ea5774ddd8f1eabecd58e9746a4a507067fb5 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 19 Jun 2024 13:54:43 +0200 Subject: [PATCH 01/17] Implement bypass input --- custom_components/unii/__init__.py | 36 +++- custom_components/unii/alarm_control_panel.py | 120 ++++++++++- custom_components/unii/config_flow.py | 199 +++++++++++------ custom_components/unii/const.py | 1 + custom_components/unii/switch.py | 201 ++++++++++++++++-- custom_components/unii/translations/en.json | 8 + custom_components/unii/translations/nl.json | 8 + 7 files changed, 489 insertions(+), 84 deletions(-) diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index 26148f2..fe525d1 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from unii import UNii, UNiiCommand, UNiiData, UNiiEncryptionError, UNiiLocal -from .const import CONF_SHARED_KEY, CONF_TYPE_LOCAL, DOMAIN +from .const import CONF_SHARED_KEY, CONF_TYPE_LOCAL, CONF_USER_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SENSOR, - # Platform.SWITCH, + Platform.SWITCH, # Platform.SELECT, # Platform.NUMBER, ] @@ -42,7 +42,13 @@ class UNiiCoordinator(DataUpdateCoordinator): unii: UNii = None device_info: DeviceInfo = None - def __init__(self, hass: HomeAssistant, unii: UNii, config_entry: ConfigEntry): + def __init__( + self, + hass: HomeAssistant, + unii: UNii, + config_entry: ConfigEntry, + user_code: str | None = None, + ): """Initialize Alphatronics UNii Data Update Coordinator.""" super().__init__( @@ -55,6 +61,7 @@ def __init__(self, hass: HomeAssistant, unii: UNii, config_entry: ConfigEntry): self.unii = unii self.config_entry = config_entry self.config_entry_id = config_entry.entry_id + self.user_code = user_code identifiers = {(DOMAIN, config_entry.entry_id)} connections = set() @@ -97,6 +104,9 @@ def __init__(self, hass: HomeAssistant, unii: UNii, config_entry: ConfigEntry): self.unii.add_event_occurred_callback(self.event_occurred_callback) + def set_user_code(self, user_code: str): + self.user_code = user_code + async def async_disconnect(self): """ Disconnect from UNii. @@ -140,6 +150,12 @@ def event_occurred_callback(self, command: UNiiCommand, data: UNiiData): async def _async_update_data(self): """Fetch data from Alphatronics UNii.""" + async def bypass_input(self, number: int) -> bool: + return await self.unii.bypass_input(number, self.user_code) + + async def unbypass_input(self, number: int) -> bool: + return await self.unii.unbypass_input(number, self.user_code) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Alphatronics UNii from a config entry.""" @@ -177,7 +193,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Setup coordinator - coordinator = UNiiCoordinator(hass, unii, entry) + user_code = entry.options.get(CONF_USER_CODE) + coordinator = UNiiCoordinator(hass, unii, entry, user_code) # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() @@ -186,6 +203,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(options_update_listener)) + await coordinator.async_request_refresh() return True @@ -200,3 +219,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + _LOGGER.debug("Configuration options updated, reloading UNii integration") + # await hass.config_entries.async_reload(entry.entry_id) + coordinator: UNiiCoordinator = hass.data[DOMAIN][entry.entry_id] + + coordinator.set_user_code(entry.options.get(CONF_USER_CODE)) diff --git a/custom_components/unii/alarm_control_panel.py b/custom_components/unii/alarm_control_panel.py index eb74055..036f7ba 100644 --- a/custom_components/unii/alarm_control_panel.py +++ b/custom_components/unii/alarm_control_panel.py @@ -16,7 +16,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED from homeassistant.helpers.update_coordinator import CoordinatorEntity -from unii import UNiiCommand, UNiiFeature, UNiiSection, UNiiSectionArmedState +from unii import ( + UNiiCommand, + UNiiFeature, + UNiiInputState, + UNiiInputStatusRecord, + UNiiSection, + UNiiSectionArmedState, +) from . import DOMAIN, UNiiCoordinator @@ -53,6 +60,30 @@ async def async_setup_entry( ) ) + # if UNiiFeature.BYPASS_INPUT in coordinator.unii.features: + # for _, unii_input in coordinator.unii.inputs.items(): + # if "status" in unii_input and unii_input.status not in [ + # None, + # UNiiInputState.DISABLED, + # ]: + # if unii_input.name is None: + # entity_description = AlarmControlPanelEntityDescription( + # key=f"input{unii_input.number}-bypass", + # ) + # else: + # entity_description = AlarmControlPanelEntityDescription( + # key=f"input{unii_input.number}-bypass", + # name=f"Bypass {unii_input.name}", + # ) + # entities.append( + # UNiiBypassInput( + # coordinator, + # entity_description, + # config_entry.entry_id, + # unii_input.number, + # ) + # ) + async_add_entities(entities) @@ -123,7 +154,7 @@ def _handle_coordinator_update(self) -> None: class UNiiArmSection(UNiiAlarmControlPanel): - """UNii Alarm Control Panel to for a Section.""" + """UNii Alarm Control Panel to arm a Section.""" _attr_translation_key = "section" @@ -212,3 +243,88 @@ async def async_alarm_disarm(self, code: str): self._attr_is_disarming = False self.async_write_ha_state() + + +class UNiiBypassInput(UNiiAlarmControlPanel): + """UNii Alarm Control Panel to bypass inputs.""" + + _attr_translation_key = "bypass_input" + + _attr_code_format = CodeFormat.NUMBER + _attr_supported_features: AlarmControlPanelEntityFeature = ( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ) + + def __init__( + self, + coordinator: UNiiCoordinator, + entity_description: AlarmControlPanelEntityDescription, + config_entry_id: str, + input_number: int, + ): + """Initialize the sensor.""" + super().__init__(coordinator, entity_description, config_entry_id) + + self.input_number = input_number + self._attr_translation_placeholders = {"input_number": input_number} + + def _handle_input_status(self, input_status: UNiiInputStatusRecord): + # if "input_type" in input_status: + # self._attr_extra_state_attributes["input_type"] = str( + # input_status.input_type + # ) + # if "sensor_type" in input_status: + # self._attr_extra_state_attributes["sensor_type"] = str( + # input_status.sensor_type + # ) + + if input_status.bypassed: + self._attr_state = "armed_custom_bypass" + else: + self._attr_state = "armed" + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + if self.coordinator.unii.connected: + input_status: UNiiInputStatusRecord = self.coordinator.unii.inputs.get( + self.input_number + ) + + self._handle_input_status(input_status) + + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + super()._handle_coordinator_update() + + if self.coordinator.data is None: + return + + command = self.coordinator.data.get("command") + data = self.coordinator.data.get("data") + + if ( + command == UNiiCommand.EVENT_OCCURRED + and data.input_number == self.input_number + ): + # ToDo + pass + elif command == UNiiCommand.INPUT_STATUS_CHANGED and self.input_number in data: + input_status: UNiiInputStatusRecord = data.get(self.input_number) + self._handle_input_status(input_status) + elif ( + command == UNiiCommand.INPUT_STATUS_UPDATE + and data.number == self.input_number + ): + self._handle_input_status(data) + else: + return + + self.async_write_ha_state() + + async def async_alarm_arm_custom_bypass(self, code=None) -> None: + """Send arm custom bypass command.""" diff --git a/custom_components/unii/config_flow.py b/custom_components/unii/config_flow.py index 2a2bb8e..bf2b2ed 100644 --- a/custom_components/unii/config_flow.py +++ b/custom_components/unii/config_flow.py @@ -13,15 +13,27 @@ from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from unii import DEFAULT_PORT, UNiiEncryptionError, UNiiLocal -from . import CONF_SHARED_KEY, CONF_TYPE_LOCAL, DOMAIN +from .const import CONF_SHARED_KEY, CONF_TYPE_LOCAL, CONF_USER_CODE, DOMAIN _LOGGER: Final = logging.getLogger(__name__) +_VALIDATE_SHARED_KEY = cv.matches_regex(r"^.{1,16}$") +_VALIDATE_USER_CODE = cv.matches_regex(r"^\d{4,6}$") + class CannotConnect(HomeAssistantError): # pylint: disable=too-few-public-methods @@ -40,14 +52,16 @@ class UNiiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOCAL_SCHEMA: Final = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_SHARED_KEY): str, + vol.Required(CONF_HOST): TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): NumberSelector( + NumberSelectorConfig(min=1, max=65535, mode=NumberSelectorMode.BOX) + ), + vol.Required(CONF_SHARED_KEY): TextSelector(), } ) DISCOVERED_SCHEMA: Final = vol.Schema( { - vol.Required(CONF_SHARED_KEY): str, + vol.Required(CONF_SHARED_KEY): TextSelector(), } ) REAUTH_SCHEMA = DISCOVERED_SCHEMA @@ -145,23 +159,29 @@ async def async_step_setup_local( host = user_input.get(CONF_HOST) port = user_input.get(CONF_PORT, DEFAULT_PORT) - shared_key = user_input.get(CONF_SHARED_KEY, "") - # shared_key = shared_key.strip() - # Validate the shared key - if shared_key == "": - errors[CONF_SHARED_KEY] = "invalid_shared_key" - else: + shared_key = user_input.get(CONF_SHARED_KEY) + # Validate the shared key. + try: + _VALIDATE_SHARED_KEY(shared_key) + # String must be 16 characters, padded with spaces. shared_key = shared_key[:16].ljust(16, " ") shared_key = shared_key.encode() + except vol.Invalid: + errors[CONF_SHARED_KEY] = "invalid_shared_key" + if not errors: unii = UNiiLocal(host, port, shared_key) # Test if we can connect to the device. - can_connect = False try: - can_connect = await unii.test_connection() - if not can_connect: + if await unii.test_connection(): + await unii.disconnect() + + # Wait a bit for the UNii to accept new connections later in the + # async_setup_entry. + await asyncio.sleep(1) + else: errors["base"] = "cannot_connect" _LOGGER.error( "Unable to connect to Alphatronics UNii on %s", unii.connection @@ -170,56 +190,51 @@ async def async_step_setup_local( _LOGGER.debug("Invalid shared key: %s", shared_key) errors[CONF_SHARED_KEY] = "invalid_shared_key" - if can_connect: - await unii.disconnect() - - # Wait a bit for the UNii to accept new connections later in the async_setup_entry. - await asyncio.sleep(1) - - # If reauthenticating only the existing configuration needs to updated with the - # new shared key. - if self._reauth_entry is not None: - return self.async_update_reload_and_abort( - self._reauth_entry, - data={ - CONF_HOST: host, - CONF_PORT: port, - CONF_SHARED_KEY: shared_key.hex(), - }, - ) - - mac_address = None - if self._discovered_mac is not None: - mac_address = format_mac(self._discovered_mac) - elif unii.equipment_information.mac_address is not None: - # Newer versions of the UNii firmware provide the mac address in the Equipment - # Information. - mac_address = format_mac(unii.equipment_information.mac_address) - - if mac_address is not None: - # Use the mac address as unique config id. - await self.async_set_unique_id(format_mac(mac_address)) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: host, - CONF_PORT: port, - CONF_SHARED_KEY: shared_key.hex(), - } - ) - else: - # Fallback to the unique id of the connection (hostname) if the firmware does - # not provide a mac address. - await self.async_set_unique_id(unii.connection.unique_id) - self._abort_if_unique_id_configured() - - title = f"Alphatronics {unii.equipment_information.device_name}" - data = { - CONF_TYPE: CONF_TYPE_LOCAL, - CONF_HOST: host, - CONF_PORT: port, - CONF_SHARED_KEY: shared_key.hex(), - } - return self.async_create_entry(title=title, data=data) + if not errors: + # If reauthenticating only the existing configuration needs to updated with the + # new shared key. + if self._reauth_entry is not None: + return self.async_update_reload_and_abort( + self._reauth_entry, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SHARED_KEY: shared_key.hex(), + }, + ) + + mac_address = None + if self._discovered_mac is not None: + mac_address = format_mac(self._discovered_mac) + elif unii.equipment_information.mac_address is not None: + # Newer versions of the UNii firmware provide the mac address in the Equipment + # Information. + mac_address = format_mac(unii.equipment_information.mac_address) + + if mac_address is not None: + # Use the mac address as unique config id. + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SHARED_KEY: shared_key.hex(), + } + ) + else: + # Fallback to the unique id of the connection (hostname) if the firmware does + # not provide a mac address. + await self.async_set_unique_id(unii.connection.unique_id) + self._abort_if_unique_id_configured() + + title = f"Alphatronics {unii.equipment_information.device_name}" + data = { + CONF_TYPE: CONF_TYPE_LOCAL, + CONF_HOST: host, + CONF_PORT: port, + CONF_SHARED_KEY: shared_key.hex(), + } + return self.async_create_entry(title=title, data=data) if self._discovered_mac is not None: data_schema = self.add_suggested_values_to_schema( @@ -258,3 +273,59 @@ async def async_step_reauth_confirm(self, user_input=None): data_schema=vol.Schema({}), ) return await self.async_step_user() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return UNiiOptionsFlowHandler(config_entry) + + +class UNiiOptionsFlowHandler(config_entries.OptionsFlow): + """Handle the options flow for Alphatronics UNii.""" + + OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USER_CODE): TextSelector(), + } + ) + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + + if user_input is not None: + self.OPTIONS_SCHEMA(user_input) + + user_code = user_input[CONF_USER_CODE] + # Validate the user code. + try: + _VALIDATE_USER_CODE(user_code) + except vol.Invalid: + errors[CONF_USER_CODE] = "invalid_user_code" + + if not errors: + return self.async_create_entry(title="", data=user_input) + + if user_input is not None: + data_schema = self.add_suggested_values_to_schema( + self.OPTIONS_SCHEMA, user_input + ) + else: + data_schema = self.add_suggested_values_to_schema( + self.OPTIONS_SCHEMA, self.config_entry.options + ) + + return self.async_show_form( + step_id="init", + data_schema=data_schema, + errors=errors, + ) diff --git a/custom_components/unii/const.py b/custom_components/unii/const.py index 310756b..347a699 100644 --- a/custom_components/unii/const.py +++ b/custom_components/unii/const.py @@ -7,3 +7,4 @@ CONF_TYPE_LOCAL: Final = "local" # CONF_TYPE_ONLINE: Final = "online" # For future use CONF_SHARED_KEY: Final = "shared_key" +CONF_USER_CODE: Final = "user_code" diff --git a/custom_components/unii/switch.py b/custom_components/unii/switch.py index 0ba87c9..8a107b1 100644 --- a/custom_components/unii/switch.py +++ b/custom_components/unii/switch.py @@ -14,7 +14,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED from homeassistant.helpers.update_coordinator import CoordinatorEntity -from unii import UNiiCommand, UNiiFeature, UNiiOutputType +from unii import ( + UNiiCommand, + UNiiFeature, + UNiiInputState, + UNiiInputStatusRecord, + UNiiSensorType, +) from . import DOMAIN, UNiiCoordinator @@ -30,32 +36,58 @@ async def async_setup_entry( coordinator: UNiiCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [] - if UNiiFeature.SET_OUTPUT in coordinator.unii.features: - for _, output in coordinator.unii.outputs.items(): - if "status" in output and output.status not in [ + if UNiiFeature.BYPASS_INPUT in coordinator.unii.features: + for _, input in coordinator.unii.inputs.items(): + if "status" in input and input.status not in [ None, - UNiiOutputType.NOT_ACTIVE, + UNiiInputState.DISABLED, ]: - if output.name is None: + if input.name is None: entity_description = SwitchEntityDescription( - key=f"output{output.number}-switch", + key=f"input{input.number}-bypass", device_class=SwitchDeviceClass.SWITCH, ) else: entity_description = SwitchEntityDescription( - key=f"output{output.number}-switch", + key=f"input{input.number}-bypass", device_class=SwitchDeviceClass.SWITCH, - name=output.name, + name=input.name, ) entities.append( - UNiiOutputSwitch( + UNiiBypassInputSwitch( coordinator, entity_description, config_entry.entry_id, - output.number, + input.number, ) ) + # if UNiiFeature.SET_OUTPUT in coordinator.unii.features: + # for _, output in coordinator.unii.outputs.items(): + # if "status" in output and output.status not in [ + # None, + # UNiiOutputType.NOT_ACTIVE, + # ]: + # if output.name is None: + # entity_description = SwitchEntityDescription( + # key=f"output{output.number}-switch", + # device_class=SwitchDeviceClass.SWITCH, + # ) + # else: + # entity_description = SwitchEntityDescription( + # key=f"output{output.number}-switch", + # device_class=SwitchDeviceClass.SWITCH, + # name=output.name, + # ) + # entities.append( + # UNiiOutputSwitch( + # coordinator, + # entity_description, + # config_entry.entry_id, + # output.number, + # ) + # ) + async_add_entities(entities) @@ -79,8 +111,8 @@ def __init__( self._attr_device_info = coordinator.device_info self._attr_unique_id = f"{config_entry_id}-{entity_description.key}" - if entity_description.name not in [UNDEFINED, None]: - self._attr_name = entity_description.name + # if entity_description.name not in [UNDEFINED, None]: + # self._attr_name = entity_description.name self.entity_description = entity_description @@ -108,7 +140,7 @@ def _handle_coordinator_update(self) -> None: if not self.coordinator.unii.connected: self._attr_available = False - if self.coordinator.data is None: + if self.coordinator.data is not None: command = self.coordinator.data.get("command") if command == UNiiCommand.NORMAL_DISCONNECT: @@ -122,6 +154,147 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() +class UNiiBypassInputSwitch(UNiiSwitch): + """UNii Switch for bypassing inputs.""" + + _attr_translation_key = "bypass_input" + + def __init__( + self, + coordinator: UNiiCoordinator, + entity_description: SwitchEntityDescription, + config_entry_id: str, + input_number: int, + ): + """Initialize the switch.""" + super().__init__(coordinator, entity_description, config_entry_id) + + self.input_number = input_number + self._attr_extra_state_attributes = {"input_number": input_number} + self._attr_translation_placeholders = {"input_number": input_number} + if entity_description.name not in [UNDEFINED, None]: + self._attr_translation_key += "_name" + self._attr_translation_placeholders = { + "input_name": entity_description.name + } + + def _handle_input_status(self, input_status: UNiiInputStatusRecord): + if "input_type" in input_status: + self._attr_extra_state_attributes["input_type"] = str( + input_status.input_type + ) + if "sensor_type" in input_status: + self._attr_extra_state_attributes["sensor_type"] = str( + input_status.sensor_type + ) + if "sections" in input_status: + self._attr_extra_state_attributes["sections"] = [ + section.number for section in input_status.sections + ] + + if input_status.bypassed: + self._attr_is_on = True + else: + self._attr_is_on = False + + match input_status.sensor_type: + # case UNiiSensorType.NOT_ACTIVE: + # self._attr_icon = "" + case UNiiSensorType.BURGLARY: + self._attr_icon = "mdi:motion-sensor" + case UNiiSensorType.FIRE: + self._attr_icon = "mdi:fire" + case UNiiSensorType.TAMPER: + self._attr_icon = "mdi:tools" + case UNiiSensorType.HOLDUP: + self._attr_icon = "mdi:robot-angry" + case UNiiSensorType.MEDICAL: + self._attr_icon = "mdi:medication" + case UNiiSensorType.GAS: + self._attr_icon = "mdi:waves-arrow-up" + case UNiiSensorType.WATER: + self._attr_icon = "mdi:water-alert" + case UNiiSensorType.TECHNICAL: + self._attr_icon = "mdi:cog" + case UNiiSensorType.DIRECT_DIALER_INPUT: + self._attr_icon = "mdi:cog" + case UNiiSensorType.KEYSWITCH: + self._attr_icon = "mdi:key" + case UNiiSensorType.NO_ALARM: + self._attr_icon = "mdi:cog" + case UNiiSensorType.EN54_FIRE: + self._attr_icon = "mdi:fire" + case UNiiSensorType.EN54_FIRE_MCP: + self._attr_icon = "mdi:fire" + case UNiiSensorType.EN54_FAULT: + self._attr_icon = "mdi:fire" + case UNiiSensorType.GLASSBREAK: + self._attr_icon = "mdi:window-closed-variant" + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + if self.coordinator.unii.connected: + input_status: UNiiInputStatusRecord = self.coordinator.unii.inputs.get( + self.input_number + ) + + self._handle_input_status(input_status) + + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + super()._handle_coordinator_update() + + if self.coordinator.data is None: + return + + command = self.coordinator.data.get("command") + data = self.coordinator.data.get("data") + + if ( + command == UNiiCommand.EVENT_OCCURRED + and data.input_number == self.input_number + ): + # ToDo + pass + elif command == UNiiCommand.INPUT_STATUS_CHANGED and self.input_number in data: + input_status: UNiiInputStatusRecord = data.get(self.input_number) + self._handle_input_status(input_status) + elif ( + command == UNiiCommand.INPUT_STATUS_UPDATE + and data.number == self.input_number + ): + self._handle_input_status(data) + else: + return + + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs): + """Bypasses the input.""" + if await self.coordinator.bypass_input(self.input_number): + self._attr_is_on = True + self.async_write_ha_state() + # else: + # self._attr_is_on = False + # + # self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Unbypasses the input.""" + if await self.coordinator.unbypass_input(self.input_number): + self._attr_is_on = False + self.async_write_ha_state() + # else: + # self._attr_is_on = True + # + # self.async_write_ha_state() + + class UNiiOutputSwitch(UNiiSwitch): """UNii Switch for outputs.""" diff --git a/custom_components/unii/translations/en.json b/custom_components/unii/translations/en.json index 333bf24..697435b 100644 --- a/custom_components/unii/translations/en.json +++ b/custom_components/unii/translations/en.json @@ -63,6 +63,14 @@ "entry_timer": "Entry Timer Active" } } + }, + "switch": { + "bypass_input": { + "name": "Bypass Input {input_number}" + }, + "bypass_input_name": { + "name": "Bypass {input_name}" + } } } } diff --git a/custom_components/unii/translations/nl.json b/custom_components/unii/translations/nl.json index 4163ef0..ca1d071 100644 --- a/custom_components/unii/translations/nl.json +++ b/custom_components/unii/translations/nl.json @@ -63,6 +63,14 @@ "entry_timer": "Inlooptijd Actief" } } + }, + "switch": { + "bypass_input": { + "name": "Blokkeer Ingang {input_number}" + }, + "bypass_input_name": { + "name": "Blokkeer {input_name}" + } } } } From 2bcd69dfdc43b0e4d9c6706568e55a4d78df0cde Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 19 Jun 2024 14:07:08 +0200 Subject: [PATCH 02/17] Ude latest UNii library --- custom_components/unii/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/unii/manifest.json b/custom_components/unii/manifest.json index b6f6e84..1161af4 100644 --- a/custom_components/unii/manifest.json +++ b/custom_components/unii/manifest.json @@ -22,7 +22,7 @@ "unii" ], "requirements": [ - "unii==1.0.0b8" + "unii==1.0.0b9" ], "version": "1.0.0-beta.4" } From 5927d64a44ee7c26276e1e15ffe4fab310714031 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 19 Jun 2024 22:38:57 +0200 Subject: [PATCH 03/17] Translations for options flow --- custom_components/unii/translations/en.json | 10 ++++++++++ custom_components/unii/translations/nl.json | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/custom_components/unii/translations/en.json b/custom_components/unii/translations/en.json index 697435b..b53da33 100644 --- a/custom_components/unii/translations/en.json +++ b/custom_components/unii/translations/en.json @@ -27,6 +27,16 @@ } } }, + "options": { + "step": { + "init": { + "title": "UNii options", + "data": { + "user_code": "User code" + } + } + } + }, "device": { "unii": { "name": "{device_name}" diff --git a/custom_components/unii/translations/nl.json b/custom_components/unii/translations/nl.json index ca1d071..d97ac9a 100644 --- a/custom_components/unii/translations/nl.json +++ b/custom_components/unii/translations/nl.json @@ -27,6 +27,16 @@ } } }, + "options": { + "step": { + "init": { + "title": "UNii instellingen", + "data": { + "user_code": "User code" + } + } + } + }, "device": { "unii": { "name": "{device_name}" From 15a38d3287ddfe41ad46faae93ab2a80df7551e2 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 19 Jun 2024 23:13:15 +0200 Subject: [PATCH 04/17] Wait for the UNii to accept connections after a reload --- custom_components/unii/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index fe525d1..59c7305 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -215,6 +215,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator: UNiiCoordinator = hass.data[DOMAIN][entry.entry_id] await coordinator.async_disconnect() + # Wait a bit for the UNii to accept new connections after an integration reload. + await asyncio.sleep(1) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) From 7d83e471e82b3e562d49342ce0cc3da6543e4f30 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 19 Jun 2024 23:14:27 +0200 Subject: [PATCH 05/17] Only create bypass input entities when we can write to the UNii --- custom_components/unii/config_flow.py | 24 +++++++++++++++++------- custom_components/unii/switch.py | 5 ++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/custom_components/unii/config_flow.py b/custom_components/unii/config_flow.py index bf2b2ed..a23919b 100644 --- a/custom_components/unii/config_flow.py +++ b/custom_components/unii/config_flow.py @@ -305,15 +305,25 @@ async def async_step_init( if user_input is not None: self.OPTIONS_SCHEMA(user_input) - user_code = user_input[CONF_USER_CODE] - # Validate the user code. - try: - _VALIDATE_USER_CODE(user_code) - except vol.Invalid: - errors[CONF_USER_CODE] = "invalid_user_code" + user_code = user_input.get(CONF_USER_CODE) + if user_code is not None: + # Validate the user code. + try: + _VALIDATE_USER_CODE(user_code) + except vol.Invalid: + errors[CONF_USER_CODE] = "invalid_user_code" if not errors: - return self.async_create_entry(title="", data=user_input) + result = self.hass.config_entries.async_update_entry( + entry=self.config_entry, + options = { + CONF_USER_CODE: None + } + ) + if result and user_code is None: + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + else: + return self.async_create_entry(title="", data=user_input) if user_input is not None: data_schema = self.add_suggested_values_to_schema( diff --git a/custom_components/unii/switch.py b/custom_components/unii/switch.py index 8a107b1..18d9a15 100644 --- a/custom_components/unii/switch.py +++ b/custom_components/unii/switch.py @@ -36,7 +36,10 @@ async def async_setup_entry( coordinator: UNiiCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [] - if UNiiFeature.BYPASS_INPUT in coordinator.unii.features: + if ( + coordinator.can_write() + and UNiiFeature.BYPASS_INPUT in coordinator.unii.features + ): for _, input in coordinator.unii.inputs.items(): if "status" in input and input.status not in [ None, From a905874c0c9106dc89f2643345964eb0f2a75e1d Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Thu, 20 Jun 2024 06:32:15 +0200 Subject: [PATCH 06/17] Use latest UNii library --- custom_components/unii/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/unii/manifest.json b/custom_components/unii/manifest.json index 1161af4..25af6b5 100644 --- a/custom_components/unii/manifest.json +++ b/custom_components/unii/manifest.json @@ -22,7 +22,7 @@ "unii" ], "requirements": [ - "unii==1.0.0b9" + "unii==1.0.0b10" ], "version": "1.0.0-beta.4" } From be555c59732df51543f522e335e1e3ad9a2cb899 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Thu, 20 Jun 2024 06:33:27 +0200 Subject: [PATCH 07/17] Only create bypass input entities when we can write to the UNii --- custom_components/unii/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index 59c7305..378f534 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -107,6 +107,9 @@ def __init__( def set_user_code(self, user_code: str): self.user_code = user_code + def can_write(self) -> bool: + return self.user_code is not None + async def async_disconnect(self): """ Disconnect from UNii. From b7c54831f11759060025df366faf51287e986191 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Thu, 20 Jun 2024 11:04:31 +0200 Subject: [PATCH 08/17] Reload writable entities if switching between read-only and writable --- custom_components/unii/__init__.py | 29 ++++++++++++++++++++------- custom_components/unii/config_flow.py | 11 +--------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index 378f534..8888cda 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -34,6 +34,8 @@ # Platform.SELECT, # Platform.NUMBER, ] +# These platforms need to be reloaded when switching between read-only and writable mode. +RW_PLATFORMS: list[Platform] = [Platform.SWITCH] class UNiiCoordinator(DataUpdateCoordinator): @@ -104,9 +106,23 @@ def __init__( self.unii.add_event_occurred_callback(self.event_occurred_callback) - def set_user_code(self, user_code: str): + async def set_user_code(self, user_code: str): + # If the configuration changes between read-only and writable the device needs to be + # reloaded to create/disable entities + reload = False + if (self.user_code is None) ^ (user_code is None): + reload = True + self.user_code = user_code + if reload: + await self.hass.config_entries.async_unload_platforms( + self.config_entry, RW_PLATFORMS + ) + await self.hass.config_entries.async_forward_entry_setups( + self.config_entry, RW_PLATFORMS + ) + def can_write(self) -> bool: return self.user_code is not None @@ -218,19 +234,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator: UNiiCoordinator = hass.data[DOMAIN][entry.entry_id] await coordinator.async_disconnect() - # Wait a bit for the UNii to accept new connections after an integration reload. - await asyncio.sleep(1) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) + # Wait a bit for the UNii to accept new connections after an integration reload. + await asyncio.sleep(1) + return unload_ok async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading UNii integration") - # await hass.config_entries.async_reload(entry.entry_id) + _LOGGER.debug("Configuration options updated") coordinator: UNiiCoordinator = hass.data[DOMAIN][entry.entry_id] - coordinator.set_user_code(entry.options.get(CONF_USER_CODE)) + await coordinator.set_user_code(entry.options.get(CONF_USER_CODE)) diff --git a/custom_components/unii/config_flow.py b/custom_components/unii/config_flow.py index a23919b..ef9fa52 100644 --- a/custom_components/unii/config_flow.py +++ b/custom_components/unii/config_flow.py @@ -314,16 +314,7 @@ async def async_step_init( errors[CONF_USER_CODE] = "invalid_user_code" if not errors: - result = self.hass.config_entries.async_update_entry( - entry=self.config_entry, - options = { - CONF_USER_CODE: None - } - ) - if result and user_code is None: - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - else: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(title="", data=user_input) if user_input is not None: data_schema = self.add_suggested_values_to_schema( From f3772aaaaefc6303e466e0b4f9f566ff62ccf20c Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Thu, 20 Jun 2024 11:21:39 +0200 Subject: [PATCH 09/17] Update readme --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3a98844..a308c24 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,14 @@ This integration is still in **beta** and **subject to change**. We're looking f ## Features - Status inputs (clear, open, tamper, masking) -- Status sections (armed, disarmed, alarm) +- (Un)bypassing inputs +- Status sections (armed, disarmed, alarm, exit timer, entry timer) - Connection status UNii panel Section in alarm and fast input status update are only available for firmware version 2.17.x and above Extra features (arming/disarming, (un)bypassing, outputs and event handling) are added shortly. -## Update -After installing the latest version of the integration the previous version must be removed and readded to fix encryption key issues. - ## Hardware Tested with the UNii 32, 128 and 512. No additional UNii license needed. @@ -69,7 +67,7 @@ Or follow these instructions: `config/custom_components/` directory of your Home Assistant installation - Restart Home Assistant -## Adding a new Alphatronics UNii to Home Assistant +## Adding a new Alphatronics UNii to Home Assistant If your UNii is on the same network as your Home Assistant server and is assigned an IP address using DHCP your UNii will most probably be automatically discovered by the integration. @@ -82,6 +80,15 @@ In case your UNii is not automatically discovered follow these instructions: A new UNii integration and device will now be added to your Integrations view. +## Write access to the UNii + +By default the UNii integration is set to read only mode, only the status of your Unii will be displayed. +To enable write access and be able to (un)bypass inputs set an user code in the UNii integration options by going to the UNii device and clicking **Configure**. By removing the user code the UNii integration is back in read only mode. + +It is recommended to use a dedicated user in your UNii which is assigned only those permissions that are needed for your scenarios. + +--- + All UNii trademarks, logos and brand names are registered and the property of Alphatronics BV [hacs]: https://hacs.xyz/ From aebdec458bc9e1fa1748d2c96db0758e62ba1e72 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Thu, 20 Jun 2024 11:30:50 +0200 Subject: [PATCH 10/17] Bump version --- custom_components/unii/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/unii/manifest.json b/custom_components/unii/manifest.json index 25af6b5..b1335bf 100644 --- a/custom_components/unii/manifest.json +++ b/custom_components/unii/manifest.json @@ -24,5 +24,5 @@ "requirements": [ "unii==1.0.0b10" ], - "version": "1.0.0-beta.4" + "version": "1.0.0-beta.4+bypass_input" } From 009d387573218eeca4012817cddea8441c9a8e40 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Thu, 20 Jun 2024 11:34:55 +0200 Subject: [PATCH 11/17] Revert bump version --- custom_components/unii/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/unii/manifest.json b/custom_components/unii/manifest.json index b1335bf..25af6b5 100644 --- a/custom_components/unii/manifest.json +++ b/custom_components/unii/manifest.json @@ -24,5 +24,5 @@ "requirements": [ "unii==1.0.0b10" ], - "version": "1.0.0-beta.4+bypass_input" + "version": "1.0.0-beta.4" } From 2e328128cc691611142fc83c13723c10e0a46395 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Tue, 25 Jun 2024 13:07:04 +0200 Subject: [PATCH 12/17] Rename options update listener --- custom_components/unii/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index 8888cda..d8bc707 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -222,7 +222,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(options_update_listener)) + entry.async_on_unload(entry.add_update_listener(update_listener)) await coordinator.async_request_refresh() @@ -243,7 +243,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" _LOGGER.debug("Configuration options updated") coordinator: UNiiCoordinator = hass.data[DOMAIN][entry.entry_id] From 82f90d18bff3f9bf1d26efeb6d049f9937040e2e Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 26 Jun 2024 08:43:02 +0200 Subject: [PATCH 13/17] Implement (dis)arming sections --- custom_components/unii/__init__.py | 8 +- custom_components/unii/alarm_control_panel.py | 105 +++++++----- custom_components/unii/switch.py | 152 ++++++++++++++++-- 3 files changed, 205 insertions(+), 60 deletions(-) diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index d8bc707..e427ed1 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ - Platform.ALARM_CONTROL_PANEL, + # Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, @@ -175,6 +175,12 @@ async def bypass_input(self, number: int) -> bool: async def unbypass_input(self, number: int) -> bool: return await self.unii.unbypass_input(number, self.user_code) + async def arm_section(self, number: int) -> bool: + return await self.unii.arm_section(number, self.user_code) + + async def disarm_section(self, number: int) -> bool: + return await self.unii.disarm_section(number, self.user_code) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Alphatronics UNii from a config entry.""" diff --git a/custom_components/unii/alarm_control_panel.py b/custom_components/unii/alarm_control_panel.py index 036f7ba..f3b63b4 100644 --- a/custom_components/unii/alarm_control_panel.py +++ b/custom_components/unii/alarm_control_panel.py @@ -12,6 +12,13 @@ ) from homeassistant.components.alarm_control_panel.const import CodeFormat from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, + STATE_ALARM_TRIGGERED, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED @@ -23,6 +30,7 @@ UNiiInputStatusRecord, UNiiSection, UNiiSectionArmedState, + UNiiSectionStatusRecord, ) from . import DOMAIN, UNiiCoordinator @@ -39,26 +47,27 @@ async def async_setup_entry( coordinator: UNiiCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [] - if UNiiFeature.ARM_SECTION in coordinator.unii.features: - for _, section in coordinator.unii.sections.items(): - if section.active: - if section.name is None: - entity_description = AlarmControlPanelEntityDescription( - key=f"section{section.number}-arm", - ) - else: - entity_description = AlarmControlPanelEntityDescription( - key=f"section{section.number}-arm", - name=section.name, - ) - entities.append( - UNiiArmSection( - coordinator, - entity_description, - config_entry.entry_id, - section.number, - ) + if coordinator.can_write() and UNiiFeature.ARM_SECTION in coordinator.unii.features: + for section in ( + section for section in coordinator.unii.sections.values() if section.active + ): + if section.name is None: + entity_description = AlarmControlPanelEntityDescription( + key=f"section{section.number}-arm", + ) + else: + entity_description = AlarmControlPanelEntityDescription( + key=f"section{section.number}-arm", + name=section.name, ) + entities.append( + UNiiArmSection( + coordinator, + entity_description, + config_entry.entry_id, + section.number, + ) + ) # if UNiiFeature.BYPASS_INPUT in coordinator.unii.features: # for _, unii_input in coordinator.unii.inputs.items(): @@ -176,18 +185,23 @@ def __init__( self.section_number = section_number self._attr_translation_placeholders = {"section_number": section_number} - def _handle_section(self, section: UNiiSection): - if section.armed_state == UNiiSectionArmedState.NOT_PROGRAMMED: + def _handle_section_status(self, section_status: UNiiSection): + if not section_status.active: self._attr_available = False - elif section.armed_state in [ - UNiiSectionArmedState.ARMED, - UNiiSectionArmedState.ALARM, - ]: - self._attr_is_disarmed = False - self._attr_state = "armed" - elif section.armed_state == UNiiSectionArmedState.DISARMED: - self._attr_is_disarmed = True - self._attr_state = "disarmed" + + match section_status.armed_state: + case UNiiSectionArmedState.NOT_PROGRAMMED: + self._attr_available = False + case UNiiSectionArmedState.ARMED: + self._attr_state = STATE_ALARM_ARMED_AWAY + case UNiiSectionArmedState.DISARMED: + self._attr_state = STATE_ALARM_DISARMED + case UNiiSectionArmedState.ALARM: + self._attr_state = STATE_ALARM_TRIGGERED + case UNiiSectionArmedState.EXIT_TIMER: + self._attr_state = STATE_ALARM_ARMING + case UNiiSectionArmedState.ENTRY_TIMER: + self._attr_state = STATE_ALARM_ARMED_AWAY async def async_added_to_hass(self) -> None: await super().async_added_to_hass() @@ -196,7 +210,7 @@ async def async_added_to_hass(self) -> None: self._attr_available = False else: section = self.coordinator.unii.sections.get(self.section_number) - self._handle_section(section) + self._handle_section_status(section) self.async_write_ha_state() @@ -212,35 +226,38 @@ def _handle_coordinator_update(self) -> None: command = self.coordinator.data.get("command") data = self.coordinator.data.get("data") - if command == UNiiCommand.RESPONSE_REQUEST_SECTION_STATUS: - section = self.coordinator.unii.sections.get(self.section_number) - self._handle_section(section) + if ( + command == UNiiCommand.EVENT_OCCURRED + and self.section_number in data.sections + ): + # ToDo + pass + elif ( + command == UNiiCommand.RESPONSE_REQUEST_SECTION_STATUS + and self.section_number in data + ): + section_status: UNiiSectionStatusRecord = data.get(self.section_number) + self._handle_section_status(section_status) self.async_write_ha_state() async def async_alarm_arm_away(self, code: str): """Send arm away command.""" - self._attr_is_arming = True + self._attr_state = STATE_ALARM_ARMING self.async_write_ha_state() if await self.coordinator.unii.arm_section(self.section_number, code): - self._attr_is_disarmed = False - self._attr_is_arming = False - else: - self._attr_is_arming = False + self._attr_state = STATE_ALARM_ARMED_AWAY self.async_write_ha_state() async def async_alarm_disarm(self, code: str): """Send disarm command.""" - self._attr_is_disarming = True + self._attr_state = STATE_ALARM_DISARMING self.async_write_ha_state() if await self.coordinator.unii.disarm_section(self.section_number, code): - self._attr_is_disarmed = True - self._attr_is_disarming = False - else: - self._attr_is_disarming = False + self._attr_state = STATE_ALARM_DISARMED self.async_write_ha_state() diff --git a/custom_components/unii/switch.py b/custom_components/unii/switch.py index 18d9a15..6b8f157 100644 --- a/custom_components/unii/switch.py +++ b/custom_components/unii/switch.py @@ -19,8 +19,10 @@ UNiiFeature, UNiiInputState, UNiiInputStatusRecord, + UNiiSectionArmedState, UNiiSensorType, ) +from unii.unii_command_data import UNiiSectionStatus, UNiiSectionStatusRecord from . import DOMAIN, UNiiCoordinator @@ -40,31 +42,55 @@ async def async_setup_entry( coordinator.can_write() and UNiiFeature.BYPASS_INPUT in coordinator.unii.features ): - for _, input in coordinator.unii.inputs.items(): - if "status" in input and input.status not in [ + for _, uni_input in coordinator.unii.inputs.items(): + if "status" in uni_input and uni_input.status not in [ None, UNiiInputState.DISABLED, ]: - if input.name is None: + if uni_input.name is None: entity_description = SwitchEntityDescription( - key=f"input{input.number}-bypass", + key=f"input{uni_input.number}-bypass", device_class=SwitchDeviceClass.SWITCH, ) else: entity_description = SwitchEntityDescription( - key=f"input{input.number}-bypass", + key=f"input{uni_input.number}-bypass", device_class=SwitchDeviceClass.SWITCH, - name=input.name, + name=uni_input.name, ) entities.append( UNiiBypassInputSwitch( coordinator, entity_description, config_entry.entry_id, - input.number, + uni_input.number, ) ) + if coordinator.can_write() and UNiiFeature.ARM_SECTION in coordinator.unii.features: + for section in ( + section for section in coordinator.unii.sections.values() if section.active + ): + if section.name is None: + entity_description = SwitchEntityDescription( + key=f"section{section.number}-arm", + device_class=SwitchDeviceClass.SWITCH, + ) + else: + entity_description = SwitchEntityDescription( + key=f"section{section.number}-arm", + device_class=SwitchDeviceClass.SWITCH, + name=section.name, + ) + entities.append( + UNiiArmSectionSwitch( + coordinator, + entity_description, + config_entry.entry_id, + section.number, + ) + ) + # if UNiiFeature.SET_OUTPUT in coordinator.unii.features: # for _, output in coordinator.unii.outputs.items(): # if "status" in output and output.status not in [ @@ -282,20 +308,116 @@ async def async_turn_on(self, **kwargs): if await self.coordinator.bypass_input(self.input_number): self._attr_is_on = True self.async_write_ha_state() - # else: - # self._attr_is_on = False - # - # self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Unbypasses the input.""" if await self.coordinator.unbypass_input(self.input_number): self._attr_is_on = False self.async_write_ha_state() - # else: - # self._attr_is_on = True - # - # self.async_write_ha_state() + + +class UNiiArmSectionSwitch(UNiiSwitch): + """UNii Switch for (dis)arming sections.""" + + _attr_translation_key = "arm_section" + + def __init__( + self, + coordinator: UNiiCoordinator, + entity_description: SwitchEntityDescription, + config_entry_id: str, + section_number: int, + ): + """Initialize the switch.""" + super().__init__(coordinator, entity_description, config_entry_id) + + self.section_number = section_number + self._attr_extra_state_attributes = {"section_number": section_number} + self._attr_translation_placeholders = {"section_number": section_number} + if entity_description.name not in [UNDEFINED, None]: + self._attr_translation_key += "_name" + self._attr_translation_placeholders = { + "section_name": entity_description.name + } + + def _handle_section_status(self, section_status: UNiiSectionStatusRecord): + if not section_status.active: + self._attr_available = False + + match section_status.armed_state: + case UNiiSectionArmedState.NOT_PROGRAMMED: + self._attr_available = False + case UNiiSectionArmedState.ARMED: + self._attr_is_on = True + self._attr_extra_state_attributes["status"] = "armed" + self._attr_icon = "mdi:lock" + case UNiiSectionArmedState.DISARMED: + self._attr_is_on = False + self._attr_extra_state_attributes["status"] = "disarmed" + self._attr_icon = "mdi:lock-open-variant" + case UNiiSectionArmedState.ALARM: + self._attr_is_on = True + self._attr_extra_state_attributes["status"] = "alarm" + self._attr_icon = "mdi:lock" + case UNiiSectionArmedState.EXIT_TIMER: + self._attr_is_on = True + self._attr_extra_state_attributes["status"] = "exit_timer" + self._attr_icon = "mdi:timer-lock" + case UNiiSectionArmedState.ENTRY_TIMER: + self._attr_is_on = True + self._attr_extra_state_attributes["status"] = "entry_timer" + self._attr_icon = "mdi:timer-lock-open" + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + if self.coordinator.unii.connected: + section_status: UNiiInputStatusRecord = self.coordinator.unii.sections.get( + self.section_number + ) + + self._handle_section_status(section_status) + + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + super()._handle_coordinator_update() + + if self.coordinator.data is None: + return + + command = self.coordinator.data.get("command") + data = self.coordinator.data.get("data") + + if ( + command == UNiiCommand.EVENT_OCCURRED + and self.section_number in data.sections + ): + # ToDo + pass + elif ( + command == UNiiCommand.RESPONSE_REQUEST_SECTION_STATUS + and self.section_number in data + ): + section_status: UNiiSectionStatusRecord = data.get(self.section_number) + self._handle_section_status(section_status) + + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs): + """Bypasses the .""" + if await self.coordinator.arm_section(self.section_number): + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Unbypasses the .""" + if await self.coordinator.disarm_section(self.section_number): + self._attr_is_on = False + self.async_write_ha_state() class UNiiOutputSwitch(UNiiSwitch): From c14164f4fd276076751e2610adcbc7f119218503 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 26 Jun 2024 08:46:35 +0200 Subject: [PATCH 14/17] Always show section sensor --- custom_components/unii/sensor.py | 51 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/custom_components/unii/sensor.py b/custom_components/unii/sensor.py index df5aa9a..efe94d5 100644 --- a/custom_components/unii/sensor.py +++ b/custom_components/unii/sensor.py @@ -61,26 +61,27 @@ async def async_setup_entry( ) ) - if UNiiFeature.ARM_SECTION not in coordinator.unii.features: - for _, section in coordinator.unii.sections.items(): - if section.active: - if section.name is None: - entity_description = SensorEntityDescription( - key=f"section{section.number}-enum", - ) - else: - entity_description = SensorEntityDescription( - key=f"section{section.number}-enum", - name=section.name, - ) - entities.append( - UNiiSectionSensor( - coordinator, - entity_description, - config_entry.entry_id, - section.number, - ) - ) + # if UNiiFeature.ARM_SECTION not in coordinator.unii.features: + for section in ( + section for section in coordinator.unii.sections.values() if section.active + ): + if section.name is None: + entity_description = SensorEntityDescription( + key=f"section{section.number}-enum", + ) + else: + entity_description = SensorEntityDescription( + key=f"section{section.number}-enum", + name=section.name, + ) + entities.append( + UNiiSectionSensor( + coordinator, + entity_description, + config_entry.entry_id, + section.number, + ) + ) async_add_entities(entities) @@ -295,11 +296,11 @@ def __init__( self._attr_extra_state_attributes = {"section_number": section_number} self._attr_translation_placeholders = {"section_number": section_number} - def _handle_section_status(self, section: UNiiSectionStatusRecord): - if not section.active: + def _handle_section_status(self, section_status: UNiiSectionStatusRecord): + if not section_status.active: self._attr_available = False - match section.armed_state: + match section_status.armed_state: case UNiiSectionArmedState.NOT_PROGRAMMED: self._attr_available = False case UNiiSectionArmedState.ARMED: @@ -322,8 +323,8 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if self.coordinator.unii.connected: - section = self.coordinator.unii.sections.get(self.section_number) - self._handle_section_status(section) + section_status = self.coordinator.unii.sections.get(self.section_number) + self._handle_section_status(section_status) self.async_write_ha_state() From 536f14d24d49ccb1451230283d0ff7a043a16562 Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 26 Jun 2024 16:20:21 +0200 Subject: [PATCH 15/17] Use latest version of the UNii library --- custom_components/unii/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/unii/manifest.json b/custom_components/unii/manifest.json index 25af6b5..acf436d 100644 --- a/custom_components/unii/manifest.json +++ b/custom_components/unii/manifest.json @@ -22,7 +22,7 @@ "unii" ], "requirements": [ - "unii==1.0.0b10" + "unii==1.0.0b11" ], "version": "1.0.0-beta.4" } From e530e9192c9ca63971cca958ae7733e8b57d6e4b Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Wed, 26 Jun 2024 16:26:32 +0200 Subject: [PATCH 16/17] Add translations for arming sections --- custom_components/unii/translations/en.json | 6 ++++++ custom_components/unii/translations/nl.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/custom_components/unii/translations/en.json b/custom_components/unii/translations/en.json index b53da33..ef6f030 100644 --- a/custom_components/unii/translations/en.json +++ b/custom_components/unii/translations/en.json @@ -80,6 +80,12 @@ }, "bypass_input_name": { "name": "Bypass {input_name}" + }, + "arm_section": { + "name": "Arm Section {section_number}" + }, + "arm_section_name": { + "name": "Arm {section_name}" } } } diff --git a/custom_components/unii/translations/nl.json b/custom_components/unii/translations/nl.json index d97ac9a..48dcabd 100644 --- a/custom_components/unii/translations/nl.json +++ b/custom_components/unii/translations/nl.json @@ -80,6 +80,12 @@ }, "bypass_input_name": { "name": "Blokkeer {input_name}" + }, + "arm_section": { + "name": "Inschakelen Sectie {section_number}" + }, + "arm_section_name": { + "name": "Inschakelen {section_name}" } } } From 8b4f9d9743616d2388a8f2e2718d7b5bdf9640ab Mon Sep 17 00:00:00 2001 From: Rogier van Staveren Date: Fri, 5 Jul 2024 13:46:32 +0200 Subject: [PATCH 17/17] Use latest unii library and bump version --- custom_components/unii/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/unii/manifest.json b/custom_components/unii/manifest.json index acf436d..6a0acba 100644 --- a/custom_components/unii/manifest.json +++ b/custom_components/unii/manifest.json @@ -22,7 +22,7 @@ "unii" ], "requirements": [ - "unii==1.0.0b11" + "unii==1.0.0" ], - "version": "1.0.0-beta.4" + "version": "1.0.0" }