From 49a6a49e2ff5f2d48f0cacb133031603a7f336c9 Mon Sep 17 00:00:00 2001 From: Seb Ruiz Date: Tue, 17 Dec 2019 20:42:47 +1100 Subject: [PATCH] Initial implementation of bhyve integration --- .gitignore | 3 + README.md | 23 ++++ custom_components/bhyve/__init__.py | 64 ++++++++++ custom_components/bhyve/manifest.json | 8 ++ custom_components/bhyve/pybhyve/__init__.py | 88 ++++++++++++++ custom_components/bhyve/pybhyve/backend.py | 113 ++++++++++++++++++ custom_components/bhyve/pybhyve/config.py | 33 ++++++ custom_components/bhyve/pybhyve/constant.py | 6 + custom_components/bhyve/pybhyve/device.py | 60 ++++++++++ custom_components/bhyve/sensor.py | 124 ++++++++++++++++++++ info.md | 16 +++ 11 files changed, 538 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 custom_components/bhyve/__init__.py create mode 100644 custom_components/bhyve/manifest.json create mode 100644 custom_components/bhyve/pybhyve/__init__.py create mode 100644 custom_components/bhyve/pybhyve/backend.py create mode 100644 custom_components/bhyve/pybhyve/config.py create mode 100644 custom_components/bhyve/pybhyve/constant.py create mode 100644 custom_components/bhyve/pybhyve/device.py create mode 100644 custom_components/bhyve/sensor.py create mode 100644 info.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3901713 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2c552e --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# bhyve-home-assistant + +BHyve component for [Home Assistant](https://www.home-assistant.io/). + +## Supported Features +* Battery sensor for `sprinkler_timer` devices + +## Installation + +Recommended installation is via HACS +[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) + +### Sample Configuration + +```yaml +bhyve: + username: !secret bhyve_username + password: !secret bhyve_password + +sensor: + - platform: bhyve +``` + diff --git a/custom_components/bhyve/__init__.py b/custom_components/bhyve/__init__.py new file mode 100644 index 0000000..75a97da --- /dev/null +++ b/custom_components/bhyve/__init__.py @@ -0,0 +1,64 @@ +import logging +from datetime import timedelta + +import voluptuous as vol +from requests.exceptions import HTTPError, ConnectTimeout + +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_HOST) +from homeassistant.helpers import config_validation as cv + +__version__ = '0.0.1' + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by api.orbitbhyve.com" +DATA_BHYVE = 'data_bhyve' +DEFAULT_BRAND = 'Orbit BHyve' +DOMAIN = 'bhyve' + +NOTIFICATION_ID = 'bhyve_notification' +NOTIFICATION_TITLE = 'Orbit BHyve Component Setup' + +DEFAULT_HOST = 'https://api.orbitbhyve.com' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.url + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the BHyve component.""" + + _LOGGER.info('bhyve loading') + + # Read config + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + host = conf.get(CONF_HOST) + + _LOGGER.info(host) + + try: + from .pybhyve import PyBHyve + + bhyve = PyBHyve(username=username, password=password) + if not bhyve.is_connected: + return False + + hass.data[DATA_BHYVE] = bhyve + + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Orbit BHyve: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
You will need to restart hass after fixing.'.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + return True diff --git a/custom_components/bhyve/manifest.json b/custom_components/bhyve/manifest.json new file mode 100644 index 0000000..a259407 --- /dev/null +++ b/custom_components/bhyve/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bhyve", + "name": "Orbit BHyve Integration", + "documentation": "https://github.com/sebr/bhyve-home-assistant/blob/master/README.md", + "dependencies": [], + "codeowners": ["@sebr"], + "requirements": [] +} diff --git a/custom_components/bhyve/pybhyve/__init__.py b/custom_components/bhyve/pybhyve/__init__.py new file mode 100644 index 0000000..890a727 --- /dev/null +++ b/custom_components/bhyve/pybhyve/__init__.py @@ -0,0 +1,88 @@ +import base64 +import datetime +import logging +import os +import threading +import time + +from .backend import BHyveBackEnd +from .config import BHyveConfig +from .constant import (DEVICES_PATH, API_POLL_PERIOD) +from .device import BHyveDevice + +logging.basicConfig(level=logging.DEBUG) +_LOGGER = logging.getLogger('pybhyve') + +__version__ = '0.0.1' + + +class PyBHyve(object): + + def __init__(self, **kwargs): + + self.info('pybhyve loading') + # Set up the config first. + self._cfg = BHyveConfig(self, **kwargs) + + self._be = BHyveBackEnd(self) + + self._devices = [] + + self.info('pybhyve starting') + self._last_poll = 0 + self._refresh_devices() + + def __repr__(self): + # Representation string of object. + return "<{0}: {1}>".format(self.__class__.__name__, self._cfg.name) + + def _refresh_devices(self): + now = time.time() + if (now - self._last_poll < API_POLL_PERIOD): + self.debug('Skipping refresh, not enough time has passed') + return + + self._devices = [] + devices = self._be.get(DEVICES_PATH + "?t={}".format(time.time())) + + for device in devices: + deviceName = device.get('name') + deviceType = device.get('type') + self.info('Created device: {} [{}]'.format(deviceType, deviceName)) + self._devices.append(BHyveDevice(deviceName, self, device)) + + self._last_poll = now + + @property + def cfg(self): + return self._cfg + + @property + def devices(self): + return self._devices + + @property + def is_connected(self): + return self._be.is_connected() + + def get_device(self, device_id): + self._refresh_devices() + for device in self._devices: + if device.device_id == device_id: + return device + return None + + def update(self): + pass + + def error(self, msg): + _LOGGER.error(msg) + + def warning(self, msg): + _LOGGER.warning(msg) + + def info(self, msg): + _LOGGER.info(msg) + + def debug(self, msg): + _LOGGER.debug(msg) diff --git a/custom_components/bhyve/pybhyve/backend.py b/custom_components/bhyve/pybhyve/backend.py new file mode 100644 index 0000000..ab0e041 --- /dev/null +++ b/custom_components/bhyve/pybhyve/backend.py @@ -0,0 +1,113 @@ +import json +import pprint +import re +import threading +import time +import uuid + +import requests +import requests.adapters + +from .constant import (LOGIN_PATH, DEVICES_PATH) + +# include token and session details +class BHyveBackEnd(object): + + def __init__(self, bhyve): + + self._bhyve = bhyve + self._lock = threading.Condition() + self._req_lock = threading.Lock() + + self._requests = {} + self._callbacks = {} + + self._token = None + + # login + self._session = None + self._logged_in = self._login() + if not self._logged_in: + self._bhyve.warning('failed to log in') + return + + + def _request(self, path, method='GET', params=None, headers=None, stream=False, timeout=None): + if params is None: + params = {} + if headers is None: + headers = {} + if timeout is None: + timeout = self._bhyve.cfg.request_timeout + try: + with self._req_lock: + url = self._bhyve.cfg.host + path + self._bhyve.debug('starting request=' + str(url)) + # self._bhyve.debug('starting request=' + str(params)) + # self._bhyve.debug('starting request=' + str(headers)) + if method == 'GET': + r = self._session.get(url, params=params, headers=headers, stream=stream, timeout=timeout) + if stream is True: + return r + elif method == 'PUT': + r = self._session.put(url, json=params, headers=headers, timeout=timeout) + elif method == 'POST': + r = self._session.post(url, json=params, headers=headers, timeout=timeout) + except Exception as e: + self._bhyve.warning('request-error={}'.format(type(e).__name__)) + return None + + self._bhyve.debug('finish request=' + str(r.status_code)) + if r.status_code != 200: + return None + + body = r.json() + # self._bhyve.debug(pprint.pformat(body, indent=2)) + + return body + + # login and set up session + def _login(self): + + # attempt login + self._session = requests.Session() + body = self.post(LOGIN_PATH, { 'session': {'email': self._bhyve.cfg.username, 'password': self._bhyve.cfg.password} }) + if body is None: + self._bhyve.debug('login failed') + return False + + # save new login information + self._token = body['orbit_session_token'] + self._user_id = body['user_id'] + + headers = { + 'Accept': 'application/json, text/plain, */*', + 'Host': re.sub('https?://', '', self._bhyve.cfg.host), + 'Content-Type': 'application/json; charset=utf-8;', + 'Referer': self._bhyve.cfg.host, + 'Orbit-Session-Token': self._token + } + headers['User-Agent'] = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/72.0.3626.81 Safari/537.36') + + self._session.headers.update(headers) + return True + + def is_connected(self): + return self._logged_in + + def get(self, path, params=None, headers=None, stream=False, timeout=None): + return self._request(path, 'GET', params, headers, stream, timeout) + + def put(self, path, params=None, headers=None, timeout=None): + return self._request(path, 'PUT', params, headers, False, timeout) + + def post(self, path, params=None, headers=None, timeout=None): + return self._request(path, 'POST', params, headers, False, timeout) + + @property + def session(self): + return self._session + + def devices(self): + return self.get(DEVICES_PATH + "?t={}".format(time.time())) diff --git a/custom_components/bhyve/pybhyve/config.py b/custom_components/bhyve/pybhyve/config.py new file mode 100644 index 0000000..dfc5790 --- /dev/null +++ b/custom_components/bhyve/pybhyve/config.py @@ -0,0 +1,33 @@ +from .constant import DEFAULT_HOST + +class BHyveConfig(object): + def __init__(self, bhyve, **kwargs): + """ The constructor. + + Args: + kwargs (kwargs): Configuration options. + + """ + self._bhyve = bhyve + self._kw = kwargs + + @property + def name(self, default='bhyve'): + return self._kw.get('name', default) + + @property + def username(self, default='unknown'): + return self._kw.get('username', default) + + @property + def password(self, default='unknown'): + return self._kw.get('password', default) + + @property + def host(self, default=DEFAULT_HOST): + return self._kw.get('host', default) + + @property + def request_timeout(self, default=60): + return self._kw.get('request_timeout', default) + diff --git a/custom_components/bhyve/pybhyve/constant.py b/custom_components/bhyve/pybhyve/constant.py new file mode 100644 index 0000000..2894fac --- /dev/null +++ b/custom_components/bhyve/pybhyve/constant.py @@ -0,0 +1,6 @@ +DEFAULT_HOST = 'https://api.orbitbhyve.com' + +LOGIN_PATH = '/v1/session' +DEVICES_PATH = '/v1/devices' + +API_POLL_PERIOD = 300 diff --git a/custom_components/bhyve/pybhyve/device.py b/custom_components/bhyve/pybhyve/device.py new file mode 100644 index 0000000..e2387e3 --- /dev/null +++ b/custom_components/bhyve/pybhyve/device.py @@ -0,0 +1,60 @@ + +class BHyveDevice(object): + + def __init__(self, name, bhyve, attrs): + self._name = name + self._bhyve = bhyve + self._attrs = attrs + + self._device_id = attrs.get('id', None) + + def __repr__(self): + # Representation string of object. + return "<{0}:{1}:{2}>".format(self.__class__.__name__, self.device_type, self._name) + + @property + def name(self): + return self._name + + @property + def device_id(self): + return self._device_id + + @property + def device_type(self): + return self._attrs.get('type', None) + + @property + def user_id(self): + return self._attrs.get('user_id', None) + + @property + def unique_id(self): + return self._attrs.get('mac_address', None) + + @property + def is_connected(self): + return self._attrs.get('is_connected') + + def attribute(self, attr, default=None): + value = self._attrs.get(attr, None) + if value is None: + value = default + return value + + @property + def state(self): + if not self.is_connected: + return 'unavailable' + return 'idle' + + @property + def is_on(self): + return True + + def turn_on(self): + pass + + def turn_off(self): + pass + diff --git a/custom_components/bhyve/sensor.py b/custom_components/bhyve/sensor.py new file mode 100644 index 0000000..fcec5b8 --- /dev/null +++ b/custom_components/bhyve/sensor.py @@ -0,0 +1,124 @@ +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (ATTR_ATTRIBUTION, DEVICE_CLASS_BATTERY) +from homeassistant.core import callback +from homeassistant.helpers.config_validation import (PLATFORM_SCHEMA) +from homeassistant.helpers.entity import (Entity) +from homeassistant.helpers.icon import icon_for_battery_level +from . import CONF_ATTRIBUTION, DATA_BHYVE, DEFAULT_BRAND + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['bhyve'] + +# sensor_type [ description, unit, icon, attribute ] +SENSOR_TYPES = { + 'battery_level': ['Battery Level', '%', 'battery-50', 'battery.percent'] +} + +async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): + bhyve = hass.data.get(DATA_BHYVE) + _LOGGER.info('Setting up sensors') + if not bhyve: + return + + _LOGGER.info('Lets go!') + + sensors = [] + for device in bhyve.devices: + if device.device_type == 'sprinkler_timer': + for sensor_type in SENSOR_TYPES: + name = '{0} {1}'.format(SENSOR_TYPES[sensor_type][0], device.name) + _LOGGER.info('Creating sensor: %s', name) + sensors.append(BHyveSensor(bhyve, name, device, sensor_type)) + + async_add_entities(sensors, True) + + +class BHyveSensor(Entity): + + def __init__(self, bhyve, name, device, sensor_type): + self._bhyve = bhyve + self._name = name + self._unique_id = self._name.lower().replace(' ', '_') + self._device = device + self._sensor_type = sensor_type + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2]) + self._available = False + self._state = None + self._do_update(device) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def state(self): + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._sensor_type == 'battery_level' and self._state is not None: + return icon_for_battery_level(battery_level=int(self._state), charging=False) + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[1] + + @property + def device_class(self): + """Return the device class of the sensor.""" + if self._sensor_type == 'battery_level': + return DEVICE_CLASS_BATTERY + return None + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attrs['brand'] = DEFAULT_BRAND + attrs['friendly_name'] = self._name + + return attrs + + async def async_update(self): + """Retrieve latest state.""" + try: + device_id = self._device.device_id + + device = self._bhyve.get_device(device_id) + if not device: + _LOGGER.info("No device found with id %s", device_id) + self._available = False + return + + self._do_update(device) + except: + _LOGGER.warning("Failed to connect to BHyve servers.") + self._available = False + + def _do_update(self, device): + if not device: + return + + self._available = device.attribute('is_connected') + if self._sensor_type == 'battery_level': + battery = device.attribute('battery') + _LOGGER.debug("Getting battery level %s", battery) + + if battery != None: + self._state = battery.get('percent') diff --git a/info.md b/info.md new file mode 100644 index 0000000..363c127 --- /dev/null +++ b/info.md @@ -0,0 +1,16 @@ +# bhyve-home-assistant + +BHyve component for [Home Assistant](https://www.home-assistant.io/). + +## Supported Features +* Battery sensor for `sprinkler_timer` devices + +```yaml +bhyve: + username: !secret bhyve_username + password: !secret bhyve_password + +sensor: + - platform: bhyve +``` +