From 65a8454c3ca83662cd9c44bcabcf93ebb3287f04 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 20 Dec 2024 20:20:22 +0000 Subject: [PATCH 1/7] Add switch platform --- homeassistant/components/iron_os/__init__.py | 1 + homeassistant/components/iron_os/icons.json | 20 ++ homeassistant/components/iron_os/strings.json | 26 +++ homeassistant/components/iron_os/switch.py | 179 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 homeassistant/components/iron_os/switch.py diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 9655f7bfcdd8ce..c3924c49c9ad9b 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -30,6 +30,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.UPDATE, ] diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 0d26b027c3f35e..75377eea7becbd 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -149,6 +149,26 @@ "estimated_power": { "default": "mdi:flash" } + }, + "switch": { + "animation_loop": { + "default": "mdi:play-box" + }, + "calibrate_cjc": { + "default": "mdi:tune-vertical" + }, + "cooling_temp_blink": { + "default": "mdi:alarm-light-outline" + }, + "display_invert": { + "default": "mdi:invert-colors" + }, + "invert_buttons": { + "default": "mdi:plus-minus-variant" + }, + "usb_pd_mode": { + "default": "mdi:meter-electric-outline" + } } } } diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 04c5528055057b..8ad0323319a494 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -214,6 +214,32 @@ "estimated_power": { "name": "Estimated power" } + }, + "switch": { + "animation_loop": { + "name": "Animation loop" + }, + "cooling_temp_blink": { + "name": "Cool down screen flashing" + }, + "idle_screen_details": { + "name": "Detailed idle screen" + }, + "solder_screen_details": { + "name": "Detailed solder screen" + }, + "invert_buttons": { + "name": "Swap +/- buttons" + }, + "display_invert": { + "name": "Invert screen" + }, + "calibrate_cjc": { + "name": "Calibrate CJC" + }, + "usb_pd_mode": { + "name": "PPS & EPR PD mode" + } } }, "exceptions": { diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py new file mode 100644 index 00000000000000..83f4e2d526ed9b --- /dev/null +++ b/homeassistant/components/iron_os/switch.py @@ -0,0 +1,179 @@ +"""Switch platform for IronOS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pynecil import CharSetting, CommunicationError, SettingsDataResponse + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .const import DOMAIN +from .coordinator import IronOSCoordinators +from .entity import IronOSBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IronOSSwitchEntityDescription(SwitchEntityDescription): + """Describes IronOS switch entity.""" + + is_on_fn: Callable[[SettingsDataResponse], bool | int | None] + characteristic: CharSetting + + +class IronOSSwitch(StrEnum): + """Switch controls for IronOS device.""" + + ANIMATION_LOOP = "animation_loop" + COOLING_TEMP_BLINK = "cooling_temp_blink" + IDLE_SCREEN_DETAILS = "idle_screen_details" + SOLDER_SCREEN_DETAILS = "solder_screen_details" + INVERT_BUTTONS = "invert_buttons" + DISPLAY_INVERT = "display_invert" + CALIBRATE_CJC = "calibrate_cjc" + USB_PD_MODE = "usb_pd_mode" + + +SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( + IronOSSwitchEntityDescription( + key=IronOSSwitch.ANIMATION_LOOP, + translation_key=IronOSSwitch.ANIMATION_LOOP, + characteristic=CharSetting.ANIMATION_LOOP, + is_on_fn=lambda x: x.get("animation_loop"), + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.COOLING_TEMP_BLINK, + translation_key=IronOSSwitch.COOLING_TEMP_BLINK, + characteristic=CharSetting.COOLING_TEMP_BLINK, + is_on_fn=lambda x: x.get("cooling_temp_blink"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.IDLE_SCREEN_DETAILS, + translation_key=IronOSSwitch.IDLE_SCREEN_DETAILS, + characteristic=CharSetting.IDLE_SCREEN_DETAILS, + is_on_fn=lambda x: x.get("idle_screen_details"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.SOLDER_SCREEN_DETAILS, + translation_key=IronOSSwitch.SOLDER_SCREEN_DETAILS, + characteristic=CharSetting.SOLDER_SCREEN_DETAILS, + is_on_fn=lambda x: x.get("solder_screen_details"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.INVERT_BUTTONS, + translation_key=IronOSSwitch.INVERT_BUTTONS, + characteristic=CharSetting.INVERT_BUTTONS, + is_on_fn=lambda x: x.get("invert_buttons"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.DISPLAY_INVERT, + translation_key=IronOSSwitch.DISPLAY_INVERT, + characteristic=CharSetting.DISPLAY_INVERT, + is_on_fn=lambda x: x.get("display_invert"), + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.CALIBRATE_CJC, + translation_key=IronOSSwitch.CALIBRATE_CJC, + characteristic=CharSetting.CALIBRATE_CJC, + is_on_fn=lambda x: x.get("calibrate_cjc"), + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.USB_PD_MODE, + translation_key=IronOSSwitch.USB_PD_MODE, + characteristic=CharSetting.USB_PD_MODE, + is_on_fn=lambda x: x.get("usb_pd_mode"), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches from a config entry.""" + + coordinators = entry.runtime_data + + async_add_entities( + IronOSSwitchEntity(coordinators, description) + for description in SWITCH_DESCRIPTIONS + ) + + +class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity): + """Representation of a IronOS Switch.""" + + entity_description: IronOSSwitchEntityDescription + + def __init__( + self, + coordinators: IronOSCoordinators, + entity_description: IronOSSwitchEntityDescription, + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinators.live_data, entity_description) + + self.settings = coordinators.settings + + @property + def is_on(self) -> bool | None: + """Return the state of the device.""" + return bool( + self.entity_description.is_on_fn( + self.settings.data, + ) + ) + + async def write(self, value: bool) -> None: + """Write value to the settings characteristic.""" + + try: + await self.coordinator.device.write( + self.entity_description.characteristic, value + ) + except CommunicationError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="submit_setting_failed", + ) from e + await self.settings.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.write(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.write(False) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + self.async_on_remove( + self.settings.async_add_listener( + self._handle_coordinator_update, self.entity_description.characteristic + ) + ) + await self.settings.async_request_refresh() From 59a536be8455ac818ee9155f5c0af1903afcc9e0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 20 Dec 2024 21:14:19 +0000 Subject: [PATCH 2/7] Add tests --- tests/components/iron_os/conftest.py | 8 + .../iron_os/snapshots/test_switch.ambr | 369 ++++++++++++++++++ tests/components/iron_os/test_switch.py | 146 +++++++ 3 files changed, 523 insertions(+) create mode 100644 tests/components/iron_os/snapshots/test_switch.ambr create mode 100644 tests/components/iron_os/test_switch.py diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 356c7358c559e3..f14043c096ed2a 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -183,6 +183,14 @@ def mock_pynecil() -> Generator[AsyncMock]: desc_scroll_speed=ScrollSpeed.FAST, logo_duration=LogoDuration.LOOP, locking_mode=LockingMode.FULL_LOCKING, + animation_loop=True, + cooling_temp_blink=True, + idle_screen_details=True, + solder_screen_details=True, + invert_buttons=True, + display_invert=True, + calibrate_cjc=True, + usb_pd_mode=True, ) client.get_live_data.return_value = LiveDataResponse( live_temp=298, diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..827f416ba80b85 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -0,0 +1,369 @@ +# serializer version: 1 +# name: test_switch_platform[switch.pinecil_animation_loop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_animation_loop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Animation loop', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_animation_loop', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_animation_loop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Animation loop', + }), + 'context': , + 'entity_id': 'switch.pinecil_animation_loop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_calibrate_cjc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_calibrate_cjc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calibrate CJC', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_calibrate_cjc', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_calibrate_cjc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Calibrate CJC', + }), + 'context': , + 'entity_id': 'switch.pinecil_calibrate_cjc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_cool_down_screen_flashing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_cool_down_screen_flashing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cool down screen flashing', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_cooling_temp_blink', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_cool_down_screen_flashing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Cool down screen flashing', + }), + 'context': , + 'entity_id': 'switch.pinecil_cool_down_screen_flashing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_idle_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_detailed_idle_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detailed idle screen', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_idle_screen_details', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_idle_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Detailed idle screen', + }), + 'context': , + 'entity_id': 'switch.pinecil_detailed_idle_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_solder_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_detailed_solder_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detailed solder screen', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_solder_screen_details', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_detailed_solder_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Detailed solder screen', + }), + 'context': , + 'entity_id': 'switch.pinecil_detailed_solder_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_invert_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_invert_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Invert screen', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_display_invert', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_invert_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Invert screen', + }), + 'context': , + 'entity_id': 'switch.pinecil_invert_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_pps_epr_pd_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_pps_epr_pd_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PPS & EPR PD mode', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_pps_epr_pd_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil PPS & EPR PD mode', + }), + 'context': , + 'entity_id': 'switch.pinecil_pps_epr_pd_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_platform[switch.pinecil_swap_buttons-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.pinecil_swap_buttons', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Swap +/- buttons', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_invert_buttons', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_swap_buttons-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Swap +/- buttons', + }), + 'context': , + 'entity_id': 'switch.pinecil_swap_buttons', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iron_os/test_switch.py b/tests/components/iron_os/test_switch.py new file mode 100644 index 00000000000000..a2faa72901f2e2 --- /dev/null +++ b/tests/components/iron_os/test_switch.py @@ -0,0 +1,146 @@ +"""Tests for the IronOS switch platform.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pynecil import CharSetting, CommunicationError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +async def switch_only() -> AsyncGenerator[None]: + """Enable only the switch platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.SWITCH], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_switch_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the IronOS switch platform.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "target"), + [ + ("switch.pinecil_animation_loop", CharSetting.ANIMATION_LOOP), + ("switch.pinecil_calibrate_cjc", CharSetting.CALIBRATE_CJC), + ("switch.pinecil_cool_down_screen_flashing", CharSetting.COOLING_TEMP_BLINK), + ("switch.pinecil_detailed_idle_screen", CharSetting.IDLE_SCREEN_DETAILS), + ("switch.pinecil_detailed_solder_screen", CharSetting.SOLDER_SCREEN_DETAILS), + ("switch.pinecil_invert_screen", CharSetting.DISPLAY_INVERT), + ("switch.pinecil_pps_epr_pd_mode", CharSetting.USB_PD_MODE), + ("switch.pinecil_swap_buttons", CharSetting.INVERT_BUTTONS), + ], +) +@pytest.mark.parametrize( + ("service", "value"), + [ + (SERVICE_TOGGLE, False), + (SERVICE_TURN_OFF, False), + (SERVICE_TURN_ON, True), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_turn_on_off_toggle( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, + service: str, + value: bool, + entity_id: str, + target: CharSetting, +) -> None: + """Test the IronOS switch turn on/off, toggle services.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(target, value) + + +@pytest.mark.parametrize( + "service", + [SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON], +) +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "ble_device", "mock_pynecil" +) +async def test_turn_on_off_toggle_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + service: str, +) -> None: + """Test the IronOS switch turn on/off, toggle service exceptions.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pynecil.write.side_effect = CommunicationError + + with pytest.raises( + ServiceValidationError, + match="Failed to submit setting to device, try again later", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + service_data={ATTR_ENTITY_ID: "switch.pinecil_animation_loop"}, + blocking=True, + ) From 77b4af6b0d289479f2e94a19b022e5899c81e066 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Dec 2024 13:45:07 +0000 Subject: [PATCH 3/7] prevent switch bouncing --- .../components/iron_os/coordinator.py | 21 ++++++++++++++++++ homeassistant/components/iron_os/switch.py | 22 +++---------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index e8ddef43bd7258..407d9b025bc2ad 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -5,8 +5,10 @@ from dataclasses import dataclass from datetime import timedelta import logging +from typing import cast from pynecil import ( + CharSetting, CommunicationError, DeviceInfoResponse, IronOSUpdate, @@ -19,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -147,3 +150,21 @@ async def _async_update_data(self) -> SettingsDataResponse: _LOGGER.debug("Failed to fetch settings", exc_info=e) return self.data or SettingsDataResponse() + + async def write(self, characteristic: CharSetting, value: bool) -> None: + """Write value to the settings characteristic.""" + + try: + await self.device.write(characteristic, value) + except CommunicationError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="submit_setting_failed", + ) from e + else: + # prevent switch bouncing while waiting for coordinator to finish refresh + self.data.update( + cast(SettingsDataResponse, {characteristic.name.lower(): value}) + ) + self.async_update_listeners() + await self.async_request_refresh() diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index 83f4e2d526ed9b..441a4325901a5a 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -7,16 +7,14 @@ from enum import StrEnum from typing import Any -from pynecil import CharSetting, CommunicationError, SettingsDataResponse +from pynecil import CharSetting, SettingsDataResponse from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IronOSConfigEntry -from .const import DOMAIN from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -145,27 +143,13 @@ def is_on(self) -> bool | None: ) ) - async def write(self, value: bool) -> None: - """Write value to the settings characteristic.""" - - try: - await self.coordinator.device.write( - self.entity_description.characteristic, value - ) - except CommunicationError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="submit_setting_failed", - ) from e - await self.settings.async_request_refresh() - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.write(True) + await self.settings.write(self.entity_description.characteristic, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.write(False) + await self.settings.write(self.entity_description.characteristic, False) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From d1b7648174aa466d1190f340dbe7f7eca68a978c Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Dec 2024 14:57:08 +0000 Subject: [PATCH 4/7] some changes --- homeassistant/components/iron_os/icons.json | 22 +++++++++++++++++-- homeassistant/components/iron_os/strings.json | 2 +- homeassistant/components/iron_os/switch.py | 10 ++++----- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 75377eea7becbd..baceb1066af384 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -152,13 +152,19 @@ }, "switch": { "animation_loop": { - "default": "mdi:play-box" + "default": "mdi:play-box", + "state": { + "on": "mdi:animation-play" + } }, "calibrate_cjc": { "default": "mdi:tune-vertical" }, "cooling_temp_blink": { - "default": "mdi:alarm-light-outline" + "default": "mdi:alarm-light-outline", + "state": { + "off": "mdi:alarm-light-off-outline" + } }, "display_invert": { "default": "mdi:invert-colors" @@ -168,6 +174,18 @@ }, "usb_pd_mode": { "default": "mdi:meter-electric-outline" + }, + "idle_screen_details": { + "default": "mdi:card-bulleted-settings-outline", + "state": { + "off": "mdi:card-bulleted-off-outline" + } + }, + "solder_screen_details": { + "default": "mdi:card-bulleted-settings-outline", + "state": { + "off": "mdi:card-bulleted-off-outline" + } } } } diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 8ad0323319a494..b7d6cc673a6629 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -238,7 +238,7 @@ "name": "Calibrate CJC" }, "usb_pd_mode": { - "name": "PPS & EPR PD mode" + "name": "Power Delivery 3.1 EPR" } } }, diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index 441a4325901a5a..4e14b240ffb693 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -25,7 +25,7 @@ class IronOSSwitchEntityDescription(SwitchEntityDescription): """Describes IronOS switch entity.""" - is_on_fn: Callable[[SettingsDataResponse], bool | int | None] + is_on_fn: Callable[[SettingsDataResponse], bool | None] characteristic: CharSetting @@ -84,6 +84,7 @@ class IronOSSwitch(StrEnum): translation_key=IronOSSwitch.DISPLAY_INVERT, characteristic=CharSetting.DISPLAY_INVERT, is_on_fn=lambda x: x.get("display_invert"), + entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), IronOSSwitchEntityDescription( @@ -99,6 +100,7 @@ class IronOSSwitch(StrEnum): translation_key=IronOSSwitch.USB_PD_MODE, characteristic=CharSetting.USB_PD_MODE, is_on_fn=lambda x: x.get("usb_pd_mode"), + entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), ) @@ -137,10 +139,8 @@ def __init__( @property def is_on(self) -> bool | None: """Return the state of the device.""" - return bool( - self.entity_description.is_on_fn( - self.settings.data, - ) + return self.entity_description.is_on_fn( + self.settings.data, ) async def async_turn_on(self, **kwargs: Any) -> None: From aa1aa11a97b6042b7e235a22bdfc178b5800a0b0 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Dec 2024 15:14:17 +0000 Subject: [PATCH 5/7] icons --- homeassistant/components/iron_os/icons.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index baceb1066af384..9636ef682cba3a 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -176,13 +176,13 @@ "default": "mdi:meter-electric-outline" }, "idle_screen_details": { - "default": "mdi:card-bulleted-settings-outline", + "default": "mdi:card-bulleted-outline", "state": { "off": "mdi:card-bulleted-off-outline" } }, "solder_screen_details": { - "default": "mdi:card-bulleted-settings-outline", + "default": "mdi:card-bulleted-outline", "state": { "off": "mdi:card-bulleted-off-outline" } From 65f39a941c54ad4f9de91d08ab08d49a796b0ee2 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Dec 2024 17:16:08 +0000 Subject: [PATCH 6/7] update tests --- tests/components/iron_os/snapshots/test_switch.ambr | 12 ++++++------ tests/components/iron_os/test_switch.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index 827f416ba80b85..0c4f5071a483f1 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -275,7 +275,7 @@ 'state': 'on', }) # --- -# name: test_switch_platform[switch.pinecil_pps_epr_pd_mode-entry] +# name: test_switch_platform[switch.pinecil_power_delivery_3_1_epr-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -287,7 +287,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.pinecil_pps_epr_pd_mode', + 'entity_id': 'switch.pinecil_power_delivery_3_1_epr', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -299,7 +299,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'PPS & EPR PD mode', + 'original_name': 'Power Delivery 3.1 EPR', 'platform': 'iron_os', 'previous_unique_id': None, 'supported_features': 0, @@ -308,13 +308,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_platform[switch.pinecil_pps_epr_pd_mode-state] +# name: test_switch_platform[switch.pinecil_power_delivery_3_1_epr-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Pinecil PPS & EPR PD mode', + 'friendly_name': 'Pinecil Power Delivery 3.1 EPR', }), 'context': , - 'entity_id': 'switch.pinecil_pps_epr_pd_mode', + 'entity_id': 'switch.pinecil_power_delivery_3_1_epr', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/iron_os/test_switch.py b/tests/components/iron_os/test_switch.py index a2faa72901f2e2..4f964133d0a98d 100644 --- a/tests/components/iron_os/test_switch.py +++ b/tests/components/iron_os/test_switch.py @@ -66,7 +66,7 @@ async def test_switch_platform( ("switch.pinecil_detailed_idle_screen", CharSetting.IDLE_SCREEN_DETAILS), ("switch.pinecil_detailed_solder_screen", CharSetting.SOLDER_SCREEN_DETAILS), ("switch.pinecil_invert_screen", CharSetting.DISPLAY_INVERT), - ("switch.pinecil_pps_epr_pd_mode", CharSetting.USB_PD_MODE), + ("switch.pinecil_power_delivery_3_1_epr", CharSetting.USB_PD_MODE), ("switch.pinecil_swap_buttons", CharSetting.INVERT_BUTTONS), ], ) From 82766dbd8d147f49b73cc1c9b9a4644abd005789 Mon Sep 17 00:00:00 2001 From: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Dec 2024 20:48:48 +0000 Subject: [PATCH 7/7] changes --- homeassistant/components/iron_os/coordinator.py | 14 +++++++------- .../components/iron_os/quality_scale.yaml | 4 +--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 407d9b025bc2ad..339cbdcca28782 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -161,10 +161,10 @@ async def write(self, characteristic: CharSetting, value: bool) -> None: translation_domain=DOMAIN, translation_key="submit_setting_failed", ) from e - else: - # prevent switch bouncing while waiting for coordinator to finish refresh - self.data.update( - cast(SettingsDataResponse, {characteristic.name.lower(): value}) - ) - self.async_update_listeners() - await self.async_request_refresh() + + # prevent switch bouncing while waiting for coordinator to finish refresh + self.data.update( + cast(SettingsDataResponse, {characteristic.name.lower(): value}) + ) + self.async_update_listeners() + await self.async_request_refresh() diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index 922702b82605b4..fd89b80d782019 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -26,9 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: Integration does not have actions + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt