Add support to fully_kiosk for hybrid local push/pull switches using MQTT (#89010)

* Support hybrid local push/pull switches using MQTT

* Update homeassistant/components/fully_kiosk/entity.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Fix MQTT subscribe method

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Mike Heath 2023-11-23 02:38:32 -07:00 committed by GitHub
parent 1144e33e68
commit d460eadce0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 126 additions and 8 deletions

View file

@ -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)

View file

@ -1,6 +1,7 @@
{
"domain": "fully_kiosk",
"name": "Fully Kiosk Browser",
"after_dependencies": ["mqtt"],
"codeowners": ["@cgarwood"],
"config_flow": true,
"dhcp": [

View file

@ -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()

View file

@ -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(