diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index 87c441dd545..5fd9f75a6a0 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,9 +1,13 @@ """Base entity for the Fully Kiosk Browser integration.""" from __future__ import annotations +import json + from yarl import URL +from homeassistant.components import mqtt from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -54,3 +58,28 @@ class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entit (CONNECTION_NETWORK_MAC, coordinator.data["Mac"]) } self._attr_device_info = device_info + + async def mqtt_subscribe( + self, event: str | None, event_callback: CALLBACK_TYPE + ) -> CALLBACK_TYPE | None: + """Subscribe to MQTT for a given event.""" + data = self.coordinator.data + if ( + event is None + or not mqtt.mqtt_config_entry_enabled(self.hass) + or not data["settings"]["mqttEnabled"] + ): + return None + + @callback + def message_callback(message: mqtt.ReceiveMessage) -> None: + payload = json.loads(message.payload) + event_callback(**payload) + + topic_template = data["settings"]["mqttEventTopic"] + topic = ( + topic_template.replace("$appId", "fully") + .replace("$event", event) + .replace("$deviceId", data["deviceID"]) + ) + return await mqtt.async_subscribe(self.hass, topic, message_callback) diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index dcd36671fce..b5dadf14184 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -1,6 +1,7 @@ { "domain": "fully_kiosk", "name": "Fully Kiosk Browser", + "after_dependencies": ["mqtt"], "codeowners": ["@cgarwood"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 500e154abd8..c1d5d4e5c75 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -10,7 +10,7 @@ from fullykiosk import FullyKiosk from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -25,6 +25,8 @@ class FullySwitchEntityDescriptionMixin: on_action: Callable[[FullyKiosk], Any] off_action: Callable[[FullyKiosk], Any] is_on_fn: Callable[[dict[str, Any]], Any] + mqtt_on_event: str | None + mqtt_off_event: str | None @dataclass @@ -41,6 +43,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.startScreensaver(), off_action=lambda fully: fully.stopScreensaver(), is_on_fn=lambda data: data.get("isInScreensaver"), + mqtt_on_event="onScreensaverStart", + mqtt_off_event="onScreensaverStop", ), FullySwitchEntityDescription( key="maintenance", @@ -49,6 +53,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.enableLockedMode(), off_action=lambda fully: fully.disableLockedMode(), is_on_fn=lambda data: data.get("maintenanceMode"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="kiosk", @@ -57,6 +63,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.lockKiosk(), off_action=lambda fully: fully.unlockKiosk(), is_on_fn=lambda data: data.get("kioskLocked"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="motion-detection", @@ -65,6 +73,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.enableMotionDetection(), off_action=lambda fully: fully.disableMotionDetection(), is_on_fn=lambda data: data["settings"].get("motionDetection"), + mqtt_on_event=None, + mqtt_off_event=None, ), FullySwitchEntityDescription( key="screenOn", @@ -72,6 +82,8 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( on_action=lambda fully: fully.screenOn(), off_action=lambda fully: fully.screenOff(), is_on_fn=lambda data: data.get("screenOn"), + mqtt_on_event="screenOn", + mqtt_off_event="screenOff", ), ) @@ -105,13 +117,27 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" + self._turned_on_subscription: CALLBACK_TYPE | None = None + self._turned_off_subscription: CALLBACK_TYPE | None = None - @property - def is_on(self) -> bool | None: - """Return true if the entity is on.""" - if (is_on := self.entity_description.is_on_fn(self.coordinator.data)) is None: - return None - return bool(is_on) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + description = self.entity_description + self._turned_on_subscription = await self.mqtt_subscribe( + description.mqtt_off_event, self._turn_off + ) + self._turned_off_subscription = await self.mqtt_subscribe( + description.mqtt_on_event, self._turn_on + ) + + async def async_will_remove_from_hass(self) -> None: + """Close MQTT subscriptions when removed.""" + await super().async_will_remove_from_hass() + if self._turned_off_subscription is not None: + self._turned_off_subscription() + if self._turned_on_subscription is not None: + self._turned_on_subscription() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -122,3 +148,19 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): """Turn the entity off.""" await self.entity_description.off_action(self.coordinator.fully) await self.coordinator.async_refresh() + + def _turn_off(self, **kwargs: Any) -> None: + """Optimistically turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + + def _turn_on(self, **kwargs: Any) -> None: + """Optimistically turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = bool(self.entity_description.is_on_fn(self.coordinator.data)) + self.async_write_ha_state() diff --git a/tests/components/fully_kiosk/test_switch.py b/tests/components/fully_kiosk/test_switch.py index 4cbdad8d63a..20b5ed11998 100644 --- a/tests/components/fully_kiosk/test_switch.py +++ b/tests/components/fully_kiosk/test_switch.py @@ -7,7 +7,8 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient async def test_switches( @@ -86,6 +87,51 @@ async def test_switches( assert device_entry.sw_version == "1.42.5" +async def test_switches_mqtt_update( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + mqtt_mock: MqttMockHAClient, + init_integration: MockConfigEntry, +) -> None: + """Test push updates over MQTT.""" + assert has_subscribed(mqtt_mock, "fully/event/onScreensaverStart/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/onScreensaverStop/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/screenOff/abcdef-123456") + assert has_subscribed(mqtt_mock, "fully/event/screenOn/abcdef-123456") + + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity + assert entity.state == "off" + + entity = hass.states.get("switch.amazon_fire_screen") + assert entity + assert entity.state == "on" + + async_fire_mqtt_message(hass, "fully/event/onScreensaverStart/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "on" + + async_fire_mqtt_message(hass, "fully/event/onScreensaverStop/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screensaver") + assert entity.state == "off" + + async_fire_mqtt_message(hass, "fully/event/screenOff/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "off" + + async_fire_mqtt_message(hass, "fully/event/screenOn/abcdef-123456", "{}") + entity = hass.states.get("switch.amazon_fire_screen") + assert entity.state == "on" + + +def has_subscribed(mqtt_mock: MqttMockHAClient, topic: str) -> bool: + """Check if MQTT topic has subscription.""" + for call in mqtt_mock.async_subscribe.call_args_list: + if call.args[0] == topic: + return True + return False + + def call_service(hass, service, entity_id): """Call any service on entity.""" return hass.services.async_call(