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/ diff --git a/custom_components/unii/__init__.py b/custom_components/unii/__init__.py index 26148f2..e427ed1 100644 --- a/custom_components/unii/__init__.py +++ b/custom_components/unii/__init__.py @@ -22,18 +22,20 @@ 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__) PLATFORMS: list[Platform] = [ - Platform.ALARM_CONTROL_PANEL, + # Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SENSOR, - # Platform.SWITCH, + Platform.SWITCH, # 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): @@ -42,7 +44,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 +63,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 +106,26 @@ def __init__(self, hass: HomeAssistant, unii: UNii, config_entry: ConfigEntry): self.unii.add_event_occurred_callback(self.event_occurred_callback) + 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 + async def async_disconnect(self): """ Disconnect from UNii. @@ -140,6 +169,18 @@ 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 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.""" @@ -177,7 +218,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 +228,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(update_listener)) + await coordinator.async_request_refresh() return True @@ -199,4 +243,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + _LOGGER.debug("Configuration options updated") + coordinator: UNiiCoordinator = hass.data[DOMAIN][entry.entry_id] + + await 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..f3b63b4 100644 --- a/custom_components/unii/alarm_control_panel.py +++ b/custom_components/unii/alarm_control_panel.py @@ -12,11 +12,26 @@ ) 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 from homeassistant.helpers.update_coordinator import CoordinatorEntity -from unii import UNiiCommand, UNiiFeature, UNiiSection, UNiiSectionArmedState +from unii import ( + UNiiCommand, + UNiiFeature, + UNiiInputState, + UNiiInputStatusRecord, + UNiiSection, + UNiiSectionArmedState, + UNiiSectionStatusRecord, +) from . import DOMAIN, UNiiCoordinator @@ -32,26 +47,51 @@ 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(): + # 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 +163,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" @@ -145,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() @@ -165,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() @@ -181,34 +226,122 @@ 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 + self._attr_state = STATE_ALARM_DISARMED + + 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: - self._attr_is_disarming = False + 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..ef9fa52 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,60 @@ 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.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) + + 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/manifest.json b/custom_components/unii/manifest.json index b6f6e84..6a0acba 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.0" ], - "version": "1.0.0-beta.4" + "version": "1.0.0" } 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() diff --git a/custom_components/unii/switch.py b/custom_components/unii/switch.py index 0ba87c9..6b8f157 100644 --- a/custom_components/unii/switch.py +++ b/custom_components/unii/switch.py @@ -14,7 +14,15 @@ 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, + UNiiSectionArmedState, + UNiiSensorType, +) +from unii.unii_command_data import UNiiSectionStatus, UNiiSectionStatusRecord from . import DOMAIN, UNiiCoordinator @@ -30,32 +38,85 @@ 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 ( + coordinator.can_write() + and UNiiFeature.BYPASS_INPUT in coordinator.unii.features + ): + for _, uni_input in coordinator.unii.inputs.items(): + if "status" in uni_input and uni_input.status not in [ None, - UNiiOutputType.NOT_ACTIVE, + UNiiInputState.DISABLED, ]: - if output.name is None: + if uni_input.name is None: entity_description = SwitchEntityDescription( - key=f"output{output.number}-switch", + key=f"input{uni_input.number}-bypass", device_class=SwitchDeviceClass.SWITCH, ) else: entity_description = SwitchEntityDescription( - key=f"output{output.number}-switch", + key=f"input{uni_input.number}-bypass", device_class=SwitchDeviceClass.SWITCH, - name=output.name, + name=uni_input.name, ) entities.append( - UNiiOutputSwitch( + UNiiBypassInputSwitch( coordinator, entity_description, config_entry.entry_id, - output.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 [ + # 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 +140,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 +169,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 +183,243 @@ 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() + + 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() + + +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): """UNii Switch for outputs.""" diff --git a/custom_components/unii/translations/en.json b/custom_components/unii/translations/en.json index 333bf24..ef6f030 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}" @@ -63,6 +73,20 @@ "entry_timer": "Entry Timer Active" } } + }, + "switch": { + "bypass_input": { + "name": "Bypass Input {input_number}" + }, + "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 4163ef0..48dcabd 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}" @@ -63,6 +73,20 @@ "entry_timer": "Inlooptijd Actief" } } + }, + "switch": { + "bypass_input": { + "name": "Blokkeer Ingang {input_number}" + }, + "bypass_input_name": { + "name": "Blokkeer {input_name}" + }, + "arm_section": { + "name": "Inschakelen Sectie {section_number}" + }, + "arm_section_name": { + "name": "Inschakelen {section_name}" + } } } }