Ejemplo n.º 1
0
 def __init__(
     self,
     hass: HomeAssistant,
     coordinator: DataUpdateCoordinator,
     config_entry: ConfigEntry,
     process_entities_callback: Callable,
     async_add_entities: AddEntitiesCallback,
 ) -> None:
     self.hass = hass
     self.coordinator = coordinator
     self.config_entry = config_entry
     self.process_entities_callback = process_entities_callback
     self.async_add_entities = async_add_entities
     hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER].append(
         coordinator.async_add_listener(self.process_entities))
     self.entity_unique_ids = set()
     self.entities = {}
Ejemplo n.º 2
0
class SensorManager:
    """Class that handles registering and updating Hue sensor entities.

    Intended to be a singleton.
    """

    SCAN_INTERVAL = timedelta(seconds=5)

    def __init__(self, bridge):
        """Initialize the sensor manager."""
        self.bridge = bridge
        self._component_add_entities = {}
        self.current = {}
        self.current_events = {}

        self._enabled_platforms = ("binary_sensor", "sensor")
        self.coordinator = DataUpdateCoordinator(
            bridge.hass,
            _LOGGER,
            name="sensor",
            update_method=self.async_update_data,
            update_interval=self.SCAN_INTERVAL,
            request_refresh_debouncer=debounce.Debouncer(
                bridge.hass,
                _LOGGER,
                cooldown=REQUEST_REFRESH_DELAY,
                immediate=True),
        )

    async def async_update_data(self):
        """Update sensor data."""
        try:
            with async_timeout.timeout(4):
                return await self.bridge.async_request_call(
                    self.bridge.api.sensors.update)
        except Unauthorized as err:
            await self.bridge.handle_unauthorized_error()
            raise UpdateFailed("Unauthorized") from err
        except AiohueException as err:
            raise UpdateFailed(f"Hue error: {err}") from err

    async def async_register_component(self, platform, async_add_entities):
        """Register async_add_entities methods for components."""
        self._component_add_entities[platform] = async_add_entities

        if len(self._component_add_entities) < len(self._enabled_platforms):
            _LOGGER.debug("Aborting start with %s, waiting for the rest",
                          platform)
            return

        # We have all components available, start the updating.
        self.bridge.reset_jobs.append(
            self.coordinator.async_add_listener(self.async_update_items))
        await self.coordinator.async_refresh()

    @callback
    def async_update_items(self):
        """Update sensors from the bridge."""
        api = self.bridge.api.sensors

        if len(self._component_add_entities) < len(self._enabled_platforms):
            return

        to_add = {}
        primary_sensor_devices = {}
        current = self.current

        # Physical Hue motion sensors present as three sensors in the API: a
        # presence sensor, a temperature sensor, and a light level sensor. Of
        # these, only the presence sensor is assigned the user-friendly name
        # that the user has given to the device. Each of these sensors is
        # linked by a common device_id, which is the first twenty-three
        # characters of the unique id (then followed by a hyphen and an ID
        # specific to the individual sensor).
        #
        # To set up neat values, and assign the sensor entities to the same
        # device, we first, iterate over all the sensors and find the Hue
        # presence sensors, then iterate over all the remaining sensors -
        # finding the remaining ones that may or may not be related to the
        # presence sensors.
        for item_id in api:
            if api[item_id].type != TYPE_ZLL_PRESENCE:
                continue

            primary_sensor_devices[_device_id(api[item_id])] = api[item_id]

        # Iterate again now we have all the presence sensors, and add the
        # related sensors with nice names where appropriate.
        for item_id in api:
            uniqueid = api[item_id].uniqueid
            if current.get(uniqueid,
                           self.current_events.get(uniqueid)) is not None:
                continue

            sensor_type = api[item_id].type

            # Check for event generator devices
            event_config = EVENT_CONFIG_MAP.get(sensor_type)
            if event_config is not None:
                base_name = api[item_id].name
                name = event_config["name_format"].format(base_name)
                new_event = event_config["class"](api[item_id], name,
                                                  self.bridge)
                self.bridge.hass.async_create_task(
                    new_event.async_update_device_registry())
                self.current_events[uniqueid] = new_event

            sensor_config = SENSOR_CONFIG_MAP.get(sensor_type)
            if sensor_config is None:
                continue

            base_name = api[item_id].name
            primary_sensor = primary_sensor_devices.get(
                _device_id(api[item_id]))
            if primary_sensor is not None:
                base_name = primary_sensor.name
            name = sensor_config["name_format"].format(base_name)

            current[uniqueid] = sensor_config["class"](
                api[item_id], name, self.bridge, primary_sensor=primary_sensor)

            to_add.setdefault(sensor_config["platform"],
                              []).append(current[uniqueid])

        self.bridge.hass.async_create_task(
            remove_devices(
                self.bridge,
                [value.uniqueid for value in api.values()],
                current,
            ))

        for platform in to_add:
            self._component_add_entities[platform](to_add[platform])
Ejemplo n.º 3
0
class DataManager:
    """Manage withing data."""
    def __init__(
        self,
        hass: HomeAssistant,
        profile: str,
        api: ConfigEntryWithingsApi,
        user_id: int,
        webhook_config: WebhookConfig,
    ):
        """Initialize the data manager."""
        self._hass = hass
        self._api = api
        self._user_id = user_id
        self._profile = profile
        self._webhook_config = webhook_config
        self._notify_subscribe_delay = datetime.timedelta(seconds=5)
        self._notify_unsubscribe_delay = datetime.timedelta(seconds=1)

        self._is_available = True
        self._cancel_interval_update_interval: Optional[CALLBACK_TYPE] = None
        self._cancel_configure_webhook_subscribe_interval: Optional[
            CALLBACK_TYPE] = None
        self._api_notification_id = f"withings_{self._user_id}"

        self.subscription_update_coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            name="subscription_update_coordinator",
            update_interval=timedelta(minutes=120),
            update_method=self.async_subscribe_webhook,
        )
        self.poll_data_update_coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            name="poll_data_update_coordinator",
            update_interval=timedelta(minutes=120)
            if self._webhook_config.enabled else timedelta(minutes=10),
            update_method=self.async_get_all_data,
        )
        self.webhook_update_coordinator = WebhookUpdateCoordinator(
            self._hass, self._user_id)
        self._cancel_subscription_update: Optional[Callable[[], None]] = None
        self._subscribe_webhook_run_count = 0

    @property
    def webhook_config(self) -> WebhookConfig:
        """Get the webhook config."""
        return self._webhook_config

    @property
    def user_id(self) -> int:
        """Get the user_id of the authenticated user."""
        return self._user_id

    @property
    def profile(self) -> str:
        """Get the profile."""
        return self._profile

    def async_start_polling_webhook_subscriptions(self) -> None:
        """Start polling webhook subscriptions (if enabled) to reconcile their setup."""
        self.async_stop_polling_webhook_subscriptions()

        def empty_listener() -> None:
            pass

        self._cancel_subscription_update = self.subscription_update_coordinator.async_add_listener(
            empty_listener)

    def async_stop_polling_webhook_subscriptions(self) -> None:
        """Stop polling webhook subscriptions."""
        if self._cancel_subscription_update:
            self._cancel_subscription_update()
            self._cancel_subscription_update = None

    async def _do_retry(self, func, attempts=3) -> Any:
        """Retry a function call.

        Withings' API occasionally and incorrectly throws errors. Retrying the call tends to work.
        """
        exception = None
        for attempt in range(1, attempts + 1):
            _LOGGER.debug("Attempt %s of %s", attempt, attempts)
            try:
                return await func()
            except Exception as exception1:  # pylint: disable=broad-except
                await asyncio.sleep(0.1)
                exception = exception1
                continue

        if exception:
            raise exception

    async def async_subscribe_webhook(self) -> None:
        """Subscribe the webhook to withings data updates."""
        return await self._do_retry(self._async_subscribe_webhook)

    async def _async_subscribe_webhook(self) -> None:
        _LOGGER.debug("Configuring withings webhook")

        # On first startup, perform a fresh re-subscribe. Withings stops pushing data
        # if the webhook fails enough times but they don't remove the old subscription
        # config. This ensures the subscription is setup correctly and they start
        # pushing again.
        if self._subscribe_webhook_run_count == 0:
            _LOGGER.debug("Refreshing withings webhook configs")
            await self.async_unsubscribe_webhook()
        self._subscribe_webhook_run_count += 1

        # Get the current webhooks.
        response = await self._hass.async_add_executor_job(
            self._api.notify_list)

        subscribed_applis = frozenset([
            profile.appli for profile in response.profiles
            if profile.callbackurl == self._webhook_config.url
        ])

        # Determine what subscriptions need to be created.
        ignored_applis = frozenset({NotifyAppli.USER})
        to_add_applis = frozenset([
            appli for appli in NotifyAppli
            if appli not in subscribed_applis and appli not in ignored_applis
        ])

        # Subscribe to each one.
        for appli in to_add_applis:
            _LOGGER.debug(
                "Subscribing %s for %s in %s seconds",
                self._webhook_config.url,
                appli,
                self._notify_subscribe_delay.total_seconds(),
            )
            # Withings will HTTP HEAD the callback_url and needs some downtime
            # between each call or there is a higher chance of failure.
            await asyncio.sleep(self._notify_subscribe_delay.total_seconds())
            await self._hass.async_add_executor_job(self._api.notify_subscribe,
                                                    self._webhook_config.url,
                                                    appli)

    async def async_unsubscribe_webhook(self) -> None:
        """Unsubscribe webhook from withings data updates."""
        return await self._do_retry(self._async_unsubscribe_webhook)

    async def _async_unsubscribe_webhook(self) -> None:
        # Get the current webhooks.
        response = await self._hass.async_add_executor_job(
            self._api.notify_list)

        # Revoke subscriptions.
        for profile in response.profiles:
            _LOGGER.debug(
                "Unsubscribing %s for %s in %s seconds",
                profile.callbackurl,
                profile.appli,
                self._notify_unsubscribe_delay.total_seconds(),
            )
            # Quick calls to Withings can result in the service returning errors. Give them
            # some time to cool down.
            await asyncio.sleep(self._notify_subscribe_delay.total_seconds())
            await self._hass.async_add_executor_job(self._api.notify_revoke,
                                                    profile.callbackurl,
                                                    profile.appli)

    async def async_get_all_data(self) -> Optional[Dict[MeasureType, Any]]:
        """Update all withings data."""
        try:
            return await self._do_retry(self._async_get_all_data)
        except Exception as exception:
            # User is not authenticated.
            if isinstance(
                    exception,
                (UnauthorizedException,
                 AuthFailedException)) or NOT_AUTHENTICATED_ERROR.match(
                     str(exception)):
                context = {
                    const.PROFILE: self._profile,
                    "userid": self._user_id,
                    "source": "reauth",
                }

                # Check if reauth flow already exists.
                flow = next(
                    iter(flow for flow in
                         self._hass.config_entries.flow.async_progress()
                         if flow.context == context),
                    None,
                )
                if flow:
                    return

                # Start a reauth flow.
                await self._hass.config_entries.flow.async_init(
                    const.DOMAIN,
                    context=context,
                )
                return

            raise exception

    async def _async_get_all_data(self) -> Optional[Dict[MeasureType, Any]]:
        _LOGGER.info("Updating all withings data")
        return {
            **await self.async_get_measures(),
            **await self.async_get_sleep_summary(),
        }

    async def async_get_measures(self) -> Dict[MeasureType, Any]:
        """Get the measures data."""
        _LOGGER.debug("Updating withings measures")

        response = await self._hass.async_add_executor_job(
            self._api.measure_get_meas)

        # Sort from oldest to newest.
        groups = sorted(
            query_measure_groups(response, MeasureTypes.ANY,
                                 MeasureGroupAttribs.UNAMBIGUOUS),
            key=lambda group: group.created.datetime,
            reverse=False,
        )

        return {
            WITHINGS_MEASURE_TYPE_MAP[measure.type].measurement:
            round(float(measure.value * pow(10, measure.unit)), 2)
            for group in groups for measure in group.measures
        }

    async def async_get_sleep_summary(self) -> Dict[MeasureType, Any]:
        """Get the sleep summary data."""
        _LOGGER.debug("Updating withing sleep summary")
        now = dt.utcnow()
        yesterday = now - datetime.timedelta(days=1)
        yesterday_noon = datetime.datetime(
            yesterday.year,
            yesterday.month,
            yesterday.day,
            12,
            0,
            0,
            0,
            datetime.timezone.utc,
        )

        def get_sleep_summary() -> SleepGetSummaryResponse:
            return self._api.sleep_get_summary(lastupdate=yesterday_noon)

        response = await self._hass.async_add_executor_job(get_sleep_summary)

        # Set the default to empty lists.
        raw_values: Dict[GetSleepSummaryField, List[int]] = {
            field: []
            for field in GetSleepSummaryField
        }

        # Collect the raw data.
        for serie in response.series:
            data = serie.data

            for field in GetSleepSummaryField:
                raw_values[field].append(data._asdict()[field.value])

        values: Dict[GetSleepSummaryField, float] = {}

        def average(data: List[int]) -> float:
            return sum(data) / len(data)

        def set_value(field: GetSleepSummaryField, func: Callable) -> None:
            non_nones = [
                value for value in raw_values.get(field, [])
                if value is not None
            ]
            values[field] = func(non_nones) if non_nones else None

        set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY,
                  average)
        set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum)
        set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average)
        set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average)
        set_value(GetSleepSummaryField.HR_AVERAGE, average)
        set_value(GetSleepSummaryField.HR_MAX, average)
        set_value(GetSleepSummaryField.HR_MIN, average)
        set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum)
        set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum)
        set_value(GetSleepSummaryField.RR_AVERAGE, average)
        set_value(GetSleepSummaryField.RR_MAX, average)
        set_value(GetSleepSummaryField.RR_MIN, average)
        set_value(GetSleepSummaryField.SLEEP_SCORE, max)
        set_value(GetSleepSummaryField.SNORING, average)
        set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum)
        set_value(GetSleepSummaryField.WAKEUP_COUNT, sum)
        set_value(GetSleepSummaryField.WAKEUP_DURATION, average)

        return {
            WITHINGS_MEASURE_TYPE_MAP[field].measurement:
            round(value, 4) if value is not None else None
            for field, value in values.items()
        }

    async def async_webhook_data_updated(self,
                                         data_category: NotifyAppli) -> None:
        """Handle scenario when data is updated from a webook."""
        _LOGGER.debug("Withings webhook triggered")
        if data_category in {
                NotifyAppli.WEIGHT,
                NotifyAppli.CIRCULATORY,
                NotifyAppli.SLEEP,
        }:
            await self.poll_data_update_coordinator.async_request_refresh()

        elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}:
            self.webhook_update_coordinator.update_data(
                Measurement.IN_BED, data_category == NotifyAppli.BED_IN)
Ejemplo n.º 4
0
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]

    light_coordinator = DataUpdateCoordinator(
        hass,
        _LOGGER,
        name="light",
        update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update),
        update_interval=SCAN_INTERVAL,
        request_refresh_debouncer=Debouncer(
            bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
        ),
    )

    # First do a refresh to see if we can reach the hub.
    # Otherwise we will declare not ready.
    await light_coordinator.async_refresh()

    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:
        return

    group_coordinator = DataUpdateCoordinator(
        hass,
        _LOGGER,
        name="group",
        update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update),
        update_interval=SCAN_INTERVAL,
        request_refresh_debouncer=Debouncer(
            bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
        ),
    )

    update_groups = partial(
        async_update_items,
        bridge,
        bridge.api.groups,
        {},
        async_add_entities,
        partial(create_light, HueLight, group_coordinator, bridge, True),
    )

    bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups))
    await group_coordinator.async_refresh()
Ejemplo n.º 5
0
async def async_setup_entry(hass, config_entry, async_add_entities):
    """Set up the Hue lights from a config entry."""
    bridge: HueBridge = hass.data[HUE_DOMAIN][config_entry.entry_id]
    api_version = tuple(
        int(v) for v in bridge.api.config.apiversion.split("."))
    rooms = {}

    allow_groups = config_entry.options.get(CONF_ALLOW_HUE_GROUPS,
                                            DEFAULT_ALLOW_HUE_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,
        LOGGER,
        name="light",
        update_method=partial(async_safe_fetch, bridge,
                              bridge.api.lights.update),
        update_interval=SCAN_INTERVAL,
        request_refresh_debouncer=Debouncer(bridge.hass,
                                            LOGGER,
                                            cooldown=REQUEST_REFRESH_DELAY,
                                            immediate=True),
    )

    # First do a refresh to see if we can reach the hub.
    # Otherwise we will declare not ready.
    await light_coordinator.async_refresh()

    if not light_coordinator.last_update_success:
        raise PlatformNotReady

    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(
        hass,
        LOGGER,
        name="group",
        update_method=partial(async_safe_fetch, bridge,
                              bridge.api.groups.update),
        update_interval=SCAN_INTERVAL,
        request_refresh_debouncer=Debouncer(bridge.hass,
                                            LOGGER,
                                            cooldown=REQUEST_REFRESH_DELAY,
                                            immediate=True),
    )

    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 not in [GROUP_TYPE_ROOM, GROUP_TYPE_ZONE]:
                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.lights,
        {},
        async_add_entities,
        partial(create_light, HueLight, light_coordinator, bridge, False,
                rooms),
        _setup_rooms_listener,
    )
    # 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()
Ejemplo n.º 6
0
class TTN_client:
    __instances = {}

    @staticmethod
    def createInstance(hass: HomeAssistantType, entry: ConfigEntry):
        """ Static access method. """
        application_id = entry.data[CONF_APP_ID]

        if application_id not in TTN_client.__instances:
            TTN_client.__instances[application_id] = TTN_client(hass, entry)

        return TTN_client.__instances[application_id]

    @staticmethod
    def getInstance(entry: ConfigEntry):
        application_id = entry.data[CONF_APP_ID]
        return TTN_client.__instances[application_id]

    @staticmethod
    async def deleteInstance(hass: HomeAssistantType, entry: ConfigEntry):
        """ Static access method. """
        application_id = entry.data[CONF_APP_ID]

        unload_ok = all(await asyncio.gather(*[
            hass.config_entries.async_forward_entry_unload(entry, component)
            for component in COMPONENT_TYPES
        ]))

        if unload_ok and application_id in TTN_client.__instances:
            del TTN_client.__instances[application_id]

        return unload_ok

    def get_device_ids(self):
        ids = []
        for entitiy in self.__entities.values():
            ids.append(entitiy.device_id)
        return sorted(list(set(ids)))

    def get_field_ids(self):
        ids = []
        for entitiy in self.__entities.values():
            ids.append(entitiy.field_id)
        return sorted(list(set(ids)))

    def get_options(self):
        return self.__entry.options

    def get_device_options(self, device_id):
        devices = self.get_options().get(OPTIONS_MENU_EDIT_DEVICES, {})
        return devices.get(device_id, {})

    def get_field_options(self, device_id, field_id):
        fields = self.get_options().get(OPTIONS_MENU_EDIT_FIELDS, {})
        field_opts = fields.get(field_id, {})
        if (field_opts.get(OPTIONS_FIELD_DEVICE_SCOPE,
                           device_id) == device_id):
            return field_opts
        else:
            return {}

    def get_first_fetch_last_h(self):
        integration_settings = self.get_options().get(
            OPTIONS_MENU_EDIT_INTEGRATION, {})
        return integration_settings.get(
            OPTIONS_MENU_INTEGRATION_FIRST_FETCH_TIME_H,
            DEFAULT_FIRST_FETCH_LAST_H)

    def get_refresh_period_s(self):
        integration_settings = self.get_options().get(
            OPTIONS_MENU_EDIT_INTEGRATION, {})
        return integration_settings.get(
            OPTIONS_MENU_INTEGRATION_REFRESH_TIME_S,
            DEFAULT_API_REFRESH_PERIOD_S)

    @property
    def hass(self):
        return self.__hass

    @property
    def entry(self):
        return self.__entry

    def __init__(self, hass, entry):

        self.__entry = entry
        self.__hass = hass
        self.__application_id = entry.data[CONF_APP_ID]
        self.__access_key = entry.data[CONF_ACCESS_KEY]
        LOGGER.debug(
            f"Creating TTN_client with application_id {self.__application_id}")

        self.__entities = {}
        self.__is_connected = False
        self.__first_fetch = True
        self.__coordinator = None
        self.__async_add_sensor_entities = None
        self.__async_add_binary_sensor_entities = None
        self.__async_add_device_tracker_entities = None

        # Register for entry update
        self.__update_listener_handler = entry.add_update_listener(
            TTN_client.__update_listener)

    def __del__(self):
        self.__disconnect()

    async def connect(self):
        # TBD connected

        # Init components
        for component in COMPONENT_TYPES:
            self.__hass.async_add_job(
                self.__hass.config_entries.async_forward_entry_setup(
                    self.__entry, component))

        async def fetch_data_from_ttn():
            await self.__fetch_data_from_ttn()

        # Init global settings
        self.__coordinator = DataUpdateCoordinator(
            self.__hass,
            LOGGER,
            # Name of the data. For logging purposes.
            name="The Things Network",
            update_method=fetch_data_from_ttn,
            # Polling interval. Will only be polled if there are subscribers.
            update_interval=timedelta(seconds=self.get_refresh_period_s()),
        )

        # Add dummy listener -> might change later to use it...
        def coordinator_update():
            pass

        self.__coordinator.async_add_listener(coordinator_update)

        # Fetch initial data so we have data when entities subscribe
        await self.__coordinator.async_refresh()

        self.__is_connected = True

    async def __fetch_data_from_ttn(self):

        if self.__first_fetch:
            self.__first_fetch = False
            fetch_last = f"{self.get_first_fetch_last_h()}h"
            LOGGER.info(f"First fetch of tth data: {fetch_last}")
        else:
            #Fetch new measurements since last time (with an extra minute margin)
            fetch_last = f"{self.get_refresh_period_s()+60}s"
            LOGGER.debug(f"Fetch of ttn data: {fetch_last}")

        map_value_re = re.compile('(\w+):(-?[\.\w]+)')

        # Discover entities
        new_entities = {}
        updated_entities = {}
        measurements = await self.storage_api_call(
            f"api/v2/query?last={fetch_last}")
        if not measurements:
            measurements = []
        LOGGER.debug(f"Fetched {len(measurements)} measurements from ttn")
        for measurement in reversed(measurements):
            # Get and delete device_id from measurement
            device_id = measurement["device_id"]
            del measurement["device_id"]
            for (field_id, value) in measurement.items():

                if value is None:
                    continue

                async def process(field_id, value):
                    unique_id = TtnDataSensor.get_unique_id(
                        device_id, field_id)
                    if unique_id not in self.__entities:
                        if unique_id not in new_entities:
                            # Create
                            if type(value) == bool:
                                # Binary Sensor
                                new_entities[unique_id] = TtnDataBinarySensor(
                                    self, device_id, field_id, value)
                            elif type(value) == dict:
                                # GPS
                                new_entities[unique_id] = TtnDataDeviceTracker(
                                    self, device_id, field_id, value)
                            else:
                                # Sensor
                                new_entities[unique_id] = TtnDataSensor(
                                    self, device_id, field_id, value)
                        else:
                            # Ignore multiple measurements - we use first (=latest)
                            pass
                    else:
                        if unique_id not in updated_entities:
                            # Update value in existing entitity
                            await self.__entities[unique_id].async_set_state(
                                value)
                            updated_entities[unique_id] = self.__entities[
                                unique_id]
                        else:
                            # Ignore multiple measurements - we use first (=latest)
                            pass

                async def process_gps(field_id, value):
                    position = {}
                    map_value = map_value_re.findall(value)
                    for (key, value) in map_value:
                        position[key] = float(value)
                    await process(field_id, position)

                entity_type = self.get_field_options(device_id, field_id).get(
                    OPTIONS_FIELD_ENTITY_TYPE, None)
                if entity_type == OPTIONS_FIELD_ENTITY_TYPE_SENSOR:
                    await process(field_id, value)
                elif entity_type == OPTIONS_FIELD_ENTITY_TYPE_BINARY_SENSOR:
                    await process(field_id, bool(value))
                elif entity_type == OPTIONS_FIELD_ENTITY_TYPE_DEVICE_TRACKER:
                    await process_gps(field_id, value)
                else:
                    if (type(value) is str) and ("map[" in value):
                        if "gps" in field_id:
                            #GPS
                            await process_gps(field_id, value)
                        else:
                            #Other - such as accelerator
                            map_value = map_value_re.findall(value)
                            for (key, value) in map_value:
                                await process(field_id + f"_{key}",
                                              float(value))
                    else:
                        # Regular sensor
                        await process(field_id, value)

        self.__add_entities(new_entities.values())

    @staticmethod
    async def __update_listener(hass, entry):
        return await TTN_client.getInstance(entry).__update(hass, entry)

    async def __update(self, hass, entry):
        self.__hass = hass
        self.__entry = entry

        if self.__coordinator:
            self.__coordinator.update_interval = timedelta(
                seconds=self.get_refresh_period_s())

        for entitiy in self.__entities.values():
            await entitiy.refresh_options()

        # Refresh data
        self.__first_fetch = True
        await self.__coordinator.async_request_refresh()

    def __disconnect(self):
        # TBD
        self.__is_connected = False

    def __add_entities(self, entities=[]):

        for entity in entities:
            assert entity.unique_id not in self.__entities
            self.__entities[entity.unique_id] = entity

        self.add_entities()

    def add_entities(self,
                     async_add_sensor_entities=None,
                     async_add_binary_sensor_entities=None,
                     async_add_device_tracker_entities=None):
        if async_add_sensor_entities:
            # Remember handling for dynamic adds later
            self.__async_add_sensor_entities = async_add_sensor_entities

        if async_add_binary_sensor_entities:
            # Remember handling for dynamic adds later
            self.__async_add_binary_sensor_entities = async_add_binary_sensor_entities

        if async_add_device_tracker_entities:
            # Remember handling for dynamic adds later
            self.__async_add_device_tracker_entities = async_add_device_tracker_entities

        sensors_to_be_added = []
        binary_sensors_to_be_added = []
        device_tracker_to_be_added = []
        for entity in self.__entities.values():
            if entity.to_be_added:
                if self.__async_add_sensor_entities and (type(entity)
                                                         == TtnDataSensor):
                    sensors_to_be_added.append(entity)
                    entity.to_be_added = False
                if self.__async_add_binary_sensor_entities and (
                        type(entity) == TtnDataBinarySensor):
                    binary_sensors_to_be_added.append(entity)
                    entity.to_be_added = False
                if self.__async_add_device_tracker_entities and (
                        type(entity) == TtnDataDeviceTracker):
                    device_tracker_to_be_added.append(entity)
                    entity.to_be_added = False

        if self.__async_add_sensor_entities:
            self.__async_add_sensor_entities(sensors_to_be_added, True)

        if self.__async_add_binary_sensor_entities:
            self.__async_add_binary_sensor_entities(binary_sensors_to_be_added,
                                                    True)

        if self.__async_add_device_tracker_entities:
            self.__async_add_device_tracker_entities(
                device_tracker_to_be_added, True)

    async def remove_all_entities(self):
        for entity in self.__entities.values():
            entity.to_be_removed = True
        return await self.remove_entities()

    async def remove_entitiy(self):
        LOGGER.debug("DELETING remove_entities")
        unload_ok = True
        for (application_id, entitiy) in self.__entities.items():
            if entitiy.to_be_removed:
                unload_ok = await entitiy.async_remove() and unload_ok
        return unload_ok

    async def storage_api_call(self, endpoint):

        url = TTN_DATA_STORAGE_URL.format(app_id=self.__application_id,
                                          endpoint=endpoint)
        headers = {
            ACCEPT: CONTENT_TYPE_JSON,
            AUTHORIZATION: f"key {self.__access_key}"
        }

        try:
            session = async_get_clientsession(self.__hass)
            with async_timeout.timeout(DEFAULT_TIMEOUT):
                response = await session.get(url, headers=headers)

        except (asyncio.TimeoutError, aiohttp.ClientError):
            LOGGER.error("Error while accessing: %s", url)
            return None

        status = response.status

        if status == 401:
            LOGGER.error("Not authorized for Application ID: %s",
                         self.__application_id)
            return None

        if status == HTTP_NOT_FOUND:
            LOGGER.error("Application ID is not available: %s",
                         self.__application_id)
            return None

        return await response.json()
Ejemplo n.º 7
0
async def async_setup_entry(hass, entry, async_add_entities):
    """Set up the entry."""
    # assuming API object stored here by __init__.py
    api = hass.data[DOMAIN][entry.entry_id]

    async def async_update_data():
        """Fetch data from API endpoint."""
        try:
            # Note: asyncio.TimeoutError and aiohttp.ClientError are already
            # handled by the data update coordinator.
            async with async_timeout.timeout(10):
                devices = await api.get_all_devices()
        except AuthenticationError as err:
            raise UpdateFailed("The API call wasn't authenticated") from err
        except TooManyRequestsError as err:
            raise UpdateFailed(
                "Too many requests have been made to the API, rate limiting is in place"
            ) from err
        except Exception as err:  # pylint: disable=broad-except
            raise UpdateFailed(f"Error communicating with API: {err}") from err

        # Populate the entities
        entities = []

        for dev in devices:
            if dev.id not in hass.data[DOMAIN]["entities"]:
                for name in TemperatureMeasurement:
                    entities.append(
                        MeaterProbeTemperature(coordinator, dev.id, name))

                for name in TimeMeasurement:
                    entities.append(MeaterCookTime(coordinator, dev.id, name))

                for name in CookStates:
                    entities.append(MeaterCookStates(coordinator, dev.id,
                                                     name))

                device_registry = await dr.async_get_registry(hass)
                device_registry.async_get_or_create(
                    config_entry_id=entry.entry_id,
                    identifiers={(DOMAIN, dev.id)},
                    name=f"Meater Probe {dev.id}",
                    manufacturer="Apption Labs",
                    model="Meater Probe",
                )
                hass.data[DOMAIN]["entities"][dev.id] = None

        async_add_entities(entities)

        return devices

    coordinator = DataUpdateCoordinator(
        hass,
        _LOGGER,
        # Name of the data. For logging purposes.
        name="meater_api",
        update_method=async_update_data,
        # Polling interval. Will only be polled if there are subscribers.
        update_interval=timedelta(seconds=30),
    )

    def null_callback():
        return

    # Add a subscriber to the coordinator that doesn't actually do anything, just so that it still updates when all probes are switched off
    coordinator.async_add_listener(null_callback)

    # Fetch initial data so we have data when entities subscribe
    await coordinator.async_refresh()
Ejemplo n.º 8
0
class SensorManager:
    """Class that handles registering and updating Hue sensor entities.

    Intended to be a singleton.
    """

    SCAN_INTERVAL = timedelta(seconds=5)

    def __init__(self, bridge):
        """Initialize the sensor manager."""
        self.bridge = bridge
        self._component_add_entities = {}
        self.current = {}
        self.coordinator = DataUpdateCoordinator(
            bridge.hass,
            _LOGGER,
            "sensor",
            self.async_update_data,
            self.SCAN_INTERVAL,
            debounce.Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY,
                               True),
        )

    async def async_update_data(self):
        """Update sensor data."""
        try:
            with async_timeout.timeout(4):
                return await self.bridge.async_request_call(
                    self.bridge.api.sensors.update())
        except Unauthorized:
            await self.bridge.handle_unauthorized_error()
            raise UpdateFailed
        except (asyncio.TimeoutError, AiohueException):
            raise UpdateFailed

    async def async_register_component(self, binary, async_add_entities):
        """Register async_add_entities methods for components."""
        self._component_add_entities[binary] = async_add_entities

        if len(self._component_add_entities) < 2:
            return

        # We have all components available, start the updating.
        self.coordinator.async_add_listener(self.async_update_items)
        self.bridge.reset_jobs.append(
            lambda: self.coordinator.async_remove_listener(self.
                                                           async_update_items))
        await self.coordinator.async_refresh()

    @callback
    def async_update_items(self):
        """Update sensors from the bridge."""
        api = self.bridge.api.sensors

        if len(self._component_add_entities) < 2:
            return

        new_sensors = []
        new_binary_sensors = []
        primary_sensor_devices = {}
        current = self.current

        # Physical Hue motion sensors present as three sensors in the API: a
        # presence sensor, a temperature sensor, and a light level sensor. Of
        # these, only the presence sensor is assigned the user-friendly name
        # that the user has given to the device. Each of these sensors is
        # linked by a common device_id, which is the first twenty-three
        # characters of the unique id (then followed by a hyphen and an ID
        # specific to the individual sensor).
        #
        # To set up neat values, and assign the sensor entities to the same
        # device, we first, iterate over all the sensors and find the Hue
        # presence sensors, then iterate over all the remaining sensors -
        # finding the remaining ones that may or may not be related to the
        # presence sensors.
        for item_id in api:
            if api[item_id].type != TYPE_ZLL_PRESENCE:
                continue

            primary_sensor_devices[_device_id(api[item_id])] = api[item_id]

        # Iterate again now we have all the presence sensors, and add the
        # related sensors with nice names where appropriate.
        for item_id in api:
            existing = current.get(api[item_id].uniqueid)
            if existing is not None:
                continue

            primary_sensor = None
            sensor_config = SENSOR_CONFIG_MAP.get(api[item_id].type)
            if sensor_config is None:
                continue

            base_name = api[item_id].name
            primary_sensor = primary_sensor_devices.get(
                _device_id(api[item_id]))
            if primary_sensor is not None:
                base_name = primary_sensor.name
            name = sensor_config["name_format"].format(base_name)

            current[api[item_id].uniqueid] = sensor_config["class"](
                api[item_id], name, self.bridge, primary_sensor=primary_sensor)
            if sensor_config["binary"]:
                new_binary_sensors.append(current[api[item_id].uniqueid])
            else:
                new_sensors.append(current[api[item_id].uniqueid])

        self.bridge.hass.async_create_task(
            remove_devices(
                self.bridge,
                [value.uniqueid for value in api.values()],
                current,
            ))

        if new_sensors:
            self._component_add_entities[False](new_sensors)
        if new_binary_sensors:
            self._component_add_entities[True](new_binary_sensors)
Ejemplo n.º 9
0
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
    """Set up Meross IoT local LAN from a config entry."""

    api = hass.data.get(DOMAIN)
    if api == None:
        api = MerossLan(hass)
        hass.data[DOMAIN] = api
        # Listen to a message on MQTT.
        @callback
        async def message_received(msg):
            device_id = msg.topic.split("/")[2]
            mqttpayload = json.loads(msg.payload)
            header = mqttpayload.get("header")
            method = header.get("method")
            namespace = header.get("namespace")
            payload = mqttpayload.get("payload")

            device = api.devices.get(device_id)
            if device == None:
                discovered = api.discovering.get(device_id)
                if discovered == None:
                    # new device discovered: try to determine the capabilities
                    api.discovering[device_id] = {}
                    mqttpayload = build_payload(NS_APPLIANCE_SYSTEM_ALL, METHOD_GET, {})
                    hass.components.mqtt.async_publish(COMMAND_TOPIC.format(device_id), mqttpayload, 1, False)
                else:
                    if (method == METHOD_GETACK):

                        if (namespace == NS_APPLIANCE_SYSTEM_ALL):
                            discovered[NS_APPLIANCE_SYSTEM_ALL] = payload
                            mqttpayload = build_payload(NS_APPLIANCE_SYSTEM_ABILITY, METHOD_GET, {})
                            hass.components.mqtt.async_publish(COMMAND_TOPIC.format(device_id), mqttpayload, 1, False)
                        elif (namespace == NS_APPLIANCE_SYSTEM_ABILITY):
                            payload.update(discovered[NS_APPLIANCE_SYSTEM_ALL])
                            api.discovering.pop(device_id)
                            await hass.config_entries.flow.async_init(
                                DOMAIN,
                                context={"source": SOURCE_DISCOVERY},
                                data={CONF_DEVICE_ID: device_id, CONF_DISCOVERY_PAYLOAD: payload},
                            )

            else:
                device.parsepayload(namespace, method, payload)
            return

        api.unsubscribe_mqtt = await hass.components.mqtt.async_subscribe( DISCOVERY_TOPIC, message_received)

        async def async_update_data():
            for device in api.devices.values():
                device.triggerupdate()
            return None

        coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            # Name of the data. For logging purposes.
            name=DOMAIN,
            update_method=async_update_data,
            # Polling interval. Will only be polled if there are subscribers.
            update_interval=timedelta(seconds=30),
        )
        api.unsubscribe_coordinator = coordinator.async_add_listener(api._handle_coordinator_update)

    device_id = entry.data.get(CONF_DEVICE_ID)
    if device_id != None:
        discoverypayload = entry.data.get(CONF_DISCOVERY_PAYLOAD)
        device = MerossDevice(device_id, discoverypayload, hass.components.mqtt.async_publish)
        api.devices[device_id] = device

        p_system = discoverypayload.get("all", {}).get("system", {})
        p_hardware = p_system.get("hardware", {})
        p_firmware = p_system.get("firmware", {})
        from homeassistant.helpers import device_registry as dr
        device_registry = await dr.async_get_registry(hass)
        device_registry.async_get_or_create(
            config_entry_id=entry.entry_id,
            connections={(dr.CONNECTION_NETWORK_MAC, p_firmware.get("wifiMac"))},
            identifiers={(DOMAIN, device_id)},
            manufacturer="Meross",
            name=p_hardware.get("type", "Meross") + " " + device_id,
            model=p_hardware.get("type"),
            sw_version=p_firmware.get("version"),
            )

        if (len(device.switches) > 0):
            hass.async_create_task(
                hass.config_entries.async_forward_entry_setup(entry, "switch")
            )

    return True
Ejemplo n.º 10
0
class PandoraApi:
    """Pandora API class."""
    def __init__(self, hass: HomeAssistantType, username: str, password: str,
                 polling_interval: int) -> None:
        """Constructor"""
        self._hass = hass
        self._username = username
        self._password = password
        self._session = None
        self._session_id = None
        self._update_ts = 0
        self._force_update_ts = 0
        self._command_response = asyncio.Event()
        self._dense_poll = False
        self._devices = {}
        self._coordinator = DataUpdateCoordinator(
            hass,
            _LOGGER,
            name=DOMAIN,
            update_interval=timedelta(seconds=polling_interval),
            update_method=self._async_update,
        )

    @property
    def devices(self) -> dict:
        """Accessor"""

        return self._devices

    async def _request(self, path, method="GET", data=None):
        """Request an information from server."""

        url = BASE_URL + path
        # Heve to do it here because async_create_clientsession uses self User-Agent which rejects by p-on.ru
        headers = {"User-Agent": USER_AGENT}

        _LOGGER.debug("Request: %s", url)

        try:
            async with self._session.request(method,
                                             url,
                                             data=data,
                                             headers=headers) as response:
                _LOGGER.debug("Response Code: %d, Body: %s", response.status,
                              await response.text())

                # Responses should be JSON
                j = await response.json()

                # We can get "status":"fail" in critical cases, so just raise an exception
                if "status" in j and j["status"] == "fail":
                    raise PandoraApiException(str(j["error_text"]))

        # JSON decode error
        except JSONDecodeError as ex:
            raise PandoraApiException("JSON decode error") from None
        # Connection related error
        except aiohttp.ClientConnectionError as ex:
            raise PandoraApiException(type(ex).__name__) from None
        # Response related error
        except aiohttp.ClientResponseError as ex:
            raise PandoraApiException(type(ex).__name__) from None
        # Something goes wrong in server-side logic
        except PandoraApiException as ex:
            raise PandoraApiException(ex) from None

        return j

    async def login(self) -> None:
        """Login on server."""

        if self._session is None:
            self._session = async_create_clientsession(self._hass)

        data = {
            "login": self._username,
            "password": self._password,
            "lang": "ru"
        }

        response = await self._request(LOGIN_PATH, method="POST", data=data)
        # _session_id isn't used now
        self._session_id = PandoraApiLoginResponseParser(response).session_id

        _LOGGER.info("Login successful")

    async def _request_safe(self,
                            path,
                            method="GET",
                            data=None,
                            relogin=False):
        """ High-level request function.

        It will make login on server if it isn't done before.
        It also checks the expiration/validity of the cookies. If problems - tries to make relogin.
        """

        if not self._session or relogin:
            self._session_id = None
            await self.login()

        response = await self._request(path, method=method, data=data)

        if "status" in response:
            if response["status"] in {
                    "Session is expired",
                    "Invalid session",
                    "sid-expired",
            }:
                _LOGGER.info("PandoraApi: %s. Making relogin.",
                             response["error_text"])
                response = await self._request_safe(path,
                                                    method=method,
                                                    data=data,
                                                    relogin=True)

        return response

    async def load_devices(self):
        """Load device list.

        It shoud be done next after constructor.
        """

        response = PandoraApiDevicesResponseParser(
            await self._request_safe(DEVICES_PATH))

        for pandora_id, info in response.devices.items():
            self._devices[pandora_id] = PandoraDevice(pandora_id, info)

    async def _async_update(self, *_) -> bool:
        """Update attributes of devices."""

        try:
            if self._update_ts >= self._force_update_ts + FORCE_UPDATE_INTERVAL:
                self._update_ts = 0

            response = PandoraApiUpdateResponseParser(
                await
                self._request_safe(UPDATE_PATH + str(self._update_ts - 1)))

            stats = response.stats
            if self._update_ts == 0:
                self._force_update_ts = self._update_ts = response.timestamp
            else:
                self._update_ts = response.timestamp

            # UCR means that device received the command and sent response (user command response?)
            # Lot's of commands executes quick: like on/off tracking, ext. cannel and so on.
            # And only engine_start requires additional 10-15 seconds on device side.
            if response.ucr is not None:
                self._command_response.set()

            try:
                for pandora_id, attrs in stats.items():
                    await self._devices[pandora_id].update(attrs)
            except KeyError:
                _LOGGER.info(
                    "Got data for unexpected PANDORA_ID '%s'. Skipping...",
                    pandora_id)

        except PandoraApiException as ex:
            _LOGGER.info("Update failed: %s", str(ex))

        # I made some experiments with my car. How long does it take between sending command
        # and getting proper state of corresponding entity?  Results is placed below:
        # ----------------------------------------------------------------------------------
        # Stop engine: about 10s
        # Start engine: about 25s
        # ----------------------------------------------------------------------------------
        # Pandora makes one request per second until ucr receives. Timeout - 35 seconds

        async def _force_refresh(*_):
            await self._coordinator.async_refresh()

        if self._dense_poll > 0:
            self._dense_poll -= 1
            now = utcnow().replace(microsecond=0)
            async_track_point_in_utc_time(
                self._hass, _force_refresh,
                now + timedelta(seconds=DENSE_POLLING_INTERVAL))

        return True

    async def async_command(self, pandora_id: str, command: str) -> bool:
        """Send the command to device.

        The response should be like this: {"PANDORA_ID": "sent"}. PANDORA_ID must be the same as in request.
        """

        if self._dense_poll:
            raise PandoraApiException("Awaiting previous command")

        self._dense_poll = COMMAND_RESPONSE_TIMEOUT
        self._command_response.clear()

        data = {"id": pandora_id, "command": command}

        try:
            status = PandoraApiCommandResponseParser(await self._request_safe(
                COMMAND_PATH, method="POST", data=data)).result[pandora_id]

            if status != "sent":
                raise PandoraApiException(status)
        except PandoraApiException as ex:
            self._dense_poll = 0
            _LOGGER.debug("async_command: %s", str(ex))
            raise PandoraApiException(str(ex)) from None

        _LOGGER.info("Command %s is sent to device %s", command, pandora_id)

        try:
            await asyncio.wait_for(self._command_response.wait(),
                                   COMMAND_RESPONSE_TIMEOUT)
        except asyncio.TimeoutError as ex:
            self._dense_poll = 0
            _LOGGER.warning("async_command: command timeout")
            raise PandoraApiException(str(ex)) from None

        self._dense_poll = 0
        _LOGGER.info("Got response for command %s on device %s", command,
                     pandora_id)

        return True

    async def async_refresh(self):
        """Refresh data through update coordinator helper."""
        await self._coordinator.async_refresh()

    @callback
    def async_add_listener(
            self, update_callback: CALLBACK_TYPE) -> Callable[[], None]:
        """Listen for data updates."""
        return self._coordinator.async_add_listener(update_callback)