From 32a1f65a111cc985125b00b26f67aa6144112483 Mon Sep 17 00:00:00 2001 From: eifinger Date: Thu, 29 Apr 2021 21:30:26 +0200 Subject: [PATCH] Update deps --- .../alexa_media/.translations/en.json | 3 +- .../alexa_media/.translations/pt_BR.json | 1 + .../alexa_media/.translations/pt_PT.json | 1 + custom_components/alexa_media/__init__.py | 95 +++++-- .../alexa_media/alarm_control_panel.py | 202 +++----------- custom_components/alexa_media/alexa_entity.py | 258 ++++++++++++++++++ custom_components/alexa_media/config_flow.py | 10 +- custom_components/alexa_media/const.py | 6 +- custom_components/alexa_media/light.py | 150 ++++++++++ custom_components/alexa_media/manifest.json | 2 +- custom_components/alexa_media/sensor.py | 85 +++++- custom_components/alexa_media/strings.json | 3 +- .../alexa_media/translations/en.json | 3 +- .../alexa_media/translations/pt_BR.json | 1 + .../alexa_media/translations/pt_PT.json | 1 + custom_components/dwd_weather/connector.py | 8 +- custom_components/dwd_weather/manifest.json | 4 +- custom_components/dwd_weather/sensor.py | 21 +- custom_components/hacs/base.py | 7 +- custom_components/hacs/const.py | 14 +- custom_components/hacs/hacsbase/data.py | 2 +- custom_components/hacs/hacsbase/hacs.py | 8 +- .../hacs/helpers/classes/repository.py | 2 +- .../functions/get_list_from_default.py | 6 +- .../hacs/helpers/functions/information.py | 7 +- custom_components/hacs/manifest.json | 7 +- custom_components/hacs/operational/setup.py | 23 +- .../operational/setup_actions/categories.py | 11 +- custom_components/weenect/__init__.py | 2 +- custom_components/weenect/const.py | 2 +- custom_components/weenect/manifest.json | 7 +- 31 files changed, 710 insertions(+), 242 deletions(-) create mode 100644 custom_components/alexa_media/alexa_entity.py create mode 100644 custom_components/alexa_media/light.py diff --git a/custom_components/alexa_media/.translations/en.json b/custom_components/alexa_media/.translations/en.json index f64e203..44b132e 100644 --- a/custom_components/alexa_media/.translations/en.json +++ b/custom_components/alexa_media/.translations/en.json @@ -105,7 +105,8 @@ "step": { "init": { "data": { - "queue_delay": "Seconds to wait to queue commands together" + "queue_delay": "Seconds to wait to queue commands together", + "extended_entity_discovery": "Include devices connected via Echo" } } } diff --git a/custom_components/alexa_media/.translations/pt_BR.json b/custom_components/alexa_media/.translations/pt_BR.json index 0033d84..7790d6f 100644 --- a/custom_components/alexa_media/.translations/pt_BR.json +++ b/custom_components/alexa_media/.translations/pt_BR.json @@ -105,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclui dispositivos conectados via Echo", "queue_delay": "Seconds to wait to queue commands together" } } diff --git a/custom_components/alexa_media/.translations/pt_PT.json b/custom_components/alexa_media/.translations/pt_PT.json index e3bfdf9..4edcbca 100644 --- a/custom_components/alexa_media/.translations/pt_PT.json +++ b/custom_components/alexa_media/.translations/pt_PT.json @@ -105,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclui dispositivos conectados via Echo", "queue_delay": "Segundos de espera para agrupar comandos" } } diff --git a/custom_components/alexa_media/__init__.py b/custom_components/alexa_media/__init__.py index 8bc07e7..11b264f 100644 --- a/custom_components/alexa_media/__init__.py +++ b/custom_components/alexa_media/__init__.py @@ -44,6 +44,7 @@ from homeassistant.util import dt, slugify import voluptuous as vol +from .alexa_entity import AlexaEntityData, get_entity_data, parse_alexa_entities from .config_flow import in_progess_instances from .const import ( ALEXA_COMPONENTS, @@ -51,6 +52,7 @@ CONF_COOKIES_TXT, CONF_DEBUG, CONF_EXCLUDE_DEVICES, + CONF_EXTENDED_ENTITY_DISCOVERY, CONF_INCLUDE_DEVICES, CONF_OAUTH, CONF_OAUTH_LOGIN, @@ -58,6 +60,7 @@ CONF_QUEUE_DELAY, DATA_ALEXAMEDIA, DATA_LISTENER, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, DEFAULT_QUEUE_DELAY, DEPENDENT_ALEXA_COMPONENTS, DOMAIN, @@ -250,11 +253,12 @@ async def login_success(event=None) -> None: "coordinator": None, "config_entry": config_entry, "setup_alexa": setup_alexa, - "devices": {"media_player": {}, "switch": {}}, + "devices": {"media_player": {}, "switch": {}, "guard": [], "light": [], "temperature": []}, "entities": { "media_player": {}, "switch": {}, "sensor": {}, + "light": [], "alarm_control_panel": {}, }, "excluded": {}, @@ -266,9 +270,13 @@ async def login_success(event=None) -> None: "websocket": None, "auth_info": None, "second_account_index": 0, + "should_get_network": True, "options": { CONF_QUEUE_DELAY: config_entry.options.get( CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY + ), + CONF_EXTENDED_ENTITY_DISCOVERY: config_entry.options.get( + CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY ) }, DATA_LISTENER: [config_entry.add_update_listener(update_listener)], @@ -312,7 +320,7 @@ async def login_success(event=None) -> None: async def setup_alexa(hass, config_entry, login_obj: AlexaLogin): """Set up a alexa api based on host parameter.""" - async def async_update_data(): + async def async_update_data() -> Optional[AlexaEntityData]: """Fetch data from API endpoint. This is the place to pre-process the data to lookup tables @@ -321,6 +329,9 @@ async def async_update_data(): This will ping Alexa API to identify all devices, bluetooth, and the last called device. + If any guards, temperature sensors, or lights are configured, their + current state will be acquired. This data is returned directly so that it is available on the coordinator. + This will add new devices and services when discovered. By default this runs every SCAN_INTERVAL seconds unless another method calls it. if websockets is connected, it will increase the delay 10-fold between updates. @@ -345,11 +356,14 @@ async def async_update_data(): ].values() auth_info = hass.data[DATA_ALEXAMEDIA]["accounts"][email].get("auth_info") new_devices = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["new_devices"] + should_get_network = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["should_get_network"] + devices = {} bluetooth = {} preferences = {} dnd = {} raw_notifications = {} + entity_state = {} tasks = [ AlexaAPI.get_devices(login_obj), AlexaAPI.get_bluetooth(login_obj), @@ -359,33 +373,58 @@ async def async_update_data(): if new_devices: tasks.append(AlexaAPI.get_authentication(login_obj)) + entities_to_monitor = set() + for sensor in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"]["sensor"].values(): + temp = sensor.get("Temperature") + if temp and temp.enabled: + entities_to_monitor.add(temp.alexa_entity_id) + + for light in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"]["light"]: + if light.enabled: + entities_to_monitor.add(light.alexa_entity_id) + + for guard in hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"]["alarm_control_panel"].values(): + if guard.enabled: + entities_to_monitor.add(guard.unique_id) + + if entities_to_monitor: + tasks.append(get_entity_data(login_obj, list(entities_to_monitor))) + + if should_get_network: + tasks.append(AlexaAPI.get_network_details(login_obj)) + try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with async_timeout.timeout(30): + ( + devices, + bluetooth, + preferences, + dnd, + *optional_task_results + ) = await asyncio.gather(*tasks) + + if should_get_network: + _LOGGER.debug("Alexa entities have been loaded. Prepared for discovery.") + alexa_entities = parse_alexa_entities(optional_task_results.pop()) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["devices"].update(alexa_entities) + hass.data[DATA_ALEXAMEDIA]["accounts"][email]["should_get_network"] = False + + if entities_to_monitor: + entity_state = optional_task_results.pop() + if new_devices: - ( - devices, - bluetooth, - preferences, - dnd, - auth_info, - ) = await asyncio.gather(*tasks) - else: - ( - devices, - bluetooth, - preferences, - dnd, - ) = await asyncio.gather(*tasks) - _LOGGER.debug( - "%s: Found %s devices, %s bluetooth", - hide_email(email), - len(devices) if devices is not None else "", - len(bluetooth.get("bluetoothStates", [])) - if bluetooth is not None - else "", - ) + auth_info = optional_task_results.pop() + _LOGGER.debug( + "%s: Found %s devices, %s bluetooth", + hide_email(email), + len(devices) if devices is not None else "", + len(bluetooth.get("bluetoothStates", [])) + if bluetooth is not None + else "", + ) + await process_notifications(login_obj, raw_notifications) # Process last_called data to fire events await update_last_called(login_obj) @@ -554,6 +593,7 @@ async def async_update_data(): }, }, ) + return entity_state @_catch_login_errors async def process_notifications(login_obj, raw_notifications=None): @@ -732,6 +772,7 @@ async def ws_handler(message_obj): seen_commands = hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "websocket_commands" ] + coord = hass.data[DATA_ALEXAMEDIA]["accounts"][email]["coordinator"] if command and json_payload: _LOGGER.debug( @@ -767,6 +808,7 @@ async def ws_handler(message_obj): "timestamp": json_payload["timestamp"], } try: + await coord.async_request_refresh() if serial and serial in existing_serials: await update_last_called(login_obj, last_called) async_dispatcher_send( @@ -1144,6 +1186,7 @@ async def update_listener(hass, config_entry): """Update when config_entry options update.""" account = config_entry.data email = account.get(CONF_EMAIL) + reload_needed: bool = False for key, old_value in hass.data[DATA_ALEXAMEDIA]["accounts"][email][ "options" ].items(): @@ -1156,6 +1199,10 @@ async def update_listener(hass, config_entry): old_value, hass.data[DATA_ALEXAMEDIA]["accounts"][email]["options"][key], ) + if key == CONF_EXTENDED_ENTITY_DISCOVERY: + reload_needed = True + if reload_needed: + await hass.config_entries.async_reload(config_entry.entry_id) async def test_login_status(hass, config_entry, login) -> bool: diff --git a/custom_components/alexa_media/alarm_control_panel.py b/custom_components/alexa_media/alarm_control_panel.py index 9a488f9..5f422ee 100644 --- a/custom_components/alexa_media/alarm_control_panel.py +++ b/custom_components/alexa_media/alarm_control_panel.py @@ -8,10 +8,9 @@ """ from asyncio import sleep import logging -from typing import Dict, List, Text # noqa pylint: disable=unused-import +from typing import Dict, List, Optional, Text # noqa pylint: disable=unused-import -from alexapy import AlexaAPI, hide_email, hide_serial -from homeassistant import util +from alexapy import hide_email, hide_serial from homeassistant.const import ( CONF_EMAIL, STATE_ALARM_ARMED_AWAY, @@ -19,10 +18,9 @@ STATE_UNAVAILABLE, ) from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_call_later -from simplejson import JSONDecodeError +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .alexa_entity import parse_guard_state_from_coordinator from .alexa_media import AlexaMedia from .const import ( CONF_EXCLUDE_DEVICES, @@ -31,8 +29,6 @@ DATA_ALEXAMEDIA, DEFAULT_QUEUE_DELAY, DOMAIN as ALEXA_DOMAIN, - MIN_TIME_BETWEEN_FORCED_SCANS, - MIN_TIME_BETWEEN_SCANS, ) from .helpers import _catch_login_errors, add_devices @@ -75,10 +71,14 @@ async def async_setup_platform( "alarm_control_panel" ] ) = {} - alexa_client: AlexaAlarmControlPanel = AlexaAlarmControlPanel( - account_dict["login_obj"], guard_media_players - ) - await alexa_client.init() + alexa_client: Optional[AlexaAlarmControlPanel] = None + guard_entities = account_dict.get("devices", {}).get("guard", []) + if guard_entities: + alexa_client = AlexaAlarmControlPanel( + account_dict["login_obj"], account_dict["coordinator"], guard_entities[0], guard_media_players + ) + else: + _LOGGER.debug("%s: No Alexa Guard entity found", account) if not (alexa_client and alexa_client.unique_id): _LOGGER.debug( "%s: Skipping creation of uninitialized device: %s", @@ -125,147 +125,30 @@ async def async_unload_entry(hass, entry) -> bool: return True -class AlexaAlarmControlPanel(AlarmControlPanel, AlexaMedia): +class AlexaAlarmControlPanel(AlarmControlPanel, AlexaMedia, CoordinatorEntity): """Implementation of Alexa Media Player alarm control panel.""" - def __init__(self, login, media_players=None) -> None: + def __init__(self, login, coordinator, guard_entity, media_players=None) -> None: # pylint: disable=unexpected-keyword-arg """Initialize the Alexa device.""" - super().__init__(None, login) + AlexaMedia.__init__(self, None, login) + CoordinatorEntity.__init__(self, coordinator) _LOGGER.debug("%s: Initiating alarm control panel", hide_email(login.email)) # AlexaAPI requires a AlexaClient object, need to clean this up - self._available = None - self._assumed_state = None # Guard info - self._appliance_id = None - self._guard_entity_id = None - self._friendly_name = "Alexa Guard" - self._state = None - self._should_poll = False - self._attrs: Dict[Text, Text] = {} + self._appliance_id = guard_entity["appliance_id"] + self._guard_entity_id = guard_entity["id"] + self._friendly_name = "Alexa Guard " + self._appliance_id[-5:] self._media_players = {} or media_players - - @_catch_login_errors - async def init(self): - """Initialize.""" - try: - - data = await self.alexa_api.get_guard_details(self._login) - guard_dict = data["locationDetails"]["locationDetails"]["Default_Location"][ - "amazonBridgeDetails" - ]["amazonBridgeDetails"]["LambdaBridge_AAA/OnGuardSmartHomeBridgeService"][ - "applianceDetails" - ][ - "applianceDetails" - ] - except (KeyError, TypeError, JSONDecodeError): - guard_dict = {} - for _, value in guard_dict.items(): - if value["modelName"] == "REDROCK_GUARD_PANEL": - self._appliance_id = value["applianceId"] - self._guard_entity_id = value["entityId"] - self._friendly_name += " " + self._appliance_id[-5:] - _LOGGER.debug( - "%s: Discovered %s: %s %s", - self.account, - self._friendly_name, - self._appliance_id, - self._guard_entity_id, - ) - if not self._appliance_id: - _LOGGER.debug("%s: No Alexa Guard entity found", self.account) - - async def async_added_to_hass(self): - """Store register state change callback.""" - try: - if not self.enabled: - return - except AttributeError: - pass - # Register event handler on bus - self._listener = async_dispatcher_connect( - self.hass, - f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32], - self._handle_event, - ) - await self.async_update() - - async def async_will_remove_from_hass(self): - """Prepare to remove entity.""" - # Register event handler on bus - self._listener() - - def _handle_event(self, event): - """Handle websocket events. - - Used instead of polling. - """ - try: - if not self.enabled: - return - except AttributeError: - pass - if "push_activity" in event: - async_call_later( - self.hass, - 2, - lambda _: self.hass.async_create_task( - self.async_update(no_throttle=True) - ), - ) - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - @_catch_login_errors - async def async_update(self): - """Update Guard state.""" - try: - if not self.enabled: - return - except AttributeError: - pass - import json - - if self._login.session.closed: - self._available = False - self._assumed_state = True - return - _LOGGER.debug("%s: Refreshing %s", self.account, self.name) - state = None - state_json = await self.alexa_api.get_guard_state( - self._login, self._appliance_id - ) - # _LOGGER.debug("%s: state_json %s", self.account, state_json) - if state_json and "deviceStates" in state_json and state_json["deviceStates"]: - cap = state_json["deviceStates"][0]["capabilityStates"] - # _LOGGER.debug("%s: cap %s", self.account, cap) - for item_json in cap: - item = json.loads(item_json) - # _LOGGER.debug("%s: item %s", self.account, item) - if item["name"] == "armState": - state = item["value"] - # _LOGGER.debug("%s: state %s", self.account, state) - elif state_json["errors"]: - _LOGGER.debug( - "%s: Error refreshing alarm_control_panel %s: %s", + self._attrs: Dict[Text, Text] = {} + _LOGGER.debug( + "%s: Guard Discovered %s: %s %s", self.account, - self.name, - json.dumps(state_json["errors"]) if state_json else None, + self._friendly_name, + hide_serial(self._appliance_id), + hide_serial(self._guard_entity_id), ) - if state is None: - self._available = False - self._assumed_state = True - return - if state == "ARMED_AWAY": - self._state = STATE_ALARM_ARMED_AWAY - elif state == "ARMED_STAY": - self._state = STATE_ALARM_DISARMED - else: - self._state = STATE_ALARM_DISARMED - self._available = True - self._assumed_state = False - _LOGGER.debug("%s: Alarm State: %s", self.account, self.state) - self.async_write_ha_state() @_catch_login_errors async def _async_alarm_set(self, command: Text = "", code=None) -> None: @@ -299,8 +182,7 @@ async def _async_alarm_set(self, command: Text = "", code=None) -> None: await self.alexa_api.static_set_guard_state( self._login, self._guard_entity_id, command ) - await self.async_update(no_throttle=True) - self.async_write_ha_state() + await self.coordinator.async_request_refresh() async def async_alarm_disarm(self, code=None) -> None: # pylint: disable=unexpected-keyword-arg @@ -325,19 +207,13 @@ def name(self): @property def state(self): """Return the state of the device.""" - return self._state - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attrs - - @property - def should_poll(self): - """Return the polling state.""" - return self._should_poll or not ( - self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email]["websocket"] - ) + _state = parse_guard_state_from_coordinator(self.coordinator, self._guard_entity_id) + if _state == "ARMED_AWAY": + return STATE_ALARM_ARMED_AWAY + elif _state == "ARMED_STAY": + return STATE_ALARM_DISARMED + else: + return STATE_ALARM_DISARMED @property def supported_features(self) -> int: @@ -351,11 +227,11 @@ def supported_features(self) -> int: return SUPPORT_ALARM_ARM_AWAY @property - def available(self): - """Return the availability of the device.""" - return self._available + def assumed_state(self) -> bool: + last_refresh_success = self.coordinator.data and self._guard_entity_id in self.coordinator.data + return not last_refresh_success @property - def assumed_state(self): - """Return whether the state is an assumed_state.""" - return self._assumed_state + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs diff --git a/custom_components/alexa_media/alexa_entity.py b/custom_components/alexa_media/alexa_entity.py new file mode 100644 index 0000000..f871190 --- /dev/null +++ b/custom_components/alexa_media/alexa_entity.py @@ -0,0 +1,258 @@ +""" +Alexa Devices Sensors. + +SPDX-License-Identifier: Apache-2.0 + +For more details about this platform, please refer to the documentation at +https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 +""" + +import json +import logging +from typing import Any, Dict, List, Optional, Text, TypedDict, Union + +from alexapy import AlexaAPI, AlexaLogin, hide_serial +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def has_capability( + appliance: Dict[Text, Any], interface_name: Text, property_name: Text +) -> bool: + """Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant. + + Args: + appliance(Dict[Text, Any]): An appliance from a call to AlexaAPI.get_network_details + interface_name(Text): One of the interfaces documented by the Alexa Smart Home Skills API + property_name(Text): The property that matches the interface name. + + """ + for cap in appliance["capabilities"]: + props = cap.get("properties") + if ( + cap["interfaceName"] == interface_name + and props + and (props["retrievable"] or props["proactivelyReported"]) + ): + for prop in props["supported"]: + if prop["name"] == property_name: + return True + return False + + +def is_hue_v1(appliance: Dict[Text, Any]) -> bool: + """Determine if an appliance is managed via the Philips Hue v1 Hub. + + This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older + Philips Hue bulbs and hubs. This includes things exposed by HA to Alexa using the emulated_hue integration. + """ + return appliance.get("manufacturerName") == "Royal Philips Electronics" + + +def is_local(appliance: Dict[Text, Any]) -> bool: + """Test whether locally connected. + + connectedVia is a flag that determines which Echo devices holds the connection. Its blank for + skill derived devices and includes an Echo name for zigbee and local devices. This is used to limit + the scope of what devices will be discovered. This is mainly present to prevent loops with the official Alexa + integration. There is probably a better way to prevent that, but this works. + """ + return appliance["connectedVia"] + + +def is_alexa_guard(appliance: Dict[Text, Any]) -> bool: + """Is the given appliance the guard alarm system of an echo.""" + return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability( + appliance, "Alexa.SecurityPanelController", "armState" + ) + + +def is_temperature_sensor(appliance: Dict[Text, Any]) -> bool: + """Is the given appliance the temperature sensor of an Echo.""" + return ( + is_local(appliance) + and appliance["manufacturerName"] == "Amazon" + and has_capability(appliance, "Alexa.TemperatureSensor", "temperature") + ) + + +def is_light(appliance: Dict[Text, Any]) -> bool: + """Is the given appliance a light controlled locally by an Echo.""" + return ( + is_local(appliance) + and "LIGHT" in appliance["applianceTypes"] + and has_capability(appliance, "Alexa.PowerController", "powerState") + ) + + +def get_friendliest_name(appliance: Dict[Text, Any]) -> Text: + """Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one.""" + aliases = appliance.get("aliases", []) + for alias in aliases: + friendly = alias.get("friendlyName") + if friendly: + return friendly + return appliance["friendlyName"] + + +def get_device_serial(appliance: Dict[Text, Any]) -> Optional[Text]: + """Find the device serial id if it is present.""" + alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", []) + for alexa_device_id in alexa_device_id_list: + if isinstance(alexa_device_id, dict): + return alexa_device_id.get("dmsDeviceSerialNumber") + return None + + +class AlexaEntity(TypedDict): + """Class for Alexaentity.""" + + id: Text + appliance_id: Text + name: Text + is_hue_v1: bool + + +class AlexaLightEntity(AlexaEntity): + """Class for AlexaLightEntity.""" + + brightness: bool + color: bool + color_temperature: bool + + +class AlexaTemperatureEntity(AlexaEntity): + """Class for AlexaTemperatureEntity.""" + + device_serial: Text + + +class AlexaEntities(TypedDict): + """Class for holding entities.""" + + light: List[AlexaLightEntity] + guard: List[AlexaEntity] + temperature: List[AlexaTemperatureEntity] + + +def parse_alexa_entities(network_details: Optional[Dict[Text, Any]]) -> AlexaEntities: + """Turn the network details into a list of useful entities with the important details extracted.""" + lights = [] + guards = [] + temperature_sensors = [] + location_details = network_details["locationDetails"]["locationDetails"] + for location in location_details.values(): + amazon_bridge_details = location["amazonBridgeDetails"]["amazonBridgeDetails"] + for bridge in amazon_bridge_details.values(): + appliance_details = bridge["applianceDetails"]["applianceDetails"] + for appliance in appliance_details.values(): + processed_appliance = { + "id": appliance["entityId"], + "appliance_id": appliance["applianceId"], + "name": get_friendliest_name(appliance), + "is_hue_v1": is_hue_v1(appliance), + } + if is_alexa_guard(appliance): + guards.append(processed_appliance) + elif is_temperature_sensor(appliance): + serial = get_device_serial(appliance) + processed_appliance["device_serial"] = ( + serial if serial else appliance["entityId"] + ) + temperature_sensors.append(processed_appliance) + elif is_light(appliance): + processed_appliance["brightness"] = has_capability( + appliance, "Alexa.BrightnessController", "brightness" + ) + processed_appliance["color"] = has_capability( + appliance, "Alexa.ColorController", "color" + ) + processed_appliance["color_temperature"] = has_capability( + appliance, + "Alexa.ColorTemperatureController", + "colorTemperatureInKelvin", + ) + lights.append(processed_appliance) + + return {"light": lights, "guard": guards, "temperature": temperature_sensors} + + +class AlexaCapabilityState(TypedDict): + """Class for AlexaCapabilityState.""" + + name: Text + namespace: Text + value: Union[int, Text, TypedDict] + + +AlexaEntityData = Dict[Text, List[AlexaCapabilityState]] + + +async def get_entity_data( + login_obj: AlexaLogin, entity_ids: List[Text] +) -> AlexaEntityData: + """Get and process the entity data into a more usable format.""" + raw = await AlexaAPI.get_entity_state(login_obj, entity_ids=entity_ids) + entities = {} + device_states = raw.get("deviceStates") + if device_states: + for device_state in device_states: + entity_id = device_state["entity"]["entityId"] + entities[entity_id] = [] + for cap_state in device_state["capabilityStates"]: + entities[entity_id].append(json.loads(cap_state)) + return entities + + +def parse_temperature_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: Text +) -> Optional[Text]: + """Get the temperature of an entity from the coordinator data.""" + value = parse_value_from_coordinator( + coordinator, entity_id, "Alexa.TemperatureSensor", "temperature" + ) + return value.get("value") if value and "value" in value else None + + +def parse_brightness_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: Text +) -> int: + """Get the brightness in the range 0-100.""" + return parse_value_from_coordinator( + coordinator, entity_id, "Alexa.BrightnessController", "brightness" + ) + + +def parse_power_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: Text +) -> Text: + """Get the power state of the entity.""" + return parse_value_from_coordinator( + coordinator, entity_id, "Alexa.PowerController", "powerState" + ) + + +def parse_guard_state_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: Text +): + """Get the guard state from the coordinator data.""" + return parse_value_from_coordinator( + coordinator, entity_id, "Alexa.SecurityPanelController", "armState" + ) + + +def parse_value_from_coordinator( + coordinator: DataUpdateCoordinator, entity_id: Text, namespace: Text, name: Text +) -> Any: + """Parse out values from coordinator for Alexa Entities.""" + if coordinator.data and entity_id in coordinator.data: + for cap_state in coordinator.data[entity_id]: + if ( + cap_state.get("namespace") == namespace + and cap_state.get("name") == name + ): + return cap_state.get("value") + else: + _LOGGER.debug("Coordinator has no data for %s", hide_serial(entity_id)) + return None diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py index f629e74..a049162 100644 --- a/custom_components/alexa_media/config_flow.py +++ b/custom_components/alexa_media/config_flow.py @@ -46,6 +46,7 @@ CONF_COOKIES_TXT, CONF_DEBUG, CONF_EXCLUDE_DEVICES, + CONF_EXTENDED_ENTITY_DISCOVERY, CONF_HASS_URL, CONF_INCLUDE_DEVICES, CONF_OAUTH, @@ -56,6 +57,7 @@ CONF_SECURITYCODE, CONF_TOTP_REGISTER, DATA_ALEXAMEDIA, + DEFAULT_EXTENDED_ENTITY_DISCOVERY, DEFAULT_QUEUE_DELAY, DOMAIN, HTTP_COOKIE_HEADER, @@ -1025,7 +1027,13 @@ async def async_step_init(self, user_input=None): default=self.config_entry.options.get( CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY ), - ): vol.All(vol.Coerce(float), vol.Clamp(min=0)) + ): vol.All(vol.Coerce(float), vol.Clamp(min=0)), + vol.Required( + CONF_EXTENDED_ENTITY_DISCOVERY, + default=self.config_entry.options.get( + CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY + ) + ): bool } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/custom_components/alexa_media/const.py b/custom_components/alexa_media/const.py index bbdc725..f54c994 100644 --- a/custom_components/alexa_media/const.py +++ b/custom_components/alexa_media/const.py @@ -8,7 +8,7 @@ """ from datetime import timedelta -__version__ = "3.8.6" +__version__ = "3.9.0" PROJECT_URL = "https://github.com/custom-components/alexa_media_player/" ISSUE_URL = f"{PROJECT_URL}issues" @@ -23,7 +23,7 @@ ALEXA_COMPONENTS = [ "media_player", ] -DEPENDENT_ALEXA_COMPONENTS = ["notify", "switch", "sensor", "alarm_control_panel"] +DEPENDENT_ALEXA_COMPONENTS = ["notify", "switch", "sensor", "alarm_control_panel", "light"] HTTP_COOKIE_HEADER = "# HTTP Cookie File" CONF_ACCOUNTS = "accounts" @@ -33,6 +33,7 @@ CONF_INCLUDE_DEVICES = "include_devices" CONF_EXCLUDE_DEVICES = "exclude_devices" CONF_QUEUE_DELAY = "queue_delay" +CONF_EXTENDED_ENTITY_DISCOVERY = "extended_entity_discovery" CONF_SECURITYCODE = "securitycode" CONF_OTPSECRET = "otp_secret" CONF_PROXY = "proxy" @@ -43,6 +44,7 @@ EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}" +DEFAULT_EXTENDED_ENTITY_DISCOVERY = False DEFAULT_QUEUE_DELAY = 1.5 SERVICE_CLEAR_HISTORY = "clear_history" SERVICE_UPDATE_LAST_CALLED = "update_last_called" diff --git a/custom_components/alexa_media/light.py b/custom_components/alexa_media/light.py new file mode 100644 index 0000000..f9dff50 --- /dev/null +++ b/custom_components/alexa_media/light.py @@ -0,0 +1,150 @@ +""" +Alexa Devices Sensors. + +SPDX-License-Identifier: Apache-2.0 + +For more details about this platform, please refer to the documentation at +https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 +""" +import asyncio +import datetime +import logging +from typing import Callable, List, Optional, Text # noqa pylint: disable=unused-import + +from alexapy import AlexaAPI, hide_serial +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + CONF_EMAIL, + CONF_EXCLUDE_DEVICES, + CONF_INCLUDE_DEVICES, + DATA_ALEXAMEDIA, + hide_email, +) +from .alexa_entity import ( + parse_brightness_from_coordinator, + parse_power_from_coordinator, +) +from .const import CONF_EXTENDED_ENTITY_DISCOVERY +from .helpers import add_devices + +_LOGGER = logging.getLogger(__name__) + +LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo + + +async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Set up the Alexa sensor platform.""" + devices: List[LightEntity] = [] + account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + include_filter = config.get(CONF_INCLUDE_DEVICES, []) + exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) + coordinator = account_dict["coordinator"] + hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get("components", set()) + light_entities = account_dict.get("devices", {}).get("light", []) + if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): + for le in light_entities: + if not (le["is_hue_v1"] and hue_emulated_enabled): + _LOGGER.debug("Creating entity %s for a light with name %s", hide_serial(le["id"]), le["name"]) + light = AlexaLight(coordinator, account_dict["login_obj"], le) + account_dict["entities"]["light"].append(light) + devices.append(light) + else: + _LOGGER.debug("Light '%s' has not been added because it may originate from emulated_hue", le["name"]) + + if devices: + await coordinator.async_refresh() + + return await add_devices( + hide_email(account), + devices, + add_devices_callback, + include_filter, + exclude_filter, + ) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alexa sensor platform by config_entry.""" + return await async_setup_platform( + hass, config_entry.data, async_add_devices, discovery_info=None + ) + + +async def async_unload_entry(hass, entry) -> bool: + """Unload a config entry.""" + account = entry.data[CONF_EMAIL] + account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] + _LOGGER.debug("Attempting to unload lights") + for light in account_dict["entities"]["light"]: + await light.async_remove() + return True + + +def ha_brightness_to_alexa(ha): + return ha / 255 * 100 + + +def alexa_brightness_to_ha(alexa): + return alexa / 100 * 255 + + +class AlexaLight(CoordinatorEntity, LightEntity): + """A light controlled by an Echo. """ + + def __init__(self, coordinator, login, details): + super().__init__(coordinator) + self.alexa_entity_id = details["id"] + self._name = details["name"] + self._login = login + self._supported_features = SUPPORT_BRIGHTNESS if details["brightness"] else 0 + + @property + def name(self): + return self._name + + @property + def unique_id(self): + return self.alexa_entity_id + + @property + def supported_features(self): + return self._supported_features + + @property + def is_on(self): + return parse_power_from_coordinator(self.coordinator, self.alexa_entity_id) == "ON" + + @property + def brightness(self): + bright = parse_brightness_from_coordinator(self.coordinator, self.alexa_entity_id) + return alexa_brightness_to_ha(bright) if bright is not None else 255 + + @property + def assumed_state(self) -> bool: + last_refresh_success = self.coordinator.data and self.alexa_entity_id in self.coordinator.data + return not last_refresh_success + + @staticmethod + async def _wait_for_lights(): + await asyncio.sleep(2) + + async def async_turn_on(self, **kwargs): + if self._supported_features & SUPPORT_BRIGHTNESS: + bright = ha_brightness_to_alexa(kwargs.get(ATTR_BRIGHTNESS, 255)) + await AlexaAPI.set_light_state(self._login, self.alexa_entity_id, power_on=True, brightness=bright) + else: + await AlexaAPI.set_light_state(self._login, self.alexa_entity_id, power_on=True) + await self._wait_for_lights() + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + await AlexaAPI.set_light_state(self._login, self.alexa_entity_id, power_on=False) + await self._wait_for_lights() + await self.coordinator.async_request_refresh() diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json index 6022d54..6e61c93 100644 --- a/custom_components/alexa_media/manifest.json +++ b/custom_components/alexa_media/manifest.json @@ -1,7 +1,7 @@ { "domain": "alexa_media", "name": "Alexa Media Player", - "version": "3.8.6", + "version": "3.9.0", "config_flow": true, "documentation": "https://github.com/custom-components/alexa_media_player/wiki", "issue_tracker": "https://github.com/custom-components/alexa_media_player/issues", diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index 3df8242..c12755a 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -13,12 +13,14 @@ from homeassistant.const import ( DEVICE_CLASS_TIMESTAMP, STATE_UNAVAILABLE, + TEMP_CELSIUS, __version__ as HA_VERSION, ) from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from packaging import version import pytz @@ -32,7 +34,12 @@ hide_email, hide_serial, ) -from .const import RECURRING_PATTERN, RECURRING_PATTERN_ISO_SET +from .alexa_entity import parse_temperature_from_coordinator +from .const import ( + CONF_EXTENDED_ENTITY_DISCOVERY, + RECURRING_PATTERN, + RECURRING_PATTERN_ISO_SET, +) from .helpers import add_devices, retry_async _LOGGER = logging.getLogger(__name__) @@ -46,7 +53,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf SENSOR_TYPES = { "Alarm": AlarmSensor, "Timer": TimerSensor, - "Reminder": ReminderSensor, + "Reminder": ReminderSensor } account = config[CONF_EMAIL] if config else discovery_info["config"][CONF_EMAIL] include_filter = config.get(CONF_INCLUDE_DEVICES, []) @@ -106,15 +113,20 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf hide_email(account), alexa_client, ) + + temperature_sensors = [] + temperature_entities = account_dict.get("devices", {}).get("temperature", []) + if temperature_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): + temperature_sensors = await create_temperature_sensors(account_dict, temperature_entities) + return await add_devices( hide_email(account), - devices, + devices + temperature_sensors, add_devices_callback, include_filter, exclude_filter, ) - async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the Alexa sensor platform by config_entry.""" return await async_setup_platform( @@ -134,6 +146,71 @@ async def async_unload_entry(hass, entry) -> bool: return True +async def create_temperature_sensors(account_dict, temperature_entities): + devices = [] + coordinator = account_dict["coordinator"] + for temp in temperature_entities: + _LOGGER.debug("Creating entity %s for a temperature sensor with name %s", temp["id"], temp["name"]) + serial = temp["device_serial"] + device_info = lookup_device_info(account_dict, serial) + sensor = TemperatureSensor(coordinator, temp["id"], temp["name"], device_info) + account_dict["entities"]["sensor"].setdefault(serial, {}) + account_dict["entities"]["sensor"][serial]["Temperature"] = sensor + devices.append(sensor) + await coordinator.async_request_refresh() + return devices + + +def lookup_device_info(account_dict, device_serial): + """Get the device to use for a given Echo based on a given device serial id. + + This may return nothing as there is no guarantee that a given temperature sensor is actually attached to an Echo. + """ + for key, mp in account_dict["entities"]["media_player"].items(): + if key == device_serial and mp.device_info and "identifiers" in mp.device_info: + for ident in mp.device_info["identifiers"]: + return ident + return None + + +class TemperatureSensor(CoordinatorEntity): + """A temperature sensor reported by an Echo. """ + + def __init__(self, coordinator, entity_id, name, media_player_device_id): + super().__init__(coordinator) + self.alexa_entity_id = entity_id + self._name = name + self._media_player_device_id = media_player_device_id + + @property + def name(self): + return self._name + " Temperature" + + @property + def device_info(self): + """Return the device_info of the device.""" + if self._media_player_device_id: + return { + "identifiers": {self._media_player_device_id}, + "via_device": self._media_player_device_id, + } + return None + + @property + def unit_of_measurement(self): + return TEMP_CELSIUS + + @property + def state(self): + return parse_temperature_from_coordinator(self.coordinator, self.alexa_entity_id) + + @property + def unique_id(self): + # This includes "_temperature" because the Alexa entityId is for a physical device + # A single physical device could have multiple HA entities + return self.alexa_entity_id + "_temperature" + + class AlexaMediaNotificationSensor(Entity): """Representation of Alexa Media sensors.""" diff --git a/custom_components/alexa_media/strings.json b/custom_components/alexa_media/strings.json index 45d7963..0844ad3 100644 --- a/custom_components/alexa_media/strings.json +++ b/custom_components/alexa_media/strings.json @@ -103,7 +103,8 @@ "step": { "init": { "data": { - "queue_delay": "Seconds to wait to queue commands together" + "queue_delay": "Seconds to wait to queue commands together", + "extended_entity_discovery": "Include devices connected via Echo" } } } diff --git a/custom_components/alexa_media/translations/en.json b/custom_components/alexa_media/translations/en.json index f64e203..44b132e 100644 --- a/custom_components/alexa_media/translations/en.json +++ b/custom_components/alexa_media/translations/en.json @@ -105,7 +105,8 @@ "step": { "init": { "data": { - "queue_delay": "Seconds to wait to queue commands together" + "queue_delay": "Seconds to wait to queue commands together", + "extended_entity_discovery": "Include devices connected via Echo" } } } diff --git a/custom_components/alexa_media/translations/pt_BR.json b/custom_components/alexa_media/translations/pt_BR.json index 0033d84..7790d6f 100644 --- a/custom_components/alexa_media/translations/pt_BR.json +++ b/custom_components/alexa_media/translations/pt_BR.json @@ -105,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclui dispositivos conectados via Echo", "queue_delay": "Seconds to wait to queue commands together" } } diff --git a/custom_components/alexa_media/translations/pt_PT.json b/custom_components/alexa_media/translations/pt_PT.json index e3bfdf9..4edcbca 100644 --- a/custom_components/alexa_media/translations/pt_PT.json +++ b/custom_components/alexa_media/translations/pt_PT.json @@ -105,6 +105,7 @@ "step": { "init": { "data": { + "extended_entity_discovery": "Inclui dispositivos conectados via Echo", "queue_delay": "Segundos de espera para agrupar comandos" } } diff --git a/custom_components/dwd_weather/connector.py b/custom_components/dwd_weather/connector.py index 71e4fad..c350e99 100644 --- a/custom_components/dwd_weather/connector.py +++ b/custom_components/dwd_weather/connector.py @@ -2,6 +2,7 @@ import logging from datetime import datetime, timedelta, timezone import time +from markdownify import markdownify from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -130,7 +131,7 @@ def _update(self): self.weather_interval, False, ), - "precipitation_probability": precipitation_prop, # ATTR_FORECAST_PRECIPITATION_PROBABILITY + "precipitation_probability": precipitation_prop, } ) timestep += timedelta(hours=self.weather_interval) @@ -141,6 +142,9 @@ def get_condition(self): datetime.now(timezone.utc), False ) + def get_weather_report(self): + return markdownify(self.dwd_weather.weather_report, strip=["br"]) + def get_weather_value(self, data_type: WeatherDataType): value = self.dwd_weather.get_forecast_data( data_type, @@ -159,7 +163,7 @@ def get_weather_value(self, data_type: WeatherDataType): elif data_type == WeatherDataType.WIND_DIRECTION: value = round(value, 0) elif data_type == WeatherDataType.WIND_GUSTS: - value = round(value, 1) + value = round(value * 3.6, 1) elif data_type == WeatherDataType.PRECIPITATION: value = round(value, 1) elif data_type == WeatherDataType.PRECIPITATION_PROBABILITY: diff --git a/custom_components/dwd_weather/manifest.json b/custom_components/dwd_weather/manifest.json index e086e91..ae201bd 100644 --- a/custom_components/dwd_weather/manifest.json +++ b/custom_components/dwd_weather/manifest.json @@ -1,6 +1,6 @@ { "domain": "dwd_weather", - "version": "1.2.8", + "version": "1.2.11", "name": "Deutscher Wetterdienst (DWD)", "documentation": "https://github.com/FL550/dwd_weather", "issue_tracker": "https://github.com/FL550/dwd_weather/issues", @@ -9,5 +9,5 @@ "codeowners": [ "@FL550" ], - "requirements": ["simple_dwd_weatherforecast==1.0.12"] + "requirements": ["simple_dwd_weatherforecast==1.0.17","markdownify==0.6.5"] } diff --git a/custom_components/dwd_weather/sensor.py b/custom_components/dwd_weather/sensor.py index 60b834c..475c10a 100644 --- a/custom_components/dwd_weather/sensor.py +++ b/custom_components/dwd_weather/sensor.py @@ -1,6 +1,7 @@ """Sensor for Deutscher Wetterdienst weather service.""" import logging +import re from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -10,7 +11,7 @@ DEVICE_CLASS_TEMPERATURE, LENGTH_KILOMETERS, PRESSURE_HPA, - SPEED_METERS_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_SECONDS, ) @@ -40,6 +41,13 @@ # variable -> [0]title, [1]device_class, [2]units, [3]icon, [4]enabled_by_default SENSOR_TYPES = { "weather": ["Weather", None, None, "mdi:weather-partly-cloudy", False], + "weather_report": [ + "Weather Report", + None, + None, + "mdi:weather-partly-cloudy", + False, + ], "temperature": [ "Temperature", DEVICE_CLASS_TEMPERATURE, @@ -58,7 +66,7 @@ "wind_speed": [ "Wind Speed", None, - SPEED_METERS_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", False, ], @@ -66,7 +74,7 @@ "wind_gusts": [ "Wind Gusts", None, - SPEED_METERS_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", False, ], @@ -155,6 +163,11 @@ def state(self): result = "" if self._type == "weather": result = self._connector.get_condition() + elif self._type == "weather_report": + result = re.search( + "\w+, \d{2}\.\d{2}\.\d{2}, \d{2}:\d{2}", + self._connector.get_weather_report(), + ).group() elif self._type == "temperature": result = self._connector.get_temperature() elif self._type == "dewpoint": @@ -218,6 +231,8 @@ def device_state_attributes(self): if self._type == "weather": attributes["data"] = self._connector.get_condition_hourly() + elif self._type == "weather_report": + attributes["data"] = self._connector.get_weather_report() elif self._type == "temperature": attributes["data"] = self._connector.get_temperature_hourly() elif self._type == "dewpoint": diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index 566e3d8..5df325d 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -1,6 +1,6 @@ """Base HACS class.""" import logging -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING import pathlib import attr @@ -14,6 +14,9 @@ from .models.frontend import HacsFrontend from .models.system import HacsSystem +if TYPE_CHECKING: + from .helpers.classes.repository import HacsRepository + class HacsCommon: """Common for HACS.""" @@ -51,7 +54,7 @@ class HacsBaseAttributes: frontend: HacsFrontend = attr.ib(HacsFrontend) log: logging.Logger = getLogger() system: HacsSystem = attr.ib(HacsSystem) - repositories: List = [] + repositories: List["HacsRepository"] = [] @attr.s diff --git a/custom_components/hacs/const.py b/custom_components/hacs/const.py index ec90fa0..815dd7b 100644 --- a/custom_components/hacs/const.py +++ b/custom_components/hacs/const.py @@ -1,7 +1,9 @@ """Constants for HACS""" +from aiogithubapi.common.const import ACCEPT_HEADERS + NAME_LONG = "HACS (Home Assistant Community Store)" NAME_SHORT = "HACS" -INTEGRATION_VERSION = "1.11.3" +INTEGRATION_VERSION = "1.12.3" DOMAIN = "hacs" CLIENT_ID = "395a8e669c5de9f7c6e8" MINIMUM_HA_VERSION = "2020.12.0" @@ -18,6 +20,16 @@ PACKAGE_NAME = "custom_components.hacs" +HACS_GITHUB_API_HEADERS = { + "User-Agent": f"HACS/{INTEGRATION_VERSION}", + "Accept": ACCEPT_HEADERS["preview"], +} + +HACS_ACTION_GITHUB_API_HEADERS = { + "User-Agent": "HACS/action", + "Accept": ACCEPT_HEADERS["preview"], +} + IFRAME = { "title": "HACS", "icon": "hacs:hacs", diff --git a/custom_components/hacs/hacsbase/data.py b/custom_components/hacs/hacsbase/data.py index 53779ea..d05bbb4 100644 --- a/custom_components/hacs/hacsbase/data.py +++ b/custom_components/hacs/hacsbase/data.py @@ -58,7 +58,7 @@ async def async_write(self): await self.queue.execute() await async_save_to_store(self.hacs.hass, "repositories", self.content) self.hacs.hass.bus.async_fire("hacs/repository", {}) - self.hacs.hass.bus.fire("hacs/config", {}) + self.hacs.hass.bus.async_fire("hacs/config", {}) async def async_store_repository_data(self, repository): repository_manifest = repository.repository_manifest.manifest diff --git a/custom_components/hacs/hacsbase/hacs.py b/custom_components/hacs/hacsbase/hacs.py index d2fab1a..1931d04 100644 --- a/custom_components/hacs/hacsbase/hacs.py +++ b/custom_components/hacs/hacsbase/hacs.py @@ -137,18 +137,18 @@ async def startup_tasks(self, _event=None): self.hass.bus.async_fire("hacs/status", {}) await self.handle_critical_repositories_startup() - await self.handle_critical_repositories() await self.async_load_default_repositories() await self.clear_out_removed_repositories() self.recuring_tasks.append( self.hass.helpers.event.async_track_time_interval( - self.recurring_tasks_installed, timedelta(minutes=30) + self.recurring_tasks_installed, timedelta(hours=2) ) ) + self.recuring_tasks.append( self.hass.helpers.event.async_track_time_interval( - self.recurring_tasks_all, timedelta(minutes=800) + self.recurring_tasks_all, timedelta(hours=25) ) ) self.recuring_tasks.append( @@ -278,6 +278,8 @@ async def recurring_tasks_installed(self, _notarealarg=None): self.hass.bus.async_fire("hacs/status", {}) for repository in self.repositories: + if self.status.startup and repository.data.full_name == "hacs/integration": + continue if ( repository.data.installed and repository.data.category in self.common.categories diff --git a/custom_components/hacs/helpers/classes/repository.py b/custom_components/hacs/helpers/classes/repository.py index 10af643..0ff89bd 100644 --- a/custom_components/hacs/helpers/classes/repository.py +++ b/custom_components/hacs/helpers/classes/repository.py @@ -269,7 +269,7 @@ async def common_update(self, ignore_issues=False, force=False): ) return False - # Update last updaeted + # Update last updated self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) # Update last available commit diff --git a/custom_components/hacs/helpers/functions/get_list_from_default.py b/custom_components/hacs/helpers/functions/get_list_from_default.py index 951951b..3b4e5ba 100644 --- a/custom_components/hacs/helpers/functions/get_list_from_default.py +++ b/custom_components/hacs/helpers/functions/get_list_from_default.py @@ -6,7 +6,6 @@ from custom_components.hacs.enums import HacsCategory from custom_components.hacs.helpers.classes.exceptions import HacsException -from custom_components.hacs.helpers.functions.information import get_repository from custom_components.hacs.share import get_hacs @@ -16,10 +15,9 @@ async def async_get_list_from_default(default: HacsCategory) -> List: repositories = [] try: - repo, _ = await get_repository( - hacs.session, hacs.configuration.token, "hacs/default", None + content = await hacs.data_repo.get_contents( + default, hacs.data_repo.default_branch ) - content = await repo.get_contents(default, repo.default_branch) repositories = json.loads(content.content) except (AIOGitHubAPIException, HacsException) as exception: diff --git a/custom_components/hacs/helpers/functions/information.py b/custom_components/hacs/helpers/functions/information.py index 5e82ad3..4e9b841 100644 --- a/custom_components/hacs/helpers/functions/information.py +++ b/custom_components/hacs/helpers/functions/information.py @@ -9,6 +9,7 @@ ) from custom_components.hacs.helpers.functions.template import render_template from custom_components.hacs.share import get_hacs +from custom_components.hacs.const import HACS_GITHUB_API_HEADERS def info_file(repository): @@ -48,7 +49,11 @@ async def get_info_md_content(repository): async def get_repository(session, token, repository_full_name, etag=None): """Return a repository object or None.""" try: - github = GitHub(token, session) + github = GitHub( + token, + session, + headers=HACS_GITHUB_API_HEADERS, + ) repository = await github.get_repo(repository_full_name, etag) return repository, github.client.last_response.etag except AIOGitHubAPINotModifiedException as exception: diff --git a/custom_components/hacs/manifest.json b/custom_components/hacs/manifest.json index 44b3e1e..f714077 100644 --- a/custom_components/hacs/manifest.json +++ b/custom_components/hacs/manifest.json @@ -12,15 +12,16 @@ ], "documentation": "https://hacs.xyz/docs/configuration/start", "domain": "hacs", + "iot_class": "cloud_polling", "issue_tracker": "https://github.com/hacs/integration/issues", "name": "HACS", "requirements": [ "aiofiles>=0.6.0", - "aiogithubapi>=21.2.0", + "aiogithubapi>=21.4.0", "awesomeversion>=21.2.2", "backoff>=1.10.0", - "hacs_frontend==20210214110032", + "hacs_frontend==20210429001005", "queueman==0.5" ], - "version": "1.11.3" + "version": "1.12.3" } \ No newline at end of file diff --git a/custom_components/hacs/operational/setup.py b/custom_components/hacs/operational/setup.py index 32440a6..c355e08 100644 --- a/custom_components/hacs/operational/setup.py +++ b/custom_components/hacs/operational/setup.py @@ -3,11 +3,17 @@ from aiogithubapi import AIOGitHubAPIException, GitHub from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import __version__ as HAVERSION +from homeassistant.core import CoreState from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_call_later -from custom_components.hacs.const import DOMAIN, INTEGRATION_VERSION, STARTUP +from custom_components.hacs.const import ( + DOMAIN, + HACS_GITHUB_API_HEADERS, + INTEGRATION_VERSION, + STARTUP, +) from custom_components.hacs.enums import HacsDisabledReason, HacsStage from custom_components.hacs.hacsbase.configuration import Configuration from custom_components.hacs.hacsbase.data import HacsData @@ -142,7 +148,9 @@ async def async_hacs_startup(): hacs.system.lovelace_mode = lovelace_info.get("mode", "yaml") hacs.enable() hacs.github = GitHub( - hacs.configuration.token, async_create_clientsession(hacs.hass) + hacs.configuration.token, + async_create_clientsession(hacs.hass), + headers=HACS_GITHUB_API_HEADERS, ) hacs.data = HacsData() @@ -157,7 +165,7 @@ async def async_hacs_startup(): else: reset = datetime.fromtimestamp(int(hacs.github.client.ratelimits.reset)) hacs.log.error( - "HACS is ratelimited, HACS will resume setup when the limit is cleared (%s:%s:%s)", + "HACS is ratelimited, HACS will resume setup when the limit is cleared (%02d:%02d:%02d)", reset.hour, reset.minute, reset.second, @@ -192,15 +200,10 @@ async def async_hacs_startup(): return False # Setup startup tasks - if hacs.status.new or hacs.configuration.config_type == "flow": + if hacs.hass.state == CoreState.running: async_call_later(hacs.hass, 5, hacs.startup_tasks) else: - if hacs.hass.state == "RUNNING": - async_call_later(hacs.hass, 5, hacs.startup_tasks) - else: - hacs.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, hacs.startup_tasks - ) + hacs.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, hacs.startup_tasks) # Set up sensor await async_add_sensor() diff --git a/custom_components/hacs/operational/setup_actions/categories.py b/custom_components/hacs/operational/setup_actions/categories.py index 9ad5d0c..5092f66 100644 --- a/custom_components/hacs/operational/setup_actions/categories.py +++ b/custom_components/hacs/operational/setup_actions/categories.py @@ -14,15 +14,13 @@ def _setup_extra_stores(): enable_category(hacs, HacsCategory(category)) if HacsCategory.PYTHON_SCRIPT in hacs.hass.config.components: - if HacsCategory.PYTHON_SCRIPT not in hacs.common.categories: - enable_category(hacs, HacsCategory.PYTHON_SCRIPT) + enable_category(hacs, HacsCategory.PYTHON_SCRIPT) if ( hacs.hass.services._services.get("frontend", {}).get("reload_themes") is not None ): - if HacsCategory.THEME not in hacs.common.categories: - enable_category(hacs, HacsCategory.THEME) + enable_category(hacs, HacsCategory.THEME) if hacs.configuration.appdaemon: enable_category(hacs, HacsCategory.APPDAEMON) @@ -39,5 +37,6 @@ async def async_setup_extra_stores(): def enable_category(hacs, category: HacsCategory): """Add category.""" - hacs.log.debug("Enable category: %s", category) - hacs.common.categories.add(category) + if category not in hacs.common.categories: + hacs.log.info("Enable category: %s", category) + hacs.common.categories.add(category) diff --git a/custom_components/weenect/__init__.py b/custom_components/weenect/__init__.py index a076b39..a2c8b46 100644 --- a/custom_components/weenect/__init__.py +++ b/custom_components/weenect/__init__.py @@ -93,7 +93,7 @@ async def _async_update_data(self): self._detect_added_and_removed_trackers(data) return data except Exception as exception: - raise UpdateFailed() from exception + raise UpdateFailed(exception) from exception def _detect_added_and_removed_trackers(self, data: Any): """Detect if trackers were added or removed.""" diff --git a/custom_components/weenect/const.py b/custom_components/weenect/const.py index 6b5cafa..65ca1a5 100644 --- a/custom_components/weenect/const.py +++ b/custom_components/weenect/const.py @@ -10,7 +10,7 @@ NAME = "Weenect" DOMAIN = "weenect" DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.0.1" +VERSION = "1.0.7" ATTRIBUTION = "Data provided by https://my.weenect.com/" ISSUE_URL = "https://github.com/eifinger/hass-weenect/issues" diff --git a/custom_components/weenect/manifest.json b/custom_components/weenect/manifest.json index 95e0b1b..2f559d7 100644 --- a/custom_components/weenect/manifest.json +++ b/custom_components/weenect/manifest.json @@ -4,12 +4,13 @@ "documentation": "https://github.com/eifinger/hass-weenect", "issue_tracker": "https://github.com/eifinger/hass-weenect/issues", "dependencies": [], - "version": "0.0.1", + "version": "1.0.7", "config_flow": true, "codeowners": [ "@eifinger" ], "requirements": [ - "aioweenect==1.0.1" - ] + "aioweenect==1.1.0" + ], + "iot_class": "cloud_polling" } \ No newline at end of file