diff --git a/.coveragerc b/.coveragerc index a9342397123..05a752764c3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -631,6 +631,8 @@ omit = homeassistant/components/msteams/notify.py homeassistant/components/mullvad/__init__.py homeassistant/components/mullvad/binary_sensor.py + homeassistant/components/mutesync/__init__.py + homeassistant/components/mutesync/binary_sensor.py homeassistant/components/nest/const.py homeassistant/components/mvglive/sensor.py homeassistant/components/mychevy/* diff --git a/CODEOWNERS b/CODEOWNERS index 4bd020ffb12..f23dda7aaaf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -301,6 +301,7 @@ homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @emontnemery homeassistant/components/msteams/* @peroyvind homeassistant/components/mullvad/* @meichthys +homeassistant/components/mutesync/* @currentoor homeassistant/components/my/* @home-assistant/core homeassistant/components/myq/* @bdraco homeassistant/components/mysensors/* @MartinHjelmare @functionpointer diff --git a/homeassistant/components/mutesync/__init__.py b/homeassistant/components/mutesync/__init__.py new file mode 100644 index 00000000000..9ed00f84feb --- /dev/null +++ b/homeassistant/components/mutesync/__init__.py @@ -0,0 +1,54 @@ +"""The mütesync integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import async_timeout +import mutesync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +PLATFORMS = ["binary_sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up mütesync from a config entry.""" + client = mutesync.PyMutesync( + entry.data["token"], + entry.data["host"], + hass.helpers.aiohttp_client.async_get_clientsession(), + ) + + async def update_data(): + """Update the data.""" + async with async_timeout.timeout(5): + return await client.get_state() + + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = update_coordinator.DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_interval=timedelta(seconds=10), + update_method=update_data, + ) + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py new file mode 100644 index 00000000000..a2f87bf9017 --- /dev/null +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -0,0 +1,53 @@ +"""mütesync binary sensor entities.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +SENSORS = { + "in_meeting": "In Meeting", + "muted": "Muted", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the mütesync button.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [MuteStatus(coordinator, sensor_type) for sensor_type in SENSORS], True + ) + + +class MuteStatus(update_coordinator.CoordinatorEntity, BinarySensorEntity): + """Mütesync binary sensors.""" + + def __init__(self, coordinator, sensor_type): + """Initialize our sensor.""" + super().__init__(coordinator) + self._sensor_type = sensor_type + + @property + def name(self): + """Return the name of the sensor.""" + return SENSORS[self._sensor_type] + + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return f"{self.coordinator.data['user-id']}-{self._sensor_type}" + + @property + def is_on(self): + """Return the state of the sensor.""" + return self.coordinator.data[self._sensor_type] + + @property + def device_info(self): + """Return the device info of the sensor.""" + return { + "identifiers": {(DOMAIN, self.coordinator.data["user-id"])}, + "name": "mutesync", + "manufacturer": "mütesync", + "model": "mutesync app", + "entry_type": "service", + } diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py new file mode 100644 index 00000000000..94d9b53a9d6 --- /dev/null +++ b/homeassistant/components/mutesync/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for mütesync integration.""" +from __future__ import annotations + +import asyncio +from typing import Any + +import aiohttp +import async_timeout +import mutesync +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultDict +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema({"host": str}) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + session = hass.helpers.aiohttp_client.async_get_clientsession() + try: + async with async_timeout.timeout(10): + token = await mutesync.authenticate(session, data["host"]) + except aiohttp.ClientResponseError as error: + if error.status == 403: + raise InvalidAuth from error + raise CannotConnect from error + except (aiohttp.ClientError, asyncio.TimeoutError) as error: + raise CannotConnect from error + + return token + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for mütesync.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResultDict: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + token = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input["host"], + data={"token": token, "host": user_input["host"]}, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/mutesync/const.py b/homeassistant/components/mutesync/const.py new file mode 100644 index 00000000000..fcf05584f42 --- /dev/null +++ b/homeassistant/components/mutesync/const.py @@ -0,0 +1,3 @@ +"""Constants for the mütesync integration.""" + +DOMAIN = "mutesync" diff --git a/homeassistant/components/mutesync/manifest.json b/homeassistant/components/mutesync/manifest.json new file mode 100644 index 00000000000..74e6d89d9f8 --- /dev/null +++ b/homeassistant/components/mutesync/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "mutesync", + "name": "mutesync", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mutesync", + "requirements": ["mutesync==0.0.1"], + "iot_class": "local_polling", + "codeowners": [ + "@currentoor" + ] +} diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json new file mode 100644 index 00000000000..9b18620acf8 --- /dev/null +++ b/homeassistant/components/mutesync/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Enable authentication in mütesync Preferences > Authentication", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/mutesync/translations/en.json b/homeassistant/components/mutesync/translations/en.json new file mode 100644 index 00000000000..0152f03bc2a --- /dev/null +++ b/homeassistant/components/mutesync/translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Enable authentication in m\u00fctesync Preferences > Authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bbf27893dc3..3b408860d59 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -155,6 +155,7 @@ FLOWS = [ "motioneye", "mqtt", "mullvad", + "mutesync", "myq", "mysensors", "neato", diff --git a/requirements_all.txt b/requirements_all.txt index d2fd9fb6155..dac6f3c3549 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -962,6 +962,9 @@ mullvad-api==1.0.0 # homeassistant.components.tts mutagen==1.45.1 +# homeassistant.components.mutesync +mutesync==0.0.1 + # homeassistant.components.mychevy mychevy==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 499b9f1b364..bf695a0eeb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -522,6 +522,9 @@ mullvad-api==1.0.0 # homeassistant.components.tts mutagen==1.45.1 +# homeassistant.components.mutesync +mutesync==0.0.1 + # homeassistant.components.keenetic_ndms2 ndms2_client==0.1.1 diff --git a/tests/components/mutesync/__init__.py b/tests/components/mutesync/__init__.py new file mode 100644 index 00000000000..5213265a7b0 --- /dev/null +++ b/tests/components/mutesync/__init__.py @@ -0,0 +1 @@ +"""Tests for the mütesync integration.""" diff --git a/tests/components/mutesync/test_config_flow.py b/tests/components/mutesync/test_config_flow.py new file mode 100644 index 00000000000..39a8feb2472 --- /dev/null +++ b/tests/components/mutesync/test_config_flow.py @@ -0,0 +1,72 @@ +"""Test the mütesync config flow.""" +import asyncio +from unittest.mock import patch + +import aiohttp +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.mutesync.const import DOMAIN +from homeassistant.core import HomeAssistant + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch("mutesync.authenticate", return_value="bla",), patch( + "homeassistant.components.mutesync.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "token": "bla", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "side_effect,error", + [ + (Exception, "unknown"), + (aiohttp.ClientResponseError(None, None, status=403), "invalid_auth"), + (aiohttp.ClientResponseError(None, None, status=500), "cannot_connect"), + (asyncio.TimeoutError, "cannot_connect"), + ], +) +async def test_form_error( + side_effect: Exception, error: str, hass: HomeAssistant +) -> None: + """Test we handle error situations.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "mutesync.authenticate", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": error}