diff --git a/custom_components/nhc2/__init__.py b/custom_components/nhc2/__init__.py index 9e31f45..4992564 100644 --- a/custom_components/nhc2/__init__.py +++ b/custom_components/nhc2/__init__.py @@ -12,8 +12,6 @@ from .const import DOMAIN, KEY_GATEWAY, CONF_SWITCHES_AS_LIGHTS from .helpers import extract_versions -REQUIREMENTS = ['nhc2-coco==1.4.1'] - _LOGGER = logging.getLogger(__name__) DOMAIN = DOMAIN @@ -69,7 +67,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Create a NHC2 gateway.""" - from nhc2_coco import CoCo + from .coco import CoCo coco = CoCo( address=entry.data[CONF_HOST], username=entry.data[CONF_USERNAME], diff --git a/custom_components/nhc2/climate.py b/custom_components/nhc2/climate.py index 384b5f1..1ba1935 100644 --- a/custom_components/nhc2/climate.py +++ b/custom_components/nhc2/climate.py @@ -29,9 +29,9 @@ SUPPORT_TARGET_TEMPERATURE_RANGE ) -from nhc2_coco import CoCo -from nhc2_coco.coco_climate import CoCoThermostat -from nhc2_coco.coco_device_class import CoCoDeviceClass +from .coco import CoCo +from .coco_climate import CoCoThermostat +from .coco_device_class import CoCoDeviceClass from .const import DOMAIN, KEY_GATEWAY, BRAND, CLIMATE from .helpers import nhc2_entity_processor diff --git a/custom_components/nhc2/coco.py b/custom_components/nhc2/coco.py new file mode 100644 index 0000000..f5dfe2d --- /dev/null +++ b/custom_components/nhc2/coco.py @@ -0,0 +1,222 @@ +import json +import logging +import os +import threading +from time import sleep +from typing import Callable + +import paho.mqtt.client as mqtt + +from .coco_device_class import CoCoDeviceClass +from .coco_fan import CoCoFan +from .coco_light import CoCoLight +from .coco_shutter import CoCoShutter +from .coco_switch import CoCoSwitch +from .coco_switched_fan import CoCoSwitchedFan +from .coco_climate import CoCoThermostat +from .coco_generic import CoCoGeneric + +from .const import * +from .helpers import * + +_LOGGER = logging.getLogger(__name__) +sem = threading.Semaphore() +DEVICE_SETS = { + CoCoDeviceClass.SWITCHED_FANS: {INTERNAL_KEY_CLASS: CoCoSwitchedFan, INTERNAL_KEY_MODELS: LIST_VALID_SWITCHED_FANS}, + CoCoDeviceClass.FANS: {INTERNAL_KEY_CLASS: CoCoFan, INTERNAL_KEY_MODELS: LIST_VALID_FANS}, + CoCoDeviceClass.SHUTTERS: {INTERNAL_KEY_CLASS: CoCoShutter, INTERNAL_KEY_MODELS: LIST_VALID_SHUTTERS}, + CoCoDeviceClass.SWITCHES: {INTERNAL_KEY_CLASS: CoCoSwitch, INTERNAL_KEY_MODELS: LIST_VALID_SWITCHES}, + CoCoDeviceClass.LIGHTS: {INTERNAL_KEY_CLASS: CoCoLight, INTERNAL_KEY_MODELS: LIST_VALID_LIGHTS}, + CoCoDeviceClass.THERMOSTATS: {INTERNAL_KEY_CLASS: CoCoThermostat, INTERNAL_KEY_MODELS: LIST_VALID_THERMOSTATS}, + CoCoDeviceClass.GENERIC: {INTERNAL_KEY_CLASS: CoCoGeneric, INTERNAL_KEY_MODELS: LIST_VALID_GENERICS} +} + + +class CoCo: + def __init__(self, address, username, password, port=8883, ca_path=None, switches_as_lights=False): + + if switches_as_lights: + DEVICE_SETS[CoCoDeviceClass.LIGHTS] = {INTERNAL_KEY_CLASS: CoCoLight, + INTERNAL_KEY_MODELS: LIST_VALID_LIGHTS + LIST_VALID_SWITCHES} + DEVICE_SETS[CoCoDeviceClass.SWITCHES] = {INTERNAL_KEY_CLASS: CoCoSwitch, INTERNAL_KEY_MODELS: []} + # The device control buffer fields + self._keep_thread_running = True + self._device_control_buffer = {} + self._device_control_buffer_size = DEVICE_CONTROL_BUFFER_SIZE + self._device_control_buffer_command_size = DEVICE_CONTROL_BUFFER_COMMAND_SIZE + self._device_control_buffer_command_count = 0 + self._device_control_buffer_thread = threading.Thread(target=self._publish_device_control_commands) + self._device_control_buffer_thread.start() + + if ca_path is None: + ca_path = os.path.dirname(os.path.realpath(__file__)) + MQTT_CERT_FILE + client = mqtt.Client(protocol=MQTT_PROTOCOL, transport=MQTT_TRANSPORT) + client.username_pw_set(username, password) + client.tls_set(ca_path) + client.tls_insecure_set(True) + self._client = client + self._address = address + self._port = port + self._profile_creation_id = username + self._all_devices = None + self._device_callbacks = {} + self._devices = {} + self._devices_callback = {} + self._system_info = None + self._system_info_callback = lambda x: None + + def __del__(self): + self._keep_thread_running = False + self._client.disconnect() + + def connect(self): + + def _on_message(client, userdata, message): + topic = message.topic + response = json.loads(message.payload) + + if topic == self._profile_creation_id + MQTT_TOPIC_PUBLIC_RSP and \ + response[KEY_METHOD] == MQTT_METHOD_SYSINFO_PUBLISH: + self._system_info = response + self._system_info_callback(self._system_info) + + elif topic == (self._profile_creation_id + MQTT_TOPIC_SUFFIX_RSP) and \ + response[KEY_METHOD] == MQTT_METHOD_DEVICES_LIST: + self._client.unsubscribe(self._profile_creation_id + MQTT_TOPIC_SUFFIX_RSP) + self._process_devices_list(response) + + elif topic == (self._profile_creation_id + MQTT_TOPIC_SUFFIX_SYS_EVT) and \ + response[KEY_METHOD] == MQTT_METHOD_SYSINFO_PUBLISHED: + # If the connected controller publishes sysinfo... we expect something to have changed. + client.subscribe(self._profile_creation_id + MQTT_TOPIC_SUFFIX_RSP, qos=1) + client.publish(self._profile_creation_id + MQTT_TOPIC_SUFFIX_CMD, + json.dumps({KEY_METHOD: MQTT_METHOD_DEVICES_LIST}), 1) + + elif topic == (self._profile_creation_id + MQTT_TOPIC_SUFFIX_EVT) \ + and (response[KEY_METHOD] == MQTT_METHOD_DEVICES_STATUS or response[ + KEY_METHOD] == MQTT_METHOD_DEVICES_CHANGED): + devices = extract_devices(response) + for device in devices: + try: + if KEY_UUID in device: + self._device_callbacks[device[KEY_UUID]][INTERNAL_KEY_CALLBACK](device) + except: + pass + + def _on_connect(client, userdata, flags, rc): + if rc == 0: + _LOGGER.info('Connected!') + client.subscribe(self._profile_creation_id + MQTT_TOPIC_SUFFIX_RSP, qos=1) + client.subscribe(self._profile_creation_id + MQTT_TOPIC_PUBLIC_RSP, qos=1) + client.subscribe(self._profile_creation_id + MQTT_TOPIC_SUFFIX_EVT, qos=1) + client.subscribe(self._profile_creation_id + MQTT_TOPIC_SUFFIX_SYS_EVT, qos=1) + client.publish(self._profile_creation_id + MQTT_TOPIC_PUBLIC_CMD, + json.dumps({KEY_METHOD: MQTT_METHOD_SYSINFO_PUBLISH}), 1) + client.publish(self._profile_creation_id + MQTT_TOPIC_SUFFIX_CMD, + json.dumps({KEY_METHOD: MQTT_METHOD_DEVICES_LIST}), 1) + elif MQTT_RC_CODES[rc]: + raise Exception(MQTT_RC_CODES[rc]) + else: + raise Exception('Unknown error') + + def _on_disconnect(client, userdata, rc): + _LOGGER.warning('Disconnected') + for uuid, device_callback in self._device_callbacks.items(): + offline = {'Online': 'False', KEY_UUID: uuid} + device_callback[INTERNAL_KEY_CALLBACK](offline) + + self._client.on_message = _on_message + self._client.on_connect = _on_connect + self._client.on_disconnect = _on_disconnect + + self._client.connect_async(self._address, self._port) + self._client.loop_start() + + def disconnect(self): + self._client.loop_stop() + self._client.disconnect() + + def get_systeminfo(self, callback): + self._system_info_callback = callback + if self._system_info: + self._system_info_callback(self._system_info) + + def get_devices(self, device_class: CoCoDeviceClass, callback: Callable): + self._devices_callback[device_class] = callback + if self._devices and device_class in self._devices: + self._devices_callback[device_class](self._devices[device_class]) + + def _publish_device_control_commands(self): + while self._keep_thread_running: + device_commands_to_process = None + sem.acquire() + if len(self._device_control_buffer.keys()) > 0: + device_commands_to_process = self._device_control_buffer + self._device_control_buffer = {} + self._device_control_buffer_command_count = 0 + sem.release() + if device_commands_to_process is not None: + command = process_device_commands(device_commands_to_process) + self._client.publish(self._profile_creation_id + MQTT_TOPIC_SUFFIX_CMD, json.dumps(command), 1) + sleep(0.05) + + def _add_device_control(self, uuid, property_key, property_value): + while len(self._device_control_buffer.keys()) >= self._device_control_buffer_size or \ + self._device_control_buffer_command_count >= self._device_control_buffer_command_size: + pass + sem.acquire() + self._device_control_buffer_command_count += 1 + if uuid not in self._device_control_buffer: + self._device_control_buffer[uuid] = {} + self._device_control_buffer[uuid][property_key] = property_value + sem.release() + + # Processes response on devices.list + def _process_devices_list(self, response): + + # Only add devices that are actionable + actionable_devices = list( + filter(lambda d: d[KEY_TYPE] == DEV_TYPE_ACTION, extract_devices(response))) + actionable_devices.extend(list( + filter(lambda d: d[KEY_TYPE] == "thermostat", extract_devices(response)))) + + # Only prepare for devices that don't already exist + # TODO - Can't we do this when we need it (in initialize_devices ?) + existing_uuids = list(self._device_callbacks.keys()) + for actionable_device in actionable_devices: + if actionable_device[KEY_UUID] not in existing_uuids: + self._device_callbacks[actionable_device[KEY_UUID]] = \ + {INTERNAL_KEY_CALLBACK: None, KEY_ENTITY: None} + + # Initialize + self.initialize_devices(CoCoDeviceClass.SWITCHED_FANS, actionable_devices) + self.initialize_devices(CoCoDeviceClass.FANS, actionable_devices) + self.initialize_devices(CoCoDeviceClass.SWITCHES, actionable_devices) + self.initialize_devices(CoCoDeviceClass.LIGHTS, actionable_devices) + self.initialize_devices(CoCoDeviceClass.SHUTTERS, actionable_devices) + self.initialize_devices(CoCoDeviceClass.THERMOSTATS, actionable_devices) + self.initialize_devices(CoCoDeviceClass.GENERIC, actionable_devices) + + def initialize_devices(self, device_class, actionable_devices): + + base_devices = [x for x in actionable_devices if x[KEY_MODEL] + in DEVICE_SETS[device_class][INTERNAL_KEY_MODELS]] + if device_class not in self._devices: + self._devices[device_class] = [] + for base_device in base_devices: + if self._device_callbacks[base_device[KEY_UUID]] and self._device_callbacks[base_device[KEY_UUID]][ + KEY_ENTITY] and \ + self._device_callbacks[base_device[KEY_UUID]][KEY_ENTITY].uuid: + self._device_callbacks[base_device[KEY_UUID]][KEY_ENTITY].update_dev(base_device) + else: + self._device_callbacks[base_device[KEY_UUID]][KEY_ENTITY] = \ + DEVICE_SETS[device_class][INTERNAL_KEY_CLASS](base_device, + self._device_callbacks[ + base_device[ + KEY_UUID]], + self._client, + self._profile_creation_id, + self._add_device_control) + self._devices[device_class].append(self._device_callbacks[base_device[KEY_UUID]][KEY_ENTITY]) + if device_class in self._devices_callback: + self._devices_callback[device_class](self._devices[device_class]) diff --git a/custom_components/nhc2/coco_ca.pem b/custom_components/nhc2/coco_ca.pem new file mode 100644 index 0000000..77d696f --- /dev/null +++ b/custom_components/nhc2/coco_ca.pem @@ -0,0 +1,68 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF6jCCA9KgAwIBAgIJANTA8rXGnhG7MA0GCSqGSIb3DQEBCwUAMIGBMQswCQYD +VQQGEwJCRTEYMBYGA1UECAwPT29zdC1WbGFhbmRlcmVuMRUwEwYDVQQHDAxTaW50 +LU5pa2xhYXMxDTALBgNVBAoMBE5pa28xFTATBgNVBAsMDEhvbWUgQ29udHJvbDEb +MBkGCSqGSIb3DQEJARYMaW5mb0BuaWtvLmJlMB4XDTcwMDEwMTAwMDAwNVoXDTM3 +MDEyOTAwMDAwNVowgYExCzAJBgNVBAYTAkJFMRgwFgYDVQQIDA9Pb3N0LVZsYWFu +ZGVyZW4xFTATBgNVBAcMDFNpbnQtTmlrbGFhczENMAsGA1UECgwETmlrbzEVMBMG +A1UECwwMSG9tZSBDb250cm9sMRswGQYJKoZIhvcNAQkBFgxpbmZvQG5pa28uYmUw +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpKNKKHC0fCND19D96G78G +Zdj+OGLvy/DRJswbLepG8cPedqEZwXjn762fvLdTlcTX/ohkeG4QPb1mxPzjpEgl +M5aNmp2rmlAFVtLWILQx7mWir5FjG5eTyYi2fbYHnPQpx8XuVk2INENd85818R4j +RfouYLaZWSd8wc7LP20N0rVtjg5RJ/zAkQ6A7KzdgeOkKhn07wSGBWu9vDw7gCdL ++Oyeo4LQmABXB7up8nIDCl+o23QL4/aSzdrS5cBCXoPWwto7OiXw0RRcEbpumQyW +mTGS8jT2FCUNAIWAxC3pKEIXbzf03pLo7EMfFcmjsLDcvcnkB+EJX0fuATwl5CLz +SneUFY7MNTpv9xgZFX83LhoiFbycZwzWEUr/Q0pmHYZdmezm84+W6EA3E9qH+oR8 +V09bwEMAMSQpbebEB8JmvvwykQHxowkpnV01bmimBEOaquAmyfiW3YSO90vJu+kg +Zrkihc0AEMFcDbLRCEKvx/u6Hs2xMmVPz0W9mPW37t5zKOV0vcrHmFgMp+9EyDAQ +vfNofLx790lD1LFp3qvD/H0+IbydQoEc7Q1/tTQDjL45TLNXwwBWQVQLIEQY5sqN +n8p2ita3MPpSnu5XU93pBcns8jUNlc6/wFIMSBDWK40RiJKzTsr/2jTGVqZX8PXA +rDnIoa0Eapt0nq87qnkQzQIDAQABo2MwYTAdBgNVHQ4EFgQU5rQ0Z/a3fq5iKhZV +qmabTvd4kFkwHwYDVR0jBBgwFoAU5rQ0Z/a3fq5iKhZVqmabTvd4kFkwDwYDVR0T +AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBAFLw +6lbxex6hElSrqOZljoFQzQg78dmtUFm4BgKu5EAqn1Ug/OHOKk8LBbNMf2X0Y+4i +SO4g8yO/C9M/YPxjnM5As1ouTu7UzQL4hEJynyHoPuCBD8nZxuaKeGMX7nQYm7GD +0iaL0iP9gFwv/A2O/isQUB/sTAclhm1zKAw4f/SaBq8t22wf59e8na0xIfHui0PD +s8PfRbC4xIOKMxHkHFv+DHeMGjCbR4x20RV/z4JNx1ALEBGo6Oh7Dph/maAQWbje +x9BCstNR3V1Bhx9rUe7BjIMyJUGEItpZXG+N+qnQr2K7xDdloJl4X0flIa74sdUE +K4s0X7p+JixLMSxbu5oS6W+d3g6EG0ZgEUwwwc98D1fsm1ziNqwcnYMkI6P2601G +kEaK/54kYqCxvw6fu5+PNmsDD8ptdazoO3/UOxWvspI1U3drcpnaEHuNclEF7WeL +yqTfi+8UiL9xJgq9ivjKjZdchkdaD2THgrnzs0XxLbZnwAPeh3cHooUJQkInmKp3 +O05Gv0rnSr29bH8vh/sy4/yJJCUd036pF9C8mPHAYsvNDVGaGYVmNt5P28z3PO16 +YKNJCOJ0x333F6PJaqWAQQP9bGMuJThX8ZQ9Fd8KMXVUfFVKICEkb4erWpL2RIz3 +9JFSC56ZtXv2losfASTyXJwCpyib7FcTZ1rJze+l +-----END CERTIFICATE----- diff --git a/custom_components/nhc2/coco_climate.py b/custom_components/nhc2/coco_climate.py new file mode 100644 index 0000000..b6e02d1 --- /dev/null +++ b/custom_components/nhc2/coco_climate.py @@ -0,0 +1,169 @@ +import logging + +from .helpers import status_prop_in_object_is_on, extract_property_definitions, extract_property_value_from_device +from .const import THERM_PROGRAM, THERM_OVERRULEACTION, THERM_OVERRULESETPOINT, THERM_OVERRULETIME, THERM_ECOSAVE +from .coco_entity import CoCoEntity + +from homeassistant.components.climate import ( + TEMP_CELSIUS, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, + SUPPORT_PRESET_MODE, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL +) + +_LOGGER = logging.getLogger(__name__) + +class CoCoThermostat(CoCoEntity): + + @property + def state(self): + return self._state + + @property + def temperature_unit(self): + return TEMP_CELSIUS + + @property + def target_temperature_low(self): + return self._target_temperature_low + + @property + def target_temperature_high(self): + return self._target_temperature_high + + @property + def target_temperature_step(self): + return self._target_temperature_step + + @property + def min_temp(self): + return self._min_temp + + @property + def max_temp(self): + return self._max_temp + + @property + def hvac_action(self): + """Return current operation ie. heating, cooling, off.""" + return self._hvac_action + + @property + def hvac_mode(self): + """Return current operation ie. heating, cooling, off.""" + return self._hvac_mode + + @property + def hvac_modes(self): + return HVAC_MODE_HEAT_COOL + + @property + def current_temperature(self): + return self._current_temperature + + @property + def target_temperature(self): + return self._target_temperature + + @property + def program(self): + return self._program + + @property + def preset_mode(self): + return self._preset_mode + + @property + def preset_modes(self): + return self._preset_modes + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._current_temperature = None + self._target_temperature = None + self._preset_mode = None + self._hvac_mode = None + self._program = None + self.update_dev(dev, callback_container) + + self.get_target_temperature_params(dev) + self.get_ambient_temperature_params(dev) + self.get_program_params(dev) + + async def async_turn_on(self): + pass + + async def async_turn_off(self): + pass + + def set_temperature(self, temperature): + _LOGGER.info('Set temperature: %s', temperature) + self._command_device_control(self._uuid, THERM_OVERRULESETPOINT, str(temperature)) + self._command_device_control(self._uuid, THERM_OVERRULETIME, str(480)) + self._command_device_control(self._uuid, THERM_OVERRULEACTION, 'True') + + def set_preset_mode(self, preset_mode): + """Set preset mode.""" + _LOGGER.info('Set preset mode: %s', preset_mode) + self._command_device_control(self._uuid, THERM_PROGRAM, preset_mode) + + def get_target_temperature_params(self, dev): + """Get parameters for target temperature""" + if dev and 'PropertyDefinitions' in dev: + params = extract_property_definitions(dev, 'SetpointTemperature')['Description'] + values = params.split("(")[1].split(")")[0].split(",") + self._target_temperature_low = float(values[0]) + self._target_temperature_high = float(values[1]) + self._target_temperature_step = float(values[2]) + + def get_ambient_temperature_params(self, dev): + """Get parameters for ambient temperature""" + if dev and 'PropertyDefinitions' in dev: + params = extract_property_definitions(dev, 'AmbientTemperature')['Description'] + values = params.split("(")[1].split(")")[0].split(",") + self._min_temp = float(values[0]) + self._max_temp = float(values[1]) + + def get_program_params(self, dev): + """Get parameters for programs""" + if dev and 'PropertyDefinitions' in dev: + params = extract_property_definitions(dev, 'Program')['Description'] + values = params.split("(")[1].split(")")[0].split(",") + self._preset_modes = values + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + if self._check_for_status_change(dev): + self._current_temperature = float(extract_property_value_from_device(dev, 'AmbientTemperature')) + self._target_temperature = float(extract_property_value_from_device(dev, 'SetpointTemperature')) + self._preset_mode = extract_property_value_from_device(dev, 'Program') + self._hvac_mode = extract_property_value_from_device(dev, 'Demand') + self._hvac_action = extract_property_value_from_device(dev, 'Demand') + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() + + def _check_for_status_change(self, dev): + status_value = extract_property_value_from_device(dev, 'AmbientTemperature') + if status_value and self._current_temperature != status_value: + self._current_temperature = float(status_value) + has_changed = True + status_value = extract_property_value_from_device(dev, 'SetpointTemperature') + if status_value and self._target_temperature != status_value: + self._target_temperature = float(status_value) + has_changed = True + status_value = extract_property_value_from_device(dev, 'Program') + if status_value and self._preset_mode != status_value: + self._preset_mode = status_value + has_changed = True + status_value = extract_property_value_from_device(dev, 'Demand') + if status_value and self._hvac_mode != status_value: + self._hvac_mode = status_value + has_changed = True + return has_changed diff --git a/custom_components/nhc2/coco_device_class.py b/custom_components/nhc2/coco_device_class.py new file mode 100644 index 0000000..a8381dc --- /dev/null +++ b/custom_components/nhc2/coco_device_class.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class CoCoDeviceClass(Enum): + SWITCHES = 'switches' + LIGHTS = 'lights' + SHUTTERS = 'shutters' + FANS = 'fans' + SWITCHED_FANS = 'switched-fans' + THERMOSTATS = 'thermostats' + GENERIC = 'generic' diff --git a/custom_components/nhc2/coco_discover.py b/custom_components/nhc2/coco_discover.py new file mode 100644 index 0000000..b1f2903 --- /dev/null +++ b/custom_components/nhc2/coco_discover.py @@ -0,0 +1,61 @@ +import binascii +import select +import threading +import socket +import netifaces +from getmac import get_mac_address + + +class CoCoDiscover: + """CoCoDiscover will help you discover NHC2. + It will also tell you about NHC1, but the result will differ. + + You create CoCoDiscover, passing along a callback an the time you max want to wait. + By default we wait 3 seconds. + + For every result with matching header the callback is called, + with the address, mac-address and a boolean if it's a NHC2. + """ + + def __init__(self, on_discover, on_done): + self._get_broadcast_ips() + + self._thread = threading.Thread(target=self._scan_for_nhc) + self._on_discover = on_discover + self._on_done = on_done + self._thread.start() + # If we discover one, we don't want to keep looking too long... + self._discovered_at_least_one = False + + def _get_broadcast_ips(self): + interfaces = netifaces.interfaces() + return filter(lambda x: x, + map(lambda x: netifaces.ifaddresses(x).get(netifaces.AF_INET)[0].get('broadcast') if ( + (netifaces.AF_INET in netifaces.ifaddresses(x)) + and ('broadcast' in netifaces.ifaddresses(x).get(netifaces.AF_INET)[0]) + + ) else None, interfaces)) + + def _scan_for_nhc(self): + server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + server.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + """ We search for all broadcast ip4s, so that we don't only search the main interface """ + broadcast_ips = self._get_broadcast_ips() + for broadcast_ip in broadcast_ips: + server.sendto(bytes([0x44]), (broadcast_ip, 10000)) + server.setblocking(0) + loops = 0 + + while loops < 200 and ((not self._discovered_at_least_one) or loops < 20): + loops = loops + 1 + ready = select.select([server], [], [], 0.01) + if ready[0]: + data, addr = server.recvfrom(4096) + if data[0] == 0x44: # NHC2 Header + is_nhc2 = (len(data) >= 16) and (data[15] == 0x02) + mac = get_mac_address(ip=addr[0]) + if self._on_discover: + self._discovered_at_least_one = True + self._on_discover(addr[0], mac, is_nhc2) + server.close() + self._on_done() \ No newline at end of file diff --git a/custom_components/nhc2/coco_discover_profiles.py b/custom_components/nhc2/coco_discover_profiles.py new file mode 100644 index 0000000..e333a25 --- /dev/null +++ b/custom_components/nhc2/coco_discover_profiles.py @@ -0,0 +1,69 @@ +import asyncio +import socket + +from .coco_discover import CoCoDiscover +from .coco_profiles import CoCoProfiles + +loop = asyncio.get_event_loop() + + +class CoCoDiscoverProfiles: + """CoCoDiscover will help you discover NHC2 Profiles on all devices on the network. It will NOT find hobby + profiles. The username then is provided by Niko (eg. hobby) This relies on not publicly documented API calls! It + also broadcasts a UDP packet on all available (ipV4) broadcast addresses. + """ + + def __init__(self, host=None): + self._controllers_found = [] + self._profiles_found = [] + self._done_scanning_profiles = asyncio.Event() + if host is None: + CoCoDiscover(self._discover_controllers_callback, self._done_discovering_controllers_callback) + else: + """If a host is provided, we only search for profiles.""" + self._search_for_one_host(host) + + def _done(self): + self._done_scanning_profiles.set() + + async def _wait_until_done(self): + await self._done_scanning_profiles.wait() + + async def get_all_profiles(self): + await self._wait_until_done() + return self._profiles_found + + def _discover_profiles_callback(self, address, mac, skip_host_search=False): + def inner_function(profiles): + if skip_host_search is not True: + try: + host = socket.gethostbyaddr(address)[0] + except: + host = None + else: + host = None + self._profiles_found.append((address, mac, profiles, host)) + + return inner_function + + def _done_discovering_controllers_callback(self): + if len(self._controllers_found) == 0: + loop.call_soon_threadsafe(callback=self._done) + for ctrl in self._controllers_found: + CoCoProfiles(self._discover_profiles_callback(ctrl[0], ctrl[1]), ctrl[0], + self._done_discovering_profiles_callback) + + def _done_discovering_profiles_callback(self): + while len(self._controllers_found) != len(self._profiles_found): + time.sleep(1) + loop.call_soon_threadsafe(callback=self._done) + + def _discover_controllers_callback(self, address, mac, is_nhc2): + if (is_nhc2): + self._controllers_found.append((address, mac)) + + def _search_for_one_host(self, host): + self._controllers_found = [(host, None)] + for ctrl in self._controllers_found: + CoCoProfiles(self._discover_profiles_callback(ctrl[0], ctrl[1], True), ctrl[0], + self._done_discovering_profiles_callback) diff --git a/custom_components/nhc2/coco_entity.py b/custom_components/nhc2/coco_entity.py new file mode 100644 index 0000000..3c51578 --- /dev/null +++ b/custom_components/nhc2/coco_entity.py @@ -0,0 +1,87 @@ +import threading +from abc import ABC, abstractmethod + +from .const import KEY_NAME, CALLBACK_HOLDER_PROP, KEY_TYPE, KEY_MODEL, KEY_ONLINE, KEY_DISPLAY_NAME +from .helpers import dev_prop_changed + +class CoCoEntity(ABC): + + @property + def uuid(self): + return self._uuid + + @property + def name(self): + return self._name + + @property + def online(self): + return self._online + + @property + def model(self): + return self._model + + @property + def type(self): + return self._type + + @property + def profile_creation_id(self): + return self._profile_creation_id + + @property + def on_change(self): + return self._on_change + + @on_change.setter + def on_change(self, func): + with self._callback_mutex: + self._on_change = func + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + self._client = client + self._profile_creation_id = profile_creation_id + self._uuid = dev['Uuid'] + self._name = None + self._online = None + self._model = None + self._type = None + self._command_device_control = command_device_control + self._callback_mutex = threading.RLock() + self._on_change = (lambda: print('%s (%s) has no _on_change callback set!' % (self._name, self._uuid))) + self._callback_container = ( + lambda: print('%s (%s) has no _callback_container callback set!' % (self._name, self._uuid))) + self._after_update_callback = ( + lambda: print('%s (%s) has no _after_update_callback callback set!' % (self._name, self._uuid))) + + def update_dev(self, dev, callback_container=None): + has_changed = False + if dev_prop_changed(self._name, dev, KEY_NAME): + self._name = dev[KEY_NAME] + has_changed = True + if dev_prop_changed(self._name, dev, KEY_DISPLAY_NAME): + self._name = dev[KEY_DISPLAY_NAME] + has_changed = True + if KEY_ONLINE in dev and self._online != (dev[KEY_ONLINE] == 'True'): + self._online = dev[KEY_ONLINE] == 'True' + has_changed = True + if dev_prop_changed(self._model, dev, KEY_MODEL): + self._model = dev[KEY_MODEL] + has_changed = True + if dev_prop_changed(self._type, dev, KEY_TYPE): + self._type = dev[KEY_TYPE] + has_changed = True + if callback_container: + self._callback_container = callback_container + if CALLBACK_HOLDER_PROP in self._callback_container: + self._callback_container[CALLBACK_HOLDER_PROP] = self._update + has_changed = True + return has_changed + + @abstractmethod + def _update(self, dev): + pass + + def _state_changed(self): + self.on_change() diff --git a/custom_components/nhc2/coco_fan.py b/custom_components/nhc2/coco_fan.py new file mode 100644 index 0000000..7f702fb --- /dev/null +++ b/custom_components/nhc2/coco_fan.py @@ -0,0 +1,32 @@ +from .coco_entity import CoCoEntity +from .coco_fan_speed import CoCoFanSpeed +from .const import KEY_FAN_SPEED +from .helpers import extract_property_value_from_device + + +class CoCoFan(CoCoEntity): + + @property + def fan_speed(self) -> CoCoFanSpeed: + return self._fan_speed + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._fan_speed = None + self.update_dev(dev, callback_container) + + def change_speed(self, speed: CoCoFanSpeed): + self._command_device_control(self._uuid, KEY_FAN_SPEED, speed.value) + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + status_value = extract_property_value_from_device(dev, KEY_FAN_SPEED) + if status_value and self._fan_speed != CoCoFanSpeed(status_value): + self._fan_speed = CoCoFanSpeed(status_value) + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() diff --git a/custom_components/nhc2/coco_fan_speed.py b/custom_components/nhc2/coco_fan_speed.py new file mode 100644 index 0000000..c70ba13 --- /dev/null +++ b/custom_components/nhc2/coco_fan_speed.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class CoCoFanSpeed(Enum): + LOW = 'Low' + MEDIUM = 'Medium' + HIGH = 'High' + BOOST = 'Boost' diff --git a/custom_components/nhc2/coco_generic.py b/custom_components/nhc2/coco_generic.py new file mode 100644 index 0000000..f74b4b4 --- /dev/null +++ b/custom_components/nhc2/coco_generic.py @@ -0,0 +1,34 @@ +from .coco_entity import CoCoEntity +from .const import KEY_BASICSTATE, VALUE_TRIGGERED, VALUE_ON, KEY_STATUS +from .helpers import extract_property_value_from_device + + +class CoCoGeneric(CoCoEntity): + + @property + def is_on(self): + return self._is_on + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._is_on = None + self.update_dev(dev, callback_container) + + def turn_on(self): + self._command_device_control(self._uuid, KEY_BASICSTATE, VALUE_TRIGGERED) + + def turn_off(self): + self._command_device_control(self._uuid, KEY_BASICSTATE, VALUE_TRIGGERED) + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + status_value = extract_property_value_from_device(dev, KEY_BASICSTATE) + if status_value and self._is_on != (status_value == VALUE_ON): + self._is_on = (status_value == VALUE_ON) + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() diff --git a/custom_components/nhc2/coco_light.py b/custom_components/nhc2/coco_light.py new file mode 100644 index 0000000..3bfda6f --- /dev/null +++ b/custom_components/nhc2/coco_light.py @@ -0,0 +1,58 @@ +import logging + +from .coco_entity import CoCoEntity +from .const import KEY_STATUS, VALUE_ON, VALUE_OFF, KEY_BRIGHTNESS, VALUE_DIMMER +from .helpers import extract_property_value_from_device + +_LOGGER = logging.getLogger(__name__) + + +class CoCoLight(CoCoEntity): + + @property + def is_on(self): + return self._is_on + + @property + def brightness(self): + return self._brightness + + @property + def support_brightness(self): + return self._model == VALUE_DIMMER + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._is_on = None + self._brightness = None + self.update_dev(dev, callback_container) + + def turn_on(self): + self._command_device_control(self._uuid, KEY_STATUS, VALUE_ON) + + def turn_off(self): + self._command_device_control(self._uuid, KEY_STATUS, VALUE_OFF) + + def set_brightness(self, brightness): + if brightness == brightness and 100 >= brightness >= 0: + self._command_device_control(self._uuid, KEY_BRIGHTNESS, str(brightness)) + else: + _LOGGER.error('Invalid brightness value passed. Must be integer [0-100]') + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + status_value = extract_property_value_from_device(dev, KEY_STATUS) + if status_value and self._is_on != (status_value == VALUE_ON): + self._is_on = (status_value == VALUE_ON) + has_changed = True + if self.support_brightness: + brightness_value = extract_property_value_from_device(dev, KEY_BRIGHTNESS) + if brightness_value is not None and self._brightness != int(brightness_value): + self._brightness = int(brightness_value) + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() diff --git a/custom_components/nhc2/coco_login_validation.py b/custom_components/nhc2/coco_login_validation.py new file mode 100644 index 0000000..d94c935 --- /dev/null +++ b/custom_components/nhc2/coco_login_validation.py @@ -0,0 +1,68 @@ +import asyncio +import os + +import paho.mqtt.client as mqtt + +from .const import MQTT_PROTOCOL, MQTT_TRANSPORT + +loop = asyncio.get_event_loop() + + +class CoCoLoginValidation: + """ Validate one can login on the CoCo + """ + + def __init__(self, address, username, password, port=8883, ca_path=None): + self._address = address + self._username = username + self._password = password + self._port = port + self._ca_path = ca_path + + """ + Try to connect with given parameters + The return indicates success or not: + 0: Connection successful + 1: Connection refused - incorrect protocol version + 2: Connection refused - invalid client identifier + 3: Connection refused - server unavailable + 4: Connection refused - bad username or password + 5: Connection refused - not authorised + 6-255: Currently unused. + """ + + async def check_connection(self, timeout=10): + result_code = 0 + done_testing = asyncio.Event() + client = self._generate_client() + + def done(): + nonlocal done_testing + done_testing.set() + + def on_connect(x, xx, xxx, reason_code): + nonlocal result_code + result_code = reason_code + loop.call_soon_threadsafe(callback=done) + + client.on_connect = on_connect + client.loop_start() + client.connect_async(self._address, self._port, keepalive=timeout) + + try: + await asyncio.wait_for(done_testing.wait(), timeout + 2) + except: + pass + + client.disconnect() + client.loop_stop() + return result_code + + def _generate_client(self): + if self._ca_path is None: + self._ca_path = os.path.dirname(os.path.realpath(__file__)) + '/coco_ca.pem' + client = mqtt.Client(protocol=MQTT_PROTOCOL, transport=MQTT_TRANSPORT) + client.username_pw_set(self._username, self._password) + client.tls_set(self._ca_path) + client.tls_insecure_set(True) + return client diff --git a/custom_components/nhc2/coco_profiles.py b/custom_components/nhc2/coco_profiles.py new file mode 100644 index 0000000..efcf960 --- /dev/null +++ b/custom_components/nhc2/coco_profiles.py @@ -0,0 +1,58 @@ +import json +import os +from time import sleep + +import paho.mqtt.client as mqtt + +from .const import MQTT_TOPIC_PUBLIC_AUTH_RSP, MQTT_PROTOCOL, MQTT_TRANSPORT, MQTT_TOPIC_PUBLIC_AUTH_CMD + + +class CoCoProfiles: + """CoCoDiscover will collect a list of profiles on a NHC2 + """ + + def __init__(self, callback, address, done_discovering_profiles_callback, port=8883, ca_path=None): + + if ca_path is None: + ca_path = os.path.dirname(os.path.realpath(__file__)) + '/coco_ca.pem' + client = mqtt.Client(protocol=MQTT_PROTOCOL, transport=MQTT_TRANSPORT) + client.tls_set(ca_path) + client.tls_insecure_set(True) + self._client = client + self._address = address + self._callback = callback + self._done_discovering_profiles_callback = done_discovering_profiles_callback + self._loop = 0 + self._max_loop = 200 + self._port = port + self._client.on_message = self._on_message + self._client.on_connect = self._on_connect + self._client.connect_async(self._address, self._port) + self._client.loop_start() + while self._max_loop > self._loop >= 0: + self._loop = self._loop + 1 + sleep(0.05) + if self._loop > 0: + self._callback(None) + self._done_discovering_profiles_callback() + self._client.disconnect() + + def _on_connect(self, client, userdata, flags, rc): + if rc == 0: + client.subscribe(MQTT_TOPIC_PUBLIC_AUTH_RSP, qos=1) + client.publish(MQTT_TOPIC_PUBLIC_AUTH_CMD, '{"Method":"profiles.list"}', 1) + else: + self._callback([]) + self._loop = -100 + + def _on_message(self, client, userdata, message): + + topic = message.topic + response = json.loads(message.payload) + if topic == MQTT_TOPIC_PUBLIC_AUTH_RSP \ + and response.get('Method') == 'profiles.list' \ + and 'Params' in response \ + and (len(response.get('Params')) == 1) \ + and 'Profiles' in response.get('Params')[0]: + self._loop = -1 + self._callback(response.get('Params')[0].get('Profiles')) diff --git a/custom_components/nhc2/coco_shutter.py b/custom_components/nhc2/coco_shutter.py new file mode 100644 index 0000000..74f7eff --- /dev/null +++ b/custom_components/nhc2/coco_shutter.py @@ -0,0 +1,40 @@ +from .coco_entity import CoCoEntity +from .const import KEY_POSITION, VALUE_OPEN, VALUE_STOP, VALUE_CLOSE, KEY_ACTION +from .helpers import extract_property_value_from_device + + +class CoCoShutter(CoCoEntity): + + @property + def position(self): + return self._position + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._position = None + self.update_dev(dev, callback_container) + + def open(self): + self._command_device_control(self._uuid, KEY_ACTION, VALUE_OPEN) + + def stop(self): + self._command_device_control(self._uuid, KEY_ACTION, VALUE_STOP) + + def close(self): + self._command_device_control(self._uuid, KEY_ACTION, VALUE_CLOSE) + + def set_position(self, position: int): + self._command_device_control(self._uuid, KEY_POSITION, str(position)) + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + position_value = extract_property_value_from_device(dev, KEY_POSITION) + if position_value is not None and self._position != int(position_value): + self._position = int(position_value) + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() diff --git a/custom_components/nhc2/coco_switch.py b/custom_components/nhc2/coco_switch.py new file mode 100644 index 0000000..cb9f38a --- /dev/null +++ b/custom_components/nhc2/coco_switch.py @@ -0,0 +1,34 @@ +from .coco_entity import CoCoEntity +from .const import KEY_STATUS, VALUE_ON, VALUE_OFF +from .helpers import extract_property_value_from_device + + +class CoCoSwitch(CoCoEntity): + + @property + def is_on(self): + return self._is_on + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._is_on = None + self.update_dev(dev, callback_container) + + def turn_on(self): + self._command_device_control(self._uuid, KEY_STATUS, VALUE_ON) + + def turn_off(self): + self._command_device_control(self._uuid, KEY_STATUS, VALUE_OFF) + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + status_value = extract_property_value_from_device(dev, KEY_STATUS) + if status_value and self._is_on != (status_value == VALUE_ON): + self._is_on = (status_value == VALUE_ON) + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() diff --git a/custom_components/nhc2/coco_switched_fan.py b/custom_components/nhc2/coco_switched_fan.py new file mode 100644 index 0000000..6ee6624 --- /dev/null +++ b/custom_components/nhc2/coco_switched_fan.py @@ -0,0 +1,34 @@ +from .coco_entity import CoCoEntity +from .const import KEY_STATUS, VALUE_ON, VALUE_OFF +from .helpers import extract_property_value_from_device + + +class CoCoSwitchedFan(CoCoEntity): + + @property + def is_on(self): + return self._is_on + + def __init__(self, dev, callback_container, client, profile_creation_id, command_device_control): + super().__init__(dev, callback_container, client, profile_creation_id, command_device_control) + self._is_on = None + self.update_dev(dev, callback_container) + + def turn_on(self): + self._command_device_control(self._uuid, KEY_STATUS, VALUE_ON) + + def turn_off(self): + self._command_device_control(self._uuid, KEY_STATUS, VALUE_OFF) + + def update_dev(self, dev, callback_container=None): + has_changed = super().update_dev(dev, callback_container) + status_value = extract_property_value_from_device(dev, KEY_STATUS) + if status_value and self._is_on != (status_value == VALUE_ON): + self._is_on = (status_value == VALUE_ON) + has_changed = True + return has_changed + + def _update(self, dev): + has_changed = self.update_dev(dev) + if has_changed: + self._state_changed() diff --git a/custom_components/nhc2/config_flow.py b/custom_components/nhc2/config_flow.py index 770fc1c..1066d87 100644 --- a/custom_components/nhc2/config_flow.py +++ b/custom_components/nhc2/config_flow.py @@ -5,8 +5,8 @@ from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_USERNAME, \ CONF_PASSWORD, CONF_ADDRESS, CONF_PORT -from nhc2_coco.coco_discover_profiles import CoCoDiscoverProfiles -from nhc2_coco.coco_login_validation import CoCoLoginValidation +from .coco_discover_profiles import CoCoDiscoverProfiles +from .coco_login_validation import CoCoLoginValidation from .const import DOMAIN, CONF_SWITCHES_AS_LIGHTS, KEY_MANUAL diff --git a/custom_components/nhc2/const.py b/custom_components/nhc2/const.py index 54b011e..e933493 100644 --- a/custom_components/nhc2/const.py +++ b/custom_components/nhc2/const.py @@ -17,3 +17,90 @@ SUN_BLIND = 'sunblind' GATE = 'gate' VENETIAN_BLIND = 'venetianblind' + + +import paho.mqtt.client as mqtt + +from enum import Enum + + +MQTT_TLS_VERSION = 2 +MQTT_PROTOCOL = mqtt.MQTTv311 +MQTT_TRANSPORT = "tcp" +MQTT_CERT_FILE = '/coco_ca.pem' + + +VALUE_DIMMER = 'dimmer' + +LIST_VALID_LIGHTS = ['light', VALUE_DIMMER] +LIST_VALID_SWITCHES = ['socket', 'switched-generic'] +LIST_VALID_SHUTTERS = ['rolldownshutter', 'sunblind', 'gate', 'venetianblind'] +LIST_VALID_FANS = ['fan'] +LIST_VALID_SWITCHED_FANS = ['switched-fan'] +LIST_VALID_THERMOSTATS = ['thermostat'] +LIST_VALID_GENERICS = ['generic'] + +DEVICE_CONTROL_BUFFER_SIZE = 16 +DEVICE_CONTROL_BUFFER_COMMAND_SIZE = 32 + +KEY_ACTION = 'Action' +KEY_BRIGHTNESS = 'Brightness' +KEY_DEVICES = 'Devices' +KEY_DISPLAY_NAME = 'DisplayName' +KEY_ENTITY = 'entity' +KEY_FAN_SPEED = 'FanSpeed' +KEY_METHOD = 'Method' +KEY_MODEL = 'Model' +KEY_NAME = 'Name' +KEY_ONLINE = 'Online' +KEY_PARAMS = 'Params' +KEY_PROPERTIES = 'Properties' +KEY_POSITION = 'Position' +KEY_STATUS = 'Status' +KEY_TYPE = 'Type' +KEY_UUID = 'Uuid' +KEY_BASICSTATE = "BasicState" + +VALUE_ON = 'On' +VALUE_OFF = 'Off' +VALUE_OPEN = 'Open' +VALUE_STOP = 'Stop' +VALUE_CLOSE = 'Close' +VALUE_TRIGGERED = 'Triggered' + +THERM_PROGRAM = 'Program' +THERM_OVERRULEACTION = 'OverruleActive' +THERM_OVERRULESETPOINT = 'OverruleSetpoint' +THERM_OVERRULETIME = 'OverruleTime' +THERM_ECOSAVE = 'EcoSave' + +DEV_TYPE_ACTION = 'action' + +INTERNAL_KEY_CALLBACK = 'callbackHolder' +INTERNAL_KEY_MODELS = 'models' +INTERNAL_KEY_CLASS = 'class' + +CALLBACK_HOLDER_PROP = 'callbackHolder' + +MQTT_METHOD_SYSINFO_PUBLISH = 'systeminfo.publish' +MQTT_METHOD_SYSINFO_PUBLISHED = 'systeminfo.published' +MQTT_METHOD_DEVICES_LIST = 'devices.list' +MQTT_METHOD_DEVICES_CONTROL = 'devices.control' +MQTT_METHOD_DEVICES_STATUS = 'devices.status' +MQTT_METHOD_DEVICES_CHANGED = 'devices.changed' + +MQTT_RC_CODES = ['', + 'Connection refused - incorrect protocol version', + 'Connection refused - invalid client identifier', + 'Connection refused - server unavailable', + 'Connection refused - bad username or password', + 'Connection refused - not authorised'] + +MQTT_TOPIC_PUBLIC_AUTH_CMD = 'public/authentication/cmd' +MQTT_TOPIC_PUBLIC_AUTH_RSP = 'public/authentication/rsp' +MQTT_TOPIC_SUFFIX_SYS_EVT = '/system/evt' +MQTT_TOPIC_PUBLIC_CMD = '/system/cmd' +MQTT_TOPIC_PUBLIC_RSP = '/system/rsp' +MQTT_TOPIC_SUFFIX_CMD = '/control/devices/cmd' +MQTT_TOPIC_SUFFIX_RSP = '/control/devices/rsp' +MQTT_TOPIC_SUFFIX_EVT = '/control/devices/evt' diff --git a/custom_components/nhc2/cover.py b/custom_components/nhc2/cover.py index 8779b19..a67e14b 100644 --- a/custom_components/nhc2/cover.py +++ b/custom_components/nhc2/cover.py @@ -3,9 +3,9 @@ from homeassistant.components.cover import CoverEntity, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, SUPPORT_SET_POSITION, \ ATTR_POSITION, DEVICE_CLASS_SHUTTER, DEVICE_CLASS_BLIND, DEVICE_CLASS_GATE -from nhc2_coco import CoCo -from nhc2_coco.coco_device_class import CoCoDeviceClass -from nhc2_coco.coco_shutter import CoCoShutter +from .coco import CoCo +from .coco_device_class import CoCoDeviceClass +from .coco_shutter import CoCoShutter from .const import DOMAIN, KEY_GATEWAY, BRAND, COVER, ROLL_DOWN_SHUTTER, SUN_BLIND, GATE, VENETIAN_BLIND from .helpers import nhc2_entity_processor diff --git a/custom_components/nhc2/fan.py b/custom_components/nhc2/fan.py index d1f9c7d..00755e4 100644 --- a/custom_components/nhc2/fan.py +++ b/custom_components/nhc2/fan.py @@ -2,12 +2,12 @@ import logging from typing import Any -from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, FanEntity, SUPPORT_SET_SPEED -from nhc2_coco import CoCo -from nhc2_coco.coco_device_class import CoCoDeviceClass -from nhc2_coco.coco_fan import CoCoFan -from nhc2_coco.coco_fan_speed import CoCoFanSpeed -from nhc2_coco.coco_switched_fan import CoCoSwitchedFan +from homeassistant.components.fan import FanEntity, SUPPORT_SET_SPEED +from .coco import CoCo +from .coco_device_class import CoCoDeviceClass +from .coco_fan import CoCoFan +from .coco_fan_speed import CoCoFanSpeed +from .coco_switched_fan import CoCoSwitchedFan from .const import DOMAIN, KEY_GATEWAY, BRAND, FAN from .helpers import nhc2_entity_processor @@ -16,6 +16,9 @@ KEY_ENTITY = 'nhc2_fans' SPEED_BOOST = 'boost' +SPEED_HIGH = 100 +SPEED_LOW = 10 +SPEED_MEDIUM = 50 _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/nhc2/helpers.py b/custom_components/nhc2/helpers.py index 2244217..26350f4 100644 --- a/custom_components/nhc2/helpers.py +++ b/custom_components/nhc2/helpers.py @@ -86,3 +86,49 @@ def extract_versions(nhc2_sysinfo): (lambda x: x and 'NhcVersion' in x), s_w_versions), None)['NhcVersion'] return coco_image, nhc_version + +from .const import KEY_DEVICES, KEY_PARAMS, KEY_PROPERTIES, KEY_UUID, KEY_METHOD, MQTT_METHOD_DEVICES_CONTROL + + +def extract_devices(response): + params = response[KEY_PARAMS] + param_with_devices = next(filter((lambda x: x and KEY_DEVICES in x), params), None) + return param_with_devices[KEY_DEVICES] + + +def extract_property_value_from_device(device, property_key): + if device and KEY_PROPERTIES in device: + properties = device[KEY_PROPERTIES] + if properties: + property_object = next(filter((lambda x: x and property_key in x), properties), None) + if property_object and property_key in property_object: + return property_object[property_key] + return None + +def extract_property_definitions(response, parameter): + if response and 'PropertyDefinitions' in response: + properties = response['PropertyDefinitions'] + if properties: + return next(filter((lambda x: x and parameter in x), properties), None)[parameter] + else: + return None + +def status_prop_in_object_is_on(property_object_with_status): + return property_object_with_status['Status'] == 'On' + +def dev_prop_changed(field, dev, prop): + return prop in dev and field != dev[prop] + +def process_device_commands(device_commands_to_process): + devices = [] + for uuid, properties in device_commands_to_process.items(): + device = {KEY_UUID: uuid, KEY_PROPERTIES: []} + for property_key, property_value in properties.items(): + device[KEY_PROPERTIES].append({property_key: property_value}) + devices.append(device) + return { + KEY_METHOD: MQTT_METHOD_DEVICES_CONTROL, + KEY_PARAMS: [{ + KEY_DEVICES: devices + }] + } \ No newline at end of file diff --git a/custom_components/nhc2/light.py b/custom_components/nhc2/light.py index 354e2d7..11c2c9d 100644 --- a/custom_components/nhc2/light.py +++ b/custom_components/nhc2/light.py @@ -2,8 +2,8 @@ import logging from homeassistant.components.light import LightEntity, SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS -from nhc2_coco import CoCoLight, CoCo -from nhc2_coco.coco_device_class import CoCoDeviceClass +from .coco import CoCoLight, CoCo +from .coco_device_class import CoCoDeviceClass from .const import DOMAIN, KEY_GATEWAY, BRAND, LIGHT from .helpers import nhc2_entity_processor diff --git a/custom_components/nhc2/manifest.json b/custom_components/nhc2/manifest.json index 8def2fa..e448836 100644 --- a/custom_components/nhc2/manifest.json +++ b/custom_components/nhc2/manifest.json @@ -1,10 +1,10 @@ { "domain": "nhc2", "name": "Niko Home Control II", - "requirements": ["nhc2-coco==1.4.1"], + "requirements": ["paho-mqtt==1.6.1"], "config_flow": true, "issue_tracker": "https://github.com/filipvh/hass-nhc2/issues", "documentation": "https://github.com/filipvh/hass-nhc2/blob/master/README.md", "codeowners": ["@filipvh"], - "version": "2021.4.1" + "version": "2022.3.1" } diff --git a/custom_components/nhc2/switch.py b/custom_components/nhc2/switch.py index e27d205..c3dc783 100644 --- a/custom_components/nhc2/switch.py +++ b/custom_components/nhc2/switch.py @@ -1,10 +1,10 @@ """Support for NHC2 switches.""" import logging from homeassistant.components.switch import SwitchEntity -from nhc2_coco.coco_device_class import CoCoDeviceClass +from .coco_device_class import CoCoDeviceClass from .helpers import nhc2_entity_processor -from nhc2_coco import CoCo, CoCoSwitch +from .coco import CoCo, CoCoSwitch from .const import DOMAIN, KEY_GATEWAY, BRAND, SWITCH