From e5e02627fc0f9d8860def2beb5d044a872e21a37 Mon Sep 17 00:00:00 2001 From: LRvdLinden <77990847+LRvdLinden@users.noreply.github.com> Date: Fri, 14 May 2021 14:19:33 +0200 Subject: [PATCH] Add files via upload --- config/custom_components/nodered/__init__.py | 239 ++++++++++++++ .../__pycache__/__init__.cpython-38.pyc | Bin 0 -> 7336 bytes .../__pycache__/config_flow.cpython-38.pyc | Bin 0 -> 1296 bytes .../nodered/__pycache__/const.cpython-38.pyc | Bin 0 -> 1531 bytes .../__pycache__/discovery.cpython-38.pyc | Bin 0 -> 2609 bytes .../__pycache__/websocket.cpython-38.pyc | Bin 0 -> 5086 bytes .../nodered/binary_sensor.py | 76 +++++ .../custom_components/nodered/config_flow.py | 34 ++ config/custom_components/nodered/const.py | 51 +++ config/custom_components/nodered/discovery.py | 109 +++++++ .../custom_components/nodered/manifest.json | 13 + config/custom_components/nodered/sensor.py | 39 +++ .../custom_components/nodered/services.yaml | 17 + config/custom_components/nodered/switch.py | 247 +++++++++++++++ .../nodered/translations/en.json | 13 + .../nodered/translations/nb.json | 13 + config/custom_components/nodered/utils.py | 19 ++ config/custom_components/nodered/websocket.py | 194 ++++++++++++ config/custom_components/p2000/__init__.py | 1 + .../p2000/__pycache__/__init__.cpython-38.pyc | Bin 0 -> 165 bytes .../p2000/__pycache__/sensor.cpython-38.pyc | Bin 0 -> 8075 bytes config/custom_components/p2000/manifest.json | 13 + config/custom_components/p2000/sensor.py | 296 ++++++++++++++++++ .../rituals_genie/__init__.py | 119 +++++++ .../__pycache__/__init__.cpython-38.pyc | Bin 0 -> 3976 bytes .../__pycache__/api.cpython-38.pyc | Bin 0 -> 3192 bytes .../__pycache__/config_flow.cpython-38.pyc | Bin 0 -> 4963 bytes .../__pycache__/const.cpython-38.pyc | Bin 0 -> 1279 bytes .../__pycache__/entity.cpython-38.pyc | Bin 0 -> 1697 bytes .../__pycache__/sensor.cpython-38.pyc | Bin 0 -> 3487 bytes .../__pycache__/switch.cpython-38.pyc | Bin 0 -> 1936 bytes config/custom_components/rituals_genie/api.py | 100 ++++++ .../rituals_genie/config_flow.py | 169 ++++++++++ .../custom_components/rituals_genie/const.py | 47 +++ .../custom_components/rituals_genie/entity.py | 40 +++ .../rituals_genie/manifest.json | 11 + .../custom_components/rituals_genie/sensor.py | 97 ++++++ .../custom_components/rituals_genie/switch.py | 44 +++ .../rituals_genie/translations/en.json | 37 +++ .../rituals_genie/translations/nl.json | 37 +++ 40 files changed, 2075 insertions(+) create mode 100644 config/custom_components/nodered/__init__.py create mode 100644 config/custom_components/nodered/__pycache__/__init__.cpython-38.pyc create mode 100644 config/custom_components/nodered/__pycache__/config_flow.cpython-38.pyc create mode 100644 config/custom_components/nodered/__pycache__/const.cpython-38.pyc create mode 100644 config/custom_components/nodered/__pycache__/discovery.cpython-38.pyc create mode 100644 config/custom_components/nodered/__pycache__/websocket.cpython-38.pyc create mode 100644 config/custom_components/nodered/binary_sensor.py create mode 100644 config/custom_components/nodered/config_flow.py create mode 100644 config/custom_components/nodered/const.py create mode 100644 config/custom_components/nodered/discovery.py create mode 100644 config/custom_components/nodered/manifest.json create mode 100644 config/custom_components/nodered/sensor.py create mode 100644 config/custom_components/nodered/services.yaml create mode 100644 config/custom_components/nodered/switch.py create mode 100644 config/custom_components/nodered/translations/en.json create mode 100644 config/custom_components/nodered/translations/nb.json create mode 100644 config/custom_components/nodered/utils.py create mode 100644 config/custom_components/nodered/websocket.py create mode 100644 config/custom_components/p2000/__init__.py create mode 100644 config/custom_components/p2000/__pycache__/__init__.cpython-38.pyc create mode 100644 config/custom_components/p2000/__pycache__/sensor.cpython-38.pyc create mode 100644 config/custom_components/p2000/manifest.json create mode 100644 config/custom_components/p2000/sensor.py create mode 100644 config/custom_components/rituals_genie/__init__.py create mode 100644 config/custom_components/rituals_genie/__pycache__/__init__.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/__pycache__/api.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/__pycache__/config_flow.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/__pycache__/const.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/__pycache__/entity.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/__pycache__/sensor.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/__pycache__/switch.cpython-38.pyc create mode 100644 config/custom_components/rituals_genie/api.py create mode 100644 config/custom_components/rituals_genie/config_flow.py create mode 100644 config/custom_components/rituals_genie/const.py create mode 100644 config/custom_components/rituals_genie/entity.py create mode 100644 config/custom_components/rituals_genie/manifest.json create mode 100644 config/custom_components/rituals_genie/sensor.py create mode 100644 config/custom_components/rituals_genie/switch.py create mode 100644 config/custom_components/rituals_genie/translations/en.json create mode 100644 config/custom_components/rituals_genie/translations/nl.json 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 0000000000000000000000000000000000000000..b7da5b71c0a7036c3d6e90fdcd37333eac4551dc GIT binary patch literal 7336 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 J<>%V?{{g6IG2{RM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..47a1478863c9440391c2c0087b23aa61f9920124 GIT binary patch literal 1296 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6e1744f372e46306bc56f449804d848035a910a9 GIT binary patch literal 5086 zcmb7HOLH5?5#E_y01FTRK~j8*k|65|JwQKYTcKr9BxIp%fhrP~tUYA2!Vbx$b^&^J z!I&s07s@5(;3`)ohg8X~B5%O>b{cPw(_h z&(}Sl=W-bZet+Bg_E#?_6y+cIa`-n5FE^q2zo&{~D~4hdo4Ujx@<|OUpQ@q3Q*{$w z(nu22(cF}$8@iV^((*mwX1pO|Nb*TH>*b7`mpAg>urcfvjDj~}jCiBQs5fSeNu88? z%o{hxy$NH&n=~f9DPzi;Hm1E9V@CGt?s0F{n3a6mJ>i`+PDwuF7QNHPX~_?{Z+K^n zGr(uz~yX=ge|Bl$hcHyaJyvIgZo{ceW{~DV|;-rU4nn(1JLg4j>}Q!rS6B4zjBDb6!V+c*#$?jC%&QV7oc^WO+k;gKhHj5H`vGQ z=7A=q=Rg|vUk2_YDZAK{m4F+Pva7(|aE4%JllD|NGh<<3Uelm=jlFg*Ab-u*VTR}8 z89E8DJYye^EZ-WiJnO(J*eC3hQSaiB)jK7>JOeX4wHdC+QVyFV}q|XUWvr@hxaOCSc)H1 zZrxd4{?x24fm?dRa@|K(<1sE&Lpp29g$Ngli1E-H+wr%ops@jd`mAZA_MMi;7JMI^ zx0*qHXN&znV8u(h=rap-Sg~85*DI?cFTcCEeD~gRtx~H;^G9Lz6H$wsZmX|gpnlTX`t*u?S{Wtv?hgkgisjYcGhN)cyodsG1-^IN1}npfuDmXjQ@eh{)P5cF# z|92o=vafXM0ePYZWS@3{x5=~;sMD~z2WnNZ=^f>X*16Wz_O-azQM&4Z7WNUekO-1p z8l+&1YHI*dV2>ydl;*ak1p4C){{vB;B#6>EDRtA4ZX(EZ0ql0wQ2Raj)|13<5??3} zmF7H*9DR{@8#t%$SX@m*(u8LxE@+srN33IH=bou4zB{O7}6v!oL8X5gj>UKtYu@r|sfK$q5YZl<> zKq%qVFq{^l`R@V=4=@A~#qc>GUD_qPnhX#vNPsknL1oi8Kx}nYd7<$)VJLqKNWE4{ z@o{{61BgiCT3P|e%86s__gCqKDp!PmfYJ|<;9)!Db_re$Xg;o~GD^YetU|$UP373v z$;WPnhxrx9QlKOq!%qx4299Xp9=N%y9*O=V&hd|6a7hn+Is-hvf#ejD6G#TV;vb{z zh$oj&2F=d`QF7?XXy^y_bcSO;B0PJ=kpyT-N2Y+m!ydrC1Qd^WcuXS@E!+r22!aiQd`!J+p=tt+F%ysFkd9M zEN*#zXXHVo&sl>x+srQhFbocMv~u}+QE0XmSOBRS;;GO!eMr9vG~|oSeamgL3RZTY z<}?Dn0OLgB%QoiTl=;~*7KyKLY4!?;yOF&BW zC{`dkihP(_&B)E?AoC}HC~BTiGC}erqf(+1{Xfa%Nzy5SURWQ;Bg*q`3lQGFQ698} zg>sU!pv{{Nw0HQ2dp`!v|0R&lC4pg(dg}@OoE-s73M(&)8EOktS%qkD^#$r7i%1W;w3GYLpUy_G3CVd zibzGPBnn|>Yd7Osh2Md3{8J!8bD99#M{o@x4S-&vHD)pi*&-9!Gab8++*q;Wbm9g6 z6R3yKM=2Sld6J|vKfe)!8Pt@It~* zV-JRN=X#~TI!wFOFin0N-bKo7t*<-Hb&=U_x$Ui>-D>+n z1zt>TzzqYc@bHFF?(eKDZH)JRs=NG3rBh~KHCX7LEKp?8V!Zu-7znttz7FLIrUB#p zPY&##H*3q0B|OK(EbAjK0RU^+ur9#-D8`POYC;i}LmgRM$=xg9>j)CubuoH$2xl@9 z#Q0#x26MLn>-aGoL>t?p_}cz*|7s`mzLu^1b?%=Zi6G@l|hL651H`sD{700Rz%^d{QmdoFl?@luii8Vm)Ko(S7)+w0xWmtxx zE#q4fjBlaOdo8=|vKx5y^Zy7$Lu{ow&60waC1=w`Jxk=D&KELjJ_FSbp;TAk2q@Zr z>5nv-$679ypj0-f?ckY*hR=caPZ%`}XX%wuc><&U1- 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 0000000000000000000000000000000000000000..b26506f7bc24f73efd3114dce58734e665132f32 GIT binary patch literal 165 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1af10e592b0dd604424599dcce1e743487f0946a GIT binary patch literal 3976 zcma)9-E-T<5yu@s@I$0TQyUGij)_ab`NH>M0{rmOA=yMX63O(?KELiGmCQ z%;A_+9I6kIXY4+>Gky1i^cGM5jQj_E#A~1Y+Q&{~clSWbVwGtN4i0;_ce{7DyT4uT z7795Hp2d6B-~TYGX@AGg#m9ioui%Z^MALRPrr8=JjQYeT>Pu}3U)|SRhHVh!8Gfo| z+NPpYe!7*hGm18W&e~Z;r~O=O#2!&}#?QA3c0tiuf3!7bk109_bkQyXJ;L(-l~&0v zDZ1c~wETU&i_I))Jj*j|_9bO=Z2qZX z--f=)u0j7gy8-?89%{^D3r{t+z{gH>`wsH?`|RdRo!w%$Va8pxf~t}C*c})tGg{Zm z?|1*2Tkb?MY*}6)c~iL33j<4r=Ur}Xd$QyDk@b)V9=E>nxPh_nNgpiX-TnNf)RBZu5MX zFfe{$=4L3jxyx|HRW~{c8cvf-r{Q}%kP(NYg#oZ8AGp%pZ8KN$Y&I>_5##ceW$v;v-`-|sMYJ$hc(4i8eT6YM@vV%5e6)Z)6V+V!-uu)S2Q*b+$f4o z7>Da`P&7{eK^pJmdw3&^a7Mt{X!l;7ORLkq$zq~(v7vm|0k>&~0a|Y0F|3nJp+Jxk`Z@RUzgO=&M48Oahj6m z86jGasCnr#a!h5Wr=5^vU1ocJb=$y(#@T`zp?k{?=bR zJ=B{~lK#<`G_F1#oSa84)4`5Lcj3R=ad)kP&Soajv{Swo8xTQmtV4*2D|pX2 zuma=zZpW8m8V3w7*bl`7kfMN18k-U1EL;q#9}UO2kXRN#Meyh#9{1fyIvo|YPSj~M zAe;r7FwQ9RIv$JDVH=Sy5@R@?ZTqeS{Vi~3x83Fe6O%aJ7wj_T9EN-GXySDaO7VCU z&m?+b;Ww^eYWT=e{w4CTTtL3lL=l^Dls1lX`rGmWI(fiFTpH}%58b|qvZ> z04zEXXa_Nbpn@f_fWk%^@9^kdWQc3XyK=$PlU+`w&eYoE);0U`mqi?uHxEy)AO zG>_1s^yF65=}iGqEP{rPyXZeiqm}U+VSq!ZX^z%}fOQ~TPqafFg2Rvyo{YS}S^_hm z-W^#3i11wB>+(t&P%Us-b3L z9p@`3Z~C0HHxpQ$;yi8*6Z*_82=e!7?i;8 zU&RsS@B61-=f+Rc%w(qJf{HuWJY^V4j~2nNt3!X4pYyxXXF{(*ReW& znvv!MNGyNGEaVyXJLcM_+4+`fvzqNG^uuzk^3aVFU_XUV31jY1UE_Tf8W zC+hcr8BIZhSWAfk7)#$6Z!>R=tU*dt{02BfhR1M4EApA2=V{` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..266144ce5449ece71f13ed97c24a939c174bb146 GIT binary patch literal 3192 zcmb7G&2JmW6`!5`;POk7Y1xj`e9&#=lr1bKPJ$Gv-9&Lz$*8NevIM(4bi3gU#g)ii zc6Jz73#up@Uk&tJ=pdn|07Xwl|AF4~2kfapPx%+pChqUeQkIm+4!Y#Nc{BUI-std3J zyLoF6$yhIb4()91bFwwN(${*FzSC~2NRPuox|qs1mDjq-a1_S7ix~4#aOcC7mF4xU zb}NZB1YFa*BbmJ%jz)v18|o;D+uLap|G3%hhboo&=H|vvul=Y?T24MCOy0wr4ge&B z&#c3xEuC$byxg~iEu4o=$HST}OTxtpJi`h+7XH2^%A$fX_Fe7O@^l zvSpcmVrJhJvtsU{+o=l+Q8<@hSTEdLk#QuKMo|%owU(Xx{qaXZKTP|1DV1poXL3(T zJyvm-(slF@&hg&Do6Z4DtO*O)gaaaZP{OnGQza*3z1HG+Ie4PO52i|>*-7P~r(DQV z9>AweRWW?A*zP8AFWPE%$Ei++0i_~|5%%;bN5K|((GEw^;%GPbgCL3{9Rv+hy0*FiiEE$Q>LNzv z6CiI|yoe7u^$h1pMGGX;TAs1K-A%)SI`}vojAh|1d3f}Yr%$|nACn2TEJ_nSa*xN3 zu=hCpcTkeXe}TfB@Jr~c(*ot9G%Zknm02iW#G|ZUgrJSwdOf#Z%PyN3AZ_jO2*zM@ zYOUVI{ASj=O3m@+!rSjJT)$4s7#7}GD(1Z~?oQ{|uV-aK-~Isqyh^1VCo%D^sh6Ot z=LxAK$n+*G& zud+Y?!=kjhka^i~Hwh57=%@3|BApmaE;zK7lL7W?INw$A>-?x9G*?}Gop z@cYC+!4dMv{=X!YAmKBTZ~zI1R>6C=zI&%^xDOlR&m5-h?eb3LPnMdu*q&#xtf4Ch zRYCT6hqeW(x8Tv9_j~U?`xT4#!BNxo9ydIzr+CUw;pu~CW_wo8ZJ(R+{oxc}J)l{8BO1zNteaA)L^XR-cl*@gzaf1gZC-0Gq}kPFLpZ|K;+b@>7Ifb( zaN%x3r(x=8AOL4{MZJp2S#a7aV!FlNkvT^(8%O7rTCtc>_1A0xgA6LU*&gTHIBN zRWD7tJ5uNVRu~y(bs2(MrJ^+~kTTO0j`!quNKsiHol#%wyQVL{3zGB(0CTwYg5_Hm zt?!~&JZIoLe4b7p`h7X?T|CC!=TG1bE4%WP?s0r(wr1$ELN|^e$SXlGOvHFVcr6Hi zi_7Qqi=AqvUMI8bh3j{Vdzq@!nlp&-{RM-HsH@e>2K^{W2I>`{#-kHnQu|SKzA8Ea z&fztOs&CD^jmC^S*TBL07|x-g;Aio#3|(s$*WN%R-5^ju1W#VVjRRMXE%gS8BJ0%m z3H*Qn9dmgZHGnJ27VZYsAQ*++op1}z6`A&?UuJ_KjN?R`U6kVMbHq=Uon>oXT!4K*2xnnte9#?_?0SJ#Gd;kCd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9288f633715def4663095cd295d50696f4938f95 GIT binary patch literal 4963 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 Nk4c&7$5LBp{s$zf&anUh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..80097d8810a00b23c395aa602aacd3e83abb174b GIT binary patch literal 1279 zcmbtU-EP}96t-pAmK8g0ndnwFL8&{;-R!h5&(pKX*u3i3X1dvZ_o>U8lp-QUep)mJe>G9F$OeDakehD_|ec8$sf^vo%{iphC2HHLoHSHJgY_5s(w*rSj%yhfYcEz)W6nC$)j~Iy zY_FcZsqW~k``)l@bH%N?<5=do>j=;@ZQC_G*P1igyjU$!Pe||DoSCR!saJ+Ia~B3u zDm2p{C^K`;E7``txl`91 z+Kz5H?v=MNY+HY4{NBjE;nzqdKA%nklB}c6q~2GLpP!T@n4jf`Q&JyHL8JLN-7)2- sErBA&KR3mN^p*=*lAHCTHY>gLfaOY4{>3$~CATC=`kTwYY}F5GA=ET1l%kNsA_Jf?`_~MU{hu0zDQ*6x&G+)H2}Om%Xr?C1tI#+Eqwy z0UNs6Kz>A8z&YkG@tRZqLeK)8;mWR2+Z1;6_&I#@W_U0d^a!-^kCWfu`-Hs5&eaBE z0?yR$SF+&z<~%I4gq&W zYO}*fGy;#;2IMid+O3DvW z(=iNzsH5evis2YMPe)(~jcEwKlSkL1GrAy>qtq7-Fgqn?&o9jbK(^Z+3rjzRu4Z6}ck=iKI+qJ?=kqOWHpqw`u^F3F13VlrANY6%L>KH& zA9#7wk2C?T`h~#D&kCuu^_{fAq875W@gl35S(XENsfHaD!qKgcUm)tDaokV_)j{(m zn*Y)33;9QpOGv3%{SfB0dqljL-l2@LzfZopf#{Vjw^K!Dh8QQ>k$3J3$n#Tb0wCxi zK?q?hoSDl+o6N{0Ge#9J8zc3|vO)|!+rJnp>PmeBCqh6fBM(((U}P97EGp}1qZF#6 zK0$LAjSI(3gdePL?_BbW^QHb8cC{(%GkTY@e;~82JzAq%HbePCS)x2pmOaivqNYR~ zbN&h*zIMiyhK-i0hB`LK>JeTP{i^T4*ulE+a#@y~UlHmvT!P7{P#ml;jkOxlIApX3 z*X>1&y1D7FZ#$feI_Et7eqJvmys-sPt;}dHOK_`4%@W=Q36*^L;3`aN8$hK)ATgru p&1ggD633;G%P73Sm7BR_a%B?z)N=kA>sO-$K8;xn<-h)U{{rBUpK<^I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b07cdc5f6314f6d863e0e9ba3cee361aa48bf52c GIT binary patch literal 3487 zcmcInTTkOg6!vADI2RyGw+PA>T-t7%UXoTq>dUGkU6K_NgcSj;>W8|i#{r`xPRC9w zqO4R2{zX*@9wYT%^k4Y3PyGu#^_=5m<3NH~iS=sEev>mkb1vWU3`cWwX$hX;Hz|3TXHNcvw5l#S3mAAhqX9_3_y>mn2Z+9ODh2Y2 z)R#|{73sBlEWKuc)k_v=T)tAoW}jUqf@ELqLoLtMD_O?1sMkqj^2d{p<(+O}2a+~Uq}uI>0?#`ZkH-F7|jM3{`i^9Pc60%otjE!tkY z;T{+5t{-?U%l29wuMM{RqKHUm9V6+A(R+o?*=P$Nvt`k)b709|1R*Iog~{{s;%F#h z4Suyz6@M_?Ef_uuM>1t-QiaQ=>M*Ww^+GzAO${*P8sP-s1Wyu90@itou+Gy^x{P=X zX0BH!95$l-72nz5b|C!;p9P?x4F4;CF|es#uN^>uqHP2xj?u2S z9K&l69wy>iHhQ`-tl~fs$mmc%>HcEjV(16vJIw|qIT9b@0*1|o51^{fDQ~44ycZE_R39SK`UC9uIcD){p!Vl`f%`ie0To$Wf%+y4~<#wn+ zvo@W9Dn5W8#R3W}t@s25awZmzMkY}+if4UxJhb*!Kv)b42rCjuHEAG2QpTxwfuA>0 zFH-2mG=CVb&d1!jBfYrvZf`iGaG>-)9eTqZBWkILQ#YJ4Hzw$3S;K~kUa^RVQRb#O zo{cXn!~%FaJD>4as)JOhTSPs}3e%R=@_4uD0M1(0uW)uCnP8T*EbiF=^p5c0vlg6* zrW+xE;DzVKzo)Ovv`^7hJ zY>uf*tVP0XqB*3+6xO2towIfcHhBov;<0}>YxgnK;n6Ztrtyv8-LMw#ME7CsVa&k^ z*2WxsE7rpOe|nD~Yk!RGHY*(u#@e4^n@zAb7TP^oi-g%lb2C_r`f5HWklCRI2bmvT zHR&EH@KZ?l5xScS9jEvQlgQ%o1YFtRIxg_iMz=Hiatr#h5>dw;M2PM!`m^Xj`5upJ SA8!Us&fff|>I=E$-17gnNA<7( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8aa07ff5997f76ba1ed60c5a8fadbc38fe17c67 GIT binary patch literal 1936 zcmb_d&2Aev5GMCeYgrDQ#4Tc^iMKf<6;MbG&|^`=jqTJxqX4mU*+baPk}Itv?J6YI zK!SR*-y$dw_onCQv2Va@PkDu0+8M5dtn-8RPzszKa%YF%%zPX@+1O|j7~OAofBxMk zq5NOWvMQD`}3IXBnpSboThL{6)~@Pk33l<}>txtyqJk+Oty z#>FprDwJ7I%TjXqMwfCymq9(jlaxlVX`y!vO_R{0UQi1p=#dLSF-l zb9x9B`H@h&_u4%ttM#%@SC1#Mh{__;1F<}!bKF~aaufU*HyP(v&?)} z?MU%rB9vxQWKyWX!ZDr_!^{NFUna7zY#AzX1)XhFUuifH9APe(ncbqZ+n*|a=fX$- zTi9Dw*jZMO`=BSc0F}Ik0y$Izf2ocm%h2Q<`aAy#eU@E-j}^qK-A7lLZB1T5Pl4Le zSL8PrZjm#)`534O^}*s4$w)-y1;B4<)OIA&(}aV`GlnJ$Dz{en)DbB;I9wi9HS!c|B; zC-g(#p-r!v_IOc|+WPB)#(w2L+k;?||2GBI& iPe_Kau-($S{um|`epdziJu*vO0^!k?)1vn0hQUA1Y~bkt literal 0 HcmV?d00001 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" + } + } + } + } +}