mirror of
https://github.com/home-assistant/core.git
synced 2026-05-05 04:14:32 +02:00
Compare commits
28 Commits
2021.3.0b1
...
2021.3.0b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6887474ddc | |||
| e93868f85b | |||
| db098d90dd | |||
| 552da0327e | |||
| 104d5c510f | |||
| d9d979d50e | |||
| e65b2231ba | |||
| 2b0f6716b3 | |||
| dd4f8bf4b4 | |||
| 505ca07c4e | |||
| 807bf15ff3 | |||
| cdf7372fd8 | |||
| 0969cc985b | |||
| 5e2bafca56 | |||
| 1d1be8ad1a | |||
| d55f0df09a | |||
| 96e118ccfe | |||
| 255b6faa7f | |||
| 4cd40d0f9f | |||
| c12769213d | |||
| 6a850a1481 | |||
| 101897c260 | |||
| 6cdd6c3f44 | |||
| 2c30579a11 | |||
| ae0d301fd9 | |||
| a7a66e8ddb | |||
| 5228bbd43c | |||
| 35bce434cc |
@@ -14,7 +14,7 @@ schedules:
|
||||
always: true
|
||||
variables:
|
||||
- name: versionBuilder
|
||||
value: '2020.11.0'
|
||||
value: '2021.02.0'
|
||||
- group: docker
|
||||
- group: github
|
||||
- group: twine
|
||||
@@ -114,10 +114,12 @@ stages:
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
strategy:
|
||||
maxParallel: 15
|
||||
maxParallel: 17
|
||||
matrix:
|
||||
qemux86-64:
|
||||
buildMachine: 'qemux86-64'
|
||||
generic-x86-64:
|
||||
buildMachine: 'generic-x86-64'
|
||||
intel-nuc:
|
||||
buildMachine: 'intel-nuc'
|
||||
qemux86:
|
||||
|
||||
@@ -178,7 +178,6 @@ class APIDiscoveryView(HomeAssistantView):
|
||||
requires_auth = False
|
||||
url = URL_API_DISCOVERY_INFO
|
||||
name = "api:discovery"
|
||||
cors_allowed = True
|
||||
|
||||
async def get(self, request):
|
||||
"""Get discovery information."""
|
||||
|
||||
@@ -122,7 +122,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
|
||||
def _update_all() -> None:
|
||||
"""Update all BMW accounts."""
|
||||
for entry in hass.data[DOMAIN][DATA_ENTRIES].values():
|
||||
for entry in hass.data[DOMAIN][DATA_ENTRIES].copy().values():
|
||||
entry[CONF_ACCOUNT].update()
|
||||
|
||||
# Add update listener for config entry changes (options)
|
||||
|
||||
@@ -42,12 +42,12 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
|
||||
@property
|
||||
def latitude(self):
|
||||
"""Return latitude value of the device."""
|
||||
return self._location[0]
|
||||
return self._location[0] if self._location else None
|
||||
|
||||
@property
|
||||
def longitude(self):
|
||||
"""Return longitude value of the device."""
|
||||
return self._location[1]
|
||||
return self._location[1] if self._location else None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"requirements": ["bimmer_connected==0.7.14"],
|
||||
"requirements": ["bimmer_connected==0.7.15"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Google Cast",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"requirements": ["pychromecast==8.1.0"],
|
||||
"requirements": ["pychromecast==8.1.2"],
|
||||
"after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"],
|
||||
"zeroconf": ["_googlecast._tcp.local."],
|
||||
"codeowners": ["@emontnemery"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20210224.0"
|
||||
"home-assistant-frontend==20210226.0"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit",
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit",
|
||||
"requirements": [
|
||||
"HAP-python==3.3.0",
|
||||
"HAP-python==3.3.1",
|
||||
"fnvhash==0.1.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1",
|
||||
|
||||
@@ -59,7 +59,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DEFAULT_DEVELOPMENT = "0"
|
||||
# Cast to be able to load custom cards.
|
||||
# My to be able to check url and version info.
|
||||
DEFAULT_CORS = ["https://cast.home-assistant.io", "https://my.home-assistant.io"]
|
||||
DEFAULT_CORS = ["https://cast.home-assistant.io"]
|
||||
NO_LOGIN_ATTEMPT_THRESHOLD = -1
|
||||
|
||||
MAX_CLIENT_SIZE: int = 1024 ** 2 * 16
|
||||
|
||||
@@ -18,6 +18,8 @@ from .bridge import authenticate_bridge
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
CONF_ALLOW_HUE_GROUPS,
|
||||
CONF_ALLOW_UNREACHABLE,
|
||||
DEFAULT_ALLOW_HUE_GROUPS,
|
||||
DEFAULT_ALLOW_UNREACHABLE,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
@@ -246,13 +248,13 @@ class HueOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
vol.Optional(
|
||||
CONF_ALLOW_HUE_GROUPS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_ALLOW_HUE_GROUPS, False
|
||||
CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS
|
||||
),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_ALLOW_UNREACHABLE,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_ALLOW_UNREACHABLE, False
|
||||
CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE
|
||||
),
|
||||
): bool,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ CONF_ALLOW_UNREACHABLE = "allow_unreachable"
|
||||
DEFAULT_ALLOW_UNREACHABLE = False
|
||||
|
||||
CONF_ALLOW_HUE_GROUPS = "allow_hue_groups"
|
||||
DEFAULT_ALLOW_HUE_GROUPS = True
|
||||
DEFAULT_ALLOW_HUE_GROUPS = False
|
||||
|
||||
DEFAULT_SCENE_TRANSITION = 4
|
||||
|
||||
GROUP_TYPE_LIGHT_GROUP = "LightGroup"
|
||||
GROUP_TYPE_ROOM = "Room"
|
||||
GROUP_TYPE_LUMINAIRE = "Luminaire"
|
||||
GROUP_TYPE_LIGHT_SOURCE = "LightSource"
|
||||
|
||||
@@ -36,7 +36,14 @@ from homeassistant.helpers.update_coordinator import (
|
||||
)
|
||||
from homeassistant.util import color
|
||||
|
||||
from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY
|
||||
from .const import (
|
||||
DOMAIN as HUE_DOMAIN,
|
||||
GROUP_TYPE_LIGHT_GROUP,
|
||||
GROUP_TYPE_LIGHT_SOURCE,
|
||||
GROUP_TYPE_LUMINAIRE,
|
||||
GROUP_TYPE_ROOM,
|
||||
REQUEST_REFRESH_DELAY,
|
||||
)
|
||||
from .helpers import remove_devices
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
@@ -74,24 +81,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
||||
"""
|
||||
|
||||
|
||||
def create_light(item_class, coordinator, bridge, is_group, api, item_id):
|
||||
def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id):
|
||||
"""Create the light."""
|
||||
api_item = api[item_id]
|
||||
|
||||
if is_group:
|
||||
supported_features = 0
|
||||
for light_id in api[item_id].lights:
|
||||
for light_id in api_item.lights:
|
||||
if light_id not in bridge.api.lights:
|
||||
continue
|
||||
light = bridge.api.lights[light_id]
|
||||
supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED)
|
||||
supported_features = supported_features or SUPPORT_HUE_EXTENDED
|
||||
else:
|
||||
supported_features = SUPPORT_HUE.get(api[item_id].type, SUPPORT_HUE_EXTENDED)
|
||||
return item_class(coordinator, bridge, is_group, api[item_id], supported_features)
|
||||
supported_features = SUPPORT_HUE.get(api_item.type, SUPPORT_HUE_EXTENDED)
|
||||
return item_class(
|
||||
coordinator, bridge, is_group, api_item, supported_features, rooms
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Hue lights from a config entry."""
|
||||
bridge = hass.data[HUE_DOMAIN][config_entry.entry_id]
|
||||
api_version = tuple(int(v) for v in bridge.api.config.apiversion.split("."))
|
||||
rooms = {}
|
||||
|
||||
allow_groups = bridge.allow_groups
|
||||
supports_groups = api_version >= GROUP_MIN_API_VERSION
|
||||
if allow_groups and not supports_groups:
|
||||
_LOGGER.warning("Please update your Hue bridge to support groups")
|
||||
|
||||
light_coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
@@ -111,27 +129,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
if not light_coordinator.last_update_success:
|
||||
raise PlatformNotReady
|
||||
|
||||
update_lights = partial(
|
||||
async_update_items,
|
||||
bridge,
|
||||
bridge.api.lights,
|
||||
{},
|
||||
async_add_entities,
|
||||
partial(create_light, HueLight, light_coordinator, bridge, False),
|
||||
)
|
||||
|
||||
# We add a listener after fetching the data, so manually trigger listener
|
||||
bridge.reset_jobs.append(light_coordinator.async_add_listener(update_lights))
|
||||
update_lights()
|
||||
|
||||
api_version = tuple(int(v) for v in bridge.api.config.apiversion.split("."))
|
||||
|
||||
allow_groups = bridge.allow_groups
|
||||
if allow_groups and api_version < GROUP_MIN_API_VERSION:
|
||||
_LOGGER.warning("Please update your Hue bridge to support groups")
|
||||
allow_groups = False
|
||||
|
||||
if not allow_groups:
|
||||
if not supports_groups:
|
||||
update_lights_without_group_support = partial(
|
||||
async_update_items,
|
||||
bridge,
|
||||
bridge.api.lights,
|
||||
{},
|
||||
async_add_entities,
|
||||
partial(create_light, HueLight, light_coordinator, bridge, False, rooms),
|
||||
None,
|
||||
)
|
||||
# We add a listener after fetching the data, so manually trigger listener
|
||||
bridge.reset_jobs.append(
|
||||
light_coordinator.async_add_listener(update_lights_without_group_support)
|
||||
)
|
||||
return
|
||||
|
||||
group_coordinator = DataUpdateCoordinator(
|
||||
@@ -145,17 +156,69 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
),
|
||||
)
|
||||
|
||||
update_groups = partial(
|
||||
if allow_groups:
|
||||
update_groups = partial(
|
||||
async_update_items,
|
||||
bridge,
|
||||
bridge.api.groups,
|
||||
{},
|
||||
async_add_entities,
|
||||
partial(create_light, HueLight, group_coordinator, bridge, True, None),
|
||||
None,
|
||||
)
|
||||
|
||||
bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups))
|
||||
|
||||
cancel_update_rooms_listener = None
|
||||
|
||||
@callback
|
||||
def _async_update_rooms():
|
||||
"""Update rooms."""
|
||||
nonlocal cancel_update_rooms_listener
|
||||
rooms.clear()
|
||||
for item_id in bridge.api.groups:
|
||||
group = bridge.api.groups[item_id]
|
||||
if group.type != GROUP_TYPE_ROOM:
|
||||
continue
|
||||
for light_id in group.lights:
|
||||
rooms[light_id] = group.name
|
||||
|
||||
# Once we do a rooms update, we cancel the listener
|
||||
# until the next time lights are added
|
||||
bridge.reset_jobs.remove(cancel_update_rooms_listener)
|
||||
cancel_update_rooms_listener() # pylint: disable=not-callable
|
||||
cancel_update_rooms_listener = None
|
||||
|
||||
@callback
|
||||
def _setup_rooms_listener():
|
||||
nonlocal cancel_update_rooms_listener
|
||||
if cancel_update_rooms_listener is not None:
|
||||
# If there are new lights added before _async_update_rooms
|
||||
# is called we should not add another listener
|
||||
return
|
||||
|
||||
cancel_update_rooms_listener = group_coordinator.async_add_listener(
|
||||
_async_update_rooms
|
||||
)
|
||||
bridge.reset_jobs.append(cancel_update_rooms_listener)
|
||||
|
||||
_setup_rooms_listener()
|
||||
await group_coordinator.async_refresh()
|
||||
|
||||
update_lights_with_group_support = partial(
|
||||
async_update_items,
|
||||
bridge,
|
||||
bridge.api.groups,
|
||||
bridge.api.lights,
|
||||
{},
|
||||
async_add_entities,
|
||||
partial(create_light, HueLight, group_coordinator, bridge, True),
|
||||
partial(create_light, HueLight, light_coordinator, bridge, False, rooms),
|
||||
_setup_rooms_listener,
|
||||
)
|
||||
|
||||
bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups))
|
||||
await group_coordinator.async_refresh()
|
||||
# We add a listener after fetching the data, so manually trigger listener
|
||||
bridge.reset_jobs.append(
|
||||
light_coordinator.async_add_listener(update_lights_with_group_support)
|
||||
)
|
||||
update_lights_with_group_support()
|
||||
|
||||
|
||||
async def async_safe_fetch(bridge, fetch_method):
|
||||
@@ -171,7 +234,9 @@ async def async_safe_fetch(bridge, fetch_method):
|
||||
|
||||
|
||||
@callback
|
||||
def async_update_items(bridge, api, current, async_add_entities, create_item):
|
||||
def async_update_items(
|
||||
bridge, api, current, async_add_entities, create_item, new_items_callback
|
||||
):
|
||||
"""Update items."""
|
||||
new_items = []
|
||||
|
||||
@@ -185,6 +250,9 @@ def async_update_items(bridge, api, current, async_add_entities, create_item):
|
||||
bridge.hass.async_create_task(remove_devices(bridge, api, current))
|
||||
|
||||
if new_items:
|
||||
# This is currently used to setup the listener to update rooms
|
||||
if new_items_callback:
|
||||
new_items_callback()
|
||||
async_add_entities(new_items)
|
||||
|
||||
|
||||
@@ -201,13 +269,14 @@ def hass_to_hue_brightness(value):
|
||||
class HueLight(CoordinatorEntity, LightEntity):
|
||||
"""Representation of a Hue light."""
|
||||
|
||||
def __init__(self, coordinator, bridge, is_group, light, supported_features):
|
||||
def __init__(self, coordinator, bridge, is_group, light, supported_features, rooms):
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator)
|
||||
self.light = light
|
||||
self.bridge = bridge
|
||||
self.is_group = is_group
|
||||
self._supported_features = supported_features
|
||||
self._rooms = rooms
|
||||
|
||||
if is_group:
|
||||
self.is_osram = False
|
||||
@@ -355,10 +424,15 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return the device info."""
|
||||
if self.light.type in ("LightGroup", "Room", "Luminaire", "LightSource"):
|
||||
if self.light.type in (
|
||||
GROUP_TYPE_LIGHT_GROUP,
|
||||
GROUP_TYPE_ROOM,
|
||||
GROUP_TYPE_LUMINAIRE,
|
||||
GROUP_TYPE_LIGHT_SOURCE,
|
||||
):
|
||||
return None
|
||||
|
||||
return {
|
||||
info = {
|
||||
"identifiers": {(HUE_DOMAIN, self.device_id)},
|
||||
"name": self.name,
|
||||
"manufacturer": self.light.manufacturername,
|
||||
@@ -370,6 +444,11 @@ class HueLight(CoordinatorEntity, LightEntity):
|
||||
"via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid),
|
||||
}
|
||||
|
||||
if self.light.id in self._rooms:
|
||||
info["suggested_area"] = self._rooms[self.light.id]
|
||||
|
||||
return info
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the specified or all lights on."""
|
||||
command = {"on": True}
|
||||
|
||||
@@ -44,6 +44,8 @@ class LutronCasetaFan(LutronCasetaDevice, FanEntity):
|
||||
@property
|
||||
def percentage(self) -> str:
|
||||
"""Return the current speed percentage."""
|
||||
if self._device["fan_speed"] is None:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(
|
||||
ORDERED_NAMED_FAN_SPEEDS, self._device["fan_speed"]
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.const import (
|
||||
CONF_DOMAIN,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -66,10 +67,11 @@ TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_AUTOMATION_TYPE): str,
|
||||
vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_PAYLOAD, default=None): vol.Any(None, cv.string),
|
||||
vol.Required(CONF_TYPE): cv.string,
|
||||
vol.Required(CONF_SUBTYPE): cv.string,
|
||||
vol.Required(CONF_TOPIC): cv.string,
|
||||
vol.Required(CONF_TYPE): cv.string,
|
||||
vol.Optional(CONF_VALUE_TEMPLATE, default=None): vol.Any(None, cv.string),
|
||||
},
|
||||
validate_device_has_at_least_one_identifier,
|
||||
)
|
||||
@@ -96,6 +98,8 @@ class TriggerInstance:
|
||||
}
|
||||
if self.trigger.payload:
|
||||
mqtt_config[CONF_PAYLOAD] = self.trigger.payload
|
||||
if self.trigger.value_template:
|
||||
mqtt_config[CONF_VALUE_TEMPLATE] = self.trigger.value_template
|
||||
mqtt_config = mqtt_trigger.TRIGGER_SCHEMA(mqtt_config)
|
||||
|
||||
if self.remove:
|
||||
@@ -121,6 +125,7 @@ class Trigger:
|
||||
subtype: str = attr.ib()
|
||||
topic: str = attr.ib()
|
||||
type: str = attr.ib()
|
||||
value_template: str = attr.ib()
|
||||
trigger_instances: List[TriggerInstance] = attr.ib(factory=list)
|
||||
|
||||
async def add_trigger(self, action, automation_info):
|
||||
@@ -153,6 +158,7 @@ class Trigger:
|
||||
self.qos = config[CONF_QOS]
|
||||
topic_changed = self.topic != config[CONF_TOPIC]
|
||||
self.topic = config[CONF_TOPIC]
|
||||
self.value_template = config[CONF_VALUE_TEMPLATE]
|
||||
|
||||
# Unsubscribe+subscribe if this trigger is in use and topic has changed
|
||||
# If topic is same unsubscribe+subscribe will execute in the wrong order
|
||||
@@ -245,6 +251,7 @@ async def async_setup_trigger(hass, config, config_entry, discovery_data):
|
||||
payload=config[CONF_PAYLOAD],
|
||||
qos=config[CONF_QOS],
|
||||
remove_signal=remove_signal,
|
||||
value_template=config[CONF_VALUE_TEMPLATE],
|
||||
)
|
||||
else:
|
||||
await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger(
|
||||
@@ -325,6 +332,7 @@ async def async_attach_trigger(
|
||||
topic=None,
|
||||
payload=None,
|
||||
qos=None,
|
||||
value_template=None,
|
||||
)
|
||||
return await hass.data[DEVICE_TRIGGERS][discovery_id].add_trigger(
|
||||
action, automation_info
|
||||
|
||||
@@ -48,11 +48,13 @@ async def async_attach_trigger(hass, config, action, automation_info):
|
||||
|
||||
template.attach(hass, wanted_payload)
|
||||
if wanted_payload:
|
||||
wanted_payload = wanted_payload.async_render(variables, limited=True)
|
||||
wanted_payload = wanted_payload.async_render(
|
||||
variables, limited=True, parse_result=False
|
||||
)
|
||||
|
||||
template.attach(hass, topic)
|
||||
if isinstance(topic, template.Template):
|
||||
topic = topic.async_render(variables, limited=True)
|
||||
topic = topic.async_render(variables, limited=True, parse_result=False)
|
||||
topic = mqtt.util.valid_subscribe_topic(topic)
|
||||
|
||||
template.attach(hass, value_template)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ffmpeg", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.10"],
|
||||
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.12"],
|
||||
"codeowners": ["@allenporter"],
|
||||
"quality_scale": "platinum",
|
||||
"dhcp": [{"macaddress":"18B430*"}]
|
||||
|
||||
@@ -8,8 +8,13 @@ from haphilipsjs import ConnectionFailure, PhilipsTV
|
||||
|
||||
from homeassistant.components.automation import AutomationActionType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_VERSION, CONF_HOST
|
||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, Context, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
@@ -30,7 +35,12 @@ async def async_setup(hass: HomeAssistant, config: dict):
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up Philips TV from a config entry."""
|
||||
|
||||
tvapi = PhilipsTV(entry.data[CONF_HOST], entry.data[CONF_API_VERSION])
|
||||
tvapi = PhilipsTV(
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_API_VERSION],
|
||||
username=entry.data.get(CONF_USERNAME),
|
||||
password=entry.data.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi)
|
||||
|
||||
@@ -103,7 +113,9 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
def __init__(self, hass, api: PhilipsTV) -> None:
|
||||
"""Set up the coordinator."""
|
||||
self.api = api
|
||||
self._notify_future: Optional[asyncio.Task] = None
|
||||
|
||||
@callback
|
||||
def _update_listeners():
|
||||
for update_callback in self._listeners:
|
||||
update_callback()
|
||||
@@ -120,9 +132,43 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
),
|
||||
)
|
||||
|
||||
async def _notify_task(self):
|
||||
while self.api.on and self.api.notify_change_supported:
|
||||
if await self.api.notifyChange(130):
|
||||
self.async_set_updated_data(None)
|
||||
|
||||
@callback
|
||||
def _async_notify_stop(self):
|
||||
if self._notify_future:
|
||||
self._notify_future.cancel()
|
||||
self._notify_future = None
|
||||
|
||||
@callback
|
||||
def _async_notify_schedule(self):
|
||||
if (
|
||||
(self._notify_future is None or self._notify_future.done())
|
||||
and self.api.on
|
||||
and self.api.notify_change_supported
|
||||
):
|
||||
self._notify_future = self.hass.loop.create_task(self._notify_task())
|
||||
|
||||
@callback
|
||||
def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None:
|
||||
"""Remove data update."""
|
||||
super().async_remove_listener(update_callback)
|
||||
if not self._listeners:
|
||||
self._async_notify_stop()
|
||||
|
||||
@callback
|
||||
def _async_stop_refresh(self, event: asyncio.Event) -> None:
|
||||
super()._async_stop_refresh(event)
|
||||
self._async_notify_stop()
|
||||
|
||||
@callback
|
||||
async def _async_update_data(self):
|
||||
"""Fetch the latest data from the source."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.api.update)
|
||||
await self.api.update()
|
||||
self._async_notify_schedule()
|
||||
except ConnectionFailure:
|
||||
pass
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
"""Config flow for Philips TV integration."""
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, TypedDict
|
||||
import platform
|
||||
from typing import Any, Dict, Optional, Tuple, TypedDict
|
||||
|
||||
from haphilipsjs import ConnectionFailure, PhilipsTV
|
||||
from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core
|
||||
from homeassistant.const import CONF_API_VERSION, CONF_HOST
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PIN,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
|
||||
from .const import DOMAIN # pylint:disable=unused-import
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from . import LOGGER
|
||||
from .const import ( # pylint:disable=unused-import
|
||||
CONF_SYSTEM,
|
||||
CONST_APP_ID,
|
||||
CONST_APP_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class FlowUserDict(TypedDict):
|
||||
"""Data for user step."""
|
||||
|
||||
host: str
|
||||
api_version: int
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data: FlowUserDict):
|
||||
async def validate_input(
|
||||
hass: core.HomeAssistant, host: str, api_version: int
|
||||
) -> Tuple[Dict, PhilipsTV]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
hub = PhilipsTV(data[CONF_HOST], data[CONF_API_VERSION])
|
||||
hub = PhilipsTV(host, api_version)
|
||||
|
||||
await hass.async_add_executor_job(hub.getSystem)
|
||||
await hub.getSystem()
|
||||
await hub.setTransport(hub.secured_transport)
|
||||
|
||||
if hub.system is None:
|
||||
raise ConnectionFailure
|
||||
if not hub.system:
|
||||
raise ConnectionFailure("System data is empty")
|
||||
|
||||
return hub.system
|
||||
return hub
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -38,7 +50,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
_default = {}
|
||||
_current = {}
|
||||
_hub: PhilipsTV
|
||||
_pair_state: Any
|
||||
|
||||
async def async_step_import(self, conf: Dict[str, Any]):
|
||||
"""Import a configuration from config.yaml."""
|
||||
@@ -53,34 +67,99 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
|
||||
async def _async_create_current(self):
|
||||
|
||||
system = self._current[CONF_SYSTEM]
|
||||
return self.async_create_entry(
|
||||
title=f"{system['name']} ({system['serialnumber']})",
|
||||
data=self._current,
|
||||
)
|
||||
|
||||
async def async_step_pair(self, user_input: Optional[Dict] = None):
|
||||
"""Attempt to pair with device."""
|
||||
assert self._hub
|
||||
|
||||
errors = {}
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
}
|
||||
)
|
||||
|
||||
if not user_input:
|
||||
try:
|
||||
self._pair_state = await self._hub.pairRequest(
|
||||
CONST_APP_ID,
|
||||
CONST_APP_NAME,
|
||||
platform.node(),
|
||||
platform.system(),
|
||||
"native",
|
||||
)
|
||||
except PairingFailure as exc:
|
||||
LOGGER.debug(str(exc))
|
||||
return self.async_abort(
|
||||
reason="pairing_failure",
|
||||
description_placeholders={"error_id": exc.data.get("error_id")},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="pair", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
try:
|
||||
username, password = await self._hub.pairGrant(
|
||||
self._pair_state, user_input[CONF_PIN]
|
||||
)
|
||||
except PairingFailure as exc:
|
||||
LOGGER.debug(str(exc))
|
||||
if exc.data.get("error_id") == "INVALID_PIN":
|
||||
errors[CONF_PIN] = "invalid_pin"
|
||||
return self.async_show_form(
|
||||
step_id="pair", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
return self.async_abort(
|
||||
reason="pairing_failure",
|
||||
description_placeholders={"error_id": exc.data.get("error_id")},
|
||||
)
|
||||
|
||||
self._current[CONF_USERNAME] = username
|
||||
self._current[CONF_PASSWORD] = password
|
||||
return await self._async_create_current()
|
||||
|
||||
async def async_step_user(self, user_input: Optional[FlowUserDict] = None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input:
|
||||
self._default = user_input
|
||||
self._current = user_input
|
||||
try:
|
||||
system = await validate_input(self.hass, user_input)
|
||||
except ConnectionFailure:
|
||||
hub = await validate_input(
|
||||
self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION]
|
||||
)
|
||||
except ConnectionFailure as exc:
|
||||
LOGGER.error(str(exc))
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(system["serialnumber"])
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
|
||||
data = {**user_input, "system": system}
|
||||
await self.async_set_unique_id(hub.system["serialnumber"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{system['name']} ({system['serialnumber']})", data=data
|
||||
)
|
||||
self._current[CONF_SYSTEM] = hub.system
|
||||
self._current[CONF_API_VERSION] = hub.api_version
|
||||
self._hub = hub
|
||||
|
||||
if hub.pairing_type == "digest_auth_pairing":
|
||||
return await self.async_step_pair()
|
||||
return await self._async_create_current()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self._default.get(CONF_HOST)): str,
|
||||
vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str,
|
||||
vol.Required(
|
||||
CONF_API_VERSION, default=self._default.get(CONF_API_VERSION)
|
||||
): vol.In([1, 6]),
|
||||
CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1)
|
||||
): vol.In([1, 5, 6]),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
|
||||
DOMAIN = "philips_js"
|
||||
CONF_SYSTEM = "system"
|
||||
|
||||
CONST_APP_ID = "homeassistant.io"
|
||||
CONST_APP_NAME = "Home Assistant"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Philips TV",
|
||||
"documentation": "https://www.home-assistant.io/integrations/philips_js",
|
||||
"requirements": [
|
||||
"ha-philipsjs==0.1.0"
|
||||
"ha-philipsjs==2.3.0"
|
||||
],
|
||||
"codeowners": [
|
||||
"@elupus"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Media Player component to integrate TVs exposing the Joint Space API."""
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from haphilipsjs import ConnectionFailure
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -11,15 +12,21 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
)
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_CLASS_APP,
|
||||
MEDIA_CLASS_CHANNEL,
|
||||
MEDIA_CLASS_DIRECTORY,
|
||||
MEDIA_TYPE_APP,
|
||||
MEDIA_TYPE_APPS,
|
||||
MEDIA_TYPE_CHANNEL,
|
||||
MEDIA_TYPE_CHANNELS,
|
||||
SUPPORT_BROWSE_MEDIA,
|
||||
SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY,
|
||||
SUPPORT_PLAY_MEDIA,
|
||||
SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_SELECT_SOURCE,
|
||||
SUPPORT_STOP,
|
||||
SUPPORT_TURN_OFF,
|
||||
SUPPORT_TURN_ON,
|
||||
SUPPORT_VOLUME_MUTE,
|
||||
@@ -27,7 +34,6 @@ from homeassistant.components.media_player.const import (
|
||||
SUPPORT_VOLUME_STEP,
|
||||
)
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.components.philips_js import PhilipsTVDataUpdateCoordinator
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_HOST,
|
||||
@@ -40,7 +46,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import LOGGER as _LOGGER
|
||||
from . import LOGGER as _LOGGER, PhilipsTVDataUpdateCoordinator
|
||||
from .const import CONF_SYSTEM, DOMAIN
|
||||
|
||||
SUPPORT_PHILIPS_JS = (
|
||||
@@ -53,16 +59,15 @@ SUPPORT_PHILIPS_JS = (
|
||||
| SUPPORT_PREVIOUS_TRACK
|
||||
| SUPPORT_PLAY_MEDIA
|
||||
| SUPPORT_BROWSE_MEDIA
|
||||
| SUPPORT_PLAY
|
||||
| SUPPORT_PAUSE
|
||||
| SUPPORT_STOP
|
||||
)
|
||||
|
||||
CONF_ON_ACTION = "turn_on_action"
|
||||
|
||||
DEFAULT_API_VERSION = 1
|
||||
|
||||
PREFIX_SEPARATOR = ": "
|
||||
PREFIX_SOURCE = "Input"
|
||||
PREFIX_CHANNEL = "Channel"
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_HOST),
|
||||
cv.deprecated(CONF_NAME),
|
||||
@@ -131,12 +136,19 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
self._supports = SUPPORT_PHILIPS_JS
|
||||
self._system = system
|
||||
self._unique_id = unique_id
|
||||
self._state = STATE_OFF
|
||||
self._media_content_type: Optional[str] = None
|
||||
self._media_content_id: Optional[str] = None
|
||||
self._media_title: Optional[str] = None
|
||||
self._media_channel: Optional[str] = None
|
||||
|
||||
super().__init__(coordinator)
|
||||
self._update_from_coordinator()
|
||||
|
||||
def _update_soon(self):
|
||||
async def _async_update_soon(self):
|
||||
"""Reschedule update task."""
|
||||
self.hass.add_job(self.coordinator.async_request_refresh)
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -147,7 +159,9 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
supports = self._supports
|
||||
if self._coordinator.turn_on:
|
||||
if self._coordinator.turn_on or (
|
||||
self._tv.on and self._tv.powerstate is not None
|
||||
):
|
||||
supports |= SUPPORT_TURN_ON
|
||||
return supports
|
||||
|
||||
@@ -155,7 +169,8 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
def state(self):
|
||||
"""Get the device state. An exception means OFF state."""
|
||||
if self._tv.on:
|
||||
return STATE_ON
|
||||
if self._tv.powerstate == "On" or self._tv.powerstate is None:
|
||||
return STATE_ON
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
@@ -168,22 +183,12 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
"""List of available input sources."""
|
||||
return list(self._sources.values())
|
||||
|
||||
def select_source(self, source):
|
||||
async def async_select_source(self, source):
|
||||
"""Set the input source."""
|
||||
data = source.split(PREFIX_SEPARATOR, 1)
|
||||
if data[0] == PREFIX_SOURCE: # Legacy way to set source
|
||||
source_id = _inverted(self._sources).get(data[1])
|
||||
if source_id:
|
||||
self._tv.setSource(source_id)
|
||||
elif data[0] == PREFIX_CHANNEL: # Legacy way to set channel
|
||||
channel_id = _inverted(self._channels).get(data[1])
|
||||
if channel_id:
|
||||
self._tv.setChannel(channel_id)
|
||||
else:
|
||||
source_id = _inverted(self._sources).get(source)
|
||||
if source_id:
|
||||
self._tv.setSource(source_id)
|
||||
self._update_soon()
|
||||
source_id = _inverted(self._sources).get(source)
|
||||
if source_id:
|
||||
await self._tv.setSource(source_id)
|
||||
await self._async_update_soon()
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
@@ -197,78 +202,118 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_turn_on(self):
|
||||
"""Turn on the device."""
|
||||
await self._coordinator.turn_on.async_run(self.hass, self._context)
|
||||
if self._tv.on and self._tv.powerstate:
|
||||
await self._tv.setPowerState("On")
|
||||
self._state = STATE_ON
|
||||
else:
|
||||
await self._coordinator.turn_on.async_run(self.hass, self._context)
|
||||
await self._async_update_soon()
|
||||
|
||||
def turn_off(self):
|
||||
async def async_turn_off(self):
|
||||
"""Turn off the device."""
|
||||
self._tv.sendKey("Standby")
|
||||
self._tv.on = False
|
||||
self._update_soon()
|
||||
await self._tv.sendKey("Standby")
|
||||
self._state = STATE_OFF
|
||||
await self._async_update_soon()
|
||||
|
||||
def volume_up(self):
|
||||
async def async_volume_up(self):
|
||||
"""Send volume up command."""
|
||||
self._tv.sendKey("VolumeUp")
|
||||
self._update_soon()
|
||||
await self._tv.sendKey("VolumeUp")
|
||||
await self._async_update_soon()
|
||||
|
||||
def volume_down(self):
|
||||
async def async_volume_down(self):
|
||||
"""Send volume down command."""
|
||||
self._tv.sendKey("VolumeDown")
|
||||
self._update_soon()
|
||||
await self._tv.sendKey("VolumeDown")
|
||||
await self._async_update_soon()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
async def async_mute_volume(self, mute):
|
||||
"""Send mute command."""
|
||||
self._tv.setVolume(None, mute)
|
||||
self._update_soon()
|
||||
if self._tv.muted != mute:
|
||||
await self._tv.sendKey("Mute")
|
||||
await self._async_update_soon()
|
||||
else:
|
||||
_LOGGER.debug("Ignoring request when already in expected state")
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
async def async_set_volume_level(self, volume):
|
||||
"""Set volume level, range 0..1."""
|
||||
self._tv.setVolume(volume, self._tv.muted)
|
||||
self._update_soon()
|
||||
await self._tv.setVolume(volume, self._tv.muted)
|
||||
await self._async_update_soon()
|
||||
|
||||
def media_previous_track(self):
|
||||
async def async_media_previous_track(self):
|
||||
"""Send rewind command."""
|
||||
self._tv.sendKey("Previous")
|
||||
self._update_soon()
|
||||
await self._tv.sendKey("Previous")
|
||||
await self._async_update_soon()
|
||||
|
||||
def media_next_track(self):
|
||||
async def async_media_next_track(self):
|
||||
"""Send fast forward command."""
|
||||
self._tv.sendKey("Next")
|
||||
self._update_soon()
|
||||
await self._tv.sendKey("Next")
|
||||
await self._async_update_soon()
|
||||
|
||||
async def async_media_play_pause(self):
|
||||
"""Send pause command to media player."""
|
||||
if self._tv.quirk_playpause_spacebar:
|
||||
await self._tv.sendUnicode(" ")
|
||||
else:
|
||||
await self._tv.sendKey("PlayPause")
|
||||
await self._async_update_soon()
|
||||
|
||||
async def async_media_play(self):
|
||||
"""Send pause command to media player."""
|
||||
await self._tv.sendKey("Play")
|
||||
await self._async_update_soon()
|
||||
|
||||
async def async_media_pause(self):
|
||||
"""Send play command to media player."""
|
||||
await self._tv.sendKey("Pause")
|
||||
await self._async_update_soon()
|
||||
|
||||
async def async_media_stop(self):
|
||||
"""Send play command to media player."""
|
||||
await self._tv.sendKey("Stop")
|
||||
await self._async_update_soon()
|
||||
|
||||
@property
|
||||
def media_channel(self):
|
||||
"""Get current channel if it's a channel."""
|
||||
if self.media_content_type == MEDIA_TYPE_CHANNEL:
|
||||
return self._channels.get(self._tv.channel_id)
|
||||
return None
|
||||
return self._media_channel
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Title of current playing media."""
|
||||
if self.media_content_type == MEDIA_TYPE_CHANNEL:
|
||||
return self._channels.get(self._tv.channel_id)
|
||||
return self._sources.get(self._tv.source_id)
|
||||
return self._media_title
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Return content type of playing media."""
|
||||
if self._tv.source_id == "tv" or self._tv.source_id == "11":
|
||||
return MEDIA_TYPE_CHANNEL
|
||||
if self._tv.source_id is None and self._tv.channels:
|
||||
return MEDIA_TYPE_CHANNEL
|
||||
return None
|
||||
return self._media_content_type
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
"""Content type of current playing media."""
|
||||
if self.media_content_type == MEDIA_TYPE_CHANNEL:
|
||||
return self._channels.get(self._tv.channel_id)
|
||||
return self._media_content_id
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
if self._media_content_id and self._media_content_type in (
|
||||
MEDIA_CLASS_APP,
|
||||
MEDIA_CLASS_CHANNEL,
|
||||
):
|
||||
return self.get_browse_image_url(
|
||||
self._media_content_type, self._media_content_id, media_image_id=None
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {"channel_list": list(self._channels.values())}
|
||||
def app_id(self):
|
||||
"""ID of the current running app."""
|
||||
return self._tv.application_id
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
"""Name of the current running app."""
|
||||
app = self._tv.applications.get(self._tv.application_id)
|
||||
if app:
|
||||
return app.get("label")
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
@@ -293,57 +338,243 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
||||
"sw_version": self._system.get("softwareversion"),
|
||||
}
|
||||
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
async def async_play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play a piece of media."""
|
||||
_LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id)
|
||||
|
||||
if media_type == MEDIA_TYPE_CHANNEL:
|
||||
channel_id = _inverted(self._channels).get(media_id)
|
||||
list_id, _, channel_id = media_id.partition("/")
|
||||
if channel_id:
|
||||
self._tv.setChannel(channel_id)
|
||||
self._update_soon()
|
||||
await self._tv.setChannel(channel_id, list_id)
|
||||
await self._async_update_soon()
|
||||
else:
|
||||
_LOGGER.error("Unable to find channel <%s>", media_id)
|
||||
elif media_type == MEDIA_TYPE_APP:
|
||||
app = self._tv.applications.get(media_id)
|
||||
if app:
|
||||
await self._tv.setApplication(app["intent"])
|
||||
await self._async_update_soon()
|
||||
else:
|
||||
_LOGGER.error("Unable to find application <%s>", media_id)
|
||||
else:
|
||||
_LOGGER.error("Unsupported media type <%s>", media_type)
|
||||
|
||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||
"""Implement the websocket media browsing helper."""
|
||||
if media_content_id not in (None, ""):
|
||||
raise BrowseError(
|
||||
f"Media not found: {media_content_type} / {media_content_id}"
|
||||
)
|
||||
async def async_browse_media_channels(self, expanded):
|
||||
"""Return channel media objects."""
|
||||
if expanded:
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=channel.get("name", f"Channel: {channel_id}"),
|
||||
media_class=MEDIA_CLASS_CHANNEL,
|
||||
media_content_id=f"alltv/{channel_id}",
|
||||
media_content_type=MEDIA_TYPE_CHANNEL,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=self.get_browse_image_url(
|
||||
MEDIA_TYPE_APP, channel_id, media_image_id=None
|
||||
),
|
||||
)
|
||||
for channel_id, channel in self._tv.channels.items()
|
||||
]
|
||||
else:
|
||||
children = None
|
||||
|
||||
return BrowseMedia(
|
||||
title="Channels",
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_id="",
|
||||
media_content_id="channels",
|
||||
media_content_type=MEDIA_TYPE_CHANNELS,
|
||||
children_media_class=MEDIA_TYPE_CHANNEL,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
)
|
||||
|
||||
async def async_browse_media_favorites(self, list_id, expanded):
|
||||
"""Return channel media objects."""
|
||||
if expanded:
|
||||
favorites = await self._tv.getFavoriteList(list_id)
|
||||
if favorites:
|
||||
|
||||
def get_name(channel):
|
||||
channel_data = self._tv.channels.get(str(channel["ccid"]))
|
||||
if channel_data:
|
||||
return channel_data["name"]
|
||||
return f"Channel: {channel['ccid']}"
|
||||
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=get_name(channel),
|
||||
media_class=MEDIA_CLASS_CHANNEL,
|
||||
media_content_id=f"{list_id}/{channel['ccid']}",
|
||||
media_content_type=MEDIA_TYPE_CHANNEL,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=self.get_browse_image_url(
|
||||
MEDIA_TYPE_APP, channel, media_image_id=None
|
||||
),
|
||||
)
|
||||
for channel in favorites
|
||||
]
|
||||
else:
|
||||
children = None
|
||||
else:
|
||||
children = None
|
||||
|
||||
favorite = self._tv.favorite_lists[list_id]
|
||||
return BrowseMedia(
|
||||
title=favorite.get("name", f"Favorites {list_id}"),
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_id=f"favorites/{list_id}",
|
||||
media_content_type=MEDIA_TYPE_CHANNELS,
|
||||
children_media_class=MEDIA_TYPE_CHANNEL,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
)
|
||||
|
||||
async def async_browse_media_applications(self, expanded):
|
||||
"""Return application media objects."""
|
||||
if expanded:
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=application["label"],
|
||||
media_class=MEDIA_CLASS_APP,
|
||||
media_content_id=application_id,
|
||||
media_content_type=MEDIA_TYPE_APP,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=self.get_browse_image_url(
|
||||
MEDIA_TYPE_APP, application_id, media_image_id=None
|
||||
),
|
||||
)
|
||||
for application_id, application in self._tv.applications.items()
|
||||
]
|
||||
else:
|
||||
children = None
|
||||
|
||||
return BrowseMedia(
|
||||
title="Applications",
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_id="applications",
|
||||
media_content_type=MEDIA_TYPE_APPS,
|
||||
children_media_class=MEDIA_TYPE_APP,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
)
|
||||
|
||||
async def async_browse_media_favorite_lists(self, expanded):
|
||||
"""Return favorite media objects."""
|
||||
if self._tv.favorite_lists and expanded:
|
||||
children = [
|
||||
await self.async_browse_media_favorites(list_id, False)
|
||||
for list_id in self._tv.favorite_lists
|
||||
]
|
||||
else:
|
||||
children = None
|
||||
|
||||
return BrowseMedia(
|
||||
title="Favorites",
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_id="favorite_lists",
|
||||
media_content_type=MEDIA_TYPE_CHANNELS,
|
||||
children_media_class=MEDIA_TYPE_CHANNEL,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
)
|
||||
|
||||
async def async_browse_media_root(self):
|
||||
"""Return root media objects."""
|
||||
|
||||
return BrowseMedia(
|
||||
title="Library",
|
||||
media_class=MEDIA_CLASS_DIRECTORY,
|
||||
media_content_id="",
|
||||
media_content_type="",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=[
|
||||
BrowseMedia(
|
||||
title=channel,
|
||||
media_class=MEDIA_CLASS_CHANNEL,
|
||||
media_content_id=channel,
|
||||
media_content_type=MEDIA_TYPE_CHANNEL,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
for channel in self._channels.values()
|
||||
await self.async_browse_media_channels(False),
|
||||
await self.async_browse_media_applications(False),
|
||||
await self.async_browse_media_favorite_lists(False),
|
||||
],
|
||||
)
|
||||
|
||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||
"""Implement the websocket media browsing helper."""
|
||||
if not self._tv.on:
|
||||
raise BrowseError("Can't browse when tv is turned off")
|
||||
|
||||
if media_content_id in (None, ""):
|
||||
return await self.async_browse_media_root()
|
||||
path = media_content_id.partition("/")
|
||||
if path[0] == "channels":
|
||||
return await self.async_browse_media_channels(True)
|
||||
if path[0] == "applications":
|
||||
return await self.async_browse_media_applications(True)
|
||||
if path[0] == "favorite_lists":
|
||||
return await self.async_browse_media_favorite_lists(True)
|
||||
if path[0] == "favorites":
|
||||
return await self.async_browse_media_favorites(path[2], True)
|
||||
|
||||
raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
|
||||
|
||||
async def async_get_browse_image(
|
||||
self, media_content_type, media_content_id, media_image_id=None
|
||||
):
|
||||
"""Serve album art. Returns (content, content_type)."""
|
||||
try:
|
||||
if media_content_type == MEDIA_TYPE_APP and media_content_id:
|
||||
return await self._tv.getApplicationIcon(media_content_id)
|
||||
if media_content_type == MEDIA_TYPE_CHANNEL and media_content_id:
|
||||
return await self._tv.getChannelLogo(media_content_id)
|
||||
except ConnectionFailure:
|
||||
_LOGGER.warning("Failed to fetch image")
|
||||
return None, None
|
||||
|
||||
async def async_get_media_image(self):
|
||||
"""Serve album art. Returns (content, content_type)."""
|
||||
return await self.async_get_browse_image(
|
||||
self.media_content_type, self.media_content_id, None
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_coordinator(self):
|
||||
|
||||
if self._tv.on:
|
||||
if self._tv.powerstate in ("Standby", "StandbyKeep"):
|
||||
self._state = STATE_OFF
|
||||
else:
|
||||
self._state = STATE_ON
|
||||
else:
|
||||
self._state = STATE_OFF
|
||||
|
||||
self._sources = {
|
||||
srcid: source.get("name") or f"Source {srcid}"
|
||||
for srcid, source in (self._tv.sources or {}).items()
|
||||
}
|
||||
|
||||
self._channels = {
|
||||
chid: channel.get("name") or f"Channel {chid}"
|
||||
for chid, channel in (self._tv.channels or {}).items()
|
||||
}
|
||||
if self._tv.channel_active:
|
||||
self._media_content_type = MEDIA_TYPE_CHANNEL
|
||||
self._media_content_id = f"all/{self._tv.channel_id}"
|
||||
self._media_title = self._tv.channels.get(self._tv.channel_id, {}).get(
|
||||
"name"
|
||||
)
|
||||
self._media_channel = self._media_title
|
||||
elif self._tv.application_id:
|
||||
self._media_content_type = MEDIA_TYPE_APP
|
||||
self._media_content_id = self._tv.application_id
|
||||
self._media_title = self._tv.applications.get(
|
||||
self._tv.application_id, {}
|
||||
).get("label")
|
||||
self._media_channel = None
|
||||
else:
|
||||
self._media_content_type = None
|
||||
self._media_content_id = None
|
||||
self._media_title = self._sources.get(self._tv.source_id)
|
||||
self._media_channel = None
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"pairing_failure": "Unable to pair: {error_id}",
|
||||
"invalid_pin": "Invalid PIN"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"unknown": "Unexpected error"
|
||||
"unknown": "Unexpected error",
|
||||
"pairing_failure": "Unable to pair: {error_id}",
|
||||
"invalid_pin": "Invalid PIN"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -74,5 +74,5 @@ INPUTS_EVENTS_SUBTYPES = {
|
||||
|
||||
# Kelvin value for colorTemp
|
||||
KELVIN_MAX_VALUE = 6500
|
||||
KELVIN_MIN_VALUE = 2700
|
||||
KELVIN_MIN_VALUE_SHBLB_1 = 3000
|
||||
KELVIN_MIN_VALUE_WHITE = 2700
|
||||
KELVIN_MIN_VALUE_COLOR = 3000
|
||||
|
||||
@@ -28,20 +28,13 @@ from .const import (
|
||||
DATA_CONFIG_ENTRY,
|
||||
DOMAIN,
|
||||
KELVIN_MAX_VALUE,
|
||||
KELVIN_MIN_VALUE,
|
||||
KELVIN_MIN_VALUE_SHBLB_1,
|
||||
KELVIN_MIN_VALUE_COLOR,
|
||||
KELVIN_MIN_VALUE_WHITE,
|
||||
)
|
||||
from .entity import ShellyBlockEntity
|
||||
from .utils import async_remove_shelly_entity
|
||||
|
||||
|
||||
def min_kelvin(model: str):
|
||||
"""Kelvin (min) for colorTemp."""
|
||||
if model in ["SHBLB-1"]:
|
||||
return KELVIN_MIN_VALUE_SHBLB_1
|
||||
return KELVIN_MIN_VALUE
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up lights for device."""
|
||||
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP]
|
||||
@@ -76,6 +69,8 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
|
||||
self.control_result = None
|
||||
self.mode_result = None
|
||||
self._supported_features = 0
|
||||
self._min_kelvin = KELVIN_MIN_VALUE_WHITE
|
||||
self._max_kelvin = KELVIN_MAX_VALUE
|
||||
|
||||
if hasattr(block, "brightness") or hasattr(block, "gain"):
|
||||
self._supported_features |= SUPPORT_BRIGHTNESS
|
||||
@@ -85,6 +80,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
|
||||
self._supported_features |= SUPPORT_WHITE_VALUE
|
||||
if hasattr(block, "red") and hasattr(block, "green") and hasattr(block, "blue"):
|
||||
self._supported_features |= SUPPORT_COLOR
|
||||
self._min_kelvin = KELVIN_MIN_VALUE_COLOR
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
@@ -168,22 +164,19 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
|
||||
else:
|
||||
color_temp = self.block.colorTemp
|
||||
|
||||
# If you set DUO to max mireds in Shelly app, 2700K,
|
||||
# It reports 0 temp
|
||||
if color_temp == 0:
|
||||
return min_kelvin(self.wrapper.model)
|
||||
color_temp = min(self._max_kelvin, max(self._min_kelvin, color_temp))
|
||||
|
||||
return int(color_temperature_kelvin_to_mired(color_temp))
|
||||
|
||||
@property
|
||||
def min_mireds(self) -> int:
|
||||
"""Return the coldest color_temp that this light supports."""
|
||||
return int(color_temperature_kelvin_to_mired(KELVIN_MAX_VALUE))
|
||||
return int(color_temperature_kelvin_to_mired(self._max_kelvin))
|
||||
|
||||
@property
|
||||
def max_mireds(self) -> int:
|
||||
"""Return the warmest color_temp that this light supports."""
|
||||
return int(color_temperature_kelvin_to_mired(min_kelvin(self.wrapper.model)))
|
||||
return int(color_temperature_kelvin_to_mired(self._min_kelvin))
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Turn on light."""
|
||||
@@ -192,6 +185,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
set_mode = None
|
||||
params = {"turn": "on"}
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
tmp_brightness = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100)
|
||||
@@ -201,27 +195,26 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
|
||||
params["brightness"] = tmp_brightness
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
color_temp = color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
|
||||
color_temp = min(
|
||||
KELVIN_MAX_VALUE, max(min_kelvin(self.wrapper.model), color_temp)
|
||||
)
|
||||
color_temp = min(self._max_kelvin, max(self._min_kelvin, color_temp))
|
||||
# Color temperature change - used only in white mode, switch device mode to white
|
||||
if self.mode == "color":
|
||||
self.mode_result = await self.wrapper.device.switch_light_mode("white")
|
||||
params["red"] = params["green"] = params["blue"] = 255
|
||||
set_mode = "white"
|
||||
params["red"] = params["green"] = params["blue"] = 255
|
||||
params["temp"] = int(color_temp)
|
||||
elif ATTR_HS_COLOR in kwargs:
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
red, green, blue = color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
|
||||
# Color channels change - used only in color mode, switch device mode to color
|
||||
if self.mode == "white":
|
||||
self.mode_result = await self.wrapper.device.switch_light_mode("color")
|
||||
set_mode = "color"
|
||||
params["red"] = red
|
||||
params["green"] = green
|
||||
params["blue"] = blue
|
||||
elif ATTR_WHITE_VALUE in kwargs:
|
||||
if ATTR_WHITE_VALUE in kwargs:
|
||||
# White channel change - used only in color mode, switch device mode device to color
|
||||
if self.mode == "white":
|
||||
self.mode_result = await self.wrapper.device.switch_light_mode("color")
|
||||
set_mode = "color"
|
||||
params["white"] = int(kwargs[ATTR_WHITE_VALUE])
|
||||
|
||||
if set_mode and self.mode != set_mode:
|
||||
self.mode_result = await self.wrapper.device.switch_light_mode(set_mode)
|
||||
|
||||
self.control_result = await self.block.set_state(**params)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Shelly",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||
"requirements": ["aioshelly==0.6.0"],
|
||||
"requirements": ["aioshelly==0.6.1"],
|
||||
"zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }],
|
||||
"codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"]
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ def get_device_channel_name(
|
||||
def is_momentary_input(settings: dict, block: aioshelly.Block) -> bool:
|
||||
"""Return true if input button settings is set to a momentary type."""
|
||||
# Shelly Button type is fixed to momentary and no btn_type
|
||||
if settings["device"]["type"] == "SHBTN-1":
|
||||
if settings["device"]["type"] in ("SHBTN-1", "SHBTN-2"):
|
||||
return True
|
||||
|
||||
button = settings.get("relays") or settings.get("lights") or settings.get("inputs")
|
||||
@@ -158,7 +158,7 @@ def get_input_triggers(
|
||||
else:
|
||||
subtype = f"button{int(block.channel)+1}"
|
||||
|
||||
if device.settings["device"]["type"] == "SHBTN-1":
|
||||
if device.settings["device"]["type"] in ("SHBTN-1", "SHBTN-2"):
|
||||
trigger_types = SHBTN_1_INPUTS_EVENTS_TYPES
|
||||
elif device.settings["device"]["type"] == "SHIX3-1":
|
||||
trigger_types = SHIX3_1_INPUTS_EVENTS_TYPES
|
||||
|
||||
@@ -27,7 +27,7 @@ UPDATER_URL = "https://updater.home-assistant.io/"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: {
|
||||
vol.Optional(DOMAIN, default={}): {
|
||||
vol.Optional(CONF_REPORTING, default=True): cv.boolean,
|
||||
vol.Optional(CONF_COMPONENT_REPORTING, default=False): cv.boolean,
|
||||
}
|
||||
@@ -56,13 +56,13 @@ async def async_setup(hass, config):
|
||||
# This component only makes sense in release versions
|
||||
_LOGGER.info("Running on 'dev', only analytics will be submitted")
|
||||
|
||||
conf = config.get(DOMAIN, {})
|
||||
if conf.get(CONF_REPORTING):
|
||||
conf = config[DOMAIN]
|
||||
if conf[CONF_REPORTING]:
|
||||
huuid = await hass.helpers.instance_id.async_get()
|
||||
else:
|
||||
huuid = None
|
||||
|
||||
include_components = conf.get(CONF_COMPONENT_REPORTING)
|
||||
include_components = conf[CONF_COMPONENT_REPORTING]
|
||||
|
||||
async def check_new_version() -> Updater:
|
||||
"""Check if a new version is available and report if one is."""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"bellows==0.21.0",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.5",
|
||||
"zha-quirks==0.0.53",
|
||||
"zha-quirks==0.0.54",
|
||||
"zigpy-cc==0.5.2",
|
||||
"zigpy-deconz==0.11.1",
|
||||
"zigpy==0.32.0",
|
||||
|
||||
@@ -95,10 +95,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
ent_reg.async_update_entity(
|
||||
entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
except ValueError:
|
||||
LOGGER.debug(
|
||||
(
|
||||
"Entity %s can't be migrated because the unique ID is taken. "
|
||||
"Cleaning it up since it is likely no longer valid."
|
||||
),
|
||||
entity_id,
|
||||
)
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
@callback
|
||||
def async_on_node_ready(node: ZwaveNode) -> None:
|
||||
|
||||
@@ -75,6 +75,8 @@ class ZWaveDiscoverySchema:
|
||||
device_class_specific: Optional[Set[Union[str, int]]] = None
|
||||
# [optional] additional values that ALL need to be present on the node for this scheme to pass
|
||||
required_values: Optional[List[ZWaveValueDiscoverySchema]] = None
|
||||
# [optional] additional values that MAY NOT be present on the node for this scheme to pass
|
||||
absent_values: Optional[List[ZWaveValueDiscoverySchema]] = None
|
||||
# [optional] bool to specify if this primary value may be discovered by multiple platforms
|
||||
allow_multi: bool = False
|
||||
|
||||
@@ -186,36 +188,30 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
),
|
||||
# climate
|
||||
# thermostats supporting mode (and optional setpoint)
|
||||
ZWaveDiscoverySchema(
|
||||
platform="climate",
|
||||
device_class_generic={"Thermostat"},
|
||||
device_class_specific={
|
||||
"Setback Thermostat",
|
||||
"Thermostat General",
|
||||
"Thermostat General V2",
|
||||
"General Thermostat",
|
||||
"General Thermostat V2",
|
||||
},
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.THERMOSTAT_MODE},
|
||||
property={"mode"},
|
||||
type={"number"},
|
||||
),
|
||||
),
|
||||
# climate
|
||||
# setpoint thermostats
|
||||
# thermostats supporting setpoint only (and thus not mode)
|
||||
ZWaveDiscoverySchema(
|
||||
platform="climate",
|
||||
device_class_generic={"Thermostat"},
|
||||
device_class_specific={
|
||||
"Setpoint Thermostat",
|
||||
"Unused",
|
||||
},
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.THERMOSTAT_SETPOINT},
|
||||
property={"setpoint"},
|
||||
type={"number"},
|
||||
),
|
||||
absent_values=[ # mode must not be present to prevent dupes
|
||||
ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.THERMOSTAT_MODE},
|
||||
property={"mode"},
|
||||
type={"number"},
|
||||
),
|
||||
],
|
||||
),
|
||||
# binary sensors
|
||||
ZWaveDiscoverySchema(
|
||||
@@ -436,6 +432,13 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None
|
||||
for val_scheme in schema.required_values
|
||||
):
|
||||
continue
|
||||
# check for values that may not be present
|
||||
if schema.absent_values is not None:
|
||||
if any(
|
||||
any(check_value(val, val_scheme) for val in node.values.values())
|
||||
for val_scheme in schema.absent_values
|
||||
):
|
||||
continue
|
||||
# all checks passed, this value belongs to an entity
|
||||
yield ZwaveDiscoveryInfo(
|
||||
node=value.node,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Z-Wave JS",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
|
||||
"requirements": ["zwave-js-server-python==0.19.0"],
|
||||
"requirements": ["zwave-js-server-python==0.20.0"],
|
||||
"codeowners": ["@home-assistant/z-wave"],
|
||||
"dependencies": ["http", "websocket_api"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Constants used by Home Assistant components."""
|
||||
MAJOR_VERSION = 2021
|
||||
MINOR_VERSION = 3
|
||||
PATCH_VERSION = "0b1"
|
||||
PATCH_VERSION = "0b4"
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER = (3, 8, 0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
PyJWT==1.7.1
|
||||
PyNaCl==1.3.0
|
||||
aiohttp==3.7.3
|
||||
aiohttp==3.7.4
|
||||
aiohttp_cors==0.7.0
|
||||
astral==1.10.1
|
||||
async-upnp-client==0.14.13
|
||||
@@ -15,7 +15,7 @@ defusedxml==0.6.0
|
||||
distro==1.5.0
|
||||
emoji==1.2.0
|
||||
hass-nabucasa==0.41.0
|
||||
home-assistant-frontend==20210224.0
|
||||
home-assistant-frontend==20210226.0
|
||||
httpx==0.16.1
|
||||
jinja2>=2.11.3
|
||||
netdisco==2.8.2
|
||||
|
||||
@@ -19,7 +19,7 @@ def ordered_list_item_to_percentage(ordered_list: List[str], item: str) -> int:
|
||||
|
||||
"""
|
||||
if item not in ordered_list:
|
||||
raise ValueError
|
||||
raise ValueError(f'The item "{item}"" is not in "{ordered_list}"')
|
||||
|
||||
list_len = len(ordered_list)
|
||||
list_position = ordered_list.index(item) + 1
|
||||
@@ -42,7 +42,7 @@ def percentage_to_ordered_list_item(ordered_list: List[str], percentage: int) ->
|
||||
"""
|
||||
list_len = len(ordered_list)
|
||||
if not list_len:
|
||||
raise ValueError
|
||||
raise ValueError("The ordered list is empty")
|
||||
|
||||
for offset, speed in enumerate(ordered_list):
|
||||
list_position = offset + 1
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
ARG BUILD_VERSION
|
||||
FROM agners/amd64-homeassistant:$BUILD_VERSION
|
||||
|
||||
RUN apk --no-cache add \
|
||||
libva-intel-driver \
|
||||
usbutils
|
||||
|
||||
##
|
||||
# Build libcec for HDMI-CEC
|
||||
ARG LIBCEC_VERSION=6.0.2
|
||||
RUN apk add --no-cache \
|
||||
eudev-libs \
|
||||
p8-platform \
|
||||
&& apk add --no-cache --virtual .build-dependencies \
|
||||
build-base \
|
||||
cmake \
|
||||
eudev-dev \
|
||||
swig \
|
||||
p8-platform-dev \
|
||||
linux-headers \
|
||||
&& git clone --depth 1 -b libcec-${LIBCEC_VERSION} https://github.com/Pulse-Eight/libcec /usr/src/libcec \
|
||||
&& cd /usr/src/libcec \
|
||||
&& mkdir -p /usr/src/libcec/build \
|
||||
&& cd /usr/src/libcec/build \
|
||||
&& cmake -DCMAKE_INSTALL_PREFIX:PATH=/usr/local \
|
||||
-DPYTHON_LIBRARY="/usr/local/lib/libpython3.8.so" \
|
||||
-DPYTHON_INCLUDE_DIR="/usr/local/include/python3.8" \
|
||||
-DHAVE_LINUX_API=1 \
|
||||
.. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& echo "cec" > "/usr/local/lib/python3.8/site-packages/cec.pth" \
|
||||
&& apk del .build-dependencies \
|
||||
&& rm -rf /usr/src/libcec*
|
||||
@@ -1,6 +1,9 @@
|
||||
ARG BUILD_VERSION
|
||||
FROM homeassistant/amd64-homeassistant:$BUILD_VERSION
|
||||
|
||||
# NOTE: intel-nuc will be replaced by generic-x86-64. Make sure to apply
|
||||
# changes in generic-x86-64 as well.
|
||||
|
||||
RUN apk --no-cache add \
|
||||
libva-intel-driver \
|
||||
usbutils
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
-c homeassistant/package_constraints.txt
|
||||
|
||||
# Home Assistant Core
|
||||
aiohttp==3.7.3
|
||||
aiohttp==3.7.4
|
||||
astral==1.10.1
|
||||
async_timeout==3.0.1
|
||||
attrs==19.3.0
|
||||
|
||||
@@ -17,7 +17,7 @@ Adafruit-SHT31==1.0.2
|
||||
# Adafruit_BBIO==1.1.1
|
||||
|
||||
# homeassistant.components.homekit
|
||||
HAP-python==3.3.0
|
||||
HAP-python==3.3.1
|
||||
|
||||
# homeassistant.components.mastodon
|
||||
Mastodon.py==1.5.1
|
||||
@@ -221,7 +221,7 @@ aiopylgtv==0.3.3
|
||||
aiorecollect==1.0.1
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==0.6.0
|
||||
aioshelly==0.6.1
|
||||
|
||||
# homeassistant.components.switcher_kis
|
||||
aioswitcher==1.2.1
|
||||
@@ -343,7 +343,7 @@ beautifulsoup4==4.9.3
|
||||
bellows==0.21.0
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.7.14
|
||||
bimmer_connected==0.7.15
|
||||
|
||||
# homeassistant.components.bizkaibus
|
||||
bizkaibus==0.1.1
|
||||
@@ -682,7 +682,7 @@ google-cloud-pubsub==2.1.0
|
||||
google-cloud-texttospeech==0.4.0
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==0.2.10
|
||||
google-nest-sdm==0.2.12
|
||||
|
||||
# homeassistant.components.google_travel_time
|
||||
googlemaps==2.5.1
|
||||
@@ -721,7 +721,7 @@ guppy3==3.1.0
|
||||
ha-ffmpeg==3.0.2
|
||||
|
||||
# homeassistant.components.philips_js
|
||||
ha-philipsjs==0.1.0
|
||||
ha-philipsjs==2.3.0
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habitipy==0.2.0
|
||||
@@ -763,7 +763,7 @@ hole==0.5.1
|
||||
holidays==0.10.5.2
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20210224.0
|
||||
home-assistant-frontend==20210226.0
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -1305,7 +1305,7 @@ pycfdns==1.2.1
|
||||
pychannels==1.0.0
|
||||
|
||||
# homeassistant.components.cast
|
||||
pychromecast==8.1.0
|
||||
pychromecast==8.1.2
|
||||
|
||||
# homeassistant.components.pocketcasts
|
||||
pycketcasts==1.0.0
|
||||
@@ -2367,7 +2367,7 @@ zengge==0.2
|
||||
zeroconf==0.28.8
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.53
|
||||
zha-quirks==0.0.54
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
@@ -2397,4 +2397,4 @@ zigpy==0.32.0
|
||||
zm-py==0.5.2
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.19.0
|
||||
zwave-js-server-python==0.20.0
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
AEMET-OpenData==0.1.8
|
||||
|
||||
# homeassistant.components.homekit
|
||||
HAP-python==3.3.0
|
||||
HAP-python==3.3.1
|
||||
|
||||
# homeassistant.components.flick_electric
|
||||
PyFlick==0.0.2
|
||||
@@ -140,7 +140,7 @@ aiopylgtv==0.3.3
|
||||
aiorecollect==1.0.1
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==0.6.0
|
||||
aioshelly==0.6.1
|
||||
|
||||
# homeassistant.components.switcher_kis
|
||||
aioswitcher==1.2.1
|
||||
@@ -196,7 +196,7 @@ base36==0.1.1
|
||||
bellows==0.21.0
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.7.14
|
||||
bimmer_connected==0.7.15
|
||||
|
||||
# homeassistant.components.blebox
|
||||
blebox_uniapi==1.3.2
|
||||
@@ -367,7 +367,7 @@ google-api-python-client==1.6.4
|
||||
google-cloud-pubsub==2.1.0
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==0.2.10
|
||||
google-nest-sdm==0.2.12
|
||||
|
||||
# homeassistant.components.gree
|
||||
greeclimate==0.10.3
|
||||
@@ -382,7 +382,7 @@ guppy3==3.1.0
|
||||
ha-ffmpeg==3.0.2
|
||||
|
||||
# homeassistant.components.philips_js
|
||||
ha-philipsjs==0.1.0
|
||||
ha-philipsjs==2.3.0
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habitipy==0.2.0
|
||||
@@ -412,7 +412,7 @@ hole==0.5.1
|
||||
holidays==0.10.5.2
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20210224.0
|
||||
home-assistant-frontend==20210226.0
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -688,7 +688,7 @@ pybotvac==0.0.20
|
||||
pycfdns==1.2.1
|
||||
|
||||
# homeassistant.components.cast
|
||||
pychromecast==8.1.0
|
||||
pychromecast==8.1.2
|
||||
|
||||
# homeassistant.components.climacell
|
||||
pyclimacell==0.14.0
|
||||
@@ -1213,7 +1213,7 @@ zeep[async]==4.0.0
|
||||
zeroconf==0.28.8
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.53
|
||||
zha-quirks==0.0.54
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-cc==0.5.2
|
||||
@@ -1234,4 +1234,4 @@ zigpy-znp==0.4.0
|
||||
zigpy==0.32.0
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.19.0
|
||||
zwave-js-server-python==0.20.0
|
||||
|
||||
@@ -32,7 +32,7 @@ PROJECT_URLS = {
|
||||
PACKAGES = find_packages(exclude=["tests", "tests.*"])
|
||||
|
||||
REQUIRES = [
|
||||
"aiohttp==3.7.3",
|
||||
"aiohttp==3.7.4",
|
||||
"astral==1.10.1",
|
||||
"async_timeout==3.0.1",
|
||||
"attrs==19.3.0",
|
||||
|
||||
@@ -26,7 +26,7 @@ from homeassistant.components.media_player.const import (
|
||||
import homeassistant.components.vacuum as vacuum
|
||||
from homeassistant.config import async_process_ha_core_config
|
||||
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
|
||||
from homeassistant.core import Context, callback
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.helpers import entityfilter
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -42,17 +42,13 @@ from . import (
|
||||
reported_properties,
|
||||
)
|
||||
|
||||
from tests.common import async_mock_service
|
||||
from tests.common import async_capture_events, async_mock_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def events(hass):
|
||||
"""Fixture that catches alexa events."""
|
||||
events = []
|
||||
hass.bus.async_listen(
|
||||
smart_home.EVENT_ALEXA_SMART_HOME, callback(lambda e: events.append(e))
|
||||
)
|
||||
yield events
|
||||
return async_capture_events(hass, smart_home.EVENT_ALEXA_SMART_HOME)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -30,7 +30,12 @@ from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from tests.common import assert_setup_component, async_mock_service, mock_restore_cache
|
||||
from tests.common import (
|
||||
assert_setup_component,
|
||||
async_capture_events,
|
||||
async_mock_service,
|
||||
mock_restore_cache,
|
||||
)
|
||||
from tests.components.logbook.test_init import MockLazyEventPartialState
|
||||
|
||||
|
||||
@@ -496,10 +501,7 @@ async def test_reload_config_service(hass, calls, hass_admin_user, hass_read_onl
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data.get("event") == "test_event"
|
||||
|
||||
test_reload_event = []
|
||||
hass.bus.async_listen(
|
||||
EVENT_AUTOMATION_RELOADED, lambda event: test_reload_event.append(event)
|
||||
)
|
||||
test_reload_event = async_capture_events(hass, EVENT_AUTOMATION_RELOADED)
|
||||
|
||||
with patch(
|
||||
"homeassistant.config.load_yaml_config_file",
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import assert_setup_component
|
||||
from tests.common import assert_setup_component, async_capture_events
|
||||
|
||||
CONFIG = {notify.DOMAIN: {"platform": "demo"}}
|
||||
|
||||
@@ -20,9 +20,7 @@ CONFIG = {notify.DOMAIN: {"platform": "demo"}}
|
||||
@pytest.fixture
|
||||
def events(hass):
|
||||
"""Fixture that catches notify events."""
|
||||
events = []
|
||||
hass.bus.async_listen(demo.EVENT_NOTIFY, callback(lambda e: events.append(e)))
|
||||
yield events
|
||||
return async_capture_events(hass, demo.EVENT_NOTIFY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -30,7 +30,12 @@ from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import BASIC_CONFIG, MockConfig
|
||||
|
||||
from tests.common import mock_area_registry, mock_device_registry, mock_registry
|
||||
from tests.common import (
|
||||
async_capture_events,
|
||||
mock_area_registry,
|
||||
mock_device_registry,
|
||||
mock_registry,
|
||||
)
|
||||
|
||||
REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
|
||||
|
||||
@@ -77,8 +82,7 @@ async def test_sync_message(hass):
|
||||
},
|
||||
)
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append)
|
||||
events = async_capture_events(hass, EVENT_SYNC_RECEIVED)
|
||||
|
||||
result = await sh.async_handle_message(
|
||||
hass,
|
||||
@@ -192,8 +196,7 @@ async def test_sync_in_area(area_on_device, hass, registries):
|
||||
|
||||
config = MockConfig(should_expose=lambda _: True, entity_config={})
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append)
|
||||
events = async_capture_events(hass, EVENT_SYNC_RECEIVED)
|
||||
|
||||
result = await sh.async_handle_message(
|
||||
hass,
|
||||
@@ -295,8 +298,7 @@ async def test_query_message(hass):
|
||||
light3.entity_id = "light.color_temp_light"
|
||||
await light3.async_update_ha_state()
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append)
|
||||
events = async_capture_events(hass, EVENT_QUERY_RECEIVED)
|
||||
|
||||
result = await sh.async_handle_message(
|
||||
hass,
|
||||
@@ -387,11 +389,8 @@ async def test_execute(hass):
|
||||
"light", "turn_off", {"entity_id": "light.ceiling_lights"}, blocking=True
|
||||
)
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append)
|
||||
|
||||
service_events = []
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, service_events.append)
|
||||
events = async_capture_events(hass, EVENT_COMMAND_RECEIVED)
|
||||
service_events = async_capture_events(hass, EVENT_CALL_SERVICE)
|
||||
|
||||
result = await sh.async_handle_message(
|
||||
hass,
|
||||
@@ -570,8 +569,7 @@ async def test_raising_error_trait(hass):
|
||||
{ATTR_MIN_TEMP: 15, ATTR_MAX_TEMP: 30, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS},
|
||||
)
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append)
|
||||
events = async_capture_events(hass, EVENT_COMMAND_RECEIVED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await sh.async_handle_message(
|
||||
@@ -660,8 +658,7 @@ async def test_unavailable_state_does_sync(hass):
|
||||
light._available = False # pylint: disable=protected-access
|
||||
await light.async_update_ha_state()
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append)
|
||||
events = async_capture_events(hass, EVENT_SYNC_RECEIVED)
|
||||
|
||||
result = await sh.async_handle_message(
|
||||
hass,
|
||||
|
||||
@@ -54,7 +54,7 @@ from homeassistant.util import color
|
||||
|
||||
from . import BASIC_CONFIG, MockConfig
|
||||
|
||||
from tests.common import async_mock_service
|
||||
from tests.common import async_capture_events, async_mock_service
|
||||
|
||||
REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf"
|
||||
|
||||
@@ -84,8 +84,7 @@ async def test_brightness_light(hass):
|
||||
|
||||
assert trt.query_attributes() == {"brightness": 95}
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
|
||||
events = async_capture_events(hass, EVENT_CALL_SERVICE)
|
||||
|
||||
calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
|
||||
await trt.execute(
|
||||
|
||||
@@ -8,17 +8,14 @@ from homeassistant.components.homeassistant import scene as ha_scene
|
||||
from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_mock_service
|
||||
from tests.common import async_capture_events, async_mock_service
|
||||
|
||||
|
||||
async def test_reload_config_service(hass):
|
||||
"""Test the reload config service."""
|
||||
assert await async_setup_component(hass, "scene", {})
|
||||
|
||||
test_reloaded_event = []
|
||||
hass.bus.async_listen(
|
||||
EVENT_SCENE_RELOADED, lambda event: test_reloaded_event.append(event)
|
||||
)
|
||||
test_reloaded_event = async_capture_events(hass, EVENT_SCENE_RELOADED)
|
||||
|
||||
with patch(
|
||||
"homeassistant.config.load_yaml_config_file",
|
||||
|
||||
@@ -5,7 +5,8 @@ from pyhap.accessory_driver import AccessoryDriver
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED
|
||||
from homeassistant.core import callback as ha_callback
|
||||
|
||||
from tests.common import async_capture_events
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -24,8 +25,4 @@ def hk_driver(loop):
|
||||
@pytest.fixture
|
||||
def events(hass):
|
||||
"""Yield caught homekit_changed events."""
|
||||
events = []
|
||||
hass.bus.async_listen(
|
||||
EVENT_HOMEKIT_CHANGED, ha_callback(lambda e: events.append(e))
|
||||
)
|
||||
yield events
|
||||
return async_capture_events(hass, EVENT_HOMEKIT_CHANGED)
|
||||
|
||||
@@ -175,10 +175,7 @@ async def test_cors_defaults(hass):
|
||||
assert await async_setup_component(hass, "http", {})
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert mock_setup.mock_calls[0][1][1] == [
|
||||
"https://cast.home-assistant.io",
|
||||
"https://my.home-assistant.io",
|
||||
]
|
||||
assert mock_setup.mock_calls[0][1][1] == ["https://cast.home-assistant.io"]
|
||||
|
||||
|
||||
async def test_storing_config(hass, aiohttp_client, aiohttp_unused_port):
|
||||
|
||||
@@ -640,6 +640,15 @@ async def test_options_flow(hass):
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "init"
|
||||
schema = result["data_schema"].schema
|
||||
assert (
|
||||
_get_schema_default(schema, const.CONF_ALLOW_HUE_GROUPS)
|
||||
== const.DEFAULT_ALLOW_HUE_GROUPS
|
||||
)
|
||||
assert (
|
||||
_get_schema_default(schema, const.CONF_ALLOW_UNREACHABLE)
|
||||
== const.DEFAULT_ALLOW_UNREACHABLE
|
||||
)
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -654,3 +663,11 @@ async def test_options_flow(hass):
|
||||
const.CONF_ALLOW_HUE_GROUPS: True,
|
||||
const.CONF_ALLOW_UNREACHABLE: True,
|
||||
}
|
||||
|
||||
|
||||
def _get_schema_default(schema, key_name):
|
||||
"""Iterate schema to find a key."""
|
||||
for schema_key in schema:
|
||||
if schema_key == key_name:
|
||||
return schema_key.default()
|
||||
raise KeyError(f"{key_name} not found in schema")
|
||||
|
||||
@@ -7,6 +7,12 @@ import aiohue
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import hue
|
||||
from homeassistant.components.hue import light as hue_light
|
||||
from homeassistant.helpers.device_registry import (
|
||||
async_get_registry as async_get_device_registry,
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
async_get_registry as async_get_entity_registry,
|
||||
)
|
||||
from homeassistant.util import color
|
||||
|
||||
HUE_LIGHT_NS = "homeassistant.components.light.hue."
|
||||
@@ -211,8 +217,10 @@ async def test_no_lights_or_groups(hass, mock_bridge):
|
||||
async def test_lights(hass, mock_bridge):
|
||||
"""Test the update_lights function with some lights."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
assert len(mock_bridge.mock_requests) == 1
|
||||
assert len(mock_bridge.mock_requests) == 2
|
||||
# 2 lights
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
@@ -230,6 +238,8 @@ async def test_lights(hass, mock_bridge):
|
||||
async def test_lights_color_mode(hass, mock_bridge):
|
||||
"""Test that lights only report appropriate color mode."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
|
||||
lamp_1 = hass.states.get("light.hue_lamp_1")
|
||||
@@ -249,8 +259,8 @@ async def test_lights_color_mode(hass, mock_bridge):
|
||||
await hass.services.async_call(
|
||||
"light", "turn_on", {"entity_id": "light.hue_lamp_2"}, blocking=True
|
||||
)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 3
|
||||
# 2x light update, 1 group update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 4
|
||||
|
||||
lamp_1 = hass.states.get("light.hue_lamp_1")
|
||||
assert lamp_1 is not None
|
||||
@@ -332,9 +342,10 @@ async def test_new_group_discovered(hass, mock_bridge):
|
||||
async def test_new_light_discovered(hass, mock_bridge):
|
||||
"""Test if 2nd update has a new light."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
assert len(mock_bridge.mock_requests) == 1
|
||||
assert len(mock_bridge.mock_requests) == 2
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
new_light_response = dict(LIGHT_RESPONSE)
|
||||
@@ -366,8 +377,8 @@ async def test_new_light_discovered(hass, mock_bridge):
|
||||
await hass.services.async_call(
|
||||
"light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True
|
||||
)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 3
|
||||
# 2x light update, 1 group update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 4
|
||||
assert len(hass.states.async_all()) == 3
|
||||
|
||||
light = hass.states.get("light.hue_lamp_3")
|
||||
@@ -407,9 +418,10 @@ async def test_group_removed(hass, mock_bridge):
|
||||
async def test_light_removed(hass, mock_bridge):
|
||||
"""Test if 2nd update has removed light."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
assert len(mock_bridge.mock_requests) == 1
|
||||
assert len(mock_bridge.mock_requests) == 2
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
mock_bridge.mock_light_responses.clear()
|
||||
@@ -420,8 +432,8 @@ async def test_light_removed(hass, mock_bridge):
|
||||
"light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True
|
||||
)
|
||||
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 3
|
||||
# 2x light update, 1 group update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 4
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
light = hass.states.get("light.hue_lamp_1")
|
||||
@@ -487,9 +499,10 @@ async def test_other_group_update(hass, mock_bridge):
|
||||
async def test_other_light_update(hass, mock_bridge):
|
||||
"""Test changing one light that will impact state of other light."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
assert len(mock_bridge.mock_requests) == 1
|
||||
assert len(mock_bridge.mock_requests) == 2
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
lamp_2 = hass.states.get("light.hue_lamp_2")
|
||||
@@ -526,8 +539,8 @@ async def test_other_light_update(hass, mock_bridge):
|
||||
await hass.services.async_call(
|
||||
"light", "turn_on", {"entity_id": "light.hue_lamp_1"}, blocking=True
|
||||
)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 3
|
||||
# 2x light update, 1 group update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 4
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
lamp_2 = hass.states.get("light.hue_lamp_2")
|
||||
@@ -549,7 +562,6 @@ async def test_update_timeout(hass, mock_bridge):
|
||||
async def test_update_unauthorized(hass, mock_bridge):
|
||||
"""Test bridge marked as not authorized if unauthorized during update."""
|
||||
mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized)
|
||||
mock_bridge.api.groups.update = Mock(side_effect=aiohue.Unauthorized)
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
assert len(mock_bridge.mock_requests) == 0
|
||||
assert len(hass.states.async_all()) == 0
|
||||
@@ -559,6 +571,8 @@ async def test_update_unauthorized(hass, mock_bridge):
|
||||
async def test_light_turn_on_service(hass, mock_bridge):
|
||||
"""Test calling the turn on service on a light."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
light = hass.states.get("light.hue_lamp_2")
|
||||
assert light is not None
|
||||
@@ -575,10 +589,10 @@ async def test_light_turn_on_service(hass, mock_bridge):
|
||||
{"entity_id": "light.hue_lamp_2", "brightness": 100, "color_temp": 300},
|
||||
blocking=True,
|
||||
)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 3
|
||||
# 2x light update, 1 group update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 4
|
||||
|
||||
assert mock_bridge.mock_requests[1]["json"] == {
|
||||
assert mock_bridge.mock_requests[2]["json"] == {
|
||||
"bri": 100,
|
||||
"on": True,
|
||||
"ct": 300,
|
||||
@@ -599,9 +613,9 @@ async def test_light_turn_on_service(hass, mock_bridge):
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert len(mock_bridge.mock_requests) == 5
|
||||
assert len(mock_bridge.mock_requests) == 6
|
||||
|
||||
assert mock_bridge.mock_requests[3]["json"] == {
|
||||
assert mock_bridge.mock_requests[4]["json"] == {
|
||||
"on": True,
|
||||
"xy": (0.138, 0.08),
|
||||
"alert": "none",
|
||||
@@ -611,6 +625,8 @@ async def test_light_turn_on_service(hass, mock_bridge):
|
||||
async def test_light_turn_off_service(hass, mock_bridge):
|
||||
"""Test calling the turn on service on a light."""
|
||||
mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
|
||||
mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
|
||||
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
light = hass.states.get("light.hue_lamp_1")
|
||||
assert light is not None
|
||||
@@ -624,10 +640,11 @@ async def test_light_turn_off_service(hass, mock_bridge):
|
||||
await hass.services.async_call(
|
||||
"light", "turn_off", {"entity_id": "light.hue_lamp_1"}, blocking=True
|
||||
)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 3
|
||||
|
||||
assert mock_bridge.mock_requests[1]["json"] == {"on": False, "alert": "none"}
|
||||
# 2x light update, 1 for group update, 1 turn on request
|
||||
assert len(mock_bridge.mock_requests) == 4
|
||||
|
||||
assert mock_bridge.mock_requests[2]["json"] == {"on": False, "alert": "none"}
|
||||
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
@@ -649,6 +666,7 @@ def test_available():
|
||||
bridge=Mock(allow_unreachable=False),
|
||||
is_group=False,
|
||||
supported_features=hue_light.SUPPORT_HUE_EXTENDED,
|
||||
rooms={},
|
||||
)
|
||||
|
||||
assert light.available is False
|
||||
@@ -664,6 +682,7 @@ def test_available():
|
||||
bridge=Mock(allow_unreachable=True),
|
||||
is_group=False,
|
||||
supported_features=hue_light.SUPPORT_HUE_EXTENDED,
|
||||
rooms={},
|
||||
)
|
||||
|
||||
assert light.available is True
|
||||
@@ -679,6 +698,7 @@ def test_available():
|
||||
bridge=Mock(allow_unreachable=False),
|
||||
is_group=True,
|
||||
supported_features=hue_light.SUPPORT_HUE_EXTENDED,
|
||||
rooms={},
|
||||
)
|
||||
|
||||
assert light.available is True
|
||||
@@ -697,6 +717,7 @@ def test_hs_color():
|
||||
bridge=Mock(),
|
||||
is_group=False,
|
||||
supported_features=hue_light.SUPPORT_HUE_EXTENDED,
|
||||
rooms={},
|
||||
)
|
||||
|
||||
assert light.hs_color is None
|
||||
@@ -712,6 +733,7 @@ def test_hs_color():
|
||||
bridge=Mock(),
|
||||
is_group=False,
|
||||
supported_features=hue_light.SUPPORT_HUE_EXTENDED,
|
||||
rooms={},
|
||||
)
|
||||
|
||||
assert light.hs_color is None
|
||||
@@ -727,6 +749,7 @@ def test_hs_color():
|
||||
bridge=Mock(),
|
||||
is_group=False,
|
||||
supported_features=hue_light.SUPPORT_HUE_EXTENDED,
|
||||
rooms={},
|
||||
)
|
||||
|
||||
assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT)
|
||||
@@ -742,7 +765,7 @@ async def test_group_features(hass, mock_bridge):
|
||||
"1": {
|
||||
"name": "Group 1",
|
||||
"lights": ["1", "2"],
|
||||
"type": "Room",
|
||||
"type": "LightGroup",
|
||||
"action": {
|
||||
"on": True,
|
||||
"bri": 254,
|
||||
@@ -757,8 +780,8 @@ async def test_group_features(hass, mock_bridge):
|
||||
"state": {"any_on": True, "all_on": False},
|
||||
},
|
||||
"2": {
|
||||
"name": "Group 2",
|
||||
"lights": ["3", "4"],
|
||||
"name": "Living Room",
|
||||
"lights": ["2", "3"],
|
||||
"type": "Room",
|
||||
"action": {
|
||||
"on": True,
|
||||
@@ -774,8 +797,8 @@ async def test_group_features(hass, mock_bridge):
|
||||
"state": {"any_on": True, "all_on": False},
|
||||
},
|
||||
"3": {
|
||||
"name": "Group 3",
|
||||
"lights": ["1", "3"],
|
||||
"name": "Dining Room",
|
||||
"lights": ["4"],
|
||||
"type": "Room",
|
||||
"action": {
|
||||
"on": True,
|
||||
@@ -900,6 +923,7 @@ async def test_group_features(hass, mock_bridge):
|
||||
mock_bridge.mock_light_responses.append(light_response)
|
||||
mock_bridge.mock_group_responses.append(group_response)
|
||||
await setup_bridge(hass, mock_bridge)
|
||||
assert len(mock_bridge.mock_requests) == 2
|
||||
|
||||
color_temp_feature = hue_light.SUPPORT_HUE["Color temperature light"]
|
||||
extended_color_feature = hue_light.SUPPORT_HUE["Extended color light"]
|
||||
@@ -907,8 +931,27 @@ async def test_group_features(hass, mock_bridge):
|
||||
group_1 = hass.states.get("light.group_1")
|
||||
assert group_1.attributes["supported_features"] == color_temp_feature
|
||||
|
||||
group_2 = hass.states.get("light.group_2")
|
||||
group_2 = hass.states.get("light.living_room")
|
||||
assert group_2.attributes["supported_features"] == extended_color_feature
|
||||
|
||||
group_3 = hass.states.get("light.group_3")
|
||||
group_3 = hass.states.get("light.dining_room")
|
||||
assert group_3.attributes["supported_features"] == extended_color_feature
|
||||
|
||||
entity_registry = await async_get_entity_registry(hass)
|
||||
device_registry = await async_get_device_registry(hass)
|
||||
|
||||
entry = entity_registry.async_get("light.hue_lamp_1")
|
||||
device_entry = device_registry.async_get(entry.device_id)
|
||||
assert device_entry.suggested_area is None
|
||||
|
||||
entry = entity_registry.async_get("light.hue_lamp_2")
|
||||
device_entry = device_registry.async_get(entry.device_id)
|
||||
assert device_entry.suggested_area == "Living Room"
|
||||
|
||||
entry = entity_registry.async_get("light.hue_lamp_3")
|
||||
device_entry = device_registry.async_get(entry.device_id)
|
||||
assert device_entry.suggested_area == "Living Room"
|
||||
|
||||
entry = entity_registry.async_get("light.hue_lamp_4")
|
||||
device_entry = device_registry.async_get(entry.device_id)
|
||||
assert device_entry.suggested_area == "Dining Room"
|
||||
|
||||
@@ -290,6 +290,81 @@ async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock):
|
||||
assert calls[1].data["some"] == "long_press"
|
||||
|
||||
|
||||
async def test_if_fires_on_mqtt_message_template(hass, device_reg, calls, mqtt_mock):
|
||||
"""Test triggers firing."""
|
||||
data1 = (
|
||||
'{ "automation_type":"trigger",'
|
||||
' "device":{"identifiers":["0AFFD2"]},'
|
||||
" \"payload\": \"{{ 'foo_press'|regex_replace('foo', 'short') }}\","
|
||||
' "topic": "foobar/triggers/button{{ sqrt(16)|round }}",'
|
||||
' "type": "button_short_press",'
|
||||
' "subtype": "button_1",'
|
||||
' "value_template": "{{ value_json.button }}"}'
|
||||
)
|
||||
data2 = (
|
||||
'{ "automation_type":"trigger",'
|
||||
' "device":{"identifiers":["0AFFD2"]},'
|
||||
" \"payload\": \"{{ 'foo_press'|regex_replace('foo', 'long') }}\","
|
||||
' "topic": "foobar/triggers/button{{ sqrt(16)|round }}",'
|
||||
' "type": "button_long_press",'
|
||||
' "subtype": "button_2",'
|
||||
' "value_template": "{{ value_json.button }}"}'
|
||||
)
|
||||
async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
|
||||
async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2)
|
||||
await hass.async_block_till_done()
|
||||
device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")})
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: [
|
||||
{
|
||||
"trigger": {
|
||||
"platform": "device",
|
||||
"domain": DOMAIN,
|
||||
"device_id": device_entry.id,
|
||||
"discovery_id": "bla1",
|
||||
"type": "button_short_press",
|
||||
"subtype": "button_1",
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {"some": ("short_press")},
|
||||
},
|
||||
},
|
||||
{
|
||||
"trigger": {
|
||||
"platform": "device",
|
||||
"domain": DOMAIN,
|
||||
"device_id": device_entry.id,
|
||||
"discovery_id": "bla2",
|
||||
"type": "button_1",
|
||||
"subtype": "button_long_press",
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {"some": ("long_press")},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Fake short press.
|
||||
async_fire_mqtt_message(hass, "foobar/triggers/button4", '{"button":"short_press"}')
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["some"] == "short_press"
|
||||
|
||||
# Fake long press.
|
||||
async_fire_mqtt_message(hass, "foobar/triggers/button4", '{"button":"long_press"}')
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
assert calls[1].data["some"] == "long_press"
|
||||
|
||||
|
||||
async def test_if_fires_on_mqtt_message_late_discover(
|
||||
hass, device_reg, calls, mqtt_mock
|
||||
):
|
||||
|
||||
@@ -81,6 +81,31 @@ async def test_if_fires_on_topic_and_payload_match(hass, calls):
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_if_fires_on_topic_and_payload_match2(hass, calls):
|
||||
"""Test if message is fired on topic and payload match.
|
||||
|
||||
Make sure a payload which would render as a non string can still be matched.
|
||||
"""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
"platform": "mqtt",
|
||||
"topic": "test-topic",
|
||||
"payload": "0",
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async_fire_mqtt_message(hass, "test-topic", "0")
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_if_fires_on_templated_topic_and_payload_match(hass, calls):
|
||||
"""Test if message is fired on templated topic and payload match."""
|
||||
assert await async_setup_component(
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
MOCK_SERIAL_NO = "1234567890"
|
||||
MOCK_NAME = "Philips TV"
|
||||
|
||||
MOCK_USERNAME = "mock_user"
|
||||
MOCK_PASSWORD = "mock_password"
|
||||
|
||||
MOCK_SYSTEM = {
|
||||
"menulanguage": "English",
|
||||
"name": MOCK_NAME,
|
||||
@@ -12,14 +15,63 @@ MOCK_SYSTEM = {
|
||||
"model": "modelname",
|
||||
}
|
||||
|
||||
MOCK_USERINPUT = {
|
||||
"host": "1.1.1.1",
|
||||
"api_version": 1,
|
||||
MOCK_SYSTEM_UNPAIRED = {
|
||||
"menulanguage": "Dutch",
|
||||
"name": "55PUS7181/12",
|
||||
"country": "Netherlands",
|
||||
"serialnumber": "ABCDEFGHIJKLF",
|
||||
"softwareversion": "TPM191E_R.101.001.208.001",
|
||||
"model": "65OLED855/12",
|
||||
"deviceid": "1234567890",
|
||||
"nettvversion": "6.0.2",
|
||||
"epgsource": "one",
|
||||
"api_version": {"Major": 6, "Minor": 2, "Patch": 0},
|
||||
"featuring": {
|
||||
"jsonfeatures": {
|
||||
"editfavorites": ["TVChannels", "SatChannels"],
|
||||
"recordings": ["List", "Schedule", "Manage"],
|
||||
"ambilight": ["LoungeLight", "Hue", "Ambilight"],
|
||||
"menuitems": ["Setup_Menu"],
|
||||
"textentry": [
|
||||
"context_based",
|
||||
"initial_string_available",
|
||||
"editor_info_available",
|
||||
],
|
||||
"applications": ["TV_Apps", "TV_Games", "TV_Settings"],
|
||||
"pointer": ["not_available"],
|
||||
"inputkey": ["key"],
|
||||
"activities": ["intent"],
|
||||
"channels": ["preset_string"],
|
||||
"mappings": ["server_mapping"],
|
||||
},
|
||||
"systemfeatures": {
|
||||
"tvtype": "consumer",
|
||||
"content": ["dmr", "dms_tad"],
|
||||
"tvsearch": "intent",
|
||||
"pairing_type": "digest_auth_pairing",
|
||||
"secured_transport": "True",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
MOCK_USERINPUT = {
|
||||
"host": "1.1.1.1",
|
||||
}
|
||||
|
||||
MOCK_IMPORT = {"host": "1.1.1.1", "api_version": 6}
|
||||
|
||||
MOCK_CONFIG = {
|
||||
**MOCK_USERINPUT,
|
||||
"host": "1.1.1.1",
|
||||
"api_version": 1,
|
||||
"system": MOCK_SYSTEM,
|
||||
}
|
||||
|
||||
MOCK_CONFIG_PAIRED = {
|
||||
"host": "1.1.1.1",
|
||||
"api_version": 6,
|
||||
"username": MOCK_USERNAME,
|
||||
"password": MOCK_PASSWORD,
|
||||
"system": MOCK_SYSTEM_UNPAIRED,
|
||||
}
|
||||
|
||||
MOCK_ENTITY_ID = "media_player.philips_tv"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Standard setup for tests."""
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import create_autospec, patch
|
||||
|
||||
from haphilipsjs import PhilipsTV
|
||||
from pytest import fixture
|
||||
|
||||
from homeassistant import setup
|
||||
@@ -20,10 +21,18 @@ async def setup_notification(hass):
|
||||
@fixture(autouse=True)
|
||||
def mock_tv():
|
||||
"""Disable component actual use."""
|
||||
tv = Mock(autospec="philips_js.PhilipsTV")
|
||||
tv = create_autospec(PhilipsTV)
|
||||
tv.sources = {}
|
||||
tv.channels = {}
|
||||
tv.application = None
|
||||
tv.applications = {}
|
||||
tv.system = MOCK_SYSTEM
|
||||
tv.api_version = 1
|
||||
tv.api_version_detected = None
|
||||
tv.on = True
|
||||
tv.notify_change_supported = False
|
||||
tv.pairing_type = None
|
||||
tv.powerstate = None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.philips_js.config_flow.PhilipsTV", return_value=tv
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Test the Philips TV config flow."""
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from haphilipsjs import PairingFailure
|
||||
from pytest import fixture
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.philips_js.const import DOMAIN
|
||||
|
||||
from . import MOCK_CONFIG, MOCK_USERINPUT
|
||||
from . import (
|
||||
MOCK_CONFIG,
|
||||
MOCK_CONFIG_PAIRED,
|
||||
MOCK_IMPORT,
|
||||
MOCK_PASSWORD,
|
||||
MOCK_SYSTEM_UNPAIRED,
|
||||
MOCK_USERINPUT,
|
||||
MOCK_USERNAME,
|
||||
)
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
@@ -27,12 +36,26 @@ def mock_setup_entry():
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@fixture
|
||||
async def mock_tv_pairable(mock_tv):
|
||||
"""Return a mock tv that is pariable."""
|
||||
mock_tv.system = MOCK_SYSTEM_UNPAIRED
|
||||
mock_tv.pairing_type = "digest_auth_pairing"
|
||||
mock_tv.api_version = 6
|
||||
mock_tv.api_version_detected = 6
|
||||
mock_tv.secured_transport = True
|
||||
|
||||
mock_tv.pairRequest.return_value = {}
|
||||
mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD
|
||||
return mock_tv
|
||||
|
||||
|
||||
async def test_import(hass, mock_setup, mock_setup_entry):
|
||||
"""Test we get an item on import."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=MOCK_USERINPUT,
|
||||
data=MOCK_IMPORT,
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
@@ -47,7 +70,7 @@ async def test_import_exist(hass, mock_config_entry):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=MOCK_USERINPUT,
|
||||
data=MOCK_IMPORT,
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
@@ -103,3 +126,116 @@ async def test_form_unexpected_error(hass, mock_tv):
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_pairing(hass, mock_tv_pairable, mock_setup, mock_setup_entry):
|
||||
"""Test we get the form."""
|
||||
mock_tv = mock_tv_pairable
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
MOCK_USERINPUT,
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
mock_tv.setTransport.assert_called_with(True)
|
||||
mock_tv.pairRequest.assert_called()
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"pin": "1234"}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"flow_id": ANY,
|
||||
"type": "create_entry",
|
||||
"description": None,
|
||||
"description_placeholders": None,
|
||||
"handler": "philips_js",
|
||||
"result": ANY,
|
||||
"title": "55PUS7181/12 (ABCDEFGHIJKLF)",
|
||||
"data": MOCK_CONFIG_PAIRED,
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_pair_request_failed(
|
||||
hass, mock_tv_pairable, mock_setup, mock_setup_entry
|
||||
):
|
||||
"""Test we get the form."""
|
||||
mock_tv = mock_tv_pairable
|
||||
mock_tv.pairRequest.side_effect = PairingFailure({})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
MOCK_USERINPUT,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"flow_id": ANY,
|
||||
"description_placeholders": {"error_id": None},
|
||||
"handler": "philips_js",
|
||||
"reason": "pairing_failure",
|
||||
"type": "abort",
|
||||
}
|
||||
|
||||
|
||||
async def test_pair_grant_failed(hass, mock_tv_pairable, mock_setup, mock_setup_entry):
|
||||
"""Test we get the form."""
|
||||
mock_tv = mock_tv_pairable
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
MOCK_USERINPUT,
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
mock_tv.setTransport.assert_called_with(True)
|
||||
mock_tv.pairRequest.assert_called()
|
||||
|
||||
# Test with invalid pin
|
||||
mock_tv.pairGrant.side_effect = PairingFailure({"error_id": "INVALID_PIN"})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"pin": "1234"}
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"pin": "invalid_pin"}
|
||||
|
||||
# Test with unexpected failure
|
||||
mock_tv.pairGrant.side_effect = PairingFailure({})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"pin": "1234"}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"flow_id": ANY,
|
||||
"description_placeholders": {"error_id": None},
|
||||
"handler": "philips_js",
|
||||
"reason": "pairing_failure",
|
||||
"type": "abort",
|
||||
}
|
||||
|
||||
@@ -33,9 +33,13 @@ async def test_get_triggers(hass, mock_device):
|
||||
assert_lists_same(triggers, expected_triggers)
|
||||
|
||||
|
||||
async def test_if_fires_on_turn_on_request(hass, calls, mock_entity, mock_device):
|
||||
async def test_if_fires_on_turn_on_request(
|
||||
hass, calls, mock_tv, mock_entity, mock_device
|
||||
):
|
||||
"""Test for turn_on and turn_off triggers firing."""
|
||||
|
||||
mock_tv.on = False
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
|
||||
@@ -10,10 +10,14 @@ from homeassistant.components.shelly.const import (
|
||||
DOMAIN,
|
||||
EVENT_SHELLY_CLICK,
|
||||
)
|
||||
from homeassistant.core import callback as ha_callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, async_mock_service, mock_device_registry
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
async_capture_events,
|
||||
async_mock_service,
|
||||
mock_device_registry,
|
||||
)
|
||||
|
||||
MOCK_SETTINGS = {
|
||||
"name": "Test name",
|
||||
@@ -81,9 +85,7 @@ def calls(hass):
|
||||
@pytest.fixture
|
||||
def events(hass):
|
||||
"""Yield caught shelly_click events."""
|
||||
ha_events = []
|
||||
hass.bus.async_listen(EVENT_SHELLY_CLICK, ha_callback(ha_events.append))
|
||||
yield ha_events
|
||||
return async_capture_events(hass, EVENT_SHELLY_CLICK)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -144,6 +144,8 @@ def mock_get_server_version(server_version_side_effect, server_version_timeout):
|
||||
driver_version="mock-driver-version",
|
||||
server_version="mock-server-version",
|
||||
home_id=1234,
|
||||
min_schema_version=0,
|
||||
max_schema_version=1,
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.zwave_js.config_flow.get_server_version",
|
||||
|
||||
@@ -124,6 +124,59 @@ async def test_on_node_added_ready(
|
||||
)
|
||||
|
||||
|
||||
async def test_unique_id_migration_dupes(
|
||||
hass, multisensor_6_state, client, integration
|
||||
):
|
||||
"""Test we remove an entity when ."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
|
||||
entity_name = AIR_TEMPERATURE_SENSOR.split(".")[1]
|
||||
|
||||
# Create entity RegistryEntry using old unique ID format
|
||||
old_unique_id_1 = (
|
||||
f"{client.driver.controller.home_id}.52.52-49-00-Air temperature-00"
|
||||
)
|
||||
entity_entry = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
old_unique_id_1,
|
||||
suggested_object_id=entity_name,
|
||||
config_entry=integration,
|
||||
original_name=entity_name,
|
||||
)
|
||||
assert entity_entry.entity_id == AIR_TEMPERATURE_SENSOR
|
||||
assert entity_entry.unique_id == old_unique_id_1
|
||||
|
||||
# Create entity RegistryEntry using b0 unique ID format
|
||||
old_unique_id_2 = (
|
||||
f"{client.driver.controller.home_id}.52.52-49-0-Air temperature-00-00"
|
||||
)
|
||||
entity_entry = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
old_unique_id_2,
|
||||
suggested_object_id=f"{entity_name}_1",
|
||||
config_entry=integration,
|
||||
original_name=entity_name,
|
||||
)
|
||||
assert entity_entry.entity_id == f"{AIR_TEMPERATURE_SENSOR}_1"
|
||||
assert entity_entry.unique_id == old_unique_id_2
|
||||
|
||||
# Add a ready node, unique ID should be migrated
|
||||
node = Node(client, multisensor_6_state)
|
||||
event = {"node": node}
|
||||
|
||||
client.driver.controller.emit("node added", event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
assert ent_reg.async_get(f"{AIR_TEMPERATURE_SENSOR}_1") is None
|
||||
|
||||
|
||||
async def test_unique_id_migration_v1(hass, multisensor_6_state, client, integration):
|
||||
"""Test unique ID is migrated from old format to new (version 1)."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
|
||||
Reference in New Issue
Block a user