diff --git a/.gitignore b/.gitignore index a2e3f51..4472829 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /htmlcov/ /.coverage +__pycache__/ +*.pyc diff --git a/README.md b/README.md index fd4fef9..eb209f7 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,352 @@ -# HomeAssistantPlugin for [StreamController](https://github.com/StreamController/StreamController) - -Control your Home Assistant instance from your StreamDeck - -__This is no official plugin - I have no affiliation with Home Assistant, StreamDeck or StreamController.__ - -## Prerequisites - -* `websocket_api` must be present in your `configuration.yaml`. Remember to restart Home Assistant after updating your - configuration. -* You need a _long-lived access token_ to connect to Home Assistant. To create one, go to your user profile and click on - the _Security_ tab. All the way at the bottom of the page is a button to create a new token. You can only see/copy the - token immediately after creating it. Once you dismiss the dialog, you won't be able to retrieve the token. - __Be very careful with your Home Assistant information and your token. If your Home Assistant instance is accessible - from the internet, anyone with this information can access and control your Home Assistant instance.__ - -## Features - -* Perform an action - * Parameters for the action can be provided - * The action is always called on `key_down`, i.e. immediately when the button is pressed - * To change this behavior, the built-in `Event Assigner` can be used to - * map other events to `key_down`, if you want to call the service on a different event (multiple events are - possible) - * map `key_down` to `None`, if you don't want to call the service on `key_down` -* Show an icon - * This can be the entity icon or a custom icon - * Color, scale and opacity of the icon can be customized - * All icon settings can also be customized based on state or attribute values - * Customizations are reevaluated when the entity is updated -* Show text - * This can be the entity state, an attribute value or custom text - * If the entity's state changes, the text is updated on the StreamDeck - * Position, text size, text color, outline size and outline color of the text can be customized - * Optionally show the unit of measurement (with or without line break) - * All text settings can also be customized based on state or attribute values - * Customizations are reevaluated when the entity is updated - -## Documentation - -### Plugin settings - -To open the Home Assistant plugin settings, open your _StreamController settings_ and select the tab -_Plugins_. Look for the entry for _Home Assistant_ and click _Open Settings_. +
+ +# 🏠 Home Assistant Plugin + +### For [StreamController](https://github.com/StreamController/StreamController) + +**Control your Home Assistant instance directly from your StreamDeck** + +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Compatible-41BDF5?logo=homeassistant)](https://www.home-assistant.io/) +[![StreamController](https://img.shields.io/badge/StreamController-Plugin-green)](https://github.com/StreamController/StreamController) + +[Features](#-features) • [Installation](#-installation) • [Documentation](#-documentation) • [Examples](#-examples) • [Support](#-support) + +--- + +
+ +> **This is not an official plugin** - I have no affiliation with Home Assistant, StreamDeck, or StreamController. + +## 📋 Table of Contents + +- [Prerequisites](#-prerequisites) +- [Features](#-features) +- [Installation](#-installation) +- [Documentation](#-documentation) + - [Plugin Settings](#-plugin-settings) + - [Perform Action](#-perform-action) + - [Show Icon](#-show-icon) + - [Show Text](#-show-text) +- [Examples](#-examples) +- [Support](#-support) + +## ✅ Prerequisites + +> Before using this plugin, ensure the following requirements are met: + +- **WebSocket API**: The `websocket_api` component must be present in your `configuration.yaml` + - Remember to restart Home Assistant after updating your configuration + +- **Long-Lived Access Token**: Required to authenticate with Home Assistant + 1. Navigate to your user profile in Home Assistant + 2. Click on the **Security** tab + 3. Scroll to the bottom and create a new _Long-Lived Access Token_ + 4. **Copy the token immediately** - you won't be able to retrieve it later! + +> **Security Notice**: Keep your Home Assistant URL and access token secure. Anyone with this information can access and control your Home Assistant instance if it's exposed to the internet. + +## ✨ Features + +### 🎬 Perform an Action +- Execute any Home Assistant action/service +- Provide custom parameters for actions +- Actions trigger on `key_down` (button press) by default +- Use the built-in **Event Assigner** to customize behavior: + - Map different events to `key_down` + - Disable `key_down` by mapping it to `None` + +### 🎨 Show an Icon +- Display entity icons or custom icons from [Material Design Icons](https://pictogrammers.com/library/mdi/) +- Customize icon appearance: + - 🎨 Color + - 📏 Scale + - 🌫️ Opacity +- **Dynamic customization** based on state or attribute values +- Automatic updates when entity state changes + +### 📝 Show Text +- Display entity state, attributes, or custom text +- Real-time updates when entity state changes +- Full customization options: + - 📍 Position + - 📐 Text size + - 🎨 Text color + - 🔲 Outline size and color +- Show unit of measurement (with optional line breaks) +- **Dynamic customization** based on state or attribute values + +## 📥 Installation + +1. Install the plugin through StreamController's plugin manager +2. Configure your Home Assistant connection in the plugin settings +3. Start adding actions to your StreamDeck buttons! + +## 📖 Documentation + +### ⚙️ Plugin Settings + +**Opening the Settings:** +1. Open your StreamController settings +2. Navigate to the **Plugins** tab +3. Find **Home Assistant** in the list +4. Click **Open Settings** + +
![Plugin settings](assets/connection_settings.png) -Once all necessary information is entered, the plugin automatically tries to connect to Home -Assistant. If you are using a self-signed certificate, you should disable _Verify certificate_ -or the connection will fail. -If the connection can't be established or is lost, the plugin automatically tries to reconnect to the server every 10 -seconds. +
-### Perform action +**Configuration:** +- Enter your Home Assistant URL +- Provide your long-lived access token +- For self-signed certificates, disable **Verify certificate** -This action allows you to perform an action in Home Assistant. +> The plugin automatically attempts to connect once all information is entered. If the connection fails or is lost, it will retry every 10 seconds. + +--- + +### 🎬 Perform Action + +Execute Home Assistant actions directly from your StreamDeck. + +
![Action settings service](assets/perform_action.png) -You can pick the action and optionally an entity, if the action requires one. Parameters for the action are also -optional but the list of parameters contains all possible parameters for the action; this does not guarantee, that the -entity supports the parameter. -Only parameters that have their checkbox checked are included when performing the action. +
+ +**Configuration:** +- **Action**: Select the Home Assistant action to perform +- **Entity** (optional): Choose the target entity if required +- **Parameters** (optional): Configure action parameters + - Only checked parameters are sent with the action + - List shows all possible parameters (not all may be supported by the entity) -### Show icon +--- -This action allows you to show an icon based on Home Assistant data. +### 🎨 Show Icon + +Display dynamic icons based on Home Assistant entity data. + +
![Action settings icon](assets/show_icon.png) -After picking in entity, by default this shows the icon defined in Home Assistant for the entity; or nothing, if no -icon is defined. In the _Icon_ field you can define an icon which overrides the setting from Home Assistant. -Valid are all icons from the [Material Design Icon](https://pictogrammers.com/library/mdi/) collection. -You can also adapt color, scale and opacity of the icon there. +
+ +**Configuration:** +- **Entity**: Select the Home Assistant entity +- **Icon**: + - Leave empty to use the entity's default icon + - Enter a [Material Design Icon](https://pictogrammers.com/library/mdi/) name to override +- **Customization**: Adjust color, scale, and opacity + +#### 🎯 Icon Customization + +Create conditional icon appearances based on entity states. + +**Creating a Customization:** +1. Click the ![Add customization](assets/action_customize_add.png) button in the **Customize** row +2. Define a condition (state or attribute value) +3. Configure icon changes when the condition is met + +
+ +![Icon customization dialog](assets/show_icon_customize.png) + +
-#### Show icon customization +**Managing Customizations:** -To create a new icon customization, click the button ( -![Action settings icon add customization](assets/action_customize_add.png)) in the _Customize_ -row. A new window opens where you can enter a condition and how the icon should change if the -condition is met. +
-![Action settings icon customization](assets/show_icon_customize.png) +![Icon customizations list](assets/show_icon_customizations.png) -Only settings whose checkbox is checked are honored. -When you have created customizations, they are shown under _Customize_. +
-![Action settings icon customizations](assets/show_icon_customizations.png) +> **Cascading Behavior**: Customizations are evaluated in order from top to bottom. When multiple conditions match, the last matching customization wins. In the example above, both conditions are met, but the icon displayed is `lamp` from the second customization. -Customizations are cascading and evaluated in order. This means, multiple customizations might have -conditions that are met, but the latest matching customization sets the eventual value for an -option. In the example above, both customizations' conditions are met, but the icon shown would be -_lamp_ as this is the latest matching customization. -For convenience, the current value that the condition is evaluated against, is also shown, and with -the buttons, you can edit, delete and rearrange your customizations. +**Features:** +- ✏️ Edit, delete, and reorder customizations +- 👁️ View current entity value for reference +- ✅ Only checked settings are applied -### Show text +--- -This action allows you to show text based on Home Assistant data. +### 📝 Show Text + +Display dynamic text based on Home Assistant entity data. + +
![Action settings text](assets/show_text.png) -After picking an entity, you can configure how the text is shown. -With _Position_ you can change where on the key the text is shown. _Attribute_ allows you to change -what value of the entity is displayed; either the state, or the value of an attribute. -When _Round value_ is active, the plugin tries to convert the value to a float number and round it -to the defined _Precision_. This has no effect if the value cannot be converted. -_Text size_ and _Outline size_ change the size of the text and the outline respectively and the same -goes for _Text color_ and _Outline color_ but for color. -If the entity state is picked to be displayed and the entity has a unit of measurement set in Home -Assistant, the option _Show unit of measurement_ becomes available. When the option is activated, -additionally the option _Line break between value and unit_ becomes available as well. They do -exactly what the labels say. +
+ +**Configuration:** +- **Entity**: Select the Home Assistant entity +- **Position**: Choose where the text appears on the key +- **Attribute**: Select what to display: + - Entity state + - Specific attribute value +- **Rounding**: + - Enable to round numeric values + - Set decimal precision +- **Styling**: + - Text size and color + - Outline size and color +- **Unit of Measurement** (if available): + - Show/hide unit + - Optional line break between value and unit + +#### 🎯 Text Customization + +Create conditional text appearances based on entity states. + +**Creating a Customization:** +1. Click the ![Add customization](assets/action_customize_add.png) button in the **Customize** row +2. Define a condition (state or attribute value) +3. Configure text changes when the condition is met + +
+ +![Text customization dialog](assets/show_text_customize.png) + +
+ +**Managing Customizations:** + +
+ +![Text customizations list](assets/show_text_customizations.png) + +
+ +> **Cascading Behavior**: Like icon customizations, text customizations are evaluated in order. The last matching customization sets the final value. In the example above, only the second customization matches, setting text size to 12. + +**Advanced Features:** + +- **Custom Text**: Select `custom_text` as an attribute to display custom messages + - Use `%s` as a placeholder for the original value + - Use `\n` for line breaks + - **Example**: For a temperature attribute, use `%s\n°C` to display: + ``` + 23.5 + °C + ``` + - Perfect for creating custom translations! + +**Features:** +- ✏️ Edit, delete, and reorder customizations +- 👁️ View current entity value for reference +- ✅ Only checked settings are applied + +--- + +## 🎯 Examples + +### 🌤️ Weather Display + +
+ +![Weather button](assets/example_1.png) + +
+ +**Setup:** +- Uses **Show Text** action with a weather entity +- Displays current weather information directly on your StreamDeck + +--- + +### 🔘 Toggle Button + +
+ +![Boolean toggle button](assets/example_2.gif) + +
+ +**Setup:** +- **Show Icon**: Display icon with customization + - Changes color to yellow when `input_boolean` is `on` +- **Perform Action**: Toggle the `input_boolean` state +- Both actions use the same entity + +--- + +### 🎵 Media Player Control + +
+ +![Media player button](assets/example_3.gif) + +
+ +**Setup:** +- **Show Icon**: Dynamic play/pause icon + - Shows `play` icon by default + - Shows `pause` icon when media is playing +- **Perform Action**: Execute `media_play_pause` action +- **Show Text** (×2): Display media title and artist +- All actions use the same media player entity + +--- + +### 💨 Air Quality Monitor -#### Show text customization +
-To create a new text customization, click the button ( -![Action settings text add customization](assets/action_customize_add.png)) in the _Customize_ -row. A new window opens where you can enter a condition and how the text should change if the -condition is met. +![Air quality button](assets/example_4.gif) -![Action settings text customization](assets/show_text_customize.png) +
-Only settings whose checkbox is checked are honored. -When you have created customizations, they are shown under _Customize_. +**Setup:** +- **Show Text**: Display CO₂ sensor value + - Includes unit of measurement + - Line break between value and unit +- **Show Icon**: Ventilator icon at 50% opacity +- **Perform Action**: Toggle ventilator on/off +- Uses both CO₂ sensor and ventilator entities -![Action settings text customizations](assets/show_text_customizations.png) +--- -Just as with icon customization, text customizations are cascading and evaluated in order. -This means, multiple customizations might have conditions that are met, but the latest matching -customization sets the eventual value for an option. In the example above, only the second -customization matches the condition so the text size is set to 12. -As an `Attribute` you can also select `custom_text` which shows a new field where you can enter any text to be displayed -instead of the original. This allows you to create your own translations on the Stream Deck. Furthermore, `%s` in the -`custom_text` field is replaced by the original text and `\n` creates a line break. As an example: on a weather entity -that has an attribute for the temperature you can select the temperature to be displayed and then create a customization -with custom text `%s\n°C` which results in the temperature being displayed in the first line and "°C" in the second. -For convenience, the current value that the condition is evaluated against, is also shown, and with -the buttons, you can edit, delete and rearrange your customizations. +## 🚀 Roadmap -## Examples +Currently, all planned features have been implemented! Have a suggestion? [Open an issue](https://github.com/gensyn/HomeAssistantPlugin/issues)! -### Weather button +--- -![Weather button](assets/example_1.png) -Action _Show text_ used with a weather entity. +## 🆘 Support -### Toggle button +### Having Issues? -![boolean toggle button](assets/example_2.gif) -Action _Show icon_ and action _Perform action_ used with the same _input_boolean_ entity. _Show icon_ is customized to change the icon color to yellow when the _input_boolean_ is _on_. _Perform action_ toggles the _input_boolean_'s state. +If you encounter any problems or have questions: -### Media Player button +1. 📖 Check the [documentation](#documentation) above +2. 🔍 Search [existing issues](https://github.com/gensyn/HomeAssistantPlugin/issues) +3. 🐛 [Open a new issue](https://github.com/gensyn/HomeAssistantPlugin/issues/new) with: + - Detailed description of the problem + - Steps to reproduce + - Expected vs actual behavior + - Screenshots (if applicable) -![media player button](assets/example_3.gif) -Action _Show icon_, action _Perform action_ and two actions _Show text_ used with the same media player entity. _Show icon_ shows the custom icon _play_ except when the media player is _playing_, then it shows the custom icon _pause_. _Perform action_ performs the action _media_play_pause_ on the media player entity. The two _Show text_ actions show the media title and artist respectively. +### Contributing -### Air quality button +Contributions are welcome! Feel free to: +- 🐛 Report bugs +- 💡 Suggest new features +- 📝 Improve documentation +- 🔧 Submit pull requests -![air quality button](assets/example_4.gif) -Action _Show text_, action _Perform action_ and action _Show icon_ used with a CO2 sensor and a ventilator entity respectively. _Show text_ shows the CO2 sensor value with unit of measurement and line break between value and unit. _Show icon_ shows the ventilator icon at 50% opacity. _Perform action_ toggles the ventilator on/off. +--- -## Planned features +
-* None at the moment +**Made with ❤️ for the Home Assistant and StreamController communities** -## Problems? +[![GitHub](https://img.shields.io/badge/GitHub-Repository-181717?logo=github)](https://github.com/gensyn/HomeAssistantPlugin) -* Please open a [GitHub issue](https://github.com/gensyn/HomeAssistantPlugin/issues). +
diff --git a/actions/cores/base_core/base_core.py b/actions/cores/base_core/base_core.py index 3bba5d3..61c4eb6 100644 --- a/actions/cores/base_core/base_core.py +++ b/actions/cores/base_core/base_core.py @@ -137,8 +137,10 @@ def _load_domains(self) -> None: domains = self._get_domains() if domain is not None and domain not in domains: domains.append(domain) + domains = [d for d in domains if d is not None] domains.sort() - self.domain_combo.populate(domains, domain, trigger_callback=False) + if domains != self._get_current_domains(): + self.domain_combo.populate(domains, domain, trigger_callback=False) @requires_initialization def _load_entities(self) -> None: @@ -147,10 +149,26 @@ def _load_entities(self) -> None: entities = self.plugin_base.backend.get_entities( str(self.domain_combo.get_selected_item()) ) - if entity not in entities: + if entity is not None and entity not in entities: entities.append(entity) + entities = [e for e in entities if e is not None] entities.sort() - self.entity_combo.populate(entities, entity, trigger_callback=False) + if entities != self._get_current_entities(): + self.entity_combo.populate(entities, entity, trigger_callback=False) + + def _get_current_domains(self) -> List[str]: + """Get the domains currently displayed in the domain combo.""" + return [ + str(self.domain_combo.get_item_at(i)) + for i in range(self.domain_combo.get_item_amount()) + ] + + def _get_current_entities(self) -> List[str]: + """Get the entities currently displayed in the entity combo.""" + return [ + str(self.entity_combo.get_item_at(i)) + for i in range(self.entity_combo.get_item_amount()) + ] @requires_initialization def _set_enabled_disabled(self) -> None: diff --git a/actions/perform_action/perform_action.py b/actions/perform_action/perform_action.py index 0bdb9a4..b457d00 100644 --- a/actions/perform_action/perform_action.py +++ b/actions/perform_action/perform_action.py @@ -124,8 +124,9 @@ def _load_actions(self) -> None: str(self.domain_combo.get_selected_item()) ) actions = list(actions_dict.keys()) - if action not in actions: + if action is not None and action not in actions: actions.append(action) + actions = [a for a in actions if a is not None] self.action_combo.populate(actions, action, update_settings=True, trigger_callback=False) parameters_helper.load_parameters(self) diff --git a/actions/show_text/text_action.py b/actions/show_text/text_action.py index 0111e46..bb8ab4d 100644 --- a/actions/show_text/text_action.py +++ b/actions/show_text/text_action.py @@ -215,7 +215,7 @@ def _on_change_entity(self, _, entity, old_entity): def _load_attributes(self): attribute = self.settings.get_attribute() attributes = self._get_attributes() - if attribute not in attributes: + if attribute is not None and attribute not in attributes: attributes.append(attribute) if Counter(attributes) != Counter(self._get_current_attributes()): self.attribute.populate(attributes, attribute, trigger_callback=False) diff --git a/backend/home_assistant_backend.py b/backend/home_assistant_backend.py index 06f3005..2dbfb7a 100644 --- a/backend/home_assistant_backend.py +++ b/backend/home_assistant_backend.py @@ -6,6 +6,7 @@ from time import sleep from typing import Dict, Callable, Any, List, Set, Optional +from gi.repository import GLib from loguru import logger as log from HomeAssistantPlugin.backend import backend_const @@ -116,7 +117,7 @@ def _on_connect(self) -> None: self._readd_tracked_entities() for ready in self._action_ready_callbacks: - ready() + GLib.idle_add(ready) def _on_event_message(self, message: Dict) -> None: new_state = ( @@ -135,15 +136,19 @@ def _on_event_message(self, message: Dict) -> None: .get(backend_const.FROM_STATE, {}) .get(backend_const.ENTITY_ID) ) - entity_settings = self._entities[entity_id.split(backend_const.DOT)[0]].get(entity_id) + entity_settings = self._entities.get(entity_id.split(backend_const.DOT)[0], {}).get(entity_id) + if entity_settings is None: + return actions = entity_settings.get(backend_const.ACTIONS, set()) for action_entity_updated in actions: - action_entity_updated() + GLib.idle_add(action_entity_updated) return entity_id = new_state.get(backend_const.ENTITY_ID) domain = entity_id.split(backend_const.DOT)[0] - entity_settings = self._entities[domain].get(entity_id) + entity_settings = self._entities.get(domain, {}).get(entity_id) + if entity_settings is None: + return actions = entity_settings.get(backend_const.ACTIONS, set()) state = new_state.get(backend_const.STATE) attributes = new_state.get(backend_const.ATTRIBUTES, {}) @@ -161,7 +166,7 @@ def _on_event_message(self, message: Dict) -> None: log.warning(backend_const.WARNING_NOT_SUBSCRIBED.format(entity_id)) for action_entity_updated in actions: - action_entity_updated(update_state) + GLib.idle_add(action_entity_updated, update_state) def _on_disconnect(self, websocket: HomeAssistantWebsocket) -> None: if websocket != self._websocket: @@ -169,7 +174,7 @@ def _on_disconnect(self, websocket: HomeAssistantWebsocket) -> None: return log.info(backend_const.INFO_DISCONNECTED) for ready in self._action_ready_callbacks: - ready() + GLib.idle_add(ready) sleep(backend_const.RECONNECT_INTERVAL) if websocket == self._websocket: # the websocket instance is still the same, so we can try to reconnect diff --git a/main.py b/main.py index 5f248d9..204c763 100644 --- a/main.py +++ b/main.py @@ -72,7 +72,7 @@ def __init__(self): self.register( plugin_name=const.HOME_ASSISTANT, github_repo="https://github.com/gensyn/HomeAssistantPlugin", - plugin_version="1.0.1", + plugin_version="1.0.2", app_version="1.5.0-beta" ) diff --git a/manifest.json b/manifest.json index c881440..21c36ca 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.1", + "version": "1.0.2", "thumbnail": "assets/homeassistant.png", "id": "HomeAssistantPlugin", "name": "Home Assistant", diff --git a/test/actions/cores/base_core/test_base_core_load_domains.py b/test/actions/cores/base_core/test_base_core_load_domains.py index b8d10a3..37454ee 100644 --- a/test/actions/cores/base_core/test_base_core_load_domains.py +++ b/test/actions/cores/base_core/test_base_core_load_domains.py @@ -29,6 +29,7 @@ def test_load_domains_domain_not_in_domains(self, get_domains_mock, _, __): domain_combo_mock = Mock() domain_combo_mock.populate = Mock() + domain_combo_mock.get_item_amount.return_value = 0 instance = BaseCore(Mock(), True) instance.initialized = True @@ -56,6 +57,7 @@ def test_load_domains_success(self, get_domains_mock, _, __): domain_combo_mock = Mock() domain_combo_mock.populate = Mock() + domain_combo_mock.get_item_amount.return_value = 0 instance = BaseCore(Mock(), True) instance.initialized = True @@ -67,3 +69,53 @@ def test_load_domains_success(self, get_domains_mock, _, __): get_domains_mock.assert_called_once() domain_combo_mock.populate.assert_called_once_with(domains_sorted, domain, trigger_callback=False) + + @patch.object(BaseCore, "_create_ui_elements") + @patch.object(BaseCore, "_create_event_assigner") + @patch.object(BaseCore, "_get_domains") + def test_load_domains_with_none_in_domains(self, get_domains_mock, _, __): + domains = ["switch", None, "sensor"] + domain = "light" + domains_sorted = sorted(["switch", "sensor", domain]) + + get_domains_mock.return_value = domains + + settings_mock = Mock() + settings_mock.get_domain = Mock(return_value=domain) + + domain_combo_mock = Mock() + domain_combo_mock.populate = Mock() + domain_combo_mock.get_item_amount.return_value = 0 + + instance = BaseCore(Mock(), True) + instance.initialized = True + instance.settings = settings_mock + instance.domain_combo = domain_combo_mock + instance._load_domains() + + domain_combo_mock.populate.assert_called_once_with(domains_sorted, domain, trigger_callback=False) + + @patch.object(BaseCore, "_create_ui_elements") + @patch.object(BaseCore, "_create_event_assigner") + @patch.object(BaseCore, "_get_domains") + def test_load_domains_no_update_needed(self, get_domains_mock, _, __): + domains = ["light", "sensor", "switch"] + domain = "light" + + get_domains_mock.return_value = domains + + settings_mock = Mock() + settings_mock.get_domain = Mock(return_value=domain) + + domain_combo_mock = Mock() + domain_combo_mock.populate = Mock() + domain_combo_mock.get_item_amount.return_value = 3 + domain_combo_mock.get_item_at.side_effect = ["light", "sensor", "switch"] + + instance = BaseCore(Mock(), True) + instance.initialized = True + instance.settings = settings_mock + instance.domain_combo = domain_combo_mock + instance._load_domains() + + domain_combo_mock.populate.assert_not_called() diff --git a/test/actions/cores/base_core/test_base_core_load_entities.py b/test/actions/cores/base_core/test_base_core_load_entities.py index b2a6d22..c202eeb 100644 --- a/test/actions/cores/base_core/test_base_core_load_entities.py +++ b/test/actions/cores/base_core/test_base_core_load_entities.py @@ -30,6 +30,7 @@ def test_load_entities_entity_not_in_entities(self, _, __): entity_combo_mock = Mock() entity_combo_mock.populate = Mock() + entity_combo_mock.get_item_amount.return_value = 0 instance = BaseCore(Mock(), True) instance.initialized = True @@ -61,6 +62,7 @@ def test_load_entities_success(self, _, __): entity_combo_mock = Mock() entity_combo_mock.populate = Mock() + entity_combo_mock.get_item_amount.return_value = 0 instance = BaseCore(Mock(), True) instance.initialized = True @@ -75,3 +77,59 @@ def test_load_entities_success(self, _, __): instance.plugin_base.backend.get_entities.assert_called_once_with(domain) entity_combo_mock.populate.assert_called_once_with(entities_sorted, entity, trigger_callback=False) + + @patch.object(BaseCore, "_create_ui_elements") + @patch.object(BaseCore, "_create_event_assigner") + def test_load_entities_with_none_entity(self, _, __): + domain = "light" + entities = ["light.kitchen", "light.bedroom"] + entity = None + entities_sorted = sorted(entities) + + settings_mock = Mock() + settings_mock.get_entity = Mock(return_value=entity) + + domain_combo_mock = Mock() + domain_combo_mock.get_selected_item = Mock(return_value=domain) + + entity_combo_mock = Mock() + entity_combo_mock.populate = Mock() + entity_combo_mock.get_item_amount.return_value = 0 + + instance = BaseCore(Mock(), True) + instance.initialized = True + instance.settings = settings_mock + instance.domain_combo = domain_combo_mock + instance.entity_combo = entity_combo_mock + instance.plugin_base.backend.get_entities.return_value = entities + instance._load_entities() + + entity_combo_mock.populate.assert_called_once_with(entities_sorted, entity, trigger_callback=False) + + @patch.object(BaseCore, "_create_ui_elements") + @patch.object(BaseCore, "_create_event_assigner") + def test_load_entities_no_update_needed(self, _, __): + domain = "light" + entities = ["light.bedroom", "light.kitchen", "light.living_room"] + entity = "light.living_room" + + settings_mock = Mock() + settings_mock.get_entity = Mock(return_value=entity) + + domain_combo_mock = Mock() + domain_combo_mock.get_selected_item = Mock(return_value=domain) + + entity_combo_mock = Mock() + entity_combo_mock.populate = Mock() + entity_combo_mock.get_item_amount.return_value = 3 + entity_combo_mock.get_item_at.side_effect = ["light.bedroom", "light.kitchen", "light.living_room"] + + instance = BaseCore(Mock(), True) + instance.initialized = True + instance.settings = settings_mock + instance.domain_combo = domain_combo_mock + instance.entity_combo = entity_combo_mock + instance.plugin_base.backend.get_entities.return_value = entities + instance._load_entities() + + entity_combo_mock.populate.assert_not_called() diff --git a/test/actions/perform_action/test_perform_action_load_actions.py b/test/actions/perform_action/test_perform_action_load_actions.py index 4b1913e..58b0d83 100644 --- a/test/actions/perform_action/test_perform_action_load_actions.py +++ b/test/actions/perform_action/test_perform_action_load_actions.py @@ -86,3 +86,36 @@ def test_load_actions_action_not_in_actions(self, load_parameters_mock, _): ) load_parameters_mock.assert_called_once_with(instance) + + @patch('HomeAssistantPlugin.actions.perform_action.perform_action.BaseCore.__init__') + @patch( + 'HomeAssistantPlugin.actions.perform_action.parameters.parameters_helper.load_parameters') + def test_load_actions_none_action(self, load_parameters_mock, _): + settings_mock = Mock() + settings_mock.get_action = Mock(return_value=None) + + domain_combo_mock = Mock() + domain_combo_mock.get_selected_item = Mock(return_value='test_domain') + + action_combo_mock = Mock() + action_combo_mock.populate = Mock() + + plugin_base_mock = Mock() + plugin_base_mock.backend = Mock() + plugin_base_mock.backend.get_actions = Mock(return_value={'one_action': {}, 'another_action': {}}) + + instance = PerformAction() + instance.initialized = True + instance.settings = settings_mock + instance.plugin_base = plugin_base_mock + instance.domain_combo = domain_combo_mock + instance.action_combo = action_combo_mock + instance._load_actions() + + action_combo_mock.populate.assert_called_once_with( + ['one_action', 'another_action'], + None, + update_settings=True, + trigger_callback=False + ) + load_parameters_mock.assert_called_once_with(instance) diff --git a/test/actions/show_text/test_text_action.py b/test/actions/show_text/test_text_action.py index ede781c..4148520 100644 --- a/test/actions/show_text/test_text_action.py +++ b/test/actions/show_text/test_text_action.py @@ -531,3 +531,21 @@ def test_get_domains(self): instance.plugin_base.backend.get_domains_for_entities.assert_called_once() self.assertEqual(result, ["domain1", "domain2"]) + + def test_load_attributes_none_attribute(self): + instance = ShowText.__new__(ShowText) + instance.settings = Mock() + instance.settings.get_attribute.return_value = None + instance._get_attributes = Mock() + instance._get_attributes.return_value = ["attribute1", "attribute2"] + instance._get_current_attributes = Mock() + instance._get_current_attributes.return_value = ["attribute1", "attribute2", "attribute3"] + instance.attribute = Mock() + + instance._load_attributes() + + instance.attribute.populate.assert_called_once_with( + ["attribute1", "attribute2"], + None, + trigger_callback=False + ) diff --git a/test/backend/test_backend_on_connect.py b/test/backend/test_backend_on_connect.py index 2d940fd..633ee0d 100644 --- a/test/backend/test_backend_on_connect.py +++ b/test/backend/test_backend_on_connect.py @@ -17,7 +17,8 @@ class TestBackendOnConnect(unittest.TestCase): @patch.object(HomeAssistantBackend, '_load_actions') @patch.object(HomeAssistantBackend, '_readd_tracked_entities') @patch("HomeAssistantPlugin.backend.home_assistant_backend.log.info") - def test_on_connect_success(self, log_mock, readd_tracked_entities_mock, load_actions_mock, load_entities_mock, _): + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_connect_success(self, idle_add_mock, log_mock, readd_tracked_entities_mock, load_actions_mock, load_entities_mock, _): connection_status_callback_mock = Mock() ready_mock = Mock() diff --git a/test/backend/test_backend_on_disconnect.py b/test/backend/test_backend_on_disconnect.py index d13721d..83dd3a1 100644 --- a/test/backend/test_backend_on_disconnect.py +++ b/test/backend/test_backend_on_disconnect.py @@ -27,7 +27,8 @@ def test_on_disconnect_wrong_websocket(self, log_mock, _): @patch.object(HomeAssistantBackend, 'connect') @patch("HomeAssistantPlugin.backend.home_assistant_backend.sleep") @patch("HomeAssistantPlugin.backend.home_assistant_backend.log.info") - def test_on_disconnect_websocket_changed_during_sleep(self, log_mock, sleep_mock, connect_mock): + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_disconnect_websocket_changed_during_sleep(self, idle_add_mock, log_mock, sleep_mock, connect_mock): action_mock = Mock() action_ready_callbacks = [action_mock] * 3 websocket_mock = Mock() @@ -46,7 +47,8 @@ def test_on_disconnect_websocket_changed_during_sleep(self, log_mock, sleep_mock @patch.object(HomeAssistantBackend, 'connect') @patch("HomeAssistantPlugin.backend.home_assistant_backend.sleep") @patch("HomeAssistantPlugin.backend.home_assistant_backend.log.info") - def test_on_disconnect_success(self, log_mock, sleep_mock, connect_mock): + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_disconnect_success(self, idle_add_mock, log_mock, sleep_mock, connect_mock): action_mock = Mock() action_ready_callbacks = [action_mock] * 3 websocket_mock = Mock() diff --git a/test/backend/test_backend_on_event_message.py b/test/backend/test_backend_on_event_message.py index 60ca220..08aa9b3 100644 --- a/test/backend/test_backend_on_event_message.py +++ b/test/backend/test_backend_on_event_message.py @@ -13,7 +13,8 @@ class TestBackendOnEventMessage(unittest.TestCase): @patch.object(HomeAssistantBackend, 'connect') - def test_on_event_message_no_new_state(self, _): + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_event_message_no_new_state(self, idle_add_mock, _): wrong_mock = Mock() right_mock = Mock() @@ -64,7 +65,8 @@ def test_on_event_message_no_new_state(self, _): @patch.object(HomeAssistantBackend, 'connect') @patch.object(HomeAssistantBackend, 'is_connected') @patch("HomeAssistantPlugin.backend.home_assistant_backend.log.warning") - def test_on_event_message_not_subscribed(self, log_mock, is_connected_mock, _): + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_event_message_not_subscribed(self, idle_add_mock, log_mock, is_connected_mock, _): wrong_mock = Mock() entities = { @@ -112,7 +114,8 @@ def test_on_event_message_not_subscribed(self, log_mock, is_connected_mock, _): @patch.object(HomeAssistantBackend, 'connect') @patch.object(HomeAssistantBackend, 'is_connected', return_value=True) - def test_on_event_message_success(self, is_connected_mock, _): + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_event_message_success(self, idle_add_mock, is_connected_mock, _): wrong_mock = Mock() right_mock = Mock() @@ -172,3 +175,69 @@ def test_on_event_message_success(self, is_connected_mock, _): backend_const.HA_CONNECTED: True, }) + @patch.object(HomeAssistantBackend, 'connect') + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_event_message_unknown_entity_no_new_state(self, idle_add_mock, _): + wrong_mock = Mock() + + entities = { + "domain1": { + "domain1.entity1": { + backend_const.ACTIONS: {wrong_mock}, + backend_const.SUBSCRIPTION_ID: "1.3" + } + } + } + + message = { + backend_const.FIELD_EVENT: { + backend_const.VARIABLES: { + backend_const.TRIGGER: { + backend_const.FROM_STATE: { + backend_const.ENTITY_ID: "domain1.unknown_entity" + }, + } + } + } + } + + instance = HomeAssistantBackend(backend_const.EMPTY_STRING, backend_const.EMPTY_STRING, True, True, backend_const.EMPTY_STRING) + instance._entities = entities + instance._on_event_message(message) + + wrong_mock.assert_not_called() + + @patch.object(HomeAssistantBackend, 'connect') + @patch.object(HomeAssistantBackend, 'is_connected', return_value=True) + @patch("HomeAssistantPlugin.backend.home_assistant_backend.GLib.idle_add", side_effect=lambda f, *args: f(*args)) + def test_on_event_message_unknown_entity_with_new_state(self, idle_add_mock, is_connected_mock, _): + wrong_mock = Mock() + + entities = { + "domain1": { + "domain1.entity1": { + backend_const.ACTIONS: {wrong_mock}, + backend_const.SUBSCRIPTION_ID: "1.3" + } + } + } + + message = { + backend_const.FIELD_EVENT: { + backend_const.VARIABLES: { + backend_const.TRIGGER: { + backend_const.TO_STATE: { + backend_const.ENTITY_ID: "domain1.unknown_entity", + backend_const.STATE: backend_const.OFF, + backend_const.ATTRIBUTES: {} + }, + } + } + } + } + + instance = HomeAssistantBackend(backend_const.EMPTY_STRING, backend_const.EMPTY_STRING, True, True, backend_const.EMPTY_STRING) + instance._entities = entities + instance._on_event_message(message) + + wrong_mock.assert_not_called() diff --git a/test/test_main_init.py b/test/test_main_init.py index 88ebebd..eb4b37a 100644 --- a/test/test_main_init.py +++ b/test/test_main_init.py @@ -84,7 +84,7 @@ def test_init_success(self, action_holder_mock, show_text_mock, show_icon_mock, register_mock.assert_called_once_with( plugin_name=const.HOME_ASSISTANT, github_repo="https://github.com/gensyn/HomeAssistantPlugin", - plugin_version="1.0.1", + plugin_version="1.0.2", app_version="1.5.0-beta" )