diff --git a/config/custom_components/nodered/__init__.py b/config/custom_components/nodered/__init__.py new file mode 100644 index 0000000..5da8553 --- /dev/null +++ b/config/custom_components/nodered/__init__.py @@ -0,0 +1,239 @@ +""" +Component to integrate with node-red. + +For more details about this component, please refer to +https://github.com/zachowj/hass-node-red +""" +import asyncio +import logging +from typing import Any, Dict, Optional, Union + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_STATE, + CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity + +from .const import ( + CONF_COMPONENT, + CONF_CONFIG, + CONF_DEVICE_INFO, + CONF_NAME, + CONF_NODE_ID, + CONF_REMOVE, + CONF_SERVER_ID, + CONF_VERSION, + DOMAIN, + DOMAIN_DATA, + NAME, + NODERED_DISCOVERY_UPDATED, + NODERED_ENTITY, + STARTUP_MESSAGE, + VERSION, +) +from .discovery import ( + ALREADY_DISCOVERED, + CHANGE_ENTITY_TYPE, + CONFIG_ENTRY_IS_SETUP, + NODERED_DISCOVERY, + start_discovery, + stop_discovery, +) +from .websocket import register_websocket_handlers + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up this integration using YAML is not supported.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up this integration using UI.""" + + if hass.data.get(DOMAIN_DATA) is None: + hass.data.setdefault(DOMAIN_DATA, {}) + _LOGGER.info(STARTUP_MESSAGE) + + register_websocket_handlers(hass) + await start_discovery(hass, hass.data[DOMAIN_DATA], entry) + hass.bus.async_fire(DOMAIN, {CONF_TYPE: "loaded", CONF_VERSION: VERSION}) + + entry.add_update_listener(async_reload_entry) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Handle removal of an entry.""" + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in hass.data[DOMAIN_DATA][CONFIG_ENTRY_IS_SETUP] + ] + ) + ) + + if unloaded: + stop_discovery(hass) + hass.data.pop(DOMAIN_DATA) + hass.bus.async_fire(DOMAIN, {CONF_TYPE: "unloaded"}) + + return unloaded + + +class NodeRedEntity(Entity): + """nodered Sensor class.""" + + def __init__(self, hass, config): + """Initialize the entity.""" + self.hass = hass + self.attr = {} + self._config = config[CONF_CONFIG] + self._component = None + self._device_info = config.get(CONF_DEVICE_INFO) + self._state = None + self._server_id = config[CONF_SERVER_ID] + self._node_id = config[CONF_NODE_ID] + self._remove_signal_discovery_update = None + self._remove_signal_entity_update = None + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID to use for this sensor.""" + return f"{DOMAIN}-{self._server_id}-{self._node_id}" + + @property + def device_class(self) -> Optional[str]: + """Return the class of this binary_sensor.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def name(self) -> Optional[str]: + """Return the name of the sensor.""" + return self._config.get(CONF_NAME, f"{NAME} {self._node_id}") + + @property + def state(self) -> Union[None, str, int, float]: + """Return the state of the sensor.""" + return self._state + + @property + def icon(self) -> Optional[str]: + """Return the icon of the sensor.""" + return self._config.get(CONF_ICON) + + @property + def unit_of_measurement(self) -> Optional[str]: + """Return the unit this state is expressed in.""" + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes.""" + return self.attr + + @property + def device_info(self) -> Optional[Dict[str, Any]]: + """Return device specific attributes.""" + info = None + if self._device_info is not None and "id" in self._device_info: + # Use the id property to create the device identifier then delete it + info = {"identifiers": {(DOMAIN, self._device_info["id"])}} + del self._device_info["id"] + info.update(self._device_info) + + return info + + @callback + def handle_entity_update(self, msg): + """Update entity state.""" + _LOGGER.debug(f"Entity Update: {msg}") + self.attr = msg.get("attributes", {}) + self._state = msg[CONF_STATE] + self.async_write_ha_state() + + @callback + def handle_discovery_update(self, msg, connection): + """Update entity config.""" + if CONF_REMOVE not in msg: + self._config = msg[CONF_CONFIG] + self.async_write_ha_state() + return + + # Otherwise, remove entity + if msg[CONF_REMOVE] == CHANGE_ENTITY_TYPE: + # recreate entity if component type changed + @callback + def recreate_entity(): + """Create entity with new type.""" + del msg[CONF_REMOVE] + async_dispatcher_send( + self.hass, + NODERED_DISCOVERY.format(msg[CONF_COMPONENT]), + msg, + connection, + ) + + self.async_on_remove(recreate_entity) + + self.hass.async_create_task(self.async_remove()) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + self._remove_signal_entity_update = async_dispatcher_connect( + self.hass, + NODERED_ENTITY.format(self._server_id, self._node_id), + self.handle_entity_update, + ) + self._remove_signal_discovery_update = async_dispatcher_connect( + self.hass, + NODERED_DISCOVERY_UPDATED.format(self.unique_id), + self.handle_discovery_update, + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._remove_signal_entity_update is not None: + self._remove_signal_entity_update() + if self._remove_signal_discovery_update is not None: + self._remove_signal_discovery_update() + + del self.hass.data[DOMAIN_DATA][ALREADY_DISCOVERED][self.unique_id] + + # Remove the entity_id from the entity registry + registry = await self.hass.helpers.entity_registry.async_get_registry() + entity_id = registry.async_get_entity_id( + self._component, + DOMAIN, + self.unique_id, + ) + if entity_id: + registry.async_remove(entity_id) + _LOGGER.info(f"Entity removed: {entity_id}") + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) diff --git a/config/custom_components/nodered/__pycache__/__init__.cpython-38.pyc b/config/custom_components/nodered/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..b7da5b7 Binary files /dev/null and b/config/custom_components/nodered/__pycache__/__init__.cpython-38.pyc differ diff --git a/config/custom_components/nodered/__pycache__/config_flow.cpython-38.pyc b/config/custom_components/nodered/__pycache__/config_flow.cpython-38.pyc new file mode 100644 index 0000000..47a1478 Binary files /dev/null and b/config/custom_components/nodered/__pycache__/config_flow.cpython-38.pyc differ diff --git a/config/custom_components/nodered/__pycache__/const.cpython-38.pyc b/config/custom_components/nodered/__pycache__/const.cpython-38.pyc new file mode 100644 index 0000000..07da0b9 Binary files /dev/null and b/config/custom_components/nodered/__pycache__/const.cpython-38.pyc differ diff --git a/config/custom_components/nodered/__pycache__/discovery.cpython-38.pyc b/config/custom_components/nodered/__pycache__/discovery.cpython-38.pyc new file mode 100644 index 0000000..75f5c76 Binary files /dev/null and b/config/custom_components/nodered/__pycache__/discovery.cpython-38.pyc differ diff --git a/config/custom_components/nodered/__pycache__/websocket.cpython-38.pyc b/config/custom_components/nodered/__pycache__/websocket.cpython-38.pyc new file mode 100644 index 0000000..6e1744f Binary files /dev/null and b/config/custom_components/nodered/__pycache__/websocket.cpython-38.pyc differ diff --git a/config/custom_components/nodered/binary_sensor.py b/config/custom_components/nodered/binary_sensor.py new file mode 100644 index 0000000..0bc0dbf --- /dev/null +++ b/config/custom_components/nodered/binary_sensor.py @@ -0,0 +1,76 @@ +"""Binary sensor platform for nodered.""" +from numbers import Number + +from homeassistant.const import ( + CONF_STATE, + STATE_HOME, + STATE_OFF, + STATE_ON, + STATE_OPEN, + STATE_UNLOCKED, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import NodeRedEntity +from .const import CONF_ATTRIBUTES, CONF_BINARY_SENSOR, NODERED_DISCOVERY_NEW + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up sensor platform.""" + + async def async_discover(config, connection): + await _async_setup_entity(hass, config, async_add_devices) + + async_dispatcher_connect( + hass, + NODERED_DISCOVERY_NEW.format(CONF_BINARY_SENSOR), + async_discover, + ) + + +async def _async_setup_entity(hass, config, async_add_devices): + """Set up the Node-RED binary-sensor.""" + async_add_devices([NodeRedBinarySensor(hass, config)]) + + +class NodeRedBinarySensor(NodeRedEntity): + """Node-RED binary-sensor class.""" + + on_states = ( + "1", + "true", + "yes", + "enable", + STATE_ON, + STATE_OPEN, + STATE_HOME, + STATE_UNLOCKED, + ) + + def __init__(self, hass, config): + """Initialize the binary sensor.""" + super().__init__(hass, config) + self._component = CONF_BINARY_SENSOR + self._state = config.get(CONF_STATE) + self.attr = config.get(CONF_ATTRIBUTES, {}) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + value = self._state + + if isinstance(value, bool): + return value + if isinstance(value, str): + value = value.lower().strip() + if value in NodeRedBinarySensor.on_states: + return True + elif isinstance(value, Number): + return value != 0 + + return False + + @property + def state(self): + """Return the state of the binary sensor.""" + return STATE_ON if self.is_on else STATE_OFF diff --git a/config/custom_components/nodered/config_flow.py b/config/custom_components/nodered/config_flow.py new file mode 100644 index 0000000..eb65aa2 --- /dev/null +++ b/config/custom_components/nodered/config_flow.py @@ -0,0 +1,34 @@ +"""Adds config flow for Node-RED.""" +import logging + +from homeassistant import config_entries + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class NodeRedFlowHandler(config_entries.ConfigFlow): + """Config flow for Node-RED.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create a webhook.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if self.hass.data.get(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + + if user_input is None: + return self.async_show_form(step_id="user") + return self.async_create_entry( + title="Node-RED", + data={}, + ) diff --git a/config/custom_components/nodered/const.py b/config/custom_components/nodered/const.py new file mode 100644 index 0000000..56a8870 --- /dev/null +++ b/config/custom_components/nodered/const.py @@ -0,0 +1,51 @@ +"""Constants for Node-RED.""" +# Base component constants +DOMAIN = "nodered" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.5.2" + +ISSUE_URL = "https://github.com/zachowj/hass-node-red/issues" + +# Configuration +CONF_ATTRIBUTES = "attributes" +CONF_BINARY_SENSOR = "binary_sensor" +CONF_COMPONENT = "component" +CONF_CONFIG = "config" +CONF_CONNECTION = "connection" +CONF_DATA = "data" +CONF_DEVICE_INFO = "device_info" +CONF_DEVICE_TRIGGER = "device_trigger" +CONF_ENABLED = "enabled" +CONF_NAME = "name" +CONF_NODE_ID = "node_id" +CONF_OUTPUT_PATH = "output_path" +CONF_PAYLOAD = "payload" +CONF_REMOVE = "remove" +CONF_SENSOR = "sensor" +CONF_SERVER_ID = "server_id" +CONF_SKIP_CONDITION = "skip_condition" +CONF_SUB_TYPE = "sub_type" +CONF_SWITCH = "switch" +CONF_TRIGGER_ENTITY_ID = "trigger_entity_id" +CONF_VERSION = "version" + +NODERED_DISCOVERY = "nodered_discovery" +NODERED_DISCOVERY_NEW = "nodered_discovery_new_{}" +NODERED_DISCOVERY_UPDATED = "nodered_discovery_updated_{}" +NODERED_ENTITY = "nodered_entity_{}_{}" + +SERVICE_TRIGGER = "trigger" + +# Defaults +NAME = DOMAIN +SWITCH_ICON = "mdi:electric-switch-closed" + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" diff --git a/config/custom_components/nodered/discovery.py b/config/custom_components/nodered/discovery.py new file mode 100644 index 0000000..898108b --- /dev/null +++ b/config/custom_components/nodered/discovery.py @@ -0,0 +1,109 @@ +"""Support for Node-RED discovery.""" +import asyncio +import logging + +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + CONF_BINARY_SENSOR, + CONF_COMPONENT, + CONF_NODE_ID, + CONF_REMOVE, + CONF_SENSOR, + CONF_SERVER_ID, + CONF_SWITCH, + DOMAIN, + DOMAIN_DATA, + NODERED_DISCOVERY, + NODERED_DISCOVERY_NEW, + NODERED_DISCOVERY_UPDATED, +) + +SUPPORTED_COMPONENTS = [ + CONF_SWITCH, + CONF_BINARY_SENSOR, + CONF_SENSOR, +] + +_LOGGER = logging.getLogger(__name__) + +ALREADY_DISCOVERED = "discovered_components" +CHANGE_ENTITY_TYPE = "change_entity_type" +CONFIG_ENTRY_LOCK = "config_entry_lock" +CONFIG_ENTRY_IS_SETUP = "config_entry_is_setup" +DISCOVERY_DISPATCHED = "discovery_dispatched" + + +async def start_discovery( + hass: HomeAssistantType, hass_config, config_entry=None +) -> bool: + """Initialize of Node-RED Discovery.""" + + async def async_device_message_received(msg, connection): + """Process the received message.""" + component = msg[CONF_COMPONENT] + server_id = msg[CONF_SERVER_ID] + node_id = msg[CONF_NODE_ID] + + if component not in SUPPORTED_COMPONENTS: + _LOGGER.warning(f"Integration {component} is not supported") + return + + discovery_hash = f"{DOMAIN}-{server_id}-{node_id}" + data = hass.data[DOMAIN_DATA] + + _LOGGER.debug(f"Discovery message: {msg}") + + if ALREADY_DISCOVERED not in data: + data[ALREADY_DISCOVERED] = {} + if discovery_hash in data[ALREADY_DISCOVERED]: + + if data[ALREADY_DISCOVERED][discovery_hash] != component: + # Remove old + log_text = f"Changing {data[ALREADY_DISCOVERED][discovery_hash]} to" + msg[CONF_REMOVE] = CHANGE_ENTITY_TYPE + elif CONF_REMOVE in msg: + log_text = "Removing" + else: + # Dispatch update + log_text = "Updating" + + _LOGGER.info(f"{log_text} {component} {server_id} {node_id}") + + data[ALREADY_DISCOVERED][discovery_hash] = component + async_dispatcher_send( + hass, NODERED_DISCOVERY_UPDATED.format(discovery_hash), msg, connection + ) + else: + # Add component + _LOGGER.info(f"Creating {component} {server_id} {node_id}") + data[ALREADY_DISCOVERED][discovery_hash] = component + + async with data[CONFIG_ENTRY_LOCK]: + if component not in data[CONFIG_ENTRY_IS_SETUP]: + await hass.config_entries.async_forward_entry_setup( + config_entry, component + ) + data[CONFIG_ENTRY_IS_SETUP].add(component) + + async_dispatcher_send( + hass, NODERED_DISCOVERY_NEW.format(component), msg, connection + ) + + hass.data[DOMAIN_DATA][CONFIG_ENTRY_LOCK] = asyncio.Lock() + hass.data[DOMAIN_DATA][CONFIG_ENTRY_IS_SETUP] = set() + + hass.data[DOMAIN_DATA][DISCOVERY_DISPATCHED] = async_dispatcher_connect( + hass, + NODERED_DISCOVERY, + async_device_message_received, + ) + + +def stop_discovery(hass: HomeAssistantType): + """Remove discovery dispatcher.""" + hass.data[DOMAIN_DATA][DISCOVERY_DISPATCHED]() diff --git a/config/custom_components/nodered/manifest.json b/config/custom_components/nodered/manifest.json new file mode 100644 index 0000000..6915648 --- /dev/null +++ b/config/custom_components/nodered/manifest.json @@ -0,0 +1,13 @@ +{ + "codeowners": [ + "@zachowj" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://zachowj.github.io/node-red-contrib-home-assistant-websocket/guide/custom_integration/", + "domain": "nodered", + "iot_class": "local_push", + "issue_tracker": "https://github.com/zachowj/hass-node-red/issues", + "name": "Node-RED Companion", + "version": "0.5.2" +} diff --git a/config/custom_components/nodered/sensor.py b/config/custom_components/nodered/sensor.py new file mode 100644 index 0000000..82d6c55 --- /dev/null +++ b/config/custom_components/nodered/sensor.py @@ -0,0 +1,39 @@ +"""Sensor platform for nodered.""" +import logging + +from homeassistant.const import CONF_STATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import NodeRedEntity +from .const import CONF_ATTRIBUTES, CONF_SENSOR, NODERED_DISCOVERY_NEW + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up sensor platform.""" + + async def async_discover(config, connection): + await _async_setup_entity(hass, config, async_add_entities) + + async_dispatcher_connect( + hass, + NODERED_DISCOVERY_NEW.format(CONF_SENSOR), + async_discover, + ) + + +async def _async_setup_entity(hass, config, async_add_entities): + """Set up the Node-RED sensor.""" + async_add_entities([NodeRedSensor(hass, config)]) + + +class NodeRedSensor(NodeRedEntity): + """Node-RED Sensor class.""" + + def __init__(self, hass, config): + """Initialize the sensor.""" + super().__init__(hass, config) + self._component = CONF_SENSOR + self._state = config.get(CONF_STATE) + self.attr = config.get(CONF_ATTRIBUTES, {}) diff --git a/config/custom_components/nodered/services.yaml b/config/custom_components/nodered/services.yaml new file mode 100644 index 0000000..345eba7 --- /dev/null +++ b/config/custom_components/nodered/services.yaml @@ -0,0 +1,17 @@ +trigger: + description: Trigger a Node-RED Node + fields: + entity_id: + description: Entity Id of the Node-RED switch + example: switch.nodered_motion + trigger_entity_id: + description: Entity Id to trigger the event node with. Only needed if the node is not triggered by a single entity. + example: sun.sun + skip_condition: + description: Skip conditions of the node (defaults to false) + example: true + output_path: + description: Which output of the node to use (defaults to true, the top output). Only used when skip_condition is set to true. + example: true + payload: + description: The payload the node will output when triggered. Work only when triggering a entity node not an event node. diff --git a/config/custom_components/nodered/switch.py b/config/custom_components/nodered/switch.py new file mode 100644 index 0000000..21dcc5a --- /dev/null +++ b/config/custom_components/nodered/switch.py @@ -0,0 +1,247 @@ +"""Sensor platform for nodered.""" +import json +import logging + +import voluptuous as vol + +from homeassistant.components.websocket_api import event_message +from homeassistant.const import ( + CONF_ENTITY_ID, + CONF_ICON, + CONF_ID, + CONF_STATE, + CONF_TYPE, + EVENT_STATE_CHANGED, +) +from homeassistant.core import callback +from homeassistant.helpers import entity_platform, trigger +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import ToggleEntity + +from . import NodeRedEntity +from .const import ( + CONF_CONFIG, + CONF_DATA, + CONF_DEVICE_TRIGGER, + CONF_OUTPUT_PATH, + CONF_PAYLOAD, + CONF_REMOVE, + CONF_SKIP_CONDITION, + CONF_SUB_TYPE, + CONF_SWITCH, + CONF_TRIGGER_ENTITY_ID, + DOMAIN, + NODERED_DISCOVERY_NEW, + SERVICE_TRIGGER, + SWITCH_ICON, +) +from .utils import NodeRedJSONEncoder + +_LOGGER = logging.getLogger(__name__) + +SERVICE_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_TRIGGER_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_SKIP_CONDITION): cv.boolean, + vol.Optional(CONF_OUTPUT_PATH): cv.boolean, + vol.Optional(CONF_PAYLOAD): vol.Extra, + } +) +EVENT_TRIGGER_NODE = "automation_triggered" +EVENT_DEVICE_TRIGGER = "device_trigger" + +TYPE_SWITCH = "switch" +TYPE_DEVICE_TRIGGER = "device_trigger" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Switch platform.""" + + async def async_discover(config, connection): + await _async_setup_entity(hass, config, async_add_entities, connection) + + async_dispatcher_connect( + hass, + NODERED_DISCOVERY_NEW.format(CONF_SWITCH), + async_discover, + ) + + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_TRIGGER, SERVICE_TRIGGER_SCHEMA, "async_trigger_node" + ) + + +async def _async_setup_entity(hass, config, async_add_entities, connection): + """Set up the Node-RED Switch.""" + + switch_type = config.get(CONF_SUB_TYPE, TYPE_SWITCH) + switch_class = ( + NodeRedDeviceTrigger if switch_type == TYPE_DEVICE_TRIGGER else NodeRedSwitch + ) + async_add_entities([switch_class(hass, config, connection)]) + + +class NodeRedSwitch(ToggleEntity, NodeRedEntity): + """Node-RED Switch class.""" + + def __init__(self, hass, config, connection): + """Initialize the switch.""" + super().__init__(hass, config) + self._message_id = config[CONF_ID] + self._connection = connection + self._state = config.get(CONF_STATE, True) + self._component = CONF_SWITCH + self._available = True + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return self._config.get(CONF_ICON, SWITCH_ICON) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the switch.""" + self._update_node_red(False) + + async def async_turn_on(self, **kwargs) -> None: + """Turn on the switch.""" + self._update_node_red(True) + + async def async_trigger_node(self, **kwargs) -> None: + """Trigger node in Node-RED.""" + data = {} + data[CONF_ENTITY_ID] = kwargs.get(CONF_TRIGGER_ENTITY_ID) + data[CONF_SKIP_CONDITION] = kwargs.get(CONF_SKIP_CONDITION, False) + data[CONF_OUTPUT_PATH] = kwargs.get(CONF_OUTPUT_PATH, True) + if kwargs.get(CONF_PAYLOAD) is not None: + data[CONF_PAYLOAD] = kwargs[CONF_PAYLOAD] + + self._connection.send_message( + event_message( + self._message_id, + {CONF_TYPE: EVENT_TRIGGER_NODE, CONF_DATA: data}, + ) + ) + + def _update_node_red(self, state): + self._connection.send_message( + event_message( + self._message_id, {CONF_TYPE: EVENT_STATE_CHANGED, CONF_STATE: state} + ) + ) + + @callback + def handle_lost_connection(self): + """Set availability to False when disconnected.""" + self._available = False + self.async_write_ha_state() + + @callback + def handle_discovery_update(self, msg, connection): + """Update entity config.""" + if CONF_REMOVE in msg: + # Remove entity + self.hass.async_create_task(self.async_remove()) + else: + self._available = True + self._state = msg[CONF_STATE] + self._config = msg[CONF_CONFIG] + self._message_id = msg[CONF_ID] + self._connection = connection + self._connection.subscriptions[msg[CONF_ID]] = self.handle_lost_connection + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._connection.subscriptions[self._message_id] = self.handle_lost_connection + + +class NodeRedDeviceTrigger(NodeRedSwitch): + """Node-RED Device Trigger class.""" + + def __init__(self, hass, config, connection): + """Initialize the switch.""" + super().__init__(hass, config, connection) + self._trigger_config = config[CONF_DEVICE_TRIGGER] + self._unsubscribe_device_trigger = None + + @callback + def handle_lost_connection(self): + """Set remove device trigger when disconnected.""" + super().handle_lost_connection() + self.remove_device_trigger() + + async def add_device_trigger(self): + """Validate device trigger.""" + + @callback + def forward_trigger(event, context=None): + """Forward events to websocket.""" + message = event_message( + self._message_id, + {"type": EVENT_DEVICE_TRIGGER, "data": event["trigger"]}, + ) + self._connection.send_message( + json.dumps(message, cls=NodeRedJSONEncoder, allow_nan=False) + ) + + try: + trigger_config = await trigger.async_validate_trigger_config( + self.hass, [self._trigger_config] + ) + self._unsubscribe_device_trigger = await trigger.async_initialize_triggers( + self.hass, + trigger_config, + forward_trigger, + DOMAIN, + DOMAIN, + _LOGGER.log, + ) + except vol.MultipleInvalid as ex: + _LOGGER.error( + f"Error initializing device trigger '{self._node_id}': {str(ex)}", + ) + + def remove_device_trigger(self): + """Remove device trigger.""" + self._trigger_config = None + if self._unsubscribe_device_trigger is not None: + _LOGGER.info(f"removed device triger - {self._server_id} {self._node_id}") + self._unsubscribe_device_trigger() + self._unsubscribe_device_trigger = None + + @callback + async def handle_discovery_update(self, msg, connection): + """Update entity config.""" + if CONF_REMOVE not in msg and self._trigger_config != msg[CONF_DEVICE_TRIGGER]: + self.remove_device_trigger() + self._trigger_config = msg[CONF_DEVICE_TRIGGER] + await self.add_device_trigger() + + super().handle_discovery_update(msg, connection) + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + await self.add_device_trigger() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self.remove_device_trigger() + await super().async_will_remove_from_hass() diff --git a/config/custom_components/nodered/translations/en.json b/config/custom_components/nodered/translations/en.json new file mode 100644 index 0000000..7db1d5b --- /dev/null +++ b/config/custom_components/nodered/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "title": "Node-RED Companion", + "description": "Are you sure you want to set up Node-RED Companion?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Node-RED Companion is allowed." + } + } +} diff --git a/config/custom_components/nodered/translations/nb.json b/config/custom_components/nodered/translations/nb.json new file mode 100644 index 0000000..917ebfd --- /dev/null +++ b/config/custom_components/nodered/translations/nb.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "title": "Node-RED", + "description": "Er du sikker på at du vil konfigurere Node-RED?" + } + }, + "abort": { + "single_instance_allowed": "Bare en enkelt konfigurasjon av Node-RED er tillatt." + } + } +} diff --git a/config/custom_components/nodered/utils.py b/config/custom_components/nodered/utils.py new file mode 100644 index 0000000..1a8df5d --- /dev/null +++ b/config/custom_components/nodered/utils.py @@ -0,0 +1,19 @@ +"""Helpers for node-red.""" +from datetime import timedelta +from typing import Any + +from homeassistant.helpers.json import JSONEncoder + + +class NodeRedJSONEncoder(JSONEncoder): + """JSONEncoder that supports timedelta objects and falls back to the Home Assistant Encoder.""" + + def default(self, o: Any) -> Any: + """Convert timedelta objects. + + Hand other objects to the Home Assistant JSONEncoder. + """ + if isinstance(o, timedelta): + return o.total_seconds() + + return JSONEncoder.default(self, o) diff --git a/config/custom_components/nodered/websocket.py b/config/custom_components/nodered/websocket.py new file mode 100644 index 0000000..9edbd52 --- /dev/null +++ b/config/custom_components/nodered/websocket.py @@ -0,0 +1,194 @@ +"""Websocket API for Node-RED.""" +import json +import logging + +import voluptuous as vol + +from homeassistant.components import device_automation +from homeassistant.components.device_automation.exceptions import ( + DeviceNotFound, + InvalidDeviceAutomationConfig, +) +from homeassistant.components.device_automation.trigger import TRIGGER_SCHEMA +from homeassistant.components.websocket_api import ( + async_register_command, + async_response, + error_message, + event_message, + require_admin, + result_message, + websocket_command, +) +from homeassistant.const import ( + CONF_DOMAIN, + CONF_ID, + CONF_NAME, + CONF_STATE, + CONF_TYPE, + CONF_WEBHOOK_ID, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + CONF_ATTRIBUTES, + CONF_COMPONENT, + CONF_CONFIG, + CONF_DEVICE_INFO, + CONF_DEVICE_TRIGGER, + CONF_NODE_ID, + CONF_REMOVE, + CONF_SERVER_ID, + CONF_SUB_TYPE, + DOMAIN, + NODERED_DISCOVERY, + NODERED_ENTITY, + VERSION, +) + +_LOGGER = logging.getLogger(__name__) + + +def register_websocket_handlers(hass: HomeAssistantType): + """Register the websocket handlers.""" + + async_register_command(hass, websocket_version) + async_register_command(hass, websocket_webhook) + async_register_command(hass, websocket_discovery) + async_register_command(hass, websocket_entity) + async_register_command(hass, websocket_device_action) + + +@require_admin +@async_response +@websocket_command( + { + vol.Required(CONF_TYPE): "nodered/device_action", + vol.Required("action"): cv.DEVICE_ACTION_SCHEMA, + } +) +async def websocket_device_action(hass, connection, msg): + """Sensor command.""" + context = connection.context(msg) + platform = await device_automation.async_get_device_automation_platform( + hass, msg["action"][CONF_DOMAIN], "action" + ) + + try: + await platform.async_call_action_from_config(hass, msg["action"], {}, context) + connection.send_message(result_message(msg[CONF_ID], {"success": True})) + except InvalidDeviceAutomationConfig as err: + connection.send_message(error_message(msg[CONF_ID], "invalid_config", str(err))) + except DeviceNotFound as err: + connection.send_message( + error_message(msg[CONF_ID], "device_not_found", str(err)) + ) + + +@require_admin +@websocket_command( + { + vol.Required(CONF_TYPE): "nodered/discovery", + vol.Required(CONF_COMPONENT): cv.string, + vol.Required(CONF_SERVER_ID): cv.string, + vol.Required(CONF_NODE_ID): cv.string, + vol.Optional(CONF_CONFIG, default={}): dict, + vol.Optional(CONF_STATE): vol.Any(bool, str, int, float), + vol.Optional(CONF_ATTRIBUTES): dict, + vol.Optional(CONF_REMOVE): bool, + vol.Optional(CONF_DEVICE_INFO): dict, + vol.Optional(CONF_DEVICE_TRIGGER): TRIGGER_SCHEMA, + vol.Optional(CONF_SUB_TYPE): str, + } +) +def websocket_discovery(hass, connection, msg): + """Sensor command.""" + async_dispatcher_send( + hass, NODERED_DISCOVERY.format(msg[CONF_COMPONENT]), msg, connection + ) + connection.send_message(result_message(msg[CONF_ID], {"success": True})) + + +@require_admin +@websocket_command( + { + vol.Required(CONF_TYPE): "nodered/entity", + vol.Required(CONF_SERVER_ID): cv.string, + vol.Required(CONF_NODE_ID): cv.string, + vol.Required(CONF_STATE): vol.Any(bool, str, int, float), + vol.Optional(CONF_ATTRIBUTES, default={}): dict, + } +) +def websocket_entity(hass, connection, msg): + """Sensor command.""" + + async_dispatcher_send( + hass, NODERED_ENTITY.format(msg[CONF_SERVER_ID], msg[CONF_NODE_ID]), msg + ) + connection.send_message(result_message(msg[CONF_ID], {"success": True})) + + +@require_admin +@websocket_command({vol.Required(CONF_TYPE): "nodered/version"}) +def websocket_version(hass, connection, msg): + """Version command.""" + + connection.send_message(result_message(msg[CONF_ID], VERSION)) + + +@require_admin +@async_response +@websocket_command( + { + vol.Required(CONF_TYPE): "nodered/webhook", + vol.Required(CONF_WEBHOOK_ID): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SERVER_ID): cv.string, + } +) +async def websocket_webhook(hass, connection, msg): + """Create webhook command.""" + webhook_id = msg[CONF_WEBHOOK_ID] + + @callback + async def handle_webhook(hass, id, request): + """Handle webhook callback.""" + body = await request.text() + try: + payload = json.loads(body) if body else {} + except ValueError: + payload = body + + data = { + "payload": payload, + "headers": dict(request.headers), + "params": dict(request.query), + } + + _LOGGER.debug(f"Webhook received {id[:15]}..: {data}") + connection.send_message(event_message(msg[CONF_ID], {"data": data})) + + def remove_webhook() -> None: + """Remove webhook command.""" + try: + hass.components.webhook.async_unregister(webhook_id) + + except ValueError: + pass + + _LOGGER.info(f"Webhook removed: {webhook_id[:15]}..") + connection.send_message(result_message(msg[CONF_ID], {"success": True})) + + try: + hass.components.webhook.async_register( + DOMAIN, msg[CONF_NAME], webhook_id, handle_webhook + ) + except ValueError: + connection.send_message(result_message(msg[CONF_ID], {"success": False})) + return + + _LOGGER.info(f"Webhook created: {webhook_id[:15]}..") + connection.subscriptions[msg[CONF_ID]] = remove_webhook + connection.send_message(result_message(msg[CONF_ID], {"success": True})) diff --git a/config/custom_components/p2000/__init__.py b/config/custom_components/p2000/__init__.py new file mode 100644 index 0000000..0ee4767 --- /dev/null +++ b/config/custom_components/p2000/__init__.py @@ -0,0 +1 @@ +"""The p2000 Component""" \ No newline at end of file diff --git a/config/custom_components/p2000/__pycache__/__init__.cpython-38.pyc b/config/custom_components/p2000/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..b26506f Binary files /dev/null and b/config/custom_components/p2000/__pycache__/__init__.cpython-38.pyc differ diff --git a/config/custom_components/p2000/__pycache__/sensor.cpython-38.pyc b/config/custom_components/p2000/__pycache__/sensor.cpython-38.pyc new file mode 100644 index 0000000..3a38337 Binary files /dev/null and b/config/custom_components/p2000/__pycache__/sensor.cpython-38.pyc differ diff --git a/config/custom_components/p2000/manifest.json b/config/custom_components/p2000/manifest.json new file mode 100644 index 0000000..690dd2b --- /dev/null +++ b/config/custom_components/p2000/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "p2000", + "name": "P2000 Sensor", + "documentation": "https://github.com/cyberjunky/home-assistant-p2000", + "dependencies": [], + "codeowners": [ + "@cyberjunky" + ], + "requirements": [ + "feedparser" + ], + "version": "1.0.18" +} diff --git a/config/custom_components/p2000/sensor.py b/config/custom_components/p2000/sensor.py new file mode 100644 index 0000000..ea1caaf --- /dev/null +++ b/config/custom_components/p2000/sensor.py @@ -0,0 +1,296 @@ +"""Support for P2000 sensors.""" +import datetime +import logging + +import feedparser +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_ICON, +) +from homeassistant.core import callback +import homeassistant.util as util +from homeassistant.util.location import distance +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +BASE_URL = "https://feeds.livep2000.nl?r={}&d={}" + +DEFAULT_INTERVAL = datetime.timedelta(seconds=10) +DATA_UPDATED = "p2000_data_updated" + +CONF_REGIOS = "regios" +CONF_DISCIPLINES = "disciplines" +CONF_CAPCODES = "capcodes" +CONF_ATTRIBUTION = "Data provided by feeds.livep2000.nl" +CONF_NOLOCATION = "nolocation" +CONF_CONTAINS = "contains" + +DEFAULT_NAME = "P2000" +DEFAULT_ICON = "mdi:ambulance" +DEFAULT_DISCIPLINES = "1,2,3,4" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_REGIOS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DISCIPLINES, default=DEFAULT_DISCIPLINES): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_RADIUS, 0): vol.Coerce(float), + vol.Optional(CONF_CAPCODES): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_NOLOCATION, default=False): cv.boolean, + vol.Optional(CONF_CONTAINS): cv.string, + vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, + } +) + + +async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the P2000 sensor.""" + data = P2000Data(hass, config) + + async_track_time_interval(hass, data.async_update, config[CONF_SCAN_INTERVAL]) + + async_add_devices([P2000Sensor(hass, data, config.get(CONF_NAME), config.get(CONF_ICON))], True) + + +class P2000Data: + """Handle P2000 object and limit updates.""" + + def __init__(self, hass, config): + """Initialize the data object.""" + self._hass = hass + self._lat = util.convert(config.get(CONF_LATITUDE, hass.config.latitude), float) + self._lon = util.convert( + config.get(CONF_LONGITUDE, hass.config.longitude), float + ) + self._url = BASE_URL.format( + config.get(CONF_REGIOS), config.get(CONF_DISCIPLINES) + ) + self._nolocation = config.get(CONF_NOLOCATION) + self._radius = config.get(CONF_RADIUS) + self._capcodes = config.get(CONF_CAPCODES) + self._contains = config.get(CONF_CONTAINS) + self._capcodelist = None + self._feed = None + self._etag = None + self._modified = None + self._restart = True + self._event_time = None + self._data = None + + if self._capcodes: + self._capcodelist = self._capcodes.split(",") + + @property + def latest_data(self): + """Return the data object.""" + return self._data + + @staticmethod + def _convert_time(time): + try: + return datetime.datetime.strptime(time.split(",")[1][:-6], " %d %b %Y %H:%M:%S") + except(IndexError): + return None + + async def async_update(self, dummy): + """Update data.""" + + if self._feed: + self._modified = self._feed.get("modified") + self._etag = self._feed.get("etag") + else: + self._modified = None + self._etag = None + + self._feed = await self._hass.async_add_executor_job( + feedparser.parse, self._url, self._etag, self._modified + ) + + if not self._feed: + _LOGGER.debug("Failed to get feed data from %s", self._url) + return + + if self._feed.bozo: + _LOGGER.debug("Error parsing feed data from %s", self._url) + return + + _LOGGER.debug("Feed url: %s data: %s", self._url, self._feed) + + if self._restart: + self._restart = False + self._event_time = self._convert_time(self._feed.entries[0]["published"]) + _LOGGER.debug("Start fresh after a restart") + return + + try: + for entry in reversed(self._feed.entries): + + event_msg = "" + event_caps = "" + event_time = self._convert_time(entry.published) + if event_time < self._event_time: + continue + self._event_time = event_time + event_msg = entry.title.replace("~", "") + "\n" + entry.published + "\n" + _LOGGER.debug("New P2000 event found: %s, at %s", event_msg, entry.published) + + if "geo_lat" in entry: + event_lat = float(entry.geo_lat) + event_lon = float(entry.geo_long) + event_dist = distance(self._lat, self._lon, event_lat, event_lon) + event_dist = int(round(event_dist)) + if self._radius: + _LOGGER.debug( + "Filtering on Radius %s, calculated distance %d m ", + self._radius, + event_dist, + ) + if event_dist > self._radius: + event_msg = "" + _LOGGER.debug("Radius filter mismatch, discarding") + continue + _LOGGER.debug("Radius filter matched") + else: + event_lat = 0.0 + event_lon = 0.0 + event_dist = 0 + if not self._nolocation: + _LOGGER.debug("No location found, discarding") + continue + + if "summary" in entry: + event_caps = entry.summary.replace("
", "\n") + + if self._capcodelist: + _LOGGER.debug("Filtering on Capcode(s) %s", self._capcodelist) + capfound = False + for capcode in self._capcodelist: + _LOGGER.debug( + "Searching for capcode %s in %s", capcode.strip(), event_caps, + ) + if event_caps.find(capcode) != -1: + _LOGGER.debug("Capcode filter matched") + capfound = True + break + _LOGGER.debug("Capcode filter mismatch, discarding") + continue + if not capfound: + continue + + if self._contains: + _LOGGER.debug("Filtering on Contains string %s", self._contains) + if event_msg.find(self._contains) != -1: + _LOGGER.debug("Contains string filter matched") + else: + _LOGGER.debug("Contains string filter mismatch, discarding") + continue + + if event_msg: + event = {} + event["msgtext"] = event_msg + event["latitude"] = event_lat + event["longitude"] = event_lon + event["distance"] = event_dist + event["msgtime"] = event_time + event["capcodetext"] = event_caps + _LOGGER.debug("Event: %s", event) + self._data = event + + dispatcher_send(self._hass, DATA_UPDATED) + + except ValueError as err: + _LOGGER.error("Error parsing feed data %s", err) + self._data = None + + +class P2000Sensor(RestoreEntity): + """Representation of a P2000 Sensor.""" + + def __init__(self, hass, data, name, icon): + """Initialize a P2000 sensor.""" + self._hass = hass + self._data = data + self._name = name + self._icon = icon + self._state = None + self.attrs = {} + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def should_poll(self): + """Return the polling requirement for this sensor.""" + return False + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if not state: + return + self._state = state.state + self.attrs = state.attributes + + async_dispatcher_connect( + self._hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + data = self._data.latest_data + if data: + attrs[ATTR_LONGITUDE] = data["longitude"] + attrs[ATTR_LATITUDE] = data["latitude"] + attrs["distance"] = data["distance"] + attrs["capcodes"] = data["capcodetext"] + attrs["time"] = data["msgtime"] + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + self.attrs = attrs + + return self.attrs + + def update(self): + """Update current values.""" + data = self._data.latest_data + if data: + self._state = data["msgtext"] + _LOGGER.debug("State updated to %s", self._state) diff --git a/config/custom_components/rituals_genie/__init__.py b/config/custom_components/rituals_genie/__init__.py new file mode 100644 index 0000000..0173170 --- /dev/null +++ b/config/custom_components/rituals_genie/__init__.py @@ -0,0 +1,119 @@ +""" +Custom integration to integrate Rituals Genie with Home Assistant. + +For more details about this integration, please refer to +https://github.com/fred-oranje/rituals-genie +""" +import asyncio +import logging +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .api import RitualsGenieApiClient +from .const import CONF_FILL_SENSOR_ENABLED +from .const import CONF_HUB_HASH +from .const import CONF_PERFUME_SENSOR_ENABLED +from .const import CONF_SWITCH_ENABLED +from .const import CONF_WIFI_SENSOR_ENABLED +from .const import DOMAIN +from .const import PLATFORMS +from .const import SENSOR +from .const import STARTUP_MESSAGE +from .const import SWITCH + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +async def async_setup(hass: HomeAssistant, config: Config): + """Set up this integration using YAML is not supported.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up this integration using UI.""" + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + _LOGGER.info(STARTUP_MESSAGE) + + hub_hash = entry.data.get(CONF_HUB_HASH) + + session = async_get_clientsession(hass) + client = RitualsGenieApiClient(hub_hash, session) + + coordinator = RitualsGenieDataUpdateCoordinator(hass, client=client) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + if entry.options.get(CONF_SWITCH_ENABLED, True): + coordinator.platforms.append(SWITCH) + hass.async_add_job(hass.config_entries.async_forward_entry_setup(entry, SWITCH)) + + if ( + entry.options.get(CONF_FILL_SENSOR_ENABLED, True) + or entry.options.get(CONF_PERFUME_SENSOR_ENABLED, True) + or entry.options.get(CONF_WIFI_SENSOR_ENABLED, True) + ): + coordinator.platforms.append(SENSOR) + hass.async_add_job(hass.config_entries.async_forward_entry_setup(entry, SENSOR)) + + entry.add_update_listener(async_reload_entry) + return True + + +class RitualsGenieDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + client: RitualsGenieApiClient, + ) -> None: + """Initialize.""" + self.api = client + self.platforms = [] + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self): + """Update data via library.""" + try: + return await self.api.async_get_data() + except Exception as exception: + raise UpdateFailed() from exception + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + if platform in coordinator.platforms + ] + ) + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) + + return unloaded + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) diff --git a/config/custom_components/rituals_genie/__pycache__/__init__.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..1af10e5 Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/__init__.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/__pycache__/api.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/api.cpython-38.pyc new file mode 100644 index 0000000..266144c Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/api.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/__pycache__/config_flow.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/config_flow.cpython-38.pyc new file mode 100644 index 0000000..9288f63 Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/config_flow.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/__pycache__/const.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/const.cpython-38.pyc new file mode 100644 index 0000000..80097d8 Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/const.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/__pycache__/entity.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/entity.cpython-38.pyc new file mode 100644 index 0000000..f1fe572 Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/entity.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/__pycache__/sensor.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/sensor.cpython-38.pyc new file mode 100644 index 0000000..b07cdc5 Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/sensor.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/__pycache__/switch.cpython-38.pyc b/config/custom_components/rituals_genie/__pycache__/switch.cpython-38.pyc new file mode 100644 index 0000000..f8aa07f Binary files /dev/null and b/config/custom_components/rituals_genie/__pycache__/switch.cpython-38.pyc differ diff --git a/config/custom_components/rituals_genie/api.py b/config/custom_components/rituals_genie/api.py new file mode 100644 index 0000000..2bdb9b0 --- /dev/null +++ b/config/custom_components/rituals_genie/api.py @@ -0,0 +1,100 @@ +"""Sample API Client.""" +import asyncio +import logging +import socket + +import aiohttp +import async_timeout + +TIMEOUT = 10 +API_URL = "https://rituals.sense-company.com" + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + + +class RitualsGenieApiClient: + def __init__(self, hub_hash: str, session: aiohttp.ClientSession) -> None: + """Rituals API Client.""" + self._hub_hash = hub_hash + self._session = session + + async def async_get_hubs(self, username: str, password: str) -> list: + """Login using the API""" + url = API_URL + "/ocapi/login" + response = await self.api_wrapper( + "post", url, data={"email": username, "password": password}, headers=HEADERS + ) + + if response["account_hash"] is None: + raise Exception("Authentication failed") + else: + _account_hash = response["account_hash"] + + """Retrieve hubs""" + url = API_URL + "/api/account/hubs/" + _account_hash + response = await self.api_wrapper("get", url) + + return response + + async def async_get_data(self) -> dict: + """Get data from the API.""" + url = API_URL + "/api/account/hub/" + self._hub_hash + return await self.api_wrapper("get", url) + + async def async_set_on_off(self, value: bool) -> None: + """Get data from the API.""" + if value is True: + fanc = "1" + else: + fanc = "0" + url = ( + API_URL + + "/api/hub/update/attr?hub=" + + self._hub_hash + + "&json=%7B%22attr%22%3A%7B%22fanc%22%3A%22" + + fanc + + "%22%7D%7D" + ) + + await self.api_wrapper("postnonjson", url) + + async def api_wrapper( + self, method: str, url: str, data: dict = {}, headers: dict = {} + ) -> dict: + """Get information from the API.""" + try: + async with async_timeout.timeout(TIMEOUT, loop=asyncio.get_event_loop()): + if method == "get": + response = await self._session.get(url, headers=headers) + return await response.json() + + elif method == "post": + response = await self._session.post(url, headers=headers, json=data) + return await response.json() + + elif method == "postnonjson": + return await self._session.post(url) + + except asyncio.TimeoutError as exception: + _LOGGER.error( + "Timeout error fetching information from %s - %s", + url, + exception, + ) + + except (KeyError, TypeError) as exception: + _LOGGER.error( + "Error parsing information from %s - %s", + url, + exception, + ) + except (aiohttp.ClientError, socket.gaierror) as exception: + _LOGGER.error( + "Error fetching information from %s - %s", + url, + exception, + ) + except Exception as exception: # pylint: disable=broad-except + _LOGGER.error("Something really wrong happened! - %s", exception) diff --git a/config/custom_components/rituals_genie/config_flow.py b/config/custom_components/rituals_genie/config_flow.py new file mode 100644 index 0000000..9a9706b --- /dev/null +++ b/config/custom_components/rituals_genie/config_flow.py @@ -0,0 +1,169 @@ +"""Adds config flow for Rituals Genie.""" +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .api import RitualsGenieApiClient +from .const import CONF_FILL_SENSOR_ENABLED +from .const import CONF_HUB_HASH +from .const import CONF_HUB_NAME +from .const import CONF_PASSWORD +from .const import CONF_PERFUME_SENSOR_ENABLED +from .const import CONF_SWITCH_ENABLED +from .const import CONF_USERNAME +from .const import CONF_WIFI_SENSOR_ENABLED +from .const import DOMAIN + + +class RitualsGenieFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for rituals_genie.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + hubs = await self._test_credentials( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if not hubs: + self._errors["base"] = "auth" + else: + self._hubs_info = hubs + + return await self.async_step_hub() + + return await self._show_config_form(user_input) + + return await self._show_config_form(user_input) + + async def async_step_hub(self, user_input=None): + """Handle second step in the user flow""" + self._errors = {} + + if user_input is not None: + # Find the hub + hub_hash = None + hub_name = None + for hub in self._hubs_info: + name = hub.get("hub").get("attributes").get("roomnamec")[0] + if name == user_input[CONF_HUB_NAME]: + hub_name = name + hub_hash = hub.get("hub").get("hash") + break + + if hub_hash is None: + self._errors["base"] = "invalid_hub" + else: + return self.async_create_entry( + title=hub_name, + data={ + CONF_HUB_NAME: hub_name, + CONF_HUB_HASH: hub_hash, + }, + ) + + return await self._show_hubs_config_form(self._hubs_info, user_input) + + return await self._show_hubs_config_form(self._hubs_info, user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return RitualsGenieOptionsFlowHandler(config_entry) + + async def _show_config_form(self, user_input): # pylint: disable=unused-argument + """Show the configuration form to edit username / password data.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=self._errors, + ) + + async def _show_hubs_config_form( + self, hubs, user_input + ): # pylint: disable=unused-argument + """Show the configuration form to choose hub""" + hub_names = [] + for hub in hubs: + name = hub.get("hub").get("attributes").get("roomnamec")[0] + try: + hub_names.index(name) + except ValueError: + hub_names.append(name) + pass + + return self.async_show_form( + step_id="hub", + data_schema=vol.Schema({vol.Required(CONF_HUB_NAME): vol.In(hub_names)}), + errors=self._errors, + ) + + async def _test_credentials(self, username, password): + """Return true if credentials is valid.""" + try: + session = async_create_clientsession(self.hass) + client = RitualsGenieApiClient("", session) + return await client.async_get_hubs(username, password) + except Exception: # pylint: disable=broad-except + pass + return False + + +class RitualsGenieOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options handler for rituals_genie.""" + + def __init__(self, config_entry): + """Initialize HACS options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self.options.update(user_input) + return await self._update_options() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_PERFUME_SENSOR_ENABLED, + default=self.options.get(CONF_PERFUME_SENSOR_ENABLED, True), + ): bool, + vol.Required( + CONF_FILL_SENSOR_ENABLED, + default=self.options.get(CONF_FILL_SENSOR_ENABLED, True), + ): bool, + vol.Required( + CONF_WIFI_SENSOR_ENABLED, + default=self.options.get(CONF_WIFI_SENSOR_ENABLED, True), + ): bool, + vol.Required( + CONF_SWITCH_ENABLED, + default=self.options.get(CONF_SWITCH_ENABLED, True), + ): bool, + } + ), + ) + + async def _update_options(self): + """Update config entry options.""" + return self.async_create_entry( + title=self.config_entry.data.get(CONF_HUB_NAME), data=self.options + ) diff --git a/config/custom_components/rituals_genie/const.py b/config/custom_components/rituals_genie/const.py new file mode 100644 index 0000000..b5f7446 --- /dev/null +++ b/config/custom_components/rituals_genie/const.py @@ -0,0 +1,47 @@ +"""Constants for Rituals Genie.""" +# Base component constants +NAME = "Rituals Genie" +MANUFACTURER = "Rituals" +MODEL = "Genie" +DOMAIN = "rituals_genie" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.0.1" + +ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" +ISSUE_URL = "https://github.com/fred-oranje/rituals-genie/issues" + +# Icons +ICON = "mdi:format-quote-close" +ICON_WIFI = "mdi:wifi" +ICON_PERFUME = "mdi:nfc-variant" +ICON_FAN = "mdi:fan" +ICON_FILL = "mdi:format-color-fill" + +# Platforms +SENSOR = "sensor" +SWITCH = "switch" +PLATFORMS = [SENSOR, SWITCH] + +# Configuration and options +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_HUB_HASH = "hub_hash" +CONF_HUB_NAME = "hub_name" +CONF_WIFI_SENSOR_ENABLED = "wifi_enabled" +CONF_PERFUME_SENSOR_ENABLED = "perfume_enabled" +CONF_FILL_SENSOR_ENABLED = "fill_enabled" +CONF_SWITCH_ENABLED = "switch_enabled" + +# Defaults +DEFAULT_NAME = "Rituals Genie" + + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" diff --git a/config/custom_components/rituals_genie/entity.py b/config/custom_components/rituals_genie/entity.py new file mode 100644 index 0000000..ec437d1 --- /dev/null +++ b/config/custom_components/rituals_genie/entity.py @@ -0,0 +1,40 @@ +"""RitualsGenieEntity class""" +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION +from .const import CONF_HUB_NAME +from .const import DOMAIN +from .const import MANUFACTURER +from .const import MODEL +from .const import NAME + + +class RitualsGenieEntity(CoordinatorEntity): + def __init__(self, coordinator, config_entry, sensor_name): + super().__init__(coordinator) + self.config_entry = config_entry + self.sensor_name = sensor_name + self.hub_name = config_entry.data.get(CONF_HUB_NAME) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}_{self.hub_name}_{self.sensor_name}" + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": f"{NAME} {self.hub_name}", + "model": MODEL, + "manufacturer": MANUFACTURER, + } + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + "attribution": ATTRIBUTION, + "id": str(self.coordinator.data.get("id")), + "integration": DOMAIN, + } diff --git a/config/custom_components/rituals_genie/manifest.json b/config/custom_components/rituals_genie/manifest.json new file mode 100644 index 0000000..3261b4c --- /dev/null +++ b/config/custom_components/rituals_genie/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "rituals_genie", + "name": "Rituals Genie", + "documentation": "https://github.com/fred-oranje/rituals-genie", + "issue_tracker": "https://github.com/fred-oranje/rituals-genie/issues", + "dependencies": [], + "config_flow": true, + "codeowners": ["@fred-oranje"], + "requirements": [], + "version": "0.0.2" +} diff --git a/config/custom_components/rituals_genie/sensor.py b/config/custom_components/rituals_genie/sensor.py new file mode 100644 index 0000000..00d324b --- /dev/null +++ b/config/custom_components/rituals_genie/sensor.py @@ -0,0 +1,97 @@ +"""Sensor platform for Rituals Genie.""" +from .const import CONF_FILL_SENSOR_ENABLED +from .const import CONF_PERFUME_SENSOR_ENABLED +from .const import CONF_WIFI_SENSOR_ENABLED +from .const import DEFAULT_NAME +from .const import DOMAIN +from .const import ICON_FILL +from .const import ICON_PERFUME +from .const import ICON_WIFI +from .entity import RitualsGenieEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [] + if entry.options.get(CONF_FILL_SENSOR_ENABLED, True): + sensors.append(RitualsGeniePerfumeSensor(coordinator, entry, "perfume")) + if entry.options.get(CONF_PERFUME_SENSOR_ENABLED, True): + sensors.append(RitualsGenieFillSensor(coordinator, entry, "fill")) + if entry.options.get(CONF_WIFI_SENSOR_ENABLED, True): + sensors.append(RitualsGenieWifiSensor(coordinator, entry, "wifi")) + + async_add_devices(sensors) + + +class RitualsGeniePerfumeSensor(RitualsGenieEntity): + """rituals_genie Sensor class.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DEFAULT_NAME} {self.hub_name} Perfume" + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data.get("hub").get("sensors").get("rfidc").get("title") + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON_PERFUME + + @property + def device_class(self): + """Return de device class of the sensor.""" + return "rituals_genie__custom_device_class" + + +class RitualsGenieFillSensor(RitualsGenieEntity): + """rituals_genie Sensor class.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DEFAULT_NAME} {self.hub_name} Fill" + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data.get("hub").get("sensors").get("fillc").get("title") + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON_FILL + + @property + def device_class(self): + """Return de device class of the sensor.""" + return "rituals_genie__custom_device_class" + + +class RitualsGenieWifiSensor(RitualsGenieEntity): + """rituals_genie Sensor class.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DEFAULT_NAME} {self.hub_name} Wifi" + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data.get("hub").get("sensors").get("wific").get("title") + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON_WIFI + + @property + def device_class(self): + """Return de device class of the sensor.""" + return "rituals_genie__custom_device_class" diff --git a/config/custom_components/rituals_genie/switch.py b/config/custom_components/rituals_genie/switch.py new file mode 100644 index 0000000..090ee8b --- /dev/null +++ b/config/custom_components/rituals_genie/switch.py @@ -0,0 +1,44 @@ +"""Switch platform for Rituals Genie.""" +from homeassistant.components.switch import SwitchEntity + +from .const import DEFAULT_NAME +from .const import DOMAIN +from .const import ICON_FAN +from .entity import RitualsGenieEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([RitualsGenieBinarySwitch(coordinator, entry, "")]) + + +class RitualsGenieBinarySwitch(RitualsGenieEntity, SwitchEntity): + """rituals_genie switch class.""" + + async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument + """Turn on the switch.""" + await self.coordinator.api.async_set_on_off(True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument + """Turn off the switch.""" + await self.coordinator.api.async_set_on_off(False) + await self.coordinator.async_request_refresh() + + @property + def name(self): + """Return the name of the switch.""" + return f"{DEFAULT_NAME} {self.hub_name}" + + @property + def icon(self): + """Return the icon of this switch.""" + return ICON_FAN + + @property + def is_on(self): + """Return true if the switch is on.""" + return ( + self.coordinator.data.get("hub").get("attributes").get("fanc", "0") == "1" + ) diff --git a/config/custom_components/rituals_genie/translations/en.json b/config/custom_components/rituals_genie/translations/en.json new file mode 100644 index 0000000..7ec1453 --- /dev/null +++ b/config/custom_components/rituals_genie/translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "title": "Rituals Genie", + "description": "Provide your Rituals username and password (these are not stored)", + "data": { + "username": "Username", + "password": "Password" + } + }, + "hub": { + "title": "Select Rituals Genie", + "description": "Choose the Genie to add", + "data": { + "hub_name": "Genie" + } + } + }, + "error": { + "auth": "Username/Password is wrong.", + "invalid_hub": "Invalid Genie selected." + } + }, + "options": { + "step": { + "user": { + "data": { + "wifi_enabled": "WiFi sensor enabled", + "perfume_enabled": "Perfume sensor enabled", + "fill_enabled": "Fill sensor enabled", + "switch_enabled": "Switch enabled" + } + } + } + } +} diff --git a/config/custom_components/rituals_genie/translations/nl.json b/config/custom_components/rituals_genie/translations/nl.json new file mode 100644 index 0000000..bbf20e1 --- /dev/null +++ b/config/custom_components/rituals_genie/translations/nl.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "title": "Rituals Genie", + "description": "Geef je Rituals gebruikersnaam en wachtwoord (worden niet opgeslagen)", + "data": { + "username": "Gebruikersnaam", + "password": "Wachtwoord" + } + }, + "hub": { + "title": "Kies Rituals Genie", + "description": "Kies de Genie om toe te voegen", + "data": { + "hub_name": "Genie" + } + } + }, + "error": { + "auth": "Gebruikersnaam/wachtwoord zijn fout.", + "invalid_hub": "Ongeldige Genie geselecteerd." + } + }, + "options": { + "step": { + "user": { + "data": { + "wifi_enabled": "WiFi sensor ingeschakeld", + "perfume_enabled": "Parfum sensor ingeschakeld", + "fill_enabled": "Vulniveau sensor ingeschakeld", + "switch_enabled": "Schakelaar ingeschakeld" + } + } + } + } +}