From 99d48795b9be6be2dc1bbfd92e375285cba508e6 Mon Sep 17 00:00:00 2001 From: Pavel Pletenev Date: Wed, 29 Aug 2018 21:13:01 +0200 Subject: [PATCH] Add support for Habitica (#15744) * Added support for Habitica Second refactoring Moved all config to component. Sensors are autodiscovered. Signed-off-by: delphi * Apply requested changes Signed-off-by: delphi * Made event fire async. Made `sensors` config implicit and opt-out-style. Signed-off-by: delphi * Removed unneeded check and await. Signed-off-by: delphi * Moved into separate component package and added service.yaml Signed-off-by: delphi * Fix coveralls Signed-off-by: delphi --- .coveragerc | 3 + homeassistant/components/habitica/__init__.py | 158 ++++++++++++++++++ .../components/habitica/services.yaml | 15 ++ homeassistant/components/sensor/habitica.py | 96 +++++++++++ requirements_all.txt | 3 + 5 files changed, 275 insertions(+) create mode 100644 homeassistant/components/habitica/__init__.py create mode 100644 homeassistant/components/habitica/services.yaml create mode 100644 homeassistant/components/sensor/habitica.py diff --git a/.coveragerc b/.coveragerc index 0c4a1f7d569..39c31e4e40b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -116,6 +116,9 @@ omit = homeassistant/components/google.py homeassistant/components/*/google.py + homeassistant/components/habitica/* + homeassistant/components/*/habitica.py + homeassistant/components/hangouts/__init__.py homeassistant/components/hangouts/const.py homeassistant/components/hangouts/hangouts_bot.py diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py new file mode 100644 index 00000000000..44b9e392157 --- /dev/null +++ b/homeassistant/components/habitica/__init__.py @@ -0,0 +1,158 @@ +""" +The Habitica API component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/habitica/ +""" + +import logging +from collections import namedtuple + +import voluptuous as vol +from homeassistant.const import \ + CONF_NAME, CONF_URL, CONF_SENSORS, CONF_PATH, CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import \ + config_validation as cv, discovery + +REQUIREMENTS = ['habitipy==0.2.0'] +_LOGGER = logging.getLogger(__name__) +DOMAIN = "habitica" + +CONF_API_USER = "api_user" + +ST = SensorType = namedtuple('SensorType', [ + "name", "icon", "unit", "path" +]) + +SENSORS_TYPES = { + 'name': ST('Name', None, '', ["profile", "name"]), + 'hp': ST('HP', 'mdi:heart', 'HP', ["stats", "hp"]), + 'maxHealth': ST('max HP', 'mdi:heart', 'HP', ["stats", "maxHealth"]), + 'mp': ST('Mana', 'mdi:auto-fix', 'MP', ["stats", "mp"]), + 'maxMP': ST('max Mana', 'mdi:auto-fix', 'MP', ["stats", "maxMP"]), + 'exp': ST('EXP', 'mdi:star', 'EXP', ["stats", "exp"]), + 'toNextLevel': ST( + 'Next Lvl', 'mdi:star', 'EXP', ["stats", "toNextLevel"]), + 'lvl': ST( + 'Lvl', 'mdi:arrow-up-bold-circle-outline', 'Lvl', ["stats", "lvl"]), + 'gp': ST('Gold', 'mdi:coin', 'Gold', ["stats", "gp"]), + 'class': ST('Class', 'mdi:sword', '', ["stats", "class"]) +} + +INSTANCE_SCHEMA = vol.Schema({ + vol.Optional(CONF_URL, default='https://habitica.com'): cv.url, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_API_USER): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): + vol.All( + cv.ensure_list, + vol.Unique(), + [vol.In(list(SENSORS_TYPES))]) +}) + +has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name +# because we want a handy alias + + +def has_all_unique_users(value): + """Validate that all `api_user`s are unique.""" + api_users = [user[CONF_API_USER] for user in value] + has_unique_values(api_users) + return value + + +def has_all_unique_users_names(value): + """Validate that all user's names are unique and set if any is set.""" + names = [user.get(CONF_NAME) for user in value] + if None in names and any(name is not None for name in names): + raise vol.Invalid( + 'user names of all users must be set if any is set') + if not all(name is None for name in names): + has_unique_values(names) + return value + + +INSTANCE_LIST_SCHEMA = vol.All( + cv.ensure_list, + has_all_unique_users, + has_all_unique_users_names, + [INSTANCE_SCHEMA]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: INSTANCE_LIST_SCHEMA +}, extra=vol.ALLOW_EXTRA) + +SERVICE_API_CALL = 'api_call' +ATTR_NAME = CONF_NAME +ATTR_PATH = CONF_PATH +ATTR_ARGS = "args" +EVENT_API_CALL_SUCCESS = "{0}_{1}_{2}".format( + DOMAIN, SERVICE_API_CALL, "success") + +SERVICE_API_CALL_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): str, + vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_ARGS): dict +}) + + +async def async_setup(hass, config): + """Set up the habitica service.""" + conf = config[DOMAIN] + data = hass.data[DOMAIN] = {} + websession = async_get_clientsession(hass) + from habitipy.aio import HabitipyAsync + + class HAHabitipyAsync(HabitipyAsync): + """Closure API class to hold session.""" + + def __call__(self, **kwargs): + return super().__call__(websession, **kwargs) + + for instance in conf: + url = instance[CONF_URL] + username = instance[CONF_API_USER] + password = instance[CONF_API_KEY] + name = instance.get(CONF_NAME) + config_dict = {"url": url, "login": username, "password": password} + api = HAHabitipyAsync(config_dict) + user = await api.user.get() + if name is None: + name = user['profile']['name'] + data[name] = api + if CONF_SENSORS in instance: + hass.async_create_task( + discovery.async_load_platform( + hass, "sensor", DOMAIN, + {"name": name, "sensors": instance[CONF_SENSORS]}, + config)) + + async def handle_api_call(call): + name = call.data[ATTR_NAME] + path = call.data[ATTR_PATH] + api = hass.data[DOMAIN].get(name) + if api is None: + _LOGGER.error( + "API_CALL: User '%s' not configured", name) + return + try: + for element in path: + api = api[element] + except KeyError: + _LOGGER.error( + "API_CALL: Path %s is invalid" + " for api on '{%s}' element", path, element) + return + kwargs = call.data.get(ATTR_ARGS, {}) + data = await api(**kwargs) + hass.bus.async_fire(EVENT_API_CALL_SUCCESS, { + "name": name, "path": path, "data": data + }) + + hass.services.async_register( + DOMAIN, SERVICE_API_CALL, + handle_api_call, + schema=SERVICE_API_CALL_SCHEMA) + return True diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml new file mode 100644 index 00000000000..a063b1577f5 --- /dev/null +++ b/homeassistant/components/habitica/services.yaml @@ -0,0 +1,15 @@ +# Describes the format for Habitica service + +--- +api_call: + description: Call Habitica api + fields: + name: + description: Habitica's username to call for + example: 'xxxNotAValidNickxxx' + path: + description: "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks" + example: '["tasks", "user", "post"]' + args: + description: Any additional json or url parameter arguments. See apidoc mentioned for path. Example uses same api endpoint + example: '{"text": "Use API from Home Assistant", "type": "todo"}' diff --git a/homeassistant/components/sensor/habitica.py b/homeassistant/components/sensor/habitica.py new file mode 100644 index 00000000000..d2f13eb30e6 --- /dev/null +++ b/homeassistant/components/sensor/habitica.py @@ -0,0 +1,96 @@ +""" +The Habitica sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.habitica/ +""" + +import logging +from datetime import timedelta + +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.components import habitica + +_LOGGER = logging.getLogger(__name__) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the habitica platform.""" + if discovery_info is None: + return + + name = discovery_info[habitica.CONF_NAME] + sensors = discovery_info[habitica.CONF_SENSORS] + sensor_data = HabitipyData(hass.data[habitica.DOMAIN][name]) + await sensor_data.update() + async_add_devices([ + HabitipySensor(name, sensor, sensor_data) + for sensor in sensors + ], True) + + +class HabitipyData: + """Habitica API user data cache.""" + + def __init__(self, api): + """ + Habitica API user data cache. + + api - HAHabitipyAsync object + """ + self.api = api + self.data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def update(self): + """Get a new fix from Habitica servers.""" + self.data = await self.api.user.get() + + +class HabitipySensor(Entity): + """A generic Habitica sensor.""" + + def __init__(self, name, sensor_name, updater): + """ + Init a generic Habitica sensor. + + name - Habitica platform name + sensor_name - one of the names from ALL_SENSOR_TYPES + """ + self._name = name + self._sensor_name = sensor_name + self._sensor_type = habitica.SENSORS_TYPES[sensor_name] + self._state = None + self._updater = updater + + async def async_update(self): + """Update Condition and Forecast.""" + await self._updater.update() + data = self._updater.data + for element in self._sensor_type.path: + data = data[element] + self._state = data + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._sensor_type.icon + + @property + def name(self): + """Return the name of the sensor.""" + return "{0}_{1}_{2}".format( + habitica.DOMAIN, self._name, self._sensor_name) + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._sensor_type.unit diff --git a/requirements_all.txt b/requirements_all.txt index ba0da439cbd..ef89fb096da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -420,6 +420,9 @@ ha-ffmpeg==1.9 # homeassistant.components.media_player.philips_js ha-philipsjs==0.0.5 +# homeassistant.components.habitica +habitipy==0.2.0 + # homeassistant.components.hangouts hangups==0.4.5