Merge remote-tracking branch 'refs/remotes/home-assistant/dev' into dev

This commit is contained in:
brg468
2020-03-31 18:26:13 -04:00
157 changed files with 5096 additions and 862 deletions

49
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,49 @@
<!-- READ THIS FIRST:
- If you need additional help with this template, please refer to https://www.home-assistant.io/help/reporting_issues/
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/core/releases
- Do not report issues for integrations if you are using custom components or integrations.
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed without comment.
-->
## The problem
<!--
Describe the issue you are experiencing here to communicate to the
maintainers. Tell us what you were trying to do and what happened.
-->
## Environment
<!--
Provide details about the versions you are using, which helps us to reproduce
and find the issue quicker. Version information is found in the
Home Assistant frontend: Developer tools -> Info.
-->
- Home Assistant Core release with the issue:
- Last working Home Assistant Core release (if known):
- Operating environment (Home Assistant/Supervised/Docker/venv):
- Integration causing this issue:
- Link to integration documentation on our website:
## Problem-relevant `configuration.yaml`
<!--
An example configuration that caused the problem for you. Fill this out even
if it seems unimportant to you. Please be sure to remove personal information
like passwords, private URLs and other credentials.
-->
```yaml
```
## Traceback/Error logs
<!--
If you come across any trace or error logs, please provide them.
-->
```txt
```
## Additional information

View File

@@ -1,10 +1,10 @@
---
name: Report a bug with Home Assistant
about: Report an issue with Home Assistant
name: Report a bug with Home Assistant Core
about: Report an issue with Home Assistant Core
---
<!-- READ THIS FIRST:
- If you need additional help with this template, please refer to https://www.home-assistant.io/help/reporting_issues/
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/core/releases
- Do not report issues for integrations if you are using custom components or integrations.
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed without comment.
@@ -12,7 +12,7 @@ about: Report an issue with Home Assistant
## The problem
<!--
Describe the issue you are experiencing here to communicate to the
maintainers. Tell us what you were trying to do and what happened instead.
maintainers. Tell us what you were trying to do and what happened.
-->
@@ -23,9 +23,9 @@ about: Report an issue with Home Assistant
Home Assistant frontend: Developer tools -> Info.
-->
- Home Assistant release with the issue:
- Last working Home Assistant release (if known):
- Operating environment (Hass.io/Docker/Windows/etc.):
- Home Assistant Core release with the issue:
- Last working Home Assistant Core release (if known):
- Operating environment (Home Assistant/Supervised/Docker/venv):
- Integration causing this issue:
- Link to integration documentation on our website:

View File

@@ -187,6 +187,7 @@ homeassistant/components/intesishome/* @jnimmo
homeassistant/components/ios/* @robbiet480
homeassistant/components/iperf3/* @rohankapoorcom
homeassistant/components/ipma/* @dgomes @abmantis
homeassistant/components/ipp/* @ctalkington
homeassistant/components/iqvia/* @bachya
homeassistant/components/irish_rail_transport/* @ttroy50
homeassistant/components/izone/* @Swamp-Ig
@@ -369,7 +370,7 @@ homeassistant/components/switchmate/* @danielhiversen
homeassistant/components/syncthru/* @nielstron
homeassistant/components/synology_srm/* @aerialls
homeassistant/components/syslog/* @fabaff
homeassistant/components/tado/* @michaelarnauts
homeassistant/components/tado/* @michaelarnauts @bdraco
homeassistant/components/tahoma/* @philklei
homeassistant/components/tankerkoenig/* @guillempages
homeassistant/components/tautulli/* @ludeeus

View File

@@ -215,12 +215,14 @@ class AuthManager:
return user
async def async_create_user(self, name: str) -> models.User:
async def async_create_user(
self, name: str, group_ids: Optional[List[str]] = None
) -> models.User:
"""Create a user."""
kwargs: Dict[str, Any] = {
"name": name,
"is_active": True,
"group_ids": [GROUP_ID_ADMIN],
"group_ids": group_ids or [],
}
if await self._user_should_be_owner():

View File

@@ -1,7 +1,7 @@
{
"config": {
"abort": {
"already_configured": "Questa chiave API \u00e8 gi\u00e0 in uso."
"already_configured": "Queste coordinate sono gi\u00e0 state registrate."
},
"error": {
"invalid_api_key": "Chiave API non valida"

View File

@@ -41,7 +41,6 @@ _LOGGER = logging.getLogger(__name__)
DATA_CONFIG = "config"
DEFAULT_SOCKET_MIN_RETRY = 15
DEFAULT_WATCHDOG_SECONDS = 5 * 60
TYPE_24HOURRAININ = "24hourrainin"
TYPE_BAROMABSIN = "baromabsin"
@@ -342,7 +341,6 @@ class AmbientStation:
self._config_entry = config_entry
self._entry_setup_complete = False
self._hass = hass
self._watchdog_listener = None
self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
self.client = client
self.stations = {}
@@ -359,21 +357,9 @@ class AmbientStation:
async def ws_connect(self):
"""Register handlers and connect to the websocket."""
async def _ws_reconnect(event_time):
"""Forcibly disconnect from and reconnect to the websocket."""
_LOGGER.debug("Watchdog expired; forcing socket reconnection")
await self.client.websocket.disconnect()
await self._attempt_connect()
def on_connect():
"""Define a handler to fire when the websocket is connected."""
_LOGGER.info("Connected to websocket")
_LOGGER.debug("Watchdog starting")
if self._watchdog_listener is not None:
self._watchdog_listener()
self._watchdog_listener = async_call_later(
self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect
)
def on_data(data):
"""Define a handler to fire when the data is received."""
@@ -385,12 +371,6 @@ class AmbientStation:
self._hass, f"ambient_station_data_update_{mac_address}"
)
_LOGGER.debug("Resetting watchdog")
self._watchdog_listener()
self._watchdog_listener = async_call_later(
self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect
)
def on_disconnect():
"""Define a handler to fire when the websocket is disconnected."""
_LOGGER.info("Disconnected from websocket")

View File

@@ -3,7 +3,7 @@
"name": "Ambient Weather Station",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ambient_station",
"requirements": ["aioambient==1.0.4"],
"requirements": ["aioambient==1.1.0"],
"dependencies": [],
"codeowners": ["@bachya"]
}

View File

@@ -1021,16 +1021,16 @@ class BluesoundPlayer(MediaPlayerDevice):
async def async_volume_up(self):
"""Volume up the media player."""
current_vol = self.volume_level
if not current_vol or current_vol < 0:
if not current_vol or current_vol >= 1:
return
return self.async_set_volume_level(((current_vol * 100) + 1) / 100)
return await self.async_set_volume_level(current_vol + 0.01)
async def async_volume_down(self):
"""Volume down the media player."""
current_vol = self.volume_level
if not current_vol or current_vol < 0:
if not current_vol or current_vol <= 0:
return
return self.async_set_volume_level(((current_vol * 100) - 1) / 100)
return await self.async_set_volume_level(current_vol - 0.01)
async def async_set_volume_level(self, volume):
"""Send volume_up command to media player."""

View File

@@ -13,11 +13,6 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str}
)
WS_TYPE_CREATE = "config/auth/create"
SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_CREATE, vol.Required("name"): str}
)
async def async_setup(hass):
"""Enable the Home Assistant views."""
@@ -27,9 +22,7 @@ async def async_setup(hass):
hass.components.websocket_api.async_register_command(
WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE
)
hass.components.websocket_api.async_register_command(
WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE
)
hass.components.websocket_api.async_register_command(websocket_create)
hass.components.websocket_api.async_register_command(websocket_update)
return True
@@ -70,9 +63,16 @@ async def websocket_delete(hass, connection, msg):
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
vol.Required("type"): "config/auth/create",
vol.Required("name"): str,
vol.Optional("group_ids"): [str],
}
)
async def websocket_create(hass, connection, msg):
"""Create a user."""
user = await hass.auth.async_create_user(msg["name"])
user = await hass.auth.async_create_user(msg["name"], msg.get("group_ids"))
connection.send_message(
websocket_api.result_message(msg["id"], {"user": _user_info(user)})

View File

@@ -2,7 +2,11 @@
"device_automation": {
"action_type": {
"close": "Schlie\u00dfe {entity_name}",
"open": "\u00d6ffne {entity_name}"
"close_tilt": "{entity_name} gekippt schlie\u00dfen",
"open": "\u00d6ffne {entity_name}",
"open_tilt": "{entity_name} gekippt \u00f6ffnen",
"set_position": "Position von {entity_name} setzen",
"set_tilt_position": "Neigeposition von {entity_name} einstellen"
},
"condition_type": {
"is_closed": "{entity_name} ist geschlossen",

View File

@@ -15,7 +15,8 @@
"one": "eins",
"other": "andere"
},
"description": "M\u00f6chten Sie {name} einrichten?"
"description": "M\u00f6chten Sie {name} einrichten?",
"title": "Stellen Sie eine Verbindung zum DirecTV-Empf\u00e4nger her"
},
"user": {
"data": {

View File

@@ -1,5 +1,8 @@
{
"config": {
"abort": {
"already_configured": "Dieser DoorBird ist bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
"invalid_auth": "Ung\u00fcltige Authentifizierung",
@@ -23,7 +26,8 @@
"init": {
"data": {
"events": "Durch Kommas getrennte Liste von Ereignissen."
}
},
"description": "F\u00fcgen Sie f\u00fcr jedes Ereignis, das Sie verfolgen m\u00f6chten, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem Sie sie hier eingegeben haben, verwenden Sie die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen finden Sie in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung"
}
}
}

View File

@@ -0,0 +1,34 @@
{
"config": {
"abort": {
"already_configured": "Questo DoorBird \u00e8 gi\u00e0 configurato"
},
"error": {
"cannot_connect": "Impossibile connettersi, si prega di riprovare",
"invalid_auth": "Autenticazione non valida",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"host": "Host (indirizzo IP)",
"name": "Nome del dispositivo",
"password": "Password",
"username": "Nome utente"
},
"title": "Connetti a DoorBird"
}
},
"title": "DoorBird"
},
"options": {
"step": {
"init": {
"data": {
"events": "Elenco di eventi separati da virgole."
},
"description": "Aggiungere un nome di evento separato da virgola per ogni evento che si desidera monitorare. Dopo averli inseriti qui, usa l'applicazione DoorBird per assegnarli a un evento specifico. Consultare la documentazione su https://www.home-assistant.io/integrations/doorbird/#events. Esempio: qualcuno_premuto_il_pulsante, movimento"
}
}
}
}

View File

@@ -4,6 +4,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ecobee",
"dependencies": [],
"requirements": ["python-ecobee-api==0.2.3"],
"requirements": ["python-ecobee-api==0.2.5"],
"codeowners": ["@marthoc"]
}

View File

@@ -1,5 +1,9 @@
{
"config": {
"abort": {
"address_already_configured": "Ein ElkM1 mit dieser Adresse ist bereits konfiguriert",
"already_configured": "Ein ElkM1 mit diesem Pr\u00e4fix ist bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
"invalid_auth": "Ung\u00fcltige Authentifizierung",
@@ -8,9 +12,14 @@
"step": {
"user": {
"data": {
"address": "Die IP-Adresse, die Domain oder der serielle Port bei einer seriellen Verbindung.",
"password": "Passwort (Nur sicher).",
"prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn Sie nur einen ElkM1 haben).",
"protocol": "Protokoll",
"temperature_unit": "Die von ElkM1 verwendete Temperatureinheit."
"temperature_unit": "Die von ElkM1 verwendete Temperatureinheit.",
"username": "Benutzername (Nur sicher)."
},
"description": "Die Adresszeichenfolge muss in der Form 'adresse[:port]' f\u00fcr 'sicher' und 'nicht sicher' vorliegen. Beispiel: '192.168.1.1'. Der Port ist optional und standardm\u00e4\u00dfig 2101 f\u00fcr \"nicht sicher\" und 2601 f\u00fcr \"sicher\". F\u00fcr das serielle Protokoll muss die Adresse die Form 'tty[:baud]' haben. Beispiel: '/dev/ttyS1'. Der Baudrate ist optional und standardm\u00e4\u00dfig 115200.",
"title": "Stellen Sie eine Verbindung zur Elk-M1-Steuerung her"
}
},

View File

@@ -0,0 +1,16 @@
{
"config": {
"error": {
"cannot_connect": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo",
"invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
"unknown": "Error inesperado"
},
"step": {
"user": {
"data": {
"protocol": "Protocolo"
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
{
"config": {
"abort": {
"address_already_configured": "Un ElkM1 con questo indirizzo \u00e8 gi\u00e0 configurato",
"already_configured": "Un ElkM1 con questo prefisso \u00e8 gi\u00e0 configurato"
},
"error": {
"cannot_connect": "Impossibile connettersi, si prega di riprovare",
"invalid_auth": "Autenticazione non valida",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"address": "L'indirizzo IP o il dominio o la porta seriale se ci si connette tramite seriale.",
"password": "Password (solo sicura).",
"prefix": "Un prefisso univoco (lasciare vuoto se si dispone di un solo ElkM1).",
"protocol": "Protocollo",
"temperature_unit": "L'unit\u00e0 di temperatura utilizzata da ElkM1.",
"username": "Nome utente (solo sicuro)."
},
"description": "La stringa di indirizzi deve essere nella forma \"address[:port]\" per \"secure\" e \"non secure\". Esempio: '192.168.1.1.1'. La porta \u00e8 facoltativa e il valore predefinito \u00e8 2101 per 'non sicuro' e 2601 per 'sicuro'. Per il protocollo seriale, l'indirizzo deve essere nella forma 'tty[:baud]'. Esempio: '/dev/ttyS1'. Il baud \u00e8 opzionale e il valore predefinito \u00e8 115200.",
"title": "Collegamento al controllo Elk-M1"
}
},
"title": "Controllo Elk-M1"
}
}

View File

@@ -0,0 +1,28 @@
{
"config": {
"abort": {
"address_already_configured": "En ElkM1 med denne adressen er allerede konfigurert",
"already_configured": "En ElkM1 med dette prefikset er allerede konfigurert"
},
"error": {
"cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen",
"invalid_auth": "Ugyldig godkjenning",
"unknown": "Uventet feil"
},
"step": {
"user": {
"data": {
"address": "IP-adressen eller domenet eller seriell port hvis du kobler til via seriell.",
"password": "Passord (bare sikkert).",
"prefix": "Et unikt prefiks (la v\u00e6re tomt hvis du bare har en ElkM1).",
"protocol": "protokoll",
"temperature_unit": "Temperaturenheten ElkM1 bruker.",
"username": "Brukernavn (bare sikkert)."
},
"description": "Adressestrengen m\u00e5 v\u00e6re i formen 'adresse [: port]' for 'sikker' og 'ikke-sikker'. Eksempel: '192.168.1.1'. Porten er valgfri og er standard til 2101 for 'ikke-sikker' og 2601 for 'sikker'. For den serielle protokollen m\u00e5 adressen v\u00e6re i formen 'tty [: baud]'. Eksempel: '/ dev / ttyS1'. Baud er valgfri og er standard til 115200.",
"title": "Koble til Elk-M1-kontroll"
}
},
"title": "Elk-M1 kontroll"
}
}

View File

@@ -0,0 +1,10 @@
{
"config": {
"step": {
"user": {
"title": "Elk-M1 Control"
}
},
"title": "Elk-M1 Control"
}
}

View File

@@ -11,11 +11,7 @@ from homeassistant.components.climate.const import (
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.components.modbus import (
CONF_HUB,
DEFAULT_HUB,
DOMAIN as MODBUS_DOMAIN,
)
from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_NAME,

View File

@@ -1,6 +1,6 @@
{
"domain": "fortios",
"name": "Home Assistant Device Tracker to support FortiOS",
"name": "FortiOS",
"documentation": "https://www.home-assistant.io/integrations/fortios/",
"requirements": ["fortiosapi==0.10.8"],
"dependencies": [],

View File

@@ -10,6 +10,7 @@
},
"step": {
"link": {
"description": "Klicken Sie auf \"Senden\" und ber\u00fchren Sie dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router]\n (/static/images/config_freebox.png)",
"title": "Link Freebox Router"
},
"user": {

View File

@@ -3,7 +3,7 @@
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": [
"home-assistant-frontend==20200318.1"
"home-assistant-frontend==20200330.0"
],
"dependencies": [
"api",
@@ -20,4 +20,4 @@
"@home-assistant/frontend"
],
"quality_scale": "internal"
}
}

View File

@@ -96,7 +96,8 @@ class GeonetnzQuakesEvent(GeolocationEvent):
self._remove_signal_update()
# Remove from entity registry.
entity_registry = await async_get_registry(self.hass)
entity_registry.async_remove(self.entity_id)
if self.entity_id in entity_registry.entities:
entity_registry.async_remove(self.entity_id)
@callback
def _delete_callback(self):

View File

@@ -9,6 +9,10 @@
},
"step": {
"user": {
"data": {
"loadzone": "Ladezone (Abwicklungspunkt)"
},
"description": "Ihre Ladezone befindet sich in Ihrem Griddy-Konto unter \"Konto > Messger\u00e4t > Ladezone\".",
"title": "Richten Sie Ihre Griddy Ladezone ein"
}
},

View File

@@ -39,7 +39,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SIGNIFICANT_DOMAINS = ("thermostat", "climate", "water_heater")
SIGNIFICANT_DOMAINS = ("climate", "device_tracker", "thermostat", "water_heater")
IGNORE_DOMAINS = ("zone", "scene")
@@ -50,6 +50,7 @@ def get_significant_states(
entity_ids=None,
filters=None,
include_start_time_state=True,
significant_changes_only=True,
):
"""
Return states changes during UTC period start_time - end_time.
@@ -61,13 +62,16 @@ def get_significant_states(
timer_start = time.perf_counter()
with session_scope(hass=hass) as session:
query = session.query(States).filter(
(
States.domain.in_(SIGNIFICANT_DOMAINS)
| (States.last_changed == States.last_updated)
if significant_changes_only:
query = session.query(States).filter(
(
States.domain.in_(SIGNIFICANT_DOMAINS)
| (States.last_changed == States.last_updated)
)
& (States.last_updated > start_time)
)
& (States.last_updated > start_time)
)
else:
query = session.query(States).filter(States.last_updated > start_time)
if filters:
query = filters.apply(query, entity_ids)
@@ -327,6 +331,9 @@ class HistoryPeriodView(HomeAssistantView):
if entity_ids:
entity_ids = entity_ids.lower().split(",")
include_start_time_state = "skip_initial_state" not in request.query
significant_changes_only = (
request.query.get("significant_changes_only", "1") != "0"
)
hass = request.app["hass"]
@@ -338,6 +345,7 @@ class HistoryPeriodView(HomeAssistantView):
entity_ids,
self.filters,
include_start_time_state,
significant_changes_only,
)
result = list(result.values())
if _LOGGER.isEnabledFor(logging.DEBUG):

View File

@@ -1,10 +1,12 @@
"""Constants used be the HomeKit component."""
# #### Misc ####
DEBOUNCE_TIMEOUT = 0.5
DEVICE_PRECISION_LEEWAY = 6
DOMAIN = "homekit"
HOMEKIT_FILE = ".homekit.state"
HOMEKIT_NOTIFY_ID = 4663548
# #### Attributes ####
ATTR_DISPLAY_NAME = "display_name"
ATTR_VALUE = "value"
@@ -106,6 +108,7 @@ CHAR_CURRENT_POSITION = "CurrentPosition"
CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity"
CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState"
CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
@@ -141,6 +144,7 @@ CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition"
CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState"
CHAR_TARGET_TEMPERATURE = "TargetTemperature"
CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle"
CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits"
CHAR_VALVE_TYPE = "ValveType"
CHAR_VOLUME = "Volume"

View File

@@ -5,8 +5,11 @@ from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
)
from homeassistant.const import (
@@ -15,6 +18,7 @@ from homeassistant.const import (
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
SERVICE_STOP_COVER,
STATE_CLOSED,
STATE_CLOSING,
@@ -27,9 +31,12 @@ from .accessories import HomeAccessory, debounce
from .const import (
CHAR_CURRENT_DOOR_STATE,
CHAR_CURRENT_POSITION,
CHAR_CURRENT_TILT_ANGLE,
CHAR_POSITION_STATE,
CHAR_TARGET_DOOR_STATE,
CHAR_TARGET_POSITION,
CHAR_TARGET_TILT_ANGLE,
DEVICE_PRECISION_LEEWAY,
SERV_GARAGE_DOOR_OPENER,
SERV_WINDOW_COVERING,
)
@@ -94,9 +101,28 @@ class WindowCovering(HomeAccessory):
def __init__(self, *args):
"""Initialize a WindowCovering accessory object."""
super().__init__(*args, category=CATEGORY_WINDOW_COVERING)
self._homekit_target = None
serv_cover = self.add_preload_service(SERV_WINDOW_COVERING)
self._homekit_target = None
self._homekit_target_tilt = None
serv_cover = self.add_preload_service(
SERV_WINDOW_COVERING,
chars=[CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE],
)
features = self.hass.states.get(self.entity_id).attributes.get(
ATTR_SUPPORTED_FEATURES, 0
)
self._supports_tilt = features & SUPPORT_SET_TILT_POSITION
if self._supports_tilt:
self.char_target_tilt = serv_cover.configure_char(
CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tilt
)
self.char_current_tilt = serv_cover.configure_char(
CHAR_CURRENT_TILT_ANGLE, value=0
)
self.char_current_position = serv_cover.configure_char(
CHAR_CURRENT_POSITION, value=0
)
@@ -107,6 +133,20 @@ class WindowCovering(HomeAccessory):
CHAR_POSITION_STATE, value=2
)
@debounce
def set_tilt(self, value):
"""Set tilt to value if call came from HomeKit."""
self._homekit_target_tilt = value
_LOGGER.info("%s: Set tilt to %d", self.entity_id, value)
# HomeKit sends values between -90 and 90.
# We'll have to normalize to [0,100]
value = round((value + 90) / 180.0 * 100.0)
params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value}
self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value)
@debounce
def move_cover(self, value):
"""Move cover to value if call came from HomeKit."""
@@ -117,14 +157,20 @@ class WindowCovering(HomeAccessory):
self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value)
def update_state(self, new_state):
"""Update cover position after state changed."""
"""Update cover position and tilt after state changed."""
current_position = new_state.attributes.get(ATTR_CURRENT_POSITION)
if isinstance(current_position, (float, int)):
current_position = int(current_position)
self.char_current_position.set_value(current_position)
# We have to assume that the device has worse precision than HomeKit.
# If it reports back a state that is only _close_ to HK's requested
# state, we'll "fix" what HomeKit requested so that it won't appear
# out of sync.
if (
self._homekit_target is None
or abs(current_position - self._homekit_target) < 6
or abs(current_position - self._homekit_target)
< DEVICE_PRECISION_LEEWAY
):
self.char_target_position.set_value(current_position)
self._homekit_target = None
@@ -135,6 +181,25 @@ class WindowCovering(HomeAccessory):
else:
self.char_position_state.set_value(2)
# update tilt
current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
if isinstance(current_tilt, (float, int)):
# HomeKit sends values between -90 and 90.
# We'll have to normalize to [0,100]
current_tilt = (current_tilt / 100.0 * 180.0) - 90.0
current_tilt = int(current_tilt)
self.char_current_tilt.set_value(current_tilt)
# We have to assume that the device has worse precision than HomeKit.
# If it reports back a state that is only _close_ to HK's requested
# state, we'll "fix" what HomeKit requested so that it won't appear
# out of sync.
if self._homekit_target_tilt is None or abs(
current_tilt - self._homekit_target_tilt < DEVICE_PRECISION_LEEWAY
):
self.char_target_tilt.set_value(current_tilt)
self._homekit_target_tilt = None
@TYPES.register("WindowCoveringBasic")
class WindowCoveringBasic(HomeAccessory):

View File

@@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit[IP]==0.2.35"],
"requirements": ["aiohomekit[IP]==0.2.37"],
"dependencies": [],
"zeroconf": ["_hap._tcp.local."],
"codeowners": ["@Jc2k"]

View File

@@ -166,12 +166,19 @@ class HomematicipGenericDevice(Entity):
def name(self) -> str:
"""Return the name of the generic device."""
name = self._device.label
if self._home.name is not None and self._home.name != "":
if name and self._home.name:
name = f"{self._home.name} {name}"
if self.post is not None and self.post != "":
if name and self.post:
name = f"{name} {self.post}"
return name
def _get_label_by_channel(self, channel: int) -> str:
"""Return the name of the channel."""
name = self._device.functionalChannels[channel].label
if name and self._home.name:
name = f"{self._home.name} {name}"
return name
@property
def should_poll(self) -> bool:
"""No polling needed."""

View File

@@ -71,6 +71,14 @@ class HomematicipLight(HomematicipGenericDevice, Light):
"""Initialize the light device."""
super().__init__(hap, device)
@property
def name(self) -> str:
"""Return the name of the multi switch channel."""
label = self._get_label_by_channel(1)
if label:
return label
return super().name
@property
def is_on(self) -> bool:
"""Return true if device is on."""
@@ -193,6 +201,9 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light):
@property
def name(self) -> str:
"""Return the name of the generic device."""
label = self._get_label_by_channel(self.channel)
if label:
return label
return f"{super().name} Notification"
@property

View File

@@ -153,6 +153,14 @@ class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice):
self.channel = channel
super().__init__(hap, device, f"Channel{channel}")
@property
def name(self) -> str:
"""Return the name of the multi switch channel."""
label = self._get_label_by_channel(self.channel)
if label:
return label
return super().name
@property
def unique_id(self) -> str:
"""Return a unique ID."""

View File

@@ -0,0 +1,32 @@
{
"config": {
"abort": {
"already_configured": "This printer is already configured.",
"connection_error": "Failed to connect to printer.",
"connection_upgrade": "Failed to connect to printer due to connection upgrade being required."
},
"error": {
"connection_error": "Failed to connect to printer.",
"connection_upgrade": "Failed to connect to printer. Please try again with SSL/TLS option checked."
},
"flow_title": "Printer: {name}",
"step": {
"user": {
"data": {
"base_path": "Relative path to the printer",
"host": "Host or IP address",
"port": "Port",
"ssl": "Printer supports communication over SSL/TLS",
"verify_ssl": "Printer uses a proper SSL certificate"
},
"description": "Set up your printer via Internet Printing Protocol (IPP) to integrate with Home Assistant.",
"title": "Link your printer"
},
"zeroconf_confirm": {
"description": "Do you want to add the printer named `{name}` to Home Assistant?",
"title": "Discovered printer"
}
},
"title": "Internet Printing Protocol (IPP)"
}
}

View File

@@ -0,0 +1,190 @@
"""The Internet Printing Protocol (IPP) integration."""
import asyncio
from datetime import timedelta
import logging
from typing import Any, Dict
from pyipp import IPP, IPPError, Printer as IPPPrinter
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_NAME,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SOFTWARE_VERSION,
CONF_BASE_PATH,
DOMAIN,
)
PLATFORMS = [SENSOR_DOMAIN]
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
"""Set up the IPP component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up IPP from a config entry."""
# Create IPP instance for this entry
coordinator = IPPDataUpdateCoordinator(
hass,
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
base_path=entry.data[CONF_BASE_PATH],
tls=entry.data[CONF_SSL],
verify_ssl=entry.data[CONF_VERIFY_SSL],
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = coordinator
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class IPPDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching IPP data from single endpoint."""
def __init__(
self,
hass: HomeAssistant,
*,
host: str,
port: int,
base_path: str,
tls: bool,
verify_ssl: bool,
):
"""Initialize global IPP data updater."""
self.ipp = IPP(
host=host,
port=port,
base_path=base_path,
tls=tls,
verify_ssl=verify_ssl,
session=async_get_clientsession(hass, verify_ssl),
)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> IPPPrinter:
"""Fetch data from IPP."""
try:
return await self.ipp.printer()
except IPPError as error:
raise UpdateFailed(f"Invalid response from API: {error}")
class IPPEntity(Entity):
"""Defines a base IPP entity."""
def __init__(
self,
*,
entry_id: str,
coordinator: IPPDataUpdateCoordinator,
name: str,
icon: str,
enabled_default: bool = True,
) -> None:
"""Initialize the IPP entity."""
self._enabled_default = enabled_default
self._entry_id = entry_id
self._icon = icon
self._name = name
self._unsub_dispatcher = None
self.coordinator = coordinator
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str:
"""Return the mdi icon of the entity."""
return self._icon
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.coordinator.last_update_success
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return self._enabled_default
@property
def should_poll(self) -> bool:
"""Return the polling requirement of the entity."""
return False
async def async_added_to_hass(self) -> None:
"""Connect to dispatcher listening for entity data notifications."""
self.coordinator.async_add_listener(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect from update signal."""
self.coordinator.async_remove_listener(self.async_write_ha_state)
async def async_update(self) -> None:
"""Update an IPP entity."""
await self.coordinator.async_request_refresh()
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this IPP device."""
return {
ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.uuid)},
ATTR_NAME: self.coordinator.data.info.name,
ATTR_MANUFACTURER: self.coordinator.data.info.manufacturer,
ATTR_MODEL: self.coordinator.data.info.model,
ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version,
}

View File

@@ -0,0 +1,144 @@
"""Config flow to configure the IPP integration."""
import logging
from typing import Any, Dict, Optional
from pyipp import IPP, IPPConnectionError, IPPConnectionUpgradeRequired
import voluptuous as vol
from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from .const import CONF_BASE_PATH, CONF_UUID
from .const import DOMAIN # pylint: disable=unused-import
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
ipp = IPP(
host=data[CONF_HOST],
port=data[CONF_PORT],
base_path=data[CONF_BASE_PATH],
tls=data[CONF_SSL],
verify_ssl=data[CONF_VERIFY_SSL],
session=session,
)
printer = await ipp.printer()
return {CONF_UUID: printer.info.uuid}
class IPPFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle an IPP config flow."""
VERSION = 1
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Set up the instance."""
self.discovery_info = {}
async def async_step_user(
self, user_input: Optional[ConfigType] = None
) -> Dict[str, Any]:
"""Handle a flow initiated by the user."""
if user_input is None:
return self._show_setup_form()
try:
info = await validate_input(self.hass, user_input)
except IPPConnectionUpgradeRequired:
return self._show_setup_form({"base": "connection_upgrade"})
except IPPConnectionError:
return self._show_setup_form({"base": "connection_error"})
user_input[CONF_UUID] = info[CONF_UUID]
await self.async_set_unique_id(user_input[CONF_UUID])
self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
return self.async_create_entry(title=user_input[CONF_HOST], data=user_input)
async def async_step_zeroconf(self, discovery_info: ConfigType) -> Dict[str, Any]:
"""Handle zeroconf discovery."""
# Hostname is format: EPSON123456.local.
host = discovery_info["hostname"].rstrip(".")
port = discovery_info["port"]
name, _ = host.rsplit(".")
tls = discovery_info["type"] == "_ipps._tcp.local."
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context.update({"title_placeholders": {"name": name}})
self.discovery_info.update(
{
CONF_HOST: host,
CONF_PORT: port,
CONF_SSL: tls,
CONF_VERIFY_SSL: False,
CONF_BASE_PATH: "/"
+ discovery_info["properties"].get("rp", "ipp/print"),
CONF_NAME: name,
CONF_UUID: discovery_info["properties"].get("UUID"),
}
)
try:
info = await validate_input(self.hass, self.discovery_info)
except IPPConnectionUpgradeRequired:
return self.async_abort(reason="connection_upgrade")
except IPPConnectionError:
return self.async_abort(reason="connection_error")
self.discovery_info[CONF_UUID] = info[CONF_UUID]
await self.async_set_unique_id(self.discovery_info[CONF_UUID])
self._abort_if_unique_id_configured(
updates={CONF_HOST: self.discovery_info[CONF_HOST]}
)
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: ConfigType = None
) -> Dict[str, Any]:
"""Handle a confirmation flow initiated by zeroconf."""
if user_input is None:
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={"name": self.discovery_info[CONF_NAME]},
errors={},
)
return self.async_create_entry(
title=self.discovery_info[CONF_NAME], data=self.discovery_info,
)
def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=631): int,
vol.Required(CONF_BASE_PATH, default="/ipp/print"): str,
vol.Required(CONF_SSL, default=False): bool,
vol.Required(CONF_VERIFY_SSL, default=False): bool,
}
),
errors=errors or {},
)

View File

@@ -0,0 +1,25 @@
"""Constants for the IPP integration."""
# Integration domain
DOMAIN = "ipp"
# Attributes
ATTR_COMMAND_SET = "command_set"
ATTR_IDENTIFIERS = "identifiers"
ATTR_INFO = "info"
ATTR_LOCATION = "location"
ATTR_MANUFACTURER = "manufacturer"
ATTR_MARKER_TYPE = "marker_type"
ATTR_MARKER_LOW_LEVEL = "marker_low_level"
ATTR_MARKER_HIGH_LEVEL = "marker_high_level"
ATTR_MODEL = "model"
ATTR_SERIAL = "serial"
ATTR_SOFTWARE_VERSION = "sw_version"
ATTR_STATE_MESSAGE = "state_message"
ATTR_STATE_REASON = "state_reason"
ATTR_URI_SUPPORTED = "uri_supported"
# Config Keys
CONF_BASE_PATH = "base_path"
CONF_TLS = "tls"
CONF_UUID = "uuid"

View File

@@ -0,0 +1,11 @@
{
"domain": "ipp",
"name": "Internet Printing Protocol (IPP)",
"documentation": "https://www.home-assistant.io/integrations/ipp",
"requirements": ["pyipp==0.8.1"],
"dependencies": [],
"codeowners": ["@ctalkington"],
"config_flow": true,
"quality_scale": "platinum",
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
}

View File

@@ -0,0 +1,178 @@
"""Support for IPP sensors."""
from datetime import timedelta
from typing import Any, Callable, Dict, List, Optional, Union
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEVICE_CLASS_TIMESTAMP, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.dt import utcnow
from . import IPPDataUpdateCoordinator, IPPEntity
from .const import (
ATTR_COMMAND_SET,
ATTR_INFO,
ATTR_LOCATION,
ATTR_MARKER_HIGH_LEVEL,
ATTR_MARKER_LOW_LEVEL,
ATTR_MARKER_TYPE,
ATTR_SERIAL,
ATTR_STATE_MESSAGE,
ATTR_STATE_REASON,
ATTR_URI_SUPPORTED,
DOMAIN,
)
async def async_setup_entry(
hass: HomeAssistantType,
entry: ConfigEntry,
async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
"""Set up IPP sensor based on a config entry."""
coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
sensors = []
sensors.append(IPPPrinterSensor(entry.entry_id, coordinator))
sensors.append(IPPUptimeSensor(entry.entry_id, coordinator))
for marker_index in range(len(coordinator.data.markers)):
sensors.append(IPPMarkerSensor(entry.entry_id, coordinator, marker_index))
async_add_entities(sensors, True)
class IPPSensor(IPPEntity):
"""Defines an IPP sensor."""
def __init__(
self,
*,
coordinator: IPPDataUpdateCoordinator,
enabled_default: bool = True,
entry_id: str,
icon: str,
key: str,
name: str,
unit_of_measurement: Optional[str] = None,
) -> None:
"""Initialize IPP sensor."""
self._unit_of_measurement = unit_of_measurement
self._key = key
super().__init__(
entry_id=entry_id,
coordinator=coordinator,
name=name,
icon=icon,
enabled_default=enabled_default,
)
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
return f"{self.coordinator.data.info.uuid}_{self._key}"
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
class IPPMarkerSensor(IPPSensor):
"""Defines an IPP marker sensor."""
def __init__(
self, entry_id: str, coordinator: IPPDataUpdateCoordinator, marker_index: int
) -> None:
"""Initialize IPP marker sensor."""
self.marker_index = marker_index
super().__init__(
coordinator=coordinator,
entry_id=entry_id,
icon="mdi:water",
key=f"marker_{marker_index}",
name=f"{coordinator.data.info.name} {coordinator.data.markers[marker_index].name}",
unit_of_measurement=UNIT_PERCENTAGE,
)
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
return {
ATTR_MARKER_HIGH_LEVEL: self.coordinator.data.markers[
self.marker_index
].high_level,
ATTR_MARKER_LOW_LEVEL: self.coordinator.data.markers[
self.marker_index
].low_level,
ATTR_MARKER_TYPE: self.coordinator.data.markers[
self.marker_index
].marker_type,
}
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return self.coordinator.data.markers[self.marker_index].level
class IPPPrinterSensor(IPPSensor):
"""Defines an IPP printer sensor."""
def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None:
"""Initialize IPP printer sensor."""
super().__init__(
coordinator=coordinator,
entry_id=entry_id,
icon="mdi:printer",
key="printer",
name=coordinator.data.info.name,
unit_of_measurement=None,
)
@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
"""Return the state attributes of the entity."""
return {
ATTR_INFO: self.coordinator.data.info.printer_info,
ATTR_SERIAL: self.coordinator.data.info.serial,
ATTR_LOCATION: self.coordinator.data.info.location,
ATTR_STATE_MESSAGE: self.coordinator.data.state.message,
ATTR_STATE_REASON: self.coordinator.data.state.reasons,
ATTR_COMMAND_SET: self.coordinator.data.info.command_set,
ATTR_URI_SUPPORTED: self.coordinator.data.info.printer_uri_supported,
}
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
return self.coordinator.data.state.printer_state
class IPPUptimeSensor(IPPSensor):
"""Defines a IPP uptime sensor."""
def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None:
"""Initialize IPP uptime sensor."""
super().__init__(
coordinator=coordinator,
enabled_default=False,
entry_id=entry_id,
icon="mdi:clock-outline",
key="uptime",
name=f"{coordinator.data.info.name} Uptime",
)
@property
def state(self) -> Union[None, str, int, float]:
"""Return the state of the sensor."""
uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime)
return uptime.replace(microsecond=0).isoformat()
@property
def device_class(self) -> Optional[str]:
"""Return the class of this sensor."""
return DEVICE_CLASS_TIMESTAMP

View File

@@ -0,0 +1,32 @@
{
"config": {
"title": "Internet Printing Protocol (IPP)",
"flow_title": "Printer: {name}",
"step": {
"user": {
"title": "Link your printer",
"description": "Set up your printer via Internet Printing Protocol (IPP) to integrate with Home Assistant.",
"data": {
"host": "Host or IP address",
"port": "Port",
"base_path": "Relative path to the printer",
"ssl": "Printer supports communication over SSL/TLS",
"verify_ssl": "Printer uses a proper SSL certificate"
}
},
"zeroconf_confirm": {
"description": "Do you want to add the printer named `{name}` to Home Assistant?",
"title": "Discovered printer"
}
},
"error": {
"connection_error": "Failed to connect to printer.",
"connection_upgrade": "Failed to connect to printer. Please try again with SSL/TLS option checked."
},
"abort": {
"already_configured": "This printer is already configured.",
"connection_error": "Failed to connect to printer.",
"connection_upgrade": "Failed to connect to printer due to connection upgrade being required."
}
}
}

View File

@@ -4,5 +4,5 @@
"documentation": "https://www.home-assistant.io/integrations/kef",
"dependencies": [],
"codeowners": ["@basnijholt"],
"requirements": ["aiokef==0.2.7", "getmac==0.8.1"]
"requirements": ["aiokef==0.2.9", "getmac==0.8.1"]
}

View File

@@ -1,11 +1,13 @@
"""Platform for the KEF Wireless Speakers."""
import asyncio
from datetime import timedelta
from functools import partial
import ipaddress
import logging
from aiokef import AsyncKefSpeaker
from aiokef.aiokef import DSP_OPTION_MAPPING
from getmac import get_mac_address
import voluptuous as vol
@@ -31,7 +33,8 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.event import async_track_time_interval
_LOGGER = logging.getLogger(__name__)
@@ -55,6 +58,17 @@ CONF_INVERSE_SPEAKER_MODE = "inverse_speaker_mode"
CONF_SUPPORTS_ON = "supports_on"
CONF_STANDBY_TIME = "standby_time"
SERVICE_MODE = "set_mode"
SERVICE_DESK_DB = "set_desk_db"
SERVICE_WALL_DB = "set_wall_db"
SERVICE_TREBLE_DB = "set_treble_db"
SERVICE_HIGH_HZ = "set_high_hz"
SERVICE_LOW_HZ = "set_low_hz"
SERVICE_SUB_DB = "set_sub_db"
SERVICE_UPDATE_DSP = "update_dsp"
DSP_SCAN_INTERVAL = 3600
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
@@ -118,6 +132,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
inverse_speaker_mode,
supports_on,
sources,
speaker_type,
ioloop=hass.loop,
unique_id=unique_id,
)
@@ -128,6 +143,36 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
hass.data[DOMAIN][host] = media_player
async_add_entities([media_player], update_before_add=True)
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_MODE,
{
vol.Optional("desk_mode"): cv.boolean,
vol.Optional("wall_mode"): cv.boolean,
vol.Optional("phase_correction"): cv.boolean,
vol.Optional("high_pass"): cv.boolean,
vol.Optional("sub_polarity"): vol.In(["-", "+"]),
vol.Optional("bass_extension"): vol.In(["Less", "Standard", "Extra"]),
},
"set_mode",
)
platform.async_register_entity_service(SERVICE_UPDATE_DSP, {}, "update_dsp")
def add_service(name, which, option):
platform.async_register_entity_service(
name,
{vol.Required(option): vol.In(DSP_OPTION_MAPPING[which])},
f"set_{which}",
)
add_service(SERVICE_DESK_DB, "desk_db", "db_value")
add_service(SERVICE_WALL_DB, "wall_db", "db_value")
add_service(SERVICE_TREBLE_DB, "treble_db", "db_value")
add_service(SERVICE_HIGH_HZ, "high_hz", "hz_value")
add_service(SERVICE_LOW_HZ, "low_hz", "hz_value")
add_service(SERVICE_SUB_DB, "sub_db", "db_value")
class KefMediaPlayer(MediaPlayerDevice):
"""Kef Player Object."""
@@ -143,6 +188,7 @@ class KefMediaPlayer(MediaPlayerDevice):
inverse_speaker_mode,
supports_on,
sources,
speaker_type,
ioloop,
unique_id,
):
@@ -160,12 +206,15 @@ class KefMediaPlayer(MediaPlayerDevice):
)
self._unique_id = unique_id
self._supports_on = supports_on
self._speaker_type = speaker_type
self._state = None
self._muted = None
self._source = None
self._volume = None
self._is_online = None
self._dsp = None
self._update_dsp_task_remover = None
@property
def name(self):
@@ -190,6 +239,9 @@ class KefMediaPlayer(MediaPlayerDevice):
state = await self._speaker.get_state()
self._source = state.source
self._state = STATE_ON if state.is_on else STATE_OFF
if self._dsp is None:
# Only do this when necessary because it is a slow operation
await self.update_dsp()
else:
self._muted = None
self._source = None
@@ -291,11 +343,11 @@ class KefMediaPlayer(MediaPlayerDevice):
async def async_media_play(self):
"""Send play command."""
await self._speaker.play_pause()
await self._speaker.set_play_pause()
async def async_media_pause(self):
"""Send pause command."""
await self._speaker.play_pause()
await self._speaker.set_play_pause()
async def async_media_previous_track(self):
"""Send previous track command."""
@@ -304,3 +356,87 @@ class KefMediaPlayer(MediaPlayerDevice):
async def async_media_next_track(self):
"""Send next track command."""
await self._speaker.next_track()
async def update_dsp(self) -> None:
"""Update the DSP settings."""
if self._speaker_type == "LS50" and self._state == STATE_OFF:
# The LSX is able to respond when off the LS50 has to be on.
return
(mode, *rest) = await asyncio.gather(
self._speaker.get_mode(),
self._speaker.get_desk_db(),
self._speaker.get_wall_db(),
self._speaker.get_treble_db(),
self._speaker.get_high_hz(),
self._speaker.get_low_hz(),
self._speaker.get_sub_db(),
)
keys = ["desk_db", "wall_db", "treble_db", "high_hz", "low_hz", "sub_db"]
self._dsp = dict(zip(keys, rest), **mode._asdict())
async def async_added_to_hass(self):
"""Subscribe to DSP updates."""
self._update_dsp_task_remover = async_track_time_interval(
self.hass, self.update_dsp, DSP_SCAN_INTERVAL
)
async def async_will_remove_from_hass(self):
"""Unsubscribe to DSP updates."""
self._update_dsp_task_remover()
self._update_dsp_task_remover = None
@property
def device_state_attributes(self):
"""Return the DSP settings of the KEF device."""
return self._dsp or {}
async def set_mode(
self,
desk_mode=None,
wall_mode=None,
phase_correction=None,
high_pass=None,
sub_polarity=None,
bass_extension=None,
):
"""Set the speaker mode."""
await self._speaker.set_mode(
desk_mode=desk_mode,
wall_mode=wall_mode,
phase_correction=phase_correction,
high_pass=high_pass,
sub_polarity=sub_polarity,
bass_extension=bass_extension,
)
self._dsp = None
async def set_desk_db(self, db_value):
"""Set desk_db of the KEF speakers."""
await self._speaker.set_desk_db(db_value)
self._dsp = None
async def set_wall_db(self, db_value):
"""Set wall_db of the KEF speakers."""
await self._speaker.set_wall_db(db_value)
self._dsp = None
async def set_treble_db(self, db_value):
"""Set treble_db of the KEF speakers."""
await self._speaker.set_treble_db(db_value)
self._dsp = None
async def set_high_hz(self, hz_value):
"""Set high_hz of the KEF speakers."""
await self._speaker.set_high_hz(hz_value)
self._dsp = None
async def set_low_hz(self, hz_value):
"""Set low_hz of the KEF speakers."""
await self._speaker.set_low_hz(hz_value)
self._dsp = None
async def set_sub_db(self, db_value):
"""Set sub_db of the KEF speakers."""
await self._speaker.set_sub_db(db_value)
self._dsp = None

View File

@@ -0,0 +1,97 @@
update_dsp:
description: Update all DSP settings.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
set_mode:
description: Set the mode of the speaker.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
desk_mode:
description: >
"Desk mode" (true or false)
example: true
wall_mode:
description: >
"Wall mode" (true or false)
example: true
phase_correction:
description: >
"Phase correction" (true or false)
example: true
high_pass:
description: >
"High-pass mode" (true or false)
example: true
sub_polarity:
description: >
"Sub polarity" ("-" or "+")
example: "+"
bass_extension:
description: >
"Bass extension" selector ("Less", "Standard", or "Extra")
example: "Extra"
set_desk_db:
description: Set the "Desk mode" slider of the speaker in dB.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
db_value:
description: Value of the slider (-6 to 0 with steps of 0.5)
example: 0.0
set_wall_db:
description: Set the "Wall mode" slider of the speaker in dB.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
db_value:
description: Value of the slider (-6 to 0 with steps of 0.5)
example: 0.0
set_treble_db:
description: Set desk the "Treble trim" slider of the speaker in dB.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
db_value:
description: Value of the slider (-2 to 2 with steps of 0.5)
example: 0.0
set_high_hz:
description: Set the "High-pass mode" slider of the speaker in Hz.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
hz_value:
description: Value of the slider (50 to 120 with steps of 5)
example: 95
set_low_hz:
description: Set the "Sub out low-pass frequency" slider of the speaker in Hz.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
hz_value:
description: Value of the slider (40 to 250 with steps of 5)
example: 80
set_sub_db:
description: Set the "Sub gain" slider of the speaker in dB.
fields:
entity_id:
description: The entity_id of the KEF speaker.
example: media_player.kef_lsx
db_value:
description: Value of the slider (-10 to 10 with steps of 1)
example: 0

View File

@@ -91,11 +91,12 @@
"data": {
"activation": "Ausgabe, wenn eingeschaltet",
"momentary": "Impulsdauer (ms) (optional)",
"more_states": "Konfigurieren Sie zus\u00e4tzliche Zust\u00e4nde f\u00fcr diese Zone",
"name": "Name (optional)",
"pause": "Pause zwischen Impulsen (ms) (optional)",
"repeat": "Zeit zum Wiederholen (-1 = unendlich) (optional)"
},
"description": "Bitte w\u00e4hlen Sie die Ausgabeoptionen f\u00fcr {zone}"
"description": "Bitte w\u00e4hlen Sie die Ausgabeoptionen f\u00fcr {zone} : Status {state}"
}
},
"title": "Konnected Alarm Panel-Optionen"

View File

@@ -91,11 +91,12 @@
"data": {
"activation": "Output when on",
"momentary": "Pulse duration (ms) (optional)",
"more_states": "Configure additional states for this zone",
"name": "Name (optional)",
"pause": "Pause between pulses (ms) (optional)",
"repeat": "Times to repeat (-1=infinite) (optional)"
},
"description": "Please select the output options for {zone}",
"description": "Please select the output options for {zone}: state {state}",
"title": "Configure Switchable Output"
}
},

View File

@@ -91,11 +91,12 @@
"data": {
"activation": "Utgang n\u00e5r den er p\u00e5",
"momentary": "Pulsvarighet (ms) (valgfritt)",
"more_states": "Konfigurere flere tilstander for denne sonen",
"name": "Navn (valgfritt)",
"pause": "Pause mellom pulser (ms) (valgfritt)",
"repeat": "Tider \u00e5 gjenta (-1 = uendelig) (valgfritt)"
},
"description": "Velg outputalternativer for {zone}",
"description": "Velg outputalternativer for {zone} : state {state}",
"title": "Konfigurere Valgbare Utgang"
}
},

View File

@@ -91,6 +91,7 @@
"data": {
"activation": "\u958b\u555f\u6642\u8f38\u51fa",
"momentary": "\u6301\u7e8c\u6642\u9593\uff08ms\uff09\uff08\u9078\u9805\uff09",
"more_states": "\u8a2d\u5b9a\u6b64\u5340\u57df\u7684\u9644\u52a0\u72c0\u614b",
"name": "\u540d\u7a31\uff08\u9078\u9805\uff09",
"pause": "\u66ab\u505c\u9593\u8ddd\uff08ms\uff09\uff08\u9078\u9805\uff09",
"repeat": "\u91cd\u8907\u6642\u9593\uff08-1=\u7121\u9650\uff09\uff08\u9078\u9805\uff09"

View File

@@ -57,6 +57,10 @@ CONF_IO_BIN = "Binary Sensor"
CONF_IO_DIG = "Digital Sensor"
CONF_IO_SWI = "Switchable Output"
CONF_MORE_STATES = "more_states"
CONF_YES = "Yes"
CONF_NO = "No"
KONN_MANUFACTURER = "konnected.io"
KONN_PANEL_MODEL_NAMES = {
KONN_MODEL: "Konnected Alarm Panel",
@@ -117,7 +121,7 @@ SWITCH_SCHEMA = vol.Schema(
vol.Required(CONF_ZONE): vol.In(ZONES),
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)
vol.Lower, vol.In([STATE_HIGH, STATE_LOW])
),
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
@@ -361,6 +365,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self.new_opt = {CONF_IO: {}}
self.active_cfg = None
self.io_cfg = {}
self.current_states = []
self.current_state = 1
@callback
def get_current_cfg(self, io_type, zone):
@@ -666,12 +672,21 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if user_input is not None:
zone = {"zone": self.active_cfg}
zone.update(user_input)
del zone[CONF_MORE_STATES]
self.new_opt[CONF_SWITCHES] = self.new_opt.get(CONF_SWITCHES, []) + [zone]
self.io_cfg.pop(self.active_cfg)
self.active_cfg = None
# iterate through multiple switch states
if self.current_states:
self.current_states.pop(0)
# only go to next zone if all states are entered
self.current_state += 1
if user_input[CONF_MORE_STATES] == CONF_NO:
self.io_cfg.pop(self.active_cfg)
self.active_cfg = None
if self.active_cfg:
current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg)
current_cfg = next(iter(self.current_states), {})
return self.async_show_form(
step_id="options_switch",
data_schema=vol.Schema(
@@ -682,7 +697,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
vol.Optional(
CONF_ACTIVATION,
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
): vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)),
): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])),
vol.Optional(
CONF_MOMENTARY,
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
@@ -695,12 +710,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_REPEAT,
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
vol.Required(
CONF_MORE_STATES,
default=CONF_YES
if len(self.current_states) > 1
else CONF_NO,
): vol.In([CONF_YES, CONF_NO]),
}
),
description_placeholders={
"zone": f"Zone {self.active_cfg}"
if len(self.active_cfg) < 3
else self.active_cfg.upper()
else self.active_cfg.upper(),
"state": str(self.current_state),
},
errors=errors,
)
@@ -709,7 +731,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
for key, value in self.io_cfg.items():
if value == CONF_IO_SWI:
self.active_cfg = key
current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg)
self.current_states = [
cfg
for cfg in self.current_opt.get(CONF_SWITCHES, [])
if cfg[CONF_ZONE] == self.active_cfg
]
current_cfg = next(iter(self.current_states), {})
self.current_state = 1
return self.async_show_form(
step_id="options_switch",
data_schema=vol.Schema(
@@ -720,7 +748,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
): str,
vol.Optional(
CONF_ACTIVATION,
default=current_cfg.get(CONF_ACTIVATION, "high"),
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
): vol.In(["low", "high"]),
vol.Optional(
CONF_MOMENTARY,
@@ -734,12 +762,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_REPEAT,
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
vol.Required(
CONF_MORE_STATES,
default=CONF_YES
if len(self.current_states) > 1
else CONF_NO,
): vol.In([CONF_YES, CONF_NO]),
}
),
description_placeholders={
"zone": f"Zone {self.active_cfg}"
if len(self.active_cfg) < 3
else self.active_cfg.upper()
else self.active_cfg.upper(),
"state": str(self.current_state),
},
errors=errors,
)

View File

@@ -80,13 +80,14 @@
},
"options_switch": {
"title": "Configure Switchable Output",
"description": "Please select the output options for {zone}",
"description": "Please select the output options for {zone}: state {state}",
"data": {
"name": "Name (optional)",
"activation": "Output when on",
"momentary": "Pulse duration (ms) (optional)",
"pause": "Pause between pulses (ms) (optional)",
"repeat": "Times to repeat (-1=infinite) (optional)"
"repeat": "Times to repeat (-1=infinite) (optional)",
"more_states": "Configure additional states for this zone"
}
},
"options_misc": {

View File

@@ -72,6 +72,7 @@ async def websocket_lovelace_config(hass, connection, msg, config):
return await config.async_load(msg["force"])
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
@@ -86,6 +87,7 @@ async def websocket_lovelace_save_config(hass, connection, msg, config):
await config.async_save(msg["config"])
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{

View File

@@ -83,9 +83,14 @@ SENSOR_TYPES = {
}
CONDITION_CLASSES = {
"clear-night": ["Nuit Claire"],
"clear-night": ["Nuit Claire", "Nuit claire"],
"cloudy": ["Très nuageux"],
"fog": ["Brume ou bancs de brouillard", "Brouillard", "Brouillard givrant"],
"fog": [
"Brume ou bancs de brouillard",
"Brume",
"Brouillard",
"Brouillard givrant",
],
"hail": ["Risque de grêle"],
"lightning": ["Risque d'orages", "Orages"],
"lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"],

View File

@@ -25,7 +25,7 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
from .const import ( # DEFAULT_HUB,
from .const import (
ATTR_ADDRESS,
ATTR_HUB,
ATTR_UNIT,
@@ -34,16 +34,12 @@ from .const import ( # DEFAULT_HUB,
CONF_BYTESIZE,
CONF_PARITY,
CONF_STOPBITS,
DEFAULT_HUB,
MODBUS_DOMAIN,
SERVICE_WRITE_COIL,
SERVICE_WRITE_REGISTER,
)
# Kept for compatibility with other integrations, TO BE REMOVED
CONF_HUB = "hub"
DEFAULT_HUB = "default"
DOMAIN = MODBUS_DOMAIN
_LOGGER = logging.getLogger(__name__)
BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string})

View File

@@ -1,11 +1,16 @@
{
"config": {
"abort": {
"already_configured": "Ger\u00e4t ist bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
"unknown": "Unerwarteter Fehler"
},
"step": {
"user": {
"data": {
"port": "Serielle Schnittstelle",
"source_1": "Name der Quelle #1",
"source_2": "Name der Quelle #2",
"source_3": "Name der Quelle #3",
@@ -15,7 +20,8 @@
},
"title": "Stellen Sie eine Verbindung zum Ger\u00e4t her"
}
}
},
"title": "Monoprice 6-Zonen-Verst\u00e4rker"
},
"options": {
"step": {

View File

@@ -0,0 +1,41 @@
{
"config": {
"abort": {
"already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
},
"error": {
"cannot_connect": "Impossibile connettersi, si prega di riprovare",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"port": "Porta seriale",
"source_1": "Nome della fonte n. 1",
"source_2": "Nome della fonte n. 2",
"source_3": "Nome della fonte n. 3",
"source_4": "Nome della fonte n. 4",
"source_5": "Nome della fonte n. 5",
"source_6": "Nome della fonte n. 6"
},
"title": "Connettersi al dispositivo"
}
},
"title": "Amplificatore a 6 zone Monoprice"
},
"options": {
"step": {
"init": {
"data": {
"source_1": "Nome della fonte n. 1",
"source_2": "Nome della fonte n. 2",
"source_3": "Nome della fonte n. 3",
"source_4": "Nome della fonte n. 4",
"source_5": "Nome della fonte n. 5",
"source_6": "Nome della fonte n. 6"
},
"title": "Configurare le sorgenti"
}
}
}
}

View File

@@ -56,7 +56,7 @@ from .const import (
DEFAULT_QOS,
PROTOCOL_311,
)
from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash
from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash, set_discovery_hash
from .models import Message, MessageCallbackType, PublishPayloadType
from .subscription import async_subscribe_topics, async_unsubscribe_topics
@@ -1181,10 +1181,12 @@ class MqttDiscoveryUpdate(Entity):
self._discovery_data = discovery_data
self._discovery_update = discovery_update
self._remove_signal = None
self._removed_from_hass = False
async def async_added_to_hass(self) -> None:
"""Subscribe to discovery updates."""
await super().async_added_to_hass()
self._removed_from_hass = False
discovery_hash = (
self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None
)
@@ -1217,6 +1219,8 @@ class MqttDiscoveryUpdate(Entity):
await self._discovery_update(payload)
if discovery_hash:
# Set in case the entity has been removed and is re-added
set_discovery_hash(self.hass, discovery_hash)
self._remove_signal = async_dispatcher_connect(
self.hass,
MQTT_DISCOVERY_UPDATED.format(discovery_hash),
@@ -1225,7 +1229,7 @@ class MqttDiscoveryUpdate(Entity):
async def async_removed_from_registry(self) -> None:
"""Clear retained discovery topic in broker."""
if self._discovery_data:
if not self._removed_from_hass:
discovery_topic = self._discovery_data[ATTR_DISCOVERY_TOPIC]
publish(
self.hass, discovery_topic, "", retain=True,
@@ -1237,9 +1241,9 @@ class MqttDiscoveryUpdate(Entity):
def _cleanup_on_remove(self) -> None:
"""Stop listening to signal and cleanup discovery data."""
if self._discovery_data:
if self._discovery_data and not self._removed_from_hass:
clear_discovery_hash(self.hass, self._discovery_data[ATTR_DISCOVERY_HASH])
self._discovery_data = None
self._removed_from_hass = True
if self._remove_signal:
self._remove_signal()

View File

@@ -64,6 +64,11 @@ def clear_discovery_hash(hass, discovery_hash):
del hass.data[ALREADY_DISCOVERED][discovery_hash]
def set_discovery_hash(hass, discovery_hash):
"""Clear entry in ALREADY_DISCOVERED list."""
hass.data[ALREADY_DISCOVERED][discovery_hash] = {}
class MQTTConfig(dict):
"""Dummy class to allow adding attributes."""

View File

@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "MyQ \u00e8 gi\u00e0 configurato"
},
"error": {
"cannot_connect": "Impossibile connettersi, si prega di riprovare",
"invalid_auth": "Autenticazione non valida",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Nome utente"
},
"title": "Connettersi al gateway MyQ"
}
},
"title": "MyQ"
}
}

View File

@@ -1,5 +1,8 @@
{
"config": {
"abort": {
"already_configured": "Dieses Nexia Home ist bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut",
"invalid_auth": "Ung\u00fcltige Authentifizierung",

View File

@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "Questo Nexia Home \u00e8 gi\u00e0 configurato"
},
"error": {
"cannot_connect": "Impossibile connettersi, si prega di riprovare",
"invalid_auth": "Autenticazione non valida",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Nome utente"
},
"title": "Connettersi a mynexia.com"
}
},
"title": "Nexia"
}
}

View File

@@ -380,7 +380,10 @@ class LeafDataStore:
)
return server_info
except CarwingsError:
_LOGGER.error("An error occurred getting battery status.")
_LOGGER.error("An error occurred getting battery status")
return None
except KeyError:
_LOGGER.error("An error occurred parsing response from server")
return None
async def async_get_climate(self):

View File

@@ -15,7 +15,9 @@
"password": "Passwort",
"serial_number": "Seriennummer des Thermostats.",
"username": "Benutzername"
}
},
"description": "Sie m\u00fcssen die numerische Seriennummer oder ID Ihres Thermostats erhalten, indem Sie sich bei https://MyNuHeat.com anmelden und Ihre Thermostate ausw\u00e4hlen.",
"title": "Stellen Sie eine Verbindung zu NuHeat her"
}
},
"title": "NuHeat"

View File

@@ -0,0 +1,25 @@
{
"config": {
"abort": {
"already_configured": "Il termostato \u00e8 gi\u00e0 configurato"
},
"error": {
"cannot_connect": "Impossibile connettersi, si prega di riprovare",
"invalid_auth": "Autenticazione non valida",
"invalid_thermostat": "Il numero di serie del termostato non \u00e8 valido.",
"unknown": "Errore imprevisto"
},
"step": {
"user": {
"data": {
"password": "Password",
"serial_number": "Numero di serie del termostato.",
"username": "Nome utente"
},
"description": "\u00c8 necessario ottenere il numero di serie o l'ID numerico del termostato accedendo a https://MyNuHeat.com e selezionando il termostato.",
"title": "Connettersi al NuHeat"
}
},
"title": "NuHeat"
}
}

View File

@@ -220,6 +220,8 @@ class NUTSensor(Entity):
self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0])
self._unit = SENSOR_TYPES[sensor_type][1]
self._state = None
self._display_state = None
self._available = False
@property
def name(self):
@@ -241,38 +243,44 @@ class NUTSensor(Entity):
"""Return the unit of measurement of this entity, if any."""
return self._unit
@property
def available(self):
"""Return if the device is polling successfully."""
return self._available
@property
def device_state_attributes(self):
"""Return the sensor attributes."""
attr = dict()
attr[ATTR_STATE] = self.display_state()
return attr
def display_state(self):
"""Return UPS display state."""
if self._data.status is None:
return STATE_TYPES["OFF"]
try:
return " ".join(
STATE_TYPES[state] for state in self._data.status[KEY_STATUS].split()
)
except KeyError:
return STATE_UNKNOWN
return {ATTR_STATE: self._display_state}
def update(self):
"""Get the latest status and use it to update our sensor state."""
if self._data.status is None:
self._state = None
status = self._data.status
if status is None:
self._available = False
return
self._available = True
self._display_state = _format_display_state(status)
# In case of the display status sensor, keep a human-readable form
# as the sensor state.
if self.type == KEY_STATUS_DISPLAY:
self._state = self.display_state()
elif self.type not in self._data.status:
self._state = self._display_state
elif self.type not in status:
self._state = None
else:
self._state = self._data.status[self.type]
self._state = status[self.type]
def _format_display_state(status):
"""Return UPS display state."""
if status is None:
return STATE_TYPES["OFF"]
try:
return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split())
except KeyError:
return STATE_UNKNOWN
class PyNUTData:

View File

@@ -3,6 +3,7 @@ import asyncio
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import callback
@@ -99,7 +100,7 @@ class UserOnboardingView(_BaseOnboardingView):
provider = _async_get_hass_provider(hass)
await provider.async_initialize()
user = await hass.auth.async_create_user(data["name"])
user = await hass.auth.async_create_user(data["name"], [GROUP_ID_ADMIN])
await hass.async_add_executor_job(
provider.data.add_auth, data["username"], data["password"]
)

View File

@@ -2,7 +2,10 @@
"domain": "opencv",
"name": "OpenCV",
"documentation": "https://www.home-assistant.io/integrations/opencv",
"requirements": ["numpy==1.18.1", "opencv-python-headless==4.1.2.30"],
"requirements": [
"numpy==1.18.1",
"opencv-python-headless==4.2.0.32"
],
"dependencies": [],
"codeowners": []
}
}

View File

@@ -1,11 +1,15 @@
{
"config": {
"abort": {
"already_configured": "Die Integration ist bereits mit einem vorhandenen Sensor mit diesem Tarif konfiguriert"
},
"step": {
"user": {
"data": {
"name": "Sensorname",
"tariff": "Vertragstarif (1, 2 oder 3 Perioden)"
},
"description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. \nWeitere Informationen finden Sie in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\nW\u00e4hlen Sie den vertraglich vereinbarten Tarif basierend auf der Anzahl der Abrechnungsperioden pro Tag aus: \n - 1 Periode: Normal \n - 2 Perioden: Diskriminierung (Nachttarif) \n - 3 Perioden: Elektroauto (Nachttarif von 3 Perioden)",
"title": "Tarifauswahl"
}
},

View File

@@ -0,0 +1,18 @@
{
"config": {
"abort": {
"already_configured": "L'integrazione \u00e8 gi\u00e0 configurata con un sensore esistente con quella tariffa"
},
"step": {
"user": {
"data": {
"name": "Nome del sensore",
"tariff": "Tariffa contrattuale (1, 2 o 3 periodi)"
},
"description": "Questo sensore utilizza l'API ufficiale per ottenere [prezzi orari dell'elettricit\u00e0 (PVPC)](https://www.esios.ree.es/es/pvpc) in Spagna.\nPer una spiegazione pi\u00f9 precisa, visitare la [documentazione di integrazione](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nSelezionare la tariffa contrattuale in base al numero di periodi di fatturazione al giorno:\n- 1 periodo: normale\n- 2 periodi: discriminazione (tariffa notturna)\n- 3 periodi: auto elettrica (tariffa notturna di 3 periodi)",
"title": "Selezione della tariffa"
}
},
"title": "Prezzo orario dell'elettricit\u00e0 in Spagna (PVPC)"
}
}

View File

@@ -13,9 +13,19 @@
"data": {
"api_key": "Der API-Schl\u00fcssel f\u00fcr das Rachio-Konto."
},
"description": "Sie ben\u00f6tigen den API-Schl\u00fcssel von https://app.rach.io/. W\u00e4hlen Sie \"Kontoeinstellungen\" und klicken Sie dann auf \"API-SCHL\u00dcSSEL ERHALTEN\".",
"title": "Stellen Sie eine Verbindung zu Ihrem Rachio-Ger\u00e4t her"
}
},
"title": "Rachio"
},
"options": {
"step": {
"init": {
"data": {
"manual_run_mins": "Wie lange, in Minuten, um eine Station einzuschalten, wenn der Schalter aktiviert ist."
}
}
}
}
}

View File

@@ -2,7 +2,7 @@
"domain": "slack",
"name": "Slack",
"documentation": "https://www.home-assistant.io/integrations/slack",
"requirements": ["slacker==0.14.0"],
"requirements": ["slackclient==2.5.0"],
"dependencies": [],
"codeowners": []
}

View File

@@ -1,10 +1,11 @@
"""Slack platform for notify component."""
import asyncio
import logging
import os
from urllib.parse import urlparse
import requests
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
import slacker
from slacker import Slacker
from slack import WebClient
from slack.errors import SlackApiError
import voluptuous as vol
from homeassistant.components.notify import (
@@ -15,157 +16,138 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.const import CONF_API_KEY, CONF_ICON, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
_LOGGER = logging.getLogger(__name__)
CONF_CHANNEL = "default_channel"
CONF_TIMEOUT = 15
# Top level attributes in 'data'
ATTR_ATTACHMENTS = "attachments"
ATTR_BLOCKS = "blocks"
ATTR_FILE = "file"
# Attributes contained in file
ATTR_FILE_URL = "url"
ATTR_FILE_PATH = "path"
ATTR_FILE_USERNAME = "username"
ATTR_FILE_PASSWORD = "password"
ATTR_FILE_AUTH = "auth"
# Any other value or absence of 'auth' lead to basic authentication being used
ATTR_FILE_AUTH_DIGEST = "digest"
CONF_DEFAULT_CHANNEL = "default_channel"
DEFAULT_TIMEOUT_SECONDS = 15
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_CHANNEL): cv.string,
vol.Required(CONF_DEFAULT_CHANNEL): cv.string,
vol.Optional(CONF_ICON): cv.string,
vol.Optional(CONF_USERNAME): cv.string,
}
)
def get_service(hass, config, discovery_info=None):
"""Get the Slack notification service."""
channel = config.get(CONF_CHANNEL)
api_key = config.get(CONF_API_KEY)
username = config.get(CONF_USERNAME)
icon = config.get(CONF_ICON)
async def async_get_service(hass, config, discovery_info=None):
"""Set up the Slack notification service."""
session = aiohttp_client.async_get_clientsession(hass)
client = WebClient(token=config[CONF_API_KEY], run_async=True, session=session)
try:
return SlackNotificationService(
channel, api_key, username, icon, hass.config.is_allowed_path
)
await client.auth_test()
except SlackApiError as err:
_LOGGER.error("Error while setting up integration: %s", err)
return
except slacker.Error:
_LOGGER.exception("Authentication failed")
return None
return SlackNotificationService(
hass,
client,
config[CONF_DEFAULT_CHANNEL],
username=config.get(CONF_USERNAME),
icon=config.get(CONF_ICON),
)
@callback
def _async_sanitize_channel_names(channel_list):
"""Remove any # symbols from a channel list."""
return [channel.lstrip("#") for channel in channel_list]
class SlackNotificationService(BaseNotificationService):
"""Implement the notification service for Slack."""
def __init__(self, default_channel, api_token, username, icon, is_allowed_path):
"""Initialize the service."""
"""Define the Slack notification logic."""
def __init__(self, hass, client, default_channel, username, icon):
"""Initialize."""
self._client = client
self._default_channel = default_channel
self._api_token = api_token
self._username = username
self._hass = hass
self._icon = icon
if self._username or self._icon:
if username or self._icon:
self._as_user = False
else:
self._as_user = True
self.is_allowed_path = is_allowed_path
self.slack = Slacker(self._api_token)
self.slack.auth.test()
async def _async_send_local_file_message(self, path, targets, message, title):
"""Upload a local file (with message) to Slack."""
if not self._hass.config.is_allowed_path(path):
_LOGGER.error("Path does not exist or is not allowed: %s", path)
return
def send_message(self, message="", **kwargs):
"""Send a message to a user."""
parsed_url = urlparse(path)
filename = os.path.basename(parsed_url.path)
if kwargs.get(ATTR_TARGET) is None:
targets = [self._default_channel]
else:
targets = kwargs.get(ATTR_TARGET)
data = kwargs.get(ATTR_DATA)
attachments = data.get(ATTR_ATTACHMENTS) if data else None
file = data.get(ATTR_FILE) if data else None
title = kwargs.get(ATTR_TITLE)
for target in targets:
try:
if file is not None:
# Load from file or URL
file_as_bytes = self.load_file(
url=file.get(ATTR_FILE_URL),
local_path=file.get(ATTR_FILE_PATH),
username=file.get(ATTR_FILE_USERNAME),
password=file.get(ATTR_FILE_PASSWORD),
auth=file.get(ATTR_FILE_AUTH),
)
# Choose filename
if file.get(ATTR_FILE_URL):
filename = file.get(ATTR_FILE_URL)
else:
filename = file.get(ATTR_FILE_PATH)
# Prepare structure for Slack API
data = {
"content": None,
"filetype": None,
"filename": filename,
# If optional title is none use the filename
"title": title if title else filename,
"initial_comment": message,
"channels": target,
}
# Post to slack
self.slack.files.post(
"files.upload", data=data, files={"file": file_as_bytes}
)
else:
self.slack.chat.post_message(
target,
message,
as_user=self._as_user,
username=self._username,
icon_emoji=self._icon,
attachments=attachments,
link_names=True,
)
except slacker.Error as err:
_LOGGER.error("Could not send notification. Error: %s", err)
def load_file(
self, url=None, local_path=None, username=None, password=None, auth=None
):
"""Load image/document/etc from a local path or URL."""
try:
if url:
# Check whether authentication parameters are provided
if username:
# Use digest or basic authentication
if ATTR_FILE_AUTH_DIGEST == auth:
auth_ = HTTPDigestAuth(username, password)
else:
auth_ = HTTPBasicAuth(username, password)
# Load file from URL with authentication
req = requests.get(url, auth=auth_, timeout=CONF_TIMEOUT)
else:
# Load file from URL without authentication
req = requests.get(url, timeout=CONF_TIMEOUT)
return req.content
await self._client.files_upload(
channels=",".join(targets),
file=path,
filename=filename,
initial_comment=message,
title=title or filename,
)
except SlackApiError as err:
_LOGGER.error("Error while uploading file-based message: %s", err)
if local_path:
# Check whether path is whitelisted in configuration.yaml
if self.is_allowed_path(local_path):
return open(local_path, "rb")
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
else:
_LOGGER.warning("Neither URL nor local path found in parameters!")
async def _async_send_text_only_message(
self, targets, message, title, attachments, blocks
):
"""Send a text-only message."""
tasks = {
target: self._client.chat_postMessage(
channel=target,
text=message,
as_user=self._as_user,
attachments=attachments,
blocks=blocks,
icon_emoji=self._icon,
link_names=True,
)
for target in targets
}
except OSError as error:
_LOGGER.error("Can't load from URL or local path: %s", error)
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
for target, result in zip(tasks, results):
if isinstance(result, SlackApiError):
_LOGGER.error(
"There was a Slack API error while sending to %s: %s",
target,
result,
)
return None
async def async_send_message(self, message, **kwargs):
"""Send a message to Slack."""
data = kwargs[ATTR_DATA] or {}
title = kwargs.get(ATTR_TITLE)
targets = _async_sanitize_channel_names(
kwargs.get(ATTR_TARGET, [self._default_channel])
)
if ATTR_FILE in data:
return await self._async_send_local_file_message(
data[ATTR_FILE], targets, message, title
)
attachments = data.get(ATTR_ATTACHMENTS, {})
if attachments:
_LOGGER.warning(
"Attachments are deprecated and part of Slack's legacy API; support "
"for them will be dropped in 0.114.0. In most cases, Blocks should be "
"used instead: https://www.home-assistant.io/integrations/slack/"
)
blocks = data.get(ATTR_BLOCKS, {})
return await self._async_send_text_only_message(
targets, message, title, attachments, blocks
)

View File

@@ -437,15 +437,25 @@ class SoundTouchDevice(MediaPlayerDevice):
# slaves for some reason. To compensate for this shortcoming we have to fetch
# the zone info from the master when the current device is a slave until this is
# fixed in the SoundTouch API or libsoundtouch, or of course until somebody has a
# better idea on how to fix this
if zone_status.is_master:
# better idea on how to fix this.
# In addition to this shortcoming, libsoundtouch seems to report the "is_master"
# property wrong on some slaves, so the only reliable way to detect if the current
# devices is the master, is by comparing the master_id of the zone with the device_id
if zone_status.master_id == self._device.config.device_id:
return self._build_zone_info(self.entity_id, zone_status.slaves)
master_instance = self._get_instance_by_ip(zone_status.master_ip)
master_zone_status = master_instance.device.zone_status()
return self._build_zone_info(
master_instance.entity_id, master_zone_status.slaves
)
# The master device has to be searched by it's ID and not IP since libsoundtouch / BOSE API
# do not return the IP of the master for some slave objects/responses
master_instance = self._get_instance_by_id(zone_status.master_id)
if master_instance is not None:
master_zone_status = master_instance.device.zone_status()
return self._build_zone_info(
master_instance.entity_id, master_zone_status.slaves
)
# We should never end up here since this means we haven't found a master device to get the
# correct zone info from. In this case, assume current device is master
return self._build_zone_info(self.entity_id, zone_status.slaves)
def _get_instance_by_ip(self, ip_address):
"""Search and return a SoundTouchDevice instance by it's IP address."""
@@ -454,6 +464,13 @@ class SoundTouchDevice(MediaPlayerDevice):
return instance
return None
def _get_instance_by_id(self, instance_id):
"""Search and return a SoundTouchDevice instance by it's ID (aka MAC address)."""
for instance in self.hass.data[DATA_SOUNDTOUCH]:
if instance and instance.device.config.device_id == instance_id:
return instance
return None
def _build_zone_info(self, master, zone_slaves):
"""Build the exposed zone attributes."""
slaves = []

View File

@@ -5,11 +5,7 @@ import logging
from pystiebeleltron import pystiebeleltron
import voluptuous as vol
from homeassistant.components.modbus import (
CONF_HUB,
DEFAULT_HUB,
DOMAIN as MODBUS_DOMAIN,
)
from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN
from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv

View File

@@ -1,9 +1,9 @@
"""Support for the (unofficial) Tado API."""
from datetime import timedelta
import logging
import urllib
from PyTado.interface import Tado
from requests import RequestException
import voluptuous as vol
from homeassistant.components.climate.const import PRESET_AWAY, PRESET_HOME
@@ -110,7 +110,7 @@ class TadoConnector:
"""Connect to Tado and fetch the zones."""
try:
self.tado = Tado(self._username, self._password)
except (RuntimeError, urllib.error.HTTPError) as exc:
except (RuntimeError, RequestException) as exc:
_LOGGER.error("Unable to connect: %s", exc)
return False
@@ -135,9 +135,14 @@ class TadoConnector:
_LOGGER.debug("Updating %s %s", sensor_type, sensor)
try:
if sensor_type == "zone":
data = self.tado.getState(sensor)
data = self.tado.getZoneState(sensor)
elif sensor_type == "device":
data = self.tado.getDevices()[0]
devices_data = self.tado.getDevices()
if not devices_data:
_LOGGER.info("There are no devices to setup on this tado account.")
return
data = devices_data[0]
else:
_LOGGER.debug("Unknown sensor: %s", sensor_type)
return
@@ -174,29 +179,40 @@ class TadoConnector:
def set_zone_overlay(
self,
zone_id,
overlay_mode,
zone_id=None,
overlay_mode=None,
temperature=None,
duration=None,
device_type="HEATING",
mode=None,
fan_speed=None,
):
"""Set a zone overlay."""
_LOGGER.debug(
"Set overlay for zone %s: mode=%s, temp=%s, duration=%s, type=%s, mode=%s",
"Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s fan_speed=%s",
zone_id,
overlay_mode,
temperature,
duration,
device_type,
mode,
fan_speed,
)
try:
self.tado.setZoneOverlay(
zone_id, overlay_mode, temperature, duration, device_type, "ON", mode
zone_id,
overlay_mode,
temperature,
duration,
device_type,
"ON",
mode,
fan_speed,
)
except urllib.error.HTTPError as exc:
_LOGGER.error("Could not set zone overlay: %s", exc.read())
except RequestException as exc:
_LOGGER.error("Could not set zone overlay: %s", exc)
self.update_sensor("zone", zone_id)
@@ -206,7 +222,7 @@ class TadoConnector:
self.tado.setZoneOverlay(
zone_id, overlay_mode, None, None, device_type, "OFF"
)
except urllib.error.HTTPError as exc:
_LOGGER.error("Could not set zone overlay: %s", exc.read())
except RequestException as exc:
_LOGGER.error("Could not set zone overlay: %s", exc)
self.update_sensor("zone", zone_id)

View File

@@ -3,21 +3,13 @@ import logging
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
CURRENT_HVAC_COOL,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
FAN_HIGH,
FAN_LOW,
FAN_MIDDLE,
FAN_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
FAN_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
PRESET_AWAY,
PRESET_HOME,
SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
)
@@ -27,49 +19,30 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED
from .const import (
CONST_FAN_AUTO,
CONST_FAN_OFF,
CONST_MODE_AUTO,
CONST_MODE_COOL,
CONST_MODE_HEAT,
CONST_MODE_OFF,
CONST_MODE_SMART_SCHEDULE,
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER,
DATA,
HA_TO_TADO_FAN_MODE_MAP,
HA_TO_TADO_HVAC_MODE_MAP,
ORDERED_KNOWN_TADO_MODES,
SUPPORT_PRESET,
TADO_HVAC_ACTION_TO_HA_HVAC_ACTION,
TADO_MODES_WITH_NO_TEMP_SETTING,
TADO_TO_HA_FAN_MODE_MAP,
TADO_TO_HA_HVAC_MODE_MAP,
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
)
_LOGGER = logging.getLogger(__name__)
FAN_MAP_TADO = {"HIGH": FAN_HIGH, "MIDDLE": FAN_MIDDLE, "LOW": FAN_LOW}
HVAC_MAP_TADO_HEAT = {
CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT,
CONST_OVERLAY_TIMER: HVAC_MODE_HEAT,
CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT,
CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO,
CONST_MODE_OFF: HVAC_MODE_OFF,
}
HVAC_MAP_TADO_COOL = {
CONST_OVERLAY_MANUAL: HVAC_MODE_COOL,
CONST_OVERLAY_TIMER: HVAC_MODE_COOL,
CONST_OVERLAY_TADO_MODE: HVAC_MODE_COOL,
CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO,
CONST_MODE_OFF: HVAC_MODE_OFF,
}
HVAC_MAP_TADO_HEAT_COOL = {
CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT_COOL,
CONST_OVERLAY_TIMER: HVAC_MODE_HEAT_COOL,
CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT_COOL,
CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO,
CONST_MODE_OFF: HVAC_MODE_OFF,
}
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
SUPPORT_HVAC_HEAT = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF]
SUPPORT_HVAC_COOL = [HVAC_MODE_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF]
SUPPORT_HVAC_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF]
SUPPORT_FAN = [FAN_HIGH, FAN_MIDDLE, FAN_LOW, FAN_OFF]
SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME]
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Tado climate platform."""
@@ -96,29 +69,80 @@ def create_climate_entity(tado, name: str, zone_id: int):
_LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities)
zone_type = capabilities["type"]
support_flags = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
supported_hvac_modes = [
TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF],
TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE],
]
supported_fan_modes = None
heat_temperatures = None
cool_temperatures = None
ac_support_heat = False
if zone_type == TYPE_AIR_CONDITIONING:
# Only use heat if available
# (you don't have to setup a heat mode, but cool is required)
# Heat is preferred as it generally has a lower minimum temperature
if "HEAT" in capabilities:
temperatures = capabilities["HEAT"]["temperatures"]
ac_support_heat = True
else:
temperatures = capabilities["COOL"]["temperatures"]
elif "temperatures" in capabilities:
temperatures = capabilities["temperatures"]
for mode in ORDERED_KNOWN_TADO_MODES:
if mode not in capabilities:
continue
supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode])
if not capabilities[mode].get("fanSpeeds"):
continue
support_flags |= SUPPORT_FAN_MODE
if supported_fan_modes:
continue
supported_fan_modes = [
TADO_TO_HA_FAN_MODE_MAP[speed]
for speed in capabilities[mode]["fanSpeeds"]
]
cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"]
else:
_LOGGER.debug("Not adding zone %s since it has no temperature", name)
supported_hvac_modes.append(HVAC_MODE_HEAT)
if CONST_MODE_HEAT in capabilities:
heat_temperatures = capabilities[CONST_MODE_HEAT]["temperatures"]
if heat_temperatures is None and "temperatures" in capabilities:
heat_temperatures = capabilities["temperatures"]
if cool_temperatures is None and heat_temperatures is None:
_LOGGER.debug("Not adding zone %s since it has no temperatures", name)
return None
min_temp = float(temperatures["celsius"]["min"])
max_temp = float(temperatures["celsius"]["max"])
step = temperatures["celsius"].get("step", PRECISION_TENTHS)
heat_min_temp = None
heat_max_temp = None
heat_step = None
cool_min_temp = None
cool_max_temp = None
cool_step = None
if heat_temperatures is not None:
heat_min_temp = float(heat_temperatures["celsius"]["min"])
heat_max_temp = float(heat_temperatures["celsius"]["max"])
heat_step = heat_temperatures["celsius"].get("step", PRECISION_TENTHS)
if cool_temperatures is not None:
cool_min_temp = float(cool_temperatures["celsius"]["min"])
cool_max_temp = float(cool_temperatures["celsius"]["max"])
cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS)
entity = TadoClimate(
tado, name, zone_id, zone_type, min_temp, max_temp, step, ac_support_heat,
tado,
name,
zone_id,
zone_type,
heat_min_temp,
heat_max_temp,
heat_step,
cool_min_temp,
cool_max_temp,
cool_step,
supported_hvac_modes,
supported_fan_modes,
support_flags,
)
return entity
@@ -132,10 +156,15 @@ class TadoClimate(ClimateDevice):
zone_name,
zone_id,
zone_type,
min_temp,
max_temp,
step,
ac_support_heat,
heat_min_temp,
heat_max_temp,
heat_step,
cool_min_temp,
cool_max_temp,
cool_step,
supported_hvac_modes,
supported_fan_modes,
support_flags,
):
"""Initialize of Tado climate entity."""
self._tado = tado
@@ -146,49 +175,51 @@ class TadoClimate(ClimateDevice):
self._unique_id = f"{zone_type} {zone_id} {tado.device_id}"
self._ac_device = zone_type == TYPE_AIR_CONDITIONING
self._ac_support_heat = ac_support_heat
self._cooling = False
self._supported_hvac_modes = supported_hvac_modes
self._supported_fan_modes = supported_fan_modes
self._support_flags = support_flags
self._active = False
self._device_is_active = False
self._available = False
self._cur_temp = None
self._cur_humidity = None
self._is_away = False
self._min_temp = min_temp
self._max_temp = max_temp
self._step = step
self._heat_min_temp = heat_min_temp
self._heat_max_temp = heat_max_temp
self._heat_step = heat_step
self._cool_min_temp = cool_min_temp
self._cool_max_temp = cool_max_temp
self._cool_step = cool_step
self._target_temp = None
if tado.fallback:
# Fallback to Smart Schedule at next Schedule switch
self._default_overlay = CONST_OVERLAY_TADO_MODE
else:
# Don't fallback to Smart Schedule, but keep in manual mode
self._default_overlay = CONST_OVERLAY_MANUAL
self._current_tado_fan_speed = CONST_FAN_OFF
self._current_tado_hvac_mode = CONST_MODE_OFF
self._current_tado_hvac_action = CURRENT_HVAC_OFF
self._current_fan = CONST_MODE_OFF
self._current_operation = CONST_MODE_SMART_SCHEDULE
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._undo_dispatcher = None
self._tado_zone_data = None
self._async_update_zone_data()
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
if self._undo_dispatcher:
self._undo_dispatcher()
async def async_added_to_hass(self):
"""Register for sensor updates."""
@callback
def async_update_callback():
"""Schedule an entity update."""
self.async_schedule_update_ha_state(True)
async_dispatcher_connect(
self._undo_dispatcher = async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id),
async_update_callback,
self._async_update_callback,
)
@property
def supported_features(self):
"""Return the list of supported features."""
return SUPPORT_FLAGS
return self._support_flags
@property
def name(self):
@@ -208,12 +239,12 @@ class TadoClimate(ClimateDevice):
@property
def current_humidity(self):
"""Return the current humidity."""
return self._cur_humidity
return self._tado_zone_data.current_humidity
@property
def current_temperature(self):
"""Return the sensor temperature."""
return self._cur_temp
return self._tado_zone_data.current_temp
@property
def hvac_mode(self):
@@ -221,11 +252,7 @@ class TadoClimate(ClimateDevice):
Need to be one of HVAC_MODE_*.
"""
if self._ac_device and self._ac_support_heat:
return HVAC_MAP_TADO_HEAT_COOL.get(self._current_operation)
if self._ac_device and not self._ac_support_heat:
return HVAC_MAP_TADO_COOL.get(self._current_operation)
return HVAC_MAP_TADO_HEAT.get(self._current_operation)
return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVAC_MODE_OFF)
@property
def hvac_modes(self):
@@ -233,11 +260,7 @@ class TadoClimate(ClimateDevice):
Need to be a subset of HVAC_MODES.
"""
if self._ac_device:
if self._ac_support_heat:
return SUPPORT_HVAC_HEAT_COOL
return SUPPORT_HVAC_COOL
return SUPPORT_HVAC_HEAT
return self._supported_hvac_modes
@property
def hvac_action(self):
@@ -245,40 +268,30 @@ class TadoClimate(ClimateDevice):
Need to be one of CURRENT_HVAC_*.
"""
if not self._device_is_active:
return CURRENT_HVAC_OFF
if self._ac_device:
if self._active:
if self._ac_support_heat and not self._cooling:
return CURRENT_HVAC_HEAT
return CURRENT_HVAC_COOL
return CURRENT_HVAC_IDLE
if self._active:
return CURRENT_HVAC_HEAT
return CURRENT_HVAC_IDLE
return TADO_HVAC_ACTION_TO_HA_HVAC_ACTION.get(
self._tado_zone_data.current_hvac_action, CURRENT_HVAC_OFF
)
@property
def fan_mode(self):
"""Return the fan setting."""
if self._ac_device:
return FAN_MAP_TADO.get(self._current_fan)
return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO)
return None
@property
def fan_modes(self):
"""List of available fan modes."""
if self._ac_device:
return SUPPORT_FAN
return None
return self._supported_fan_modes
def set_fan_mode(self, fan_mode: str):
"""Turn fan on/off."""
pass
self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
@property
def preset_mode(self):
"""Return the current preset mode (home, away)."""
if self._is_away:
if self._tado_zone_data.is_away:
return PRESET_AWAY
return PRESET_HOME
@@ -299,12 +312,18 @@ class TadoClimate(ClimateDevice):
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
return self._step
if self._tado_zone_data.current_hvac_mode == CONST_MODE_COOL:
return self._cool_step or self._heat_step
return self._heat_step or self._cool_step
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self._target_temp
# If the target temperature will be None
# if the device is performing an action
# that does not affect the temperature or
# the device is switching states
return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
def set_temperature(self, **kwargs):
"""Set new target temperature."""
@@ -312,174 +331,149 @@ class TadoClimate(ClimateDevice):
if temperature is None:
return
self._current_operation = self._default_overlay
self._overlay_mode = None
self._target_temp = temperature
self._control_heating()
if self._current_tado_hvac_mode not in (
CONST_MODE_OFF,
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
self._control_hvac(target_temp=temperature)
return
new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT
self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode)
def set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
mode = None
if hvac_mode == HVAC_MODE_OFF:
mode = CONST_MODE_OFF
elif hvac_mode == HVAC_MODE_AUTO:
mode = CONST_MODE_SMART_SCHEDULE
elif hvac_mode == HVAC_MODE_HEAT:
mode = self._default_overlay
elif hvac_mode == HVAC_MODE_COOL:
mode = self._default_overlay
elif hvac_mode == HVAC_MODE_HEAT_COOL:
mode = self._default_overlay
self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode])
self._current_operation = mode
self._overlay_mode = None
# Set a target temperature if we don't have any
# This can happen when we switch from Off to On
if self._target_temp is None:
if self._ac_device:
self._target_temp = self.max_temp
else:
self._target_temp = self.min_temp
self.schedule_update_ha_state()
self._control_heating()
@property
def available(self):
"""Return if the device is available."""
return self._tado_zone_data.available
@property
def min_temp(self):
"""Return the minimum temperature."""
return self._min_temp
if (
self._current_tado_hvac_mode == CONST_MODE_COOL
and self._cool_min_temp is not None
):
return self._cool_min_temp
if self._heat_min_temp is not None:
return self._heat_min_temp
return self._cool_min_temp
@property
def max_temp(self):
"""Return the maximum temperature."""
return self._max_temp
def update(self):
"""Handle update callbacks."""
_LOGGER.debug("Updating climate platform for zone %d", self.zone_id)
data = self._tado.data["zone"][self.zone_id]
if "sensorDataPoints" in data:
sensor_data = data["sensorDataPoints"]
if "insideTemperature" in sensor_data:
temperature = float(sensor_data["insideTemperature"]["celsius"])
self._cur_temp = temperature
if "humidity" in sensor_data:
humidity = float(sensor_data["humidity"]["percentage"])
self._cur_humidity = humidity
# temperature setting will not exist when device is off
if (
"temperature" in data["setting"]
and data["setting"]["temperature"] is not None
self._current_tado_hvac_mode == CONST_MODE_HEAT
and self._heat_max_temp is not None
):
setting = float(data["setting"]["temperature"]["celsius"])
self._target_temp = setting
return self._heat_max_temp
if self._heat_max_temp is not None:
return self._heat_max_temp
if "tadoMode" in data:
mode = data["tadoMode"]
self._is_away = mode == "AWAY"
return self._heat_max_temp
if "setting" in data:
power = data["setting"]["power"]
if power == "OFF":
self._current_operation = CONST_MODE_OFF
self._current_fan = CONST_MODE_OFF
# There is no overlay, the mode will always be
# "SMART_SCHEDULE"
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._device_is_active = False
else:
self._device_is_active = True
@callback
def _async_update_zone_data(self):
"""Load tado data into zone."""
self._tado_zone_data = self._tado.data["zone"][self.zone_id]
self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
active = False
if "activityDataPoints" in data:
activity_data = data["activityDataPoints"]
if self._ac_device:
if "acPower" in activity_data and activity_data["acPower"] is not None:
if not activity_data["acPower"]["value"] == "OFF":
active = True
else:
if (
"heatingPower" in activity_data
and activity_data["heatingPower"] is not None
):
if float(activity_data["heatingPower"]["percentage"]) > 0.0:
active = True
self._active = active
@callback
def _async_update_callback(self):
"""Load tado data and update state."""
self._async_update_zone_data()
self.async_write_ha_state()
overlay = False
overlay_data = None
termination = CONST_MODE_SMART_SCHEDULE
cooling = False
fan_speed = CONST_MODE_OFF
def _normalize_target_temp_for_hvac_mode(self):
# Set a target temperature if we don't have any
# This can happen when we switch from Off to On
if self._target_temp is None:
self._target_temp = self._tado_zone_data.current_temp
elif self._current_tado_hvac_mode == CONST_MODE_COOL:
if self._target_temp > self._cool_max_temp:
self._target_temp = self._cool_max_temp
elif self._target_temp < self._cool_min_temp:
self._target_temp = self._cool_min_temp
elif self._current_tado_hvac_mode == CONST_MODE_HEAT:
if self._target_temp > self._heat_max_temp:
self._target_temp = self._heat_max_temp
elif self._target_temp < self._heat_min_temp:
self._target_temp = self._heat_min_temp
if "overlay" in data:
overlay_data = data["overlay"]
overlay = overlay_data is not None
if overlay:
termination = overlay_data["termination"]["type"]
setting = False
setting_data = None
if "setting" in overlay_data:
setting_data = overlay_data["setting"]
setting = setting_data is not None
if setting:
if "mode" in setting_data:
cooling = setting_data["mode"] == "COOL"
if "fanSpeed" in setting_data:
fan_speed = setting_data["fanSpeed"]
if self._device_is_active:
# If you set mode manually to off, there will be an overlay
# and a termination, but we want to see the mode "OFF"
self._overlay_mode = termination
self._current_operation = termination
self._cooling = cooling
self._current_fan = fan_speed
def _control_heating(self):
def _control_hvac(self, hvac_mode=None, target_temp=None, fan_mode=None):
"""Send new target temperature to Tado."""
if self._current_operation == CONST_MODE_SMART_SCHEDULE:
if hvac_mode:
self._current_tado_hvac_mode = hvac_mode
if target_temp:
self._target_temp = target_temp
if fan_mode:
self._current_tado_fan_speed = fan_mode
self._normalize_target_temp_for_hvac_mode()
# tado does not permit setting the fan speed to
# off, you must turn off the device
if (
self._current_tado_fan_speed == CONST_FAN_OFF
and self._current_tado_hvac_mode != CONST_MODE_OFF
):
self._current_tado_fan_speed = CONST_FAN_AUTO
if self._current_tado_hvac_mode == CONST_MODE_OFF:
_LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id
)
self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type)
return
if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE:
_LOGGER.debug(
"Switching to SMART_SCHEDULE for zone %s (%d)",
self.zone_name,
self.zone_id,
)
self._tado.reset_zone_overlay(self.zone_id)
self._overlay_mode = self._current_operation
return
if self._current_operation == CONST_MODE_OFF:
_LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id
)
self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type)
self._overlay_mode = self._current_operation
return
_LOGGER.debug(
"Switching to %s for zone %s (%d) with temperature %s °C",
self._current_operation,
self._current_tado_hvac_mode,
self.zone_name,
self.zone_id,
self._target_temp,
)
self._tado.set_zone_overlay(
self.zone_id,
self._current_operation,
self._target_temp,
None,
self.zone_type,
"COOL" if self._ac_device else None,
# Fallback to Smart Schedule at next Schedule switch if we have fallback enabled
overlay_mode = (
CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL
)
temperature_to_send = self._target_temp
if self._current_tado_hvac_mode in TADO_MODES_WITH_NO_TEMP_SETTING:
# A temperature cannot be passed with these modes
temperature_to_send = None
self._tado.set_zone_overlay(
zone_id=self.zone_id,
overlay_mode=overlay_mode, # What to do when the period ends
temperature=temperature_to_send,
duration=None,
device_type=self.zone_type,
mode=self._current_tado_hvac_mode,
fan_speed=(
self._current_tado_fan_speed
if (self._support_flags & SUPPORT_FAN_MODE)
else None
), # api defaults to not sending fanSpeed if not specified
)
self._overlay_mode = self._current_operation

View File

@@ -1,5 +1,48 @@
"""Constant values for the Tado component."""
from PyTado.const import (
CONST_HVAC_COOL,
CONST_HVAC_DRY,
CONST_HVAC_FAN,
CONST_HVAC_HEAT,
CONST_HVAC_HOT_WATER,
CONST_HVAC_IDLE,
CONST_HVAC_OFF,
)
from homeassistant.components.climate.const import (
CURRENT_HVAC_COOL,
CURRENT_HVAC_DRY,
CURRENT_HVAC_FAN,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
PRESET_AWAY,
PRESET_HOME,
)
TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = {
CONST_HVAC_HEAT: CURRENT_HVAC_HEAT,
CONST_HVAC_DRY: CURRENT_HVAC_DRY,
CONST_HVAC_FAN: CURRENT_HVAC_FAN,
CONST_HVAC_COOL: CURRENT_HVAC_COOL,
CONST_HVAC_IDLE: CURRENT_HVAC_IDLE,
CONST_HVAC_OFF: CURRENT_HVAC_OFF,
CONST_HVAC_HOT_WATER: CURRENT_HVAC_HEAT,
}
# Configuration
CONF_FALLBACK = "fallback"
DATA = "data"
@@ -10,10 +53,81 @@ TYPE_HEATING = "HEATING"
TYPE_HOT_WATER = "HOT_WATER"
# Base modes
CONST_MODE_OFF = "OFF"
CONST_MODE_SMART_SCHEDULE = "SMART_SCHEDULE" # Use the schedule
CONST_MODE_OFF = "OFF" # Switch off heating in a zone
CONST_MODE_AUTO = "AUTO"
CONST_MODE_COOL = "COOL"
CONST_MODE_HEAT = "HEAT"
CONST_MODE_DRY = "DRY"
CONST_MODE_FAN = "FAN"
CONST_LINK_OFFLINE = "OFFLINE"
CONST_FAN_OFF = "OFF"
CONST_FAN_AUTO = "AUTO"
CONST_FAN_LOW = "LOW"
CONST_FAN_MIDDLE = "MIDDLE"
CONST_FAN_HIGH = "HIGH"
# When we change the temperature setting, we need an overlay mode
CONST_OVERLAY_TADO_MODE = "TADO_MODE" # wait until tado changes the mode automatic
CONST_OVERLAY_MANUAL = "MANUAL" # the user has change the temperature or mode manually
CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan
# Heat always comes first since we get the
# min and max tempatures for the zone from
# it.
# Heat is preferred as it generally has a lower minimum temperature
ORDERED_KNOWN_TADO_MODES = [
CONST_MODE_HEAT,
CONST_MODE_COOL,
CONST_MODE_AUTO,
CONST_MODE_DRY,
CONST_MODE_FAN,
]
TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = {
CONST_MODE_HEAT: CURRENT_HVAC_HEAT,
CONST_MODE_DRY: CURRENT_HVAC_DRY,
CONST_MODE_FAN: CURRENT_HVAC_FAN,
CONST_MODE_COOL: CURRENT_HVAC_COOL,
}
# These modes will not allow a temp to be set
TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN]
#
# HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO
# This lets tado decide on a temp
#
# HVAC_MODE_AUTO is mapped to CONST_MODE_SMART_SCHEDULE
# This runs the smart schedule
#
HA_TO_TADO_HVAC_MODE_MAP = {
HVAC_MODE_OFF: CONST_MODE_OFF,
HVAC_MODE_HEAT_COOL: CONST_MODE_AUTO,
HVAC_MODE_AUTO: CONST_MODE_SMART_SCHEDULE,
HVAC_MODE_HEAT: CONST_MODE_HEAT,
HVAC_MODE_COOL: CONST_MODE_COOL,
HVAC_MODE_DRY: CONST_MODE_DRY,
HVAC_MODE_FAN_ONLY: CONST_MODE_FAN,
}
HA_TO_TADO_FAN_MODE_MAP = {
FAN_AUTO: CONST_FAN_AUTO,
FAN_OFF: CONST_FAN_OFF,
FAN_LOW: CONST_FAN_LOW,
FAN_MEDIUM: CONST_FAN_MIDDLE,
FAN_HIGH: CONST_FAN_HIGH,
}
TADO_TO_HA_HVAC_MODE_MAP = {
value: key for key, value in HA_TO_TADO_HVAC_MODE_MAP.items()
}
TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.items()}
DEFAULT_TADO_PRECISION = 0.1
SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME]

View File

@@ -7,6 +7,6 @@
],
"dependencies": [],
"codeowners": [
"@michaelarnauts"
"@michaelarnauts", "@bdraco"
]
}

View File

@@ -31,6 +31,7 @@ ZONE_SENSORS = {
"ac",
"tado mode",
"overlay",
"open window",
],
TYPE_HOT_WATER: ["power", "link", "tado mode", "overlay"],
}
@@ -46,20 +47,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for tado in api_list:
# Create zone sensors
zones = tado.zones
devices = tado.devices
for zone in zones:
zone_type = zone["type"]
if zone_type not in ZONE_SENSORS:
_LOGGER.warning("Unknown zone type skipped: %s", zone_type)
continue
for zone in tado.zones:
entities.extend(
[
create_zone_sensor(tado, zone["name"], zone["id"], variable)
for variable in ZONE_SENSORS.get(zone["type"])
TadoZoneSensor(tado, zone["name"], zone["id"], variable)
for variable in ZONE_SENSORS[zone_type]
]
)
# Create device sensors
for home in tado.devices:
for device in devices:
entities.extend(
[
create_device_sensor(tado, home["name"], home["id"], variable)
TadoDeviceSensor(tado, device["name"], device["id"], variable)
for variable in DEVICE_SENSORS
]
)
@@ -67,46 +75,38 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
add_entities(entities, True)
def create_zone_sensor(tado, name, zone_id, variable):
"""Create a zone sensor."""
return TadoSensor(tado, name, "zone", zone_id, variable)
def create_device_sensor(tado, name, device_id, variable):
"""Create a device sensor."""
return TadoSensor(tado, name, "device", device_id, variable)
class TadoSensor(Entity):
class TadoZoneSensor(Entity):
"""Representation of a tado Sensor."""
def __init__(self, tado, zone_name, sensor_type, zone_id, zone_variable):
def __init__(self, tado, zone_name, zone_id, zone_variable):
"""Initialize of the Tado Sensor."""
self._tado = tado
self.zone_name = zone_name
self.zone_id = zone_id
self.zone_variable = zone_variable
self.sensor_type = sensor_type
self._unique_id = f"{zone_variable} {zone_id} {tado.device_id}"
self._state = None
self._state_attributes = None
self._tado_zone_data = None
self._undo_dispatcher = None
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
if self._undo_dispatcher:
self._undo_dispatcher()
async def async_added_to_hass(self):
"""Register for sensor updates."""
@callback
def async_update_callback():
"""Schedule an entity update."""
self.async_schedule_update_ha_state(True)
async_dispatcher_connect(
self._undo_dispatcher = async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(self.sensor_type, self.zone_id),
async_update_callback,
SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id),
self._async_update_callback,
)
self._async_update_zone_data()
@property
def unique_id(self):
@@ -138,7 +138,7 @@ class TadoSensor(Entity):
if self.zone_variable == "heating":
return UNIT_PERCENTAGE
if self.zone_variable == "ac":
return ""
return None
@property
def icon(self):
@@ -149,97 +149,143 @@ class TadoSensor(Entity):
return "mdi:water-percent"
@property
def should_poll(self) -> bool:
def should_poll(self):
"""Do not poll."""
return False
def update(self):
@callback
def _async_update_callback(self):
"""Update and write state."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_zone_data(self):
"""Handle update callbacks."""
try:
data = self._tado.data[self.sensor_type][self.zone_id]
self._tado_zone_data = self._tado.data["zone"][self.zone_id]
except KeyError:
return
unit = TEMP_CELSIUS
if self.zone_variable == "temperature":
if "sensorDataPoints" in data:
sensor_data = data["sensorDataPoints"]
temperature = float(sensor_data["insideTemperature"]["celsius"])
self._state = self.hass.config.units.temperature(temperature, unit)
self._state_attributes = {
"time": sensor_data["insideTemperature"]["timestamp"],
"setting": 0, # setting is used in climate device
}
# temperature setting will not exist when device is off
if (
"temperature" in data["setting"]
and data["setting"]["temperature"] is not None
):
temperature = float(data["setting"]["temperature"]["celsius"])
self._state_attributes[
"setting"
] = self.hass.config.units.temperature(temperature, unit)
self._state = self.hass.config.units.temperature(
self._tado_zone_data.current_temp, TEMP_CELSIUS
)
self._state_attributes = {
"time": self._tado_zone_data.current_temp_timestamp,
"setting": 0, # setting is used in climate device
}
elif self.zone_variable == "humidity":
if "sensorDataPoints" in data:
sensor_data = data["sensorDataPoints"]
self._state = float(sensor_data["humidity"]["percentage"])
self._state_attributes = {"time": sensor_data["humidity"]["timestamp"]}
self._state = self._tado_zone_data.current_humidity
self._state_attributes = {
"time": self._tado_zone_data.current_humidity_timestamp
}
elif self.zone_variable == "power":
if "setting" in data:
self._state = data["setting"]["power"]
self._state = self._tado_zone_data.power
elif self.zone_variable == "link":
if "link" in data:
self._state = data["link"]["state"]
self._state = self._tado_zone_data.link
elif self.zone_variable == "heating":
if "activityDataPoints" in data:
activity_data = data["activityDataPoints"]
if (
"heatingPower" in activity_data
and activity_data["heatingPower"] is not None
):
self._state = float(activity_data["heatingPower"]["percentage"])
self._state_attributes = {
"time": activity_data["heatingPower"]["timestamp"]
}
self._state = self._tado_zone_data.heating_power_percentage
self._state_attributes = {
"time": self._tado_zone_data.heating_power_timestamp
}
elif self.zone_variable == "ac":
if "activityDataPoints" in data:
activity_data = data["activityDataPoints"]
if "acPower" in activity_data and activity_data["acPower"] is not None:
self._state = activity_data["acPower"]["value"]
self._state_attributes = {
"time": activity_data["acPower"]["timestamp"]
}
self._state = self._tado_zone_data.ac_power
self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp}
elif self.zone_variable == "tado bridge status":
if "connectionState" in data:
self._state = data["connectionState"]["value"]
self._state = self._tado_zone_data.connection
elif self.zone_variable == "tado mode":
if "tadoMode" in data:
self._state = data["tadoMode"]
self._state = self._tado_zone_data.tado_mode
elif self.zone_variable == "overlay":
self._state = "overlay" in data and data["overlay"] is not None
self._state = self._tado_zone_data.overlay_active
self._state_attributes = (
{"termination": data["overlay"]["termination"]["type"]}
if self._state
{"termination": self._tado_zone_data.overlay_termination_type}
if self._tado_zone_data.overlay_active
else {}
)
elif self.zone_variable == "early start":
self._state = "preparation" in data and data["preparation"] is not None
self._state = self._tado_zone_data.preparation
elif self.zone_variable == "open window":
self._state = "openWindow" in data and data["openWindow"] is not None
self._state_attributes = data["openWindow"] if self._state else {}
self._state = self._tado_zone_data.open_window
self._state_attributes = self._tado_zone_data.open_window_attr
class TadoDeviceSensor(Entity):
"""Representation of a tado Sensor."""
def __init__(self, tado, device_name, device_id, device_variable):
"""Initialize of the Tado Sensor."""
self._tado = tado
self.device_name = device_name
self.device_id = device_id
self.device_variable = device_variable
self._unique_id = f"{device_variable} {device_id} {tado.device_id}"
self._state = None
self._state_attributes = None
self._tado_device_data = None
self._undo_dispatcher = None
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
if self._undo_dispatcher:
self._undo_dispatcher()
async def async_added_to_hass(self):
"""Register for sensor updates."""
self._undo_dispatcher = async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format("device", self.device_id),
self._async_update_callback,
)
self._async_update_device_data()
@property
def unique_id(self):
"""Return the unique id."""
return self._unique_id
@property
def name(self):
"""Return the name of the sensor."""
return f"{self.device_name} {self.device_variable}"
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def should_poll(self):
"""Do not poll."""
return False
@callback
def _async_update_callback(self):
"""Update and write state."""
self._async_update_device_data()
self.async_write_ha_state()
@callback
def _async_update_device_data(self):
"""Handle update callbacks."""
try:
data = self._tado.data["device"][self.device_id]
except KeyError:
return
if self.device_variable == "tado bridge status":
self._state = data.get("connectionState", {}).get("value", False)

View File

@@ -12,6 +12,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED
from .const import (
CONST_HVAC_HEAT,
CONST_MODE_AUTO,
CONST_MODE_HEAT,
CONST_MODE_OFF,
CONST_MODE_SMART_SCHEDULE,
CONST_OVERLAY_MANUAL,
@@ -33,6 +36,7 @@ WATER_HEATER_MAP_TADO = {
CONST_OVERLAY_MANUAL: MODE_HEAT,
CONST_OVERLAY_TIMER: MODE_HEAT,
CONST_OVERLAY_TADO_MODE: MODE_HEAT,
CONST_HVAC_HEAT: MODE_HEAT,
CONST_MODE_SMART_SCHEDULE: MODE_AUTO,
CONST_MODE_OFF: MODE_OFF,
}
@@ -50,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for tado in api_list:
for zone in tado.zones:
if zone["type"] in [TYPE_HOT_WATER]:
if zone["type"] == TYPE_HOT_WATER:
entity = create_water_heater_entity(tado, zone["name"], zone["id"])
entities.append(entity)
@@ -61,6 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
def create_water_heater_entity(tado, name: str, zone_id: int):
"""Create a Tado water heater device."""
capabilities = tado.get_capabilities(zone_id)
supports_temperature_control = capabilities["canSetTemperature"]
if supports_temperature_control and "temperatures" in capabilities:
@@ -98,7 +103,6 @@ class TadoWaterHeater(WaterHeaterDevice):
self._unique_id = f"{zone_id} {tado.device_id}"
self._device_is_active = False
self._is_away = False
self._supports_temperature_control = supports_temperature_control
self._min_temperature = min_temp
@@ -110,29 +114,25 @@ class TadoWaterHeater(WaterHeaterDevice):
if self._supports_temperature_control:
self._supported_features |= SUPPORT_TARGET_TEMPERATURE
if tado.fallback:
# Fallback to Smart Schedule at next Schedule switch
self._default_overlay = CONST_OVERLAY_TADO_MODE
else:
# Don't fallback to Smart Schedule, but keep in manual mode
self._default_overlay = CONST_OVERLAY_MANUAL
self._current_operation = CONST_MODE_SMART_SCHEDULE
self._current_tado_hvac_mode = CONST_MODE_SMART_SCHEDULE
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._tado_zone_data = None
self._undo_dispatcher = None
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
if self._undo_dispatcher:
self._undo_dispatcher()
async def async_added_to_hass(self):
"""Register for sensor updates."""
@callback
def async_update_callback():
"""Schedule an entity update."""
self.async_schedule_update_ha_state(True)
async_dispatcher_connect(
self._undo_dispatcher = async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id),
async_update_callback,
self._async_update_callback,
)
self._async_update_data()
@property
def supported_features(self):
@@ -157,17 +157,17 @@ class TadoWaterHeater(WaterHeaterDevice):
@property
def current_operation(self):
"""Return current readable operation mode."""
return WATER_HEATER_MAP_TADO.get(self._current_operation)
return WATER_HEATER_MAP_TADO.get(self._current_tado_hvac_mode)
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self._target_temp
return self._tado_zone_data.target_temp
@property
def is_away_mode_on(self):
"""Return true if away mode is on."""
return self._is_away
return self._tado_zone_data.is_away
@property
def operation_list(self):
@@ -198,16 +198,9 @@ class TadoWaterHeater(WaterHeaterDevice):
elif operation_mode == MODE_AUTO:
mode = CONST_MODE_SMART_SCHEDULE
elif operation_mode == MODE_HEAT:
mode = self._default_overlay
mode = CONST_MODE_HEAT
self._current_operation = mode
self._overlay_mode = None
# Set a target temperature if we don't have any
if mode == CONST_OVERLAY_TADO_MODE and self._target_temp is None:
self._target_temp = self.min_temp
self._control_heater()
self._control_heater(hvac_mode=mode)
def set_temperature(self, **kwargs):
"""Set new target temperature."""
@@ -215,88 +208,75 @@ class TadoWaterHeater(WaterHeaterDevice):
if not self._supports_temperature_control or temperature is None:
return
self._current_operation = self._default_overlay
self._overlay_mode = None
self._target_temp = temperature
self._control_heater()
def update(self):
"""Handle update callbacks."""
_LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id)
data = self._tado.data["zone"][self.zone_id]
if "tadoMode" in data:
mode = data["tadoMode"]
self._is_away = mode == "AWAY"
if "setting" in data:
power = data["setting"]["power"]
if power == "OFF":
self._current_operation = CONST_MODE_OFF
# There is no overlay, the mode will always be
# "SMART_SCHEDULE"
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._device_is_active = False
else:
self._device_is_active = True
# temperature setting will not exist when device is off
if (
"temperature" in data["setting"]
and data["setting"]["temperature"] is not None
if self._current_tado_hvac_mode not in (
CONST_MODE_OFF,
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
setting = float(data["setting"]["temperature"]["celsius"])
self._target_temp = setting
self._control_heater(target_temp=temperature)
return
overlay = False
overlay_data = None
termination = CONST_MODE_SMART_SCHEDULE
self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT)
if "overlay" in data:
overlay_data = data["overlay"]
overlay = overlay_data is not None
@callback
def _async_update_callback(self):
"""Load tado data and update state."""
self._async_update_data()
self.async_write_ha_state()
if overlay:
termination = overlay_data["termination"]["type"]
@callback
def _async_update_data(self):
"""Load tado data."""
_LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id)
self._tado_zone_data = self._tado.data["zone"][self.zone_id]
self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
if self._device_is_active:
# If you set mode manually to off, there will be an overlay
# and a termination, but we want to see the mode "OFF"
self._overlay_mode = termination
self._current_operation = termination
def _control_heater(self):
def _control_heater(self, hvac_mode=None, target_temp=None):
"""Send new target temperature."""
if self._current_operation == CONST_MODE_SMART_SCHEDULE:
if hvac_mode:
self._current_tado_hvac_mode = hvac_mode
if target_temp:
self._target_temp = target_temp
# Set a target temperature if we don't have any
if self._target_temp is None:
self._target_temp = self.min_temp
if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE:
_LOGGER.debug(
"Switching to SMART_SCHEDULE for zone %s (%d)",
self.zone_name,
self.zone_id,
)
self._tado.reset_zone_overlay(self.zone_id)
self._overlay_mode = self._current_operation
return
if self._current_operation == CONST_MODE_OFF:
if self._current_tado_hvac_mode == CONST_MODE_OFF:
_LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id
)
self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER)
self._overlay_mode = self._current_operation
return
# Fallback to Smart Schedule at next Schedule switch if we have fallback enabled
overlay_mode = (
CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL
)
_LOGGER.debug(
"Switching to %s for zone %s (%d) with temperature %s",
self._current_operation,
self._current_tado_hvac_mode,
self.zone_name,
self.zone_id,
self._target_temp,
)
self._tado.set_zone_overlay(
self.zone_id,
self._current_operation,
self._target_temp,
None,
TYPE_HOT_WATER,
zone_id=self.zone_id,
overlay_mode=overlay_mode,
temperature=self._target_temp,
duration=None,
device_type=TYPE_HOT_WATER,
)
self._overlay_mode = self._current_operation
self._overlay_mode = self._current_tado_hvac_mode

View File

@@ -22,6 +22,7 @@
"step": {
"init": {
"data": {
"enable_wake_on_start": "Forza il risveglio delle auto all'avvio",
"scan_interval": "Secondi tra le scansioni"
}
}

View File

@@ -71,6 +71,7 @@
"timeout": "API Request Timeout (Sekunden)",
"volume_step": "Lautst\u00e4rken-Schrittgr\u00f6\u00dfe"
},
"description": "Wenn Sie \u00fcber ein Smart-TV-Ger\u00e4t verf\u00fcgen, k\u00f6nnen Sie Ihre Quellliste optional filtern, indem Sie ausw\u00e4hlen, welche Apps in Ihre Quellliste aufgenommen oder ausgeschlossen werden sollen.",
"title": "Aktualisieren Sie die Vizo SmartCast-Optionen"
}
},

View File

@@ -950,6 +950,15 @@ def async_load_api(hass):
schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND],
)
def _get_ias_wd_channel(zha_device):
"""Get the IASWD channel for a device."""
cluster_channels = {
ch.name: ch
for pool in zha_device.channels.pools
for ch in pool.claimed_channels.values()
}
return cluster_channels.get(CHANNEL_IAS_WD)
async def warning_device_squawk(service):
"""Issue the squawk command for an IAS warning device."""
ieee = service.data[ATTR_IEEE]
@@ -959,9 +968,9 @@ def async_load_api(hass):
zha_device = zha_gateway.get_device(ieee)
if zha_device is not None:
channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD)
channel = _get_ias_wd_channel(zha_device)
if channel:
await channel.squawk(mode, strobe, level)
await channel.issue_squawk(mode, strobe, level)
else:
_LOGGER.error(
"Squawking IASWD: %s: [%s] is missing the required IASWD channel!",
@@ -1003,9 +1012,9 @@ def async_load_api(hass):
zha_device = zha_gateway.get_device(ieee)
if zha_device is not None:
channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD)
channel = _get_ias_wd_channel(zha_device)
if channel:
await channel.start_warning(
await channel.issue_start_warning(
mode, strobe, level, duration, duty_mode, intensity
)
else:

View File

@@ -51,7 +51,7 @@ class IasWd(ZigbeeChannel):
"""Get the specified bit from the value."""
return (value & (1 << bit)) != 0
async def squawk(
async def issue_squawk(
self,
mode=WARNING_DEVICE_SQUAWK_MODE_ARMED,
strobe=WARNING_DEVICE_STROBE_YES,
@@ -76,7 +76,7 @@ class IasWd(ZigbeeChannel):
await self.squawk(value)
async def start_warning(
async def issue_start_warning(
self,
mode=WARNING_DEVICE_MODE_EMERGENCY,
strobe=WARNING_DEVICE_STROBE_YES,

View File

@@ -474,7 +474,6 @@ class ZHAGateway:
"""Update the devices in the store."""
for device in self.devices.values():
self.zha_storage.async_update_device(device)
await self.zha_storage.async_save()
async def async_device_initialized(self, device: zha_typing.ZigpyDeviceType):
"""Handle device joined and basic information discovered (async)."""

View File

@@ -6,7 +6,7 @@
"requirements": [
"bellows-homeassistant==0.14.0",
"zha-quirks==0.0.37",
"zigpy-cc==0.3.0",
"zigpy-cc==0.3.1",
"zigpy-deconz==0.7.0",
"zigpy-homeassistant==0.16.0",
"zigpy-xbee-homeassistant==0.10.0",

View File

@@ -184,6 +184,7 @@ EVENT_CORE_CONFIG_UPDATE = "core_config_updated"
EVENT_HOMEASSISTANT_CLOSE = "homeassistant_close"
EVENT_HOMEASSISTANT_START = "homeassistant_start"
EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
EVENT_HOMEASSISTANT_FINAL_WRITE = "homeassistant_final_write"
EVENT_LOGBOOK_ENTRY = "logbook_entry"
EVENT_PLATFORM_DISCOVERED = "platform_discovered"
EVENT_SCRIPT_STARTED = "script_started"

View File

@@ -47,6 +47,7 @@ from homeassistant.const import (
EVENT_CALL_SERVICE,
EVENT_CORE_CONFIG_UPDATE,
EVENT_HOMEASSISTANT_CLOSE,
EVENT_HOMEASSISTANT_FINAL_WRITE,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
EVENT_SERVICE_REGISTERED,
@@ -151,6 +152,7 @@ class CoreState(enum.Enum):
starting = "STARTING"
running = "RUNNING"
stopping = "STOPPING"
writing_data = "WRITING_DATA"
def __str__(self) -> str:
"""Return the event."""
@@ -412,7 +414,7 @@ class HomeAssistant:
# regardless of the state of the loop.
if self.state == CoreState.not_running: # just ignore
return
if self.state == CoreState.stopping:
if self.state == CoreState.stopping or self.state == CoreState.writing_data:
_LOGGER.info("async_stop called twice: ignored")
return
if self.state == CoreState.starting:
@@ -426,6 +428,11 @@ class HomeAssistant:
await self.async_block_till_done()
# stage 2
self.state = CoreState.writing_data
self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
await self.async_block_till_done()
# stage 3
self.state = CoreState.not_running
self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
await self.async_block_till_done()

View File

@@ -54,6 +54,7 @@ FLOWS = [
"ifttt",
"ios",
"ipma",
"ipp",
"iqvia",
"izone",
"konnected",

View File

@@ -25,6 +25,12 @@ ZEROCONF = {
"_hap._tcp.local.": [
"homekit_controller"
],
"_ipp._tcp.local.": [
"ipp"
],
"_ipps._tcp.local.": [
"ipp"
],
"_printer._tcp.local.": [
"brother"
],

View File

@@ -4,7 +4,10 @@ from datetime import datetime, timedelta
import logging
from typing import Any, Awaitable, Dict, List, Optional, Set, cast
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import (
EVENT_HOMEASSISTANT_FINAL_WRITE,
EVENT_HOMEASSISTANT_START,
)
from homeassistant.core import (
CoreState,
HomeAssistant,
@@ -184,7 +187,9 @@ class RestoreStateData:
async_track_time_interval(self.hass, _async_dump_states, STATE_DUMP_INTERVAL)
# Dump states when stopping hass
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_dump_states)
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_FINAL_WRITE, _async_dump_states
)
@callback
def async_restore_entity_added(self, entity_id: str) -> None:

View File

@@ -5,7 +5,7 @@ import logging
import os
from typing import Any, Callable, Dict, List, Optional, Type, Union
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import bind_hass
@@ -153,7 +153,7 @@ class Store:
"""Ensure that we write if we quit before delay has passed."""
if self._unsub_stop_listener is None:
self._unsub_stop_listener = self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self._async_callback_stop_write
EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_stop_write
)
@callback

View File

@@ -12,7 +12,7 @@ cryptography==2.8
defusedxml==0.6.0
distro==1.4.0
hass-nabucasa==0.32.2
home-assistant-frontend==20200318.1
home-assistant-frontend==20200330.0
importlib-metadata==1.5.0
jinja2>=2.11.1
netdisco==2.6.0

View File

@@ -140,7 +140,7 @@ aio_geojson_nsw_rfs_incidents==0.3
aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
aioambient==1.0.4
aioambient==1.1.0
# homeassistant.components.asuswrt
aioasuswrt==1.2.3
@@ -168,7 +168,7 @@ aioftp==0.12.0
aioharmony==0.1.13
# homeassistant.components.homekit_controller
aiohomekit[IP]==0.2.35
aiohomekit[IP]==0.2.37
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -184,7 +184,7 @@ aioimaplib==0.7.15
aiokafka==0.5.1
# homeassistant.components.kef
aiokef==0.2.7
aiokef==0.2.9
# homeassistant.components.lifx
aiolifx==0.6.7
@@ -704,7 +704,7 @@ hole==0.5.1
holidays==0.10.1
# homeassistant.components.frontend
home-assistant-frontend==20200318.1
home-assistant-frontend==20200330.0
# homeassistant.components.zwave
homeassistant-pyozw==0.1.10
@@ -964,7 +964,7 @@ onkyo-eiscp==1.2.7
onvif-zeep-async==0.2.0
# homeassistant.components.opencv
# opencv-python-headless==4.1.2.30
# opencv-python-headless==4.2.0.32
# homeassistant.components.openevse
openevsewifi==0.4
@@ -1335,6 +1335,9 @@ pyintesishome==1.7.1
# homeassistant.components.ipma
pyipma==2.0.5
# homeassistant.components.ipp
pyipp==0.8.1
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -1596,7 +1599,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2
# homeassistant.components.ecobee
python-ecobee-api==0.2.3
python-ecobee-api==0.2.5
# homeassistant.components.eq3btsmart
# python-eq3bt==0.1.11
@@ -1876,7 +1879,7 @@ sisyphus-control==2.2.1
skybellpy==0.4.0
# homeassistant.components.slack
slacker==0.14.0
slackclient==2.5.0
# homeassistant.components.sleepiq
sleepyq==0.7
@@ -2182,7 +2185,7 @@ zhong_hong_hvac==1.0.9
ziggo-mediabox-xl==1.1.0
# homeassistant.components.zha
zigpy-cc==0.3.0
zigpy-cc==0.3.1
# homeassistant.components.zha
zigpy-deconz==0.7.0

View File

@@ -47,7 +47,7 @@ aio_geojson_nsw_rfs_incidents==0.3
aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
aioambient==1.0.4
aioambient==1.1.0
# homeassistant.components.asuswrt
aioasuswrt==1.2.3
@@ -72,7 +72,7 @@ aiofreepybox==0.0.8
aioharmony==0.1.13
# homeassistant.components.homekit_controller
aiohomekit[IP]==0.2.35
aiohomekit[IP]==0.2.37
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -282,7 +282,7 @@ hole==0.5.1
holidays==0.10.1
# homeassistant.components.frontend
home-assistant-frontend==20200318.1
home-assistant-frontend==20200330.0
# homeassistant.components.zwave
homeassistant-pyozw==0.1.10
@@ -518,6 +518,9 @@ pyicloud==0.9.6.1
# homeassistant.components.ipma
pyipma==2.0.5
# homeassistant.components.ipp
pyipp==0.8.1
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -602,7 +605,7 @@ pysonos==0.0.25
pyspcwebgw==0.4.0
# homeassistant.components.ecobee
python-ecobee-api==0.2.3
python-ecobee-api==0.2.5
# homeassistant.components.darksky
python-forecastio==1.4.0
@@ -616,6 +619,9 @@ python-miio==0.4.8
# homeassistant.components.nest
python-nest==4.1.0
# homeassistant.components.tado
python-tado==0.5.0
# homeassistant.components.twitch
python-twitch-client==0.6.0
@@ -792,7 +798,7 @@ zeroconf==0.24.5
zha-quirks==0.0.37
# homeassistant.components.zha
zigpy-cc==0.3.0
zigpy-cc==0.3.1
# homeassistant.components.zha
zigpy-deconz==0.7.0

View File

@@ -899,8 +899,8 @@ async def test_async_remove_user(hass):
assert events[0].data["user_id"] == user.id
async def test_new_users_admin(mock_hass):
"""Test newly created users are admin."""
async def test_new_users(mock_hass):
"""Test newly created users."""
manager = await auth.auth_manager_from_config(
mock_hass,
[
@@ -911,7 +911,17 @@ async def test_new_users_admin(mock_hass):
"username": "test-user",
"password": "test-pass",
"name": "Test Name",
}
},
{
"username": "test-user-2",
"password": "test-pass",
"name": "Test Name",
},
{
"username": "test-user-3",
"password": "test-pass",
"name": "Test Name",
},
],
}
],
@@ -920,7 +930,18 @@ async def test_new_users_admin(mock_hass):
ensure_auth_manager_loaded(manager)
user = await manager.async_create_user("Hello")
# first user in the system is owner and admin
assert user.is_owner
assert user.is_admin
assert user.groups == []
user = await manager.async_create_user("Hello 2")
assert not user.is_admin
assert user.groups == []
user = await manager.async_create_user("Hello 3", ["system-admin"])
assert user.is_admin
assert user.groups[0].id == "system-admin"
user_cred = await manager.async_get_or_create_user(
auth_models.Credentials(

View File

@@ -156,8 +156,35 @@ async def test_create(hass, hass_ws_client, hass_access_token):
assert len(await hass.auth.async_get_users()) == 1
await client.send_json({"id": 5, "type": "config/auth/create", "name": "Paulus"})
result = await client.receive_json()
assert result["success"], result
assert len(await hass.auth.async_get_users()) == 2
data_user = result["result"]["user"]
user = await hass.auth.async_get_user(data_user["id"])
assert user is not None
assert user.name == data_user["name"]
assert user.is_active
assert user.groups == []
assert not user.is_admin
assert not user.is_owner
assert not user.system_generated
async def test_create_user_group(hass, hass_ws_client, hass_access_token):
"""Test create user with a group."""
client = await hass_ws_client(hass, hass_access_token)
assert len(await hass.auth.async_get_users()) == 1
await client.send_json(
{"id": 5, "type": auth_config.WS_TYPE_CREATE, "name": "Paulus"}
{
"id": 5,
"type": "config/auth/create",
"name": "Paulus",
"group_ids": ["system-admin"],
}
)
result = await client.receive_json()
@@ -168,6 +195,8 @@ async def test_create(hass, hass_ws_client, hass_access_token):
assert user is not None
assert user.name == data_user["name"]
assert user.is_active
assert user.groups[0].id == "system-admin"
assert user.is_admin
assert not user.is_owner
assert not user.system_generated
@@ -176,7 +205,7 @@ async def test_create_requires_admin(hass, hass_ws_client, hass_read_only_access
"""Test create command requires an admin."""
client = await hass_ws_client(hass, hass_read_only_access_token)
await client.send_json({"id": 5, "type": auth_config.WS_TYPE_CREATE, "name": "YO"})
await client.send_json({"id": 5, "type": "config/auth/create", "name": "YO"})
result = await client.receive_json()
assert not result["success"], result

View File

@@ -522,6 +522,62 @@ class TestComponentHistory(unittest.TestCase):
)
assert list(hist.keys()) == entity_ids
def test_get_significant_states_only(self):
"""Test significant states when significant_states_only is set."""
self.init_recorder()
entity_id = "sensor.test"
def set_state(state, **kwargs):
"""Set the state."""
self.hass.states.set(entity_id, state, **kwargs)
wait_recording_done(self.hass)
return self.hass.states.get(entity_id)
start = dt_util.utcnow() - timedelta(minutes=4)
points = []
for i in range(1, 4):
points.append(start + timedelta(minutes=i))
states = []
with patch(
"homeassistant.components.recorder.dt_util.utcnow", return_value=start
):
set_state("123", attributes={"attribute": 10.64})
with patch(
"homeassistant.components.recorder.dt_util.utcnow", return_value=points[0]
):
# Attributes are different, state not
states.append(set_state("123", attributes={"attribute": 21.42}))
with patch(
"homeassistant.components.recorder.dt_util.utcnow", return_value=points[1]
):
# state is different, attributes not
states.append(set_state("32", attributes={"attribute": 21.42}))
with patch(
"homeassistant.components.recorder.dt_util.utcnow", return_value=points[2]
):
# everything is different
states.append(set_state("412", attributes={"attribute": 54.23}))
hist = history.get_significant_states(
self.hass, start, significant_changes_only=True
)
assert len(hist[entity_id]) == 2
assert states[0] not in hist[entity_id]
assert states[1] in hist[entity_id]
assert states[2] in hist[entity_id]
hist = history.get_significant_states(
self.hass, start, significant_changes_only=False
)
assert len(hist[entity_id]) == 3
assert states == hist[entity_id]
def check_significant_states(self, zero, four, states, config):
"""Check if significant states are retrieved."""
filters = history.Filters()

View File

@@ -5,8 +5,11 @@ import pytest
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
)
from homeassistant.components.homekit.const import ATTR_VALUE
@@ -14,6 +17,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
EVENT_HOMEASSISTANT_START,
SERVICE_SET_COVER_TILT_POSITION,
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
@@ -193,6 +197,72 @@ async def test_window_set_cover_position(hass, hk_driver, cls, events):
assert events[-1].data[ATTR_VALUE] == 75
async def test_window_cover_set_tilt(hass, hk_driver, cls, events):
"""Test if accessory and HA update slat tilt accordingly."""
entity_id = "cover.window"
hass.states.async_set(
entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_TILT_POSITION}
)
await hass.async_block_till_done()
acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None)
await hass.async_add_job(acc.run)
assert acc.aid == 2
assert acc.category == 14 # CATEGORY_WINDOW_COVERING
assert acc.char_current_tilt.value == 0
assert acc.char_target_tilt.value == 0
hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: None})
await hass.async_block_till_done()
assert acc.char_current_tilt.value == 0
assert acc.char_target_tilt.value == 0
hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 100})
await hass.async_block_till_done()
assert acc.char_current_tilt.value == 90
assert acc.char_target_tilt.value == 90
hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 50})
await hass.async_block_till_done()
assert acc.char_current_tilt.value == 0
assert acc.char_target_tilt.value == 0
hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 0})
await hass.async_block_till_done()
assert acc.char_current_tilt.value == -90
assert acc.char_target_tilt.value == -90
# set from HomeKit
call_set_tilt_position = async_mock_service(
hass, DOMAIN, SERVICE_SET_COVER_TILT_POSITION
)
# HomeKit sets tilts between -90 and 90 (degrees), whereas
# Homeassistant expects a % between 0 and 100. Keep that in mind
# when comparing
await hass.async_add_job(acc.char_target_tilt.client_update_value, 90)
await hass.async_block_till_done()
assert call_set_tilt_position[0]
assert call_set_tilt_position[0].data[ATTR_ENTITY_ID] == entity_id
assert call_set_tilt_position[0].data[ATTR_TILT_POSITION] == 100
assert acc.char_current_tilt.value == -90
assert acc.char_target_tilt.value == 90
assert len(events) == 1
assert events[-1].data[ATTR_VALUE] == 100
await hass.async_add_job(acc.char_target_tilt.client_update_value, 45)
await hass.async_block_till_done()
assert call_set_tilt_position[1]
assert call_set_tilt_position[1].data[ATTR_ENTITY_ID] == entity_id
assert call_set_tilt_position[1].data[ATTR_TILT_POSITION] == 75
assert acc.char_current_tilt.value == -90
assert acc.char_target_tilt.value == 45
assert len(events) == 2
assert events[-1].data[ATTR_VALUE] == 75
async def test_window_open_close(hass, hk_driver, cls, events):
"""Test if accessory and HA are updated accordingly."""
entity_id = "cover.window"

View File

@@ -26,11 +26,11 @@ async def test_hmip_load_all_supported_devices(hass, default_mock_hap_factory):
async def test_hmip_remove_device(hass, default_mock_hap_factory):
"""Test Remove of hmip device."""
entity_id = "light.treppe"
entity_name = "Treppe"
entity_id = "light.treppe_ch"
entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=[entity_name]
test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -58,11 +58,11 @@ async def test_hmip_remove_device(hass, default_mock_hap_factory):
async def test_hmip_add_device(hass, default_mock_hap_factory, hmip_config_entry):
"""Test Remove of hmip device."""
entity_id = "light.treppe"
entity_name = "Treppe"
entity_id = "light.treppe_ch"
entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=[entity_name]
test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -137,11 +137,11 @@ async def test_all_devices_unavailable_when_hap_not_connected(
hass, default_mock_hap_factory
):
"""Test make all devices unavaulable when hap is not connected."""
entity_id = "light.treppe"
entity_name = "Treppe"
entity_id = "light.treppe_ch"
entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=[entity_name]
test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -161,11 +161,11 @@ async def test_all_devices_unavailable_when_hap_not_connected(
async def test_hap_reconnected(hass, default_mock_hap_factory):
"""Test reconnect hap."""
entity_id = "light.treppe"
entity_name = "Treppe"
entity_id = "light.treppe_ch"
entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=[entity_name]
test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -192,8 +192,8 @@ async def test_hap_reconnected(hass, default_mock_hap_factory):
async def test_hap_with_name(hass, mock_connection, hmip_config_entry):
"""Test hap with name."""
home_name = "TestName"
entity_id = f"light.{home_name.lower()}_treppe"
entity_name = f"{home_name} Treppe"
entity_id = f"light.{home_name.lower()}_treppe_ch"
entity_name = f"{home_name} Treppe CH"
device_model = "HmIP-BSL"
hmip_config_entry.data = {**hmip_config_entry.data, "name": home_name}

View File

@@ -27,11 +27,11 @@ async def test_manually_configured_platform(hass):
async def test_hmip_light(hass, default_mock_hap_factory):
"""Test HomematicipLight."""
entity_id = "light.treppe"
entity_name = "Treppe"
entity_id = "light.treppe_ch"
entity_name = "Treppe CH"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=[entity_name]
test_devices=["Treppe"]
)
ha_state, hmip_device = get_and_check_entity_basics(
@@ -66,8 +66,8 @@ async def test_hmip_light(hass, default_mock_hap_factory):
async def test_hmip_notification_light(hass, default_mock_hap_factory):
"""Test HomematicipNotificationLight."""
entity_id = "light.treppe_top_notification"
entity_name = "Treppe Top Notification"
entity_id = "light.alarm_status"
entity_name = "Alarm Status"
device_model = "HmIP-BSL"
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
test_devices=["Treppe"]

View File

@@ -0,0 +1,95 @@
"""Tests for the IPP integration."""
import os
from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID, DOMAIN
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PORT,
CONF_SSL,
CONF_TYPE,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
ATTR_HOSTNAME = "hostname"
ATTR_PROPERTIES = "properties"
IPP_ZEROCONF_SERVICE_TYPE = "_ipp._tcp.local."
IPPS_ZEROCONF_SERVICE_TYPE = "_ipps._tcp.local."
ZEROCONF_NAME = "EPSON123456"
ZEROCONF_HOST = "1.2.3.4"
ZEROCONF_HOSTNAME = "EPSON123456.local."
ZEROCONF_PORT = 631
MOCK_USER_INPUT = {
CONF_HOST: "EPSON123456.local",
CONF_PORT: 361,
CONF_SSL: False,
CONF_VERIFY_SSL: False,
CONF_BASE_PATH: "/ipp/print",
}
MOCK_ZEROCONF_IPP_SERVICE_INFO = {
CONF_TYPE: IPP_ZEROCONF_SERVICE_TYPE,
CONF_NAME: ZEROCONF_NAME,
CONF_HOST: ZEROCONF_HOST,
ATTR_HOSTNAME: ZEROCONF_HOSTNAME,
CONF_PORT: ZEROCONF_PORT,
ATTR_PROPERTIES: {"rp": "ipp/print"},
}
MOCK_ZEROCONF_IPPS_SERVICE_INFO = {
CONF_TYPE: IPPS_ZEROCONF_SERVICE_TYPE,
CONF_NAME: ZEROCONF_NAME,
CONF_HOST: ZEROCONF_HOST,
ATTR_HOSTNAME: ZEROCONF_HOSTNAME,
CONF_PORT: ZEROCONF_PORT,
ATTR_PROPERTIES: {"rp": "ipp/print"},
}
def load_fixture_binary(filename):
"""Load a binary fixture."""
path = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", filename)
with open(path, "rb") as fptr:
return fptr.read()
async def init_integration(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the IPP integration in Home Assistant."""
fixture = "ipp/get-printer-attributes.bin"
aioclient_mock.post(
"http://EPSON123456.local:631/ipp/print",
content=load_fixture_binary(fixture),
headers={"Content-Type": "application/ipp"},
)
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="cfe92100-67c4-11d4-a45f-f8d027761251",
data={
CONF_HOST: "EPSON123456.local",
CONF_PORT: 631,
CONF_SSL: False,
CONF_VERIFY_SSL: True,
CONF_BASE_PATH: "/ipp/print",
CONF_UUID: "cfe92100-67c4-11d4-a45f-f8d027761251",
},
)
entry.add_to_hass(hass)
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@@ -0,0 +1,306 @@
"""Tests for the IPP config flow."""
import aiohttp
from pyipp import IPPConnectionUpgradeRequired
from homeassistant import data_entry_flow
from homeassistant.components.ipp import config_flow
from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SSL
from homeassistant.core import HomeAssistant
from . import (
MOCK_USER_INPUT,
MOCK_ZEROCONF_IPP_SERVICE_INFO,
MOCK_ZEROCONF_IPPS_SERVICE_INFO,
init_integration,
load_fixture_binary,
)
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_show_user_form(hass: HomeAssistant) -> None:
"""Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
async def test_show_zeroconf_confirm_form(hass: HomeAssistant) -> None:
"""Test that the zeroconf confirmation form is served."""
flow = config_flow.IPPFlowHandler()
flow.hass = hass
flow.context = {"source": SOURCE_ZEROCONF}
flow.discovery_info = {CONF_NAME: "EPSON123456"}
result = await flow.async_step_zeroconf_confirm()
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"}
async def test_show_zeroconf_form(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test that the zeroconf confirmation form is served."""
aioclient_mock.post(
"http://EPSON123456.local:631/ipp/print",
content=load_fixture_binary("ipp/get-printer-attributes.bin"),
headers={"Content-Type": "application/ipp"},
)
flow = config_flow.IPPFlowHandler()
flow.hass = hass
flow.context = {"source": SOURCE_ZEROCONF}
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await flow.async_step_zeroconf(discovery_info)
assert flow.discovery_info[CONF_HOST] == "EPSON123456.local"
assert flow.discovery_info[CONF_NAME] == "EPSON123456"
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"}
async def test_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show user form on IPP connection error."""
aioclient_mock.post(
"http://EPSON123456.local:631/ipp/print", exc=aiohttp.ClientError
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER}, data=user_input,
)
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "connection_error"}
async def test_zeroconf_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on IPP connection error."""
aioclient_mock.post("http://EPSON123456.local/ipp/print", exc=aiohttp.ClientError)
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "connection_error"
async def test_zeroconf_confirm_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on IPP connection error."""
aioclient_mock.post("http://EPSON123456.local/ipp/print", exc=aiohttp.ClientError)
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
context={
"source": SOURCE_ZEROCONF,
CONF_HOST: "EPSON123456.local",
CONF_NAME: "EPSON123456",
},
data=discovery_info,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "connection_error"
async def test_user_connection_upgrade_required(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show the user form if connection upgrade required by server."""
aioclient_mock.post(
"http://EPSON123456.local:631/ipp/print", exc=IPPConnectionUpgradeRequired
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER}, data=user_input,
)
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "connection_upgrade"}
async def test_zeroconf_connection_upgrade_required(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on IPP connection error."""
aioclient_mock.post(
"http://EPSON123456.local/ipp/print", exc=IPPConnectionUpgradeRequired
)
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "connection_upgrade"
async def test_user_device_exists_abort(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort user flow if printer already configured."""
await init_integration(hass, aioclient_mock)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER}, data=user_input,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_device_exists_abort(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow if printer already configured."""
await init_integration(hass, aioclient_mock)
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_with_uuid_device_exists_abort(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow if printer already configured."""
await init_integration(hass, aioclient_mock)
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
discovery_info["properties"]["UUID"] = "cfe92100-67c4-11d4-a45f-f8d027761251"
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_full_user_flow_implementation(
hass: HomeAssistant, aioclient_mock
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"http://EPSON123456.local:631/ipp/print",
content=load_fixture_binary("ipp/get-printer-attributes.bin"),
headers={"Content-Type": "application/ipp"},
)
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "EPSON123456.local", CONF_BASE_PATH: "/ipp/print"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "EPSON123456.local"
assert result["data"]
assert result["data"][CONF_HOST] == "EPSON123456.local"
assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251"
async def test_full_zeroconf_flow_implementation(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"http://EPSON123456.local:631/ipp/print",
content=load_fixture_binary("ipp/get-printer-attributes.bin"),
headers={"Content-Type": "application/ipp"},
)
flow = config_flow.IPPFlowHandler()
flow.hass = hass
flow.context = {"source": SOURCE_ZEROCONF}
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await flow.async_step_zeroconf(discovery_info)
assert flow.discovery_info
assert flow.discovery_info[CONF_HOST] == "EPSON123456.local"
assert flow.discovery_info[CONF_NAME] == "EPSON123456"
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await flow.async_step_zeroconf_confirm(
user_input={CONF_HOST: "EPSON123456.local"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "EPSON123456"
assert result["data"]
assert result["data"][CONF_HOST] == "EPSON123456.local"
assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251"
assert not result["data"][CONF_SSL]
async def test_full_zeroconf_tls_flow_implementation(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"https://EPSON123456.local:631/ipp/print",
content=load_fixture_binary("ipp/get-printer-attributes.bin"),
headers={"Content-Type": "application/ipp"},
)
flow = config_flow.IPPFlowHandler()
flow.hass = hass
flow.context = {"source": SOURCE_ZEROCONF}
discovery_info = MOCK_ZEROCONF_IPPS_SERVICE_INFO.copy()
result = await flow.async_step_zeroconf(discovery_info)
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"}
result = await flow.async_step_zeroconf_confirm(
user_input={CONF_HOST: "EPSON123456.local"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "EPSON123456"
assert result["data"]
assert result["data"][CONF_HOST] == "EPSON123456.local"
assert result["data"][CONF_NAME] == "EPSON123456"
assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251"
assert result["data"][CONF_SSL]

Some files were not shown because too many files have changed in this diff Show More