diff --git a/README.md b/README.md index 2b05a1d..56348e6 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,9 @@ HACS is a community store for Home Assistant. You can install [HACS](https://git ## Usage -Add to configuration.yaml: +Add cameras via Integrations (search for Tapo) in Home Assistant UI. -``` -tapo_control: - - host: [IP ADDRESS TO TAPO CAMERA] - username: [USERNAME SET IN ADVANCED OPTIONS IN CAMERA APP] - password: [PASSWORD SET IN ADVANCED OPTIONS IN CAMERA APP] -``` - -You are able to add multiple cameras. +To add multiple cameras, add integration multiple times. ## Services @@ -78,6 +71,14 @@ This custom component creates tapo_control.* entities in your Home Assistant. Us - **led_mode** Required: Sets LED mode for camera. Possible values: on, off +
+ tapo_control.format + + Formats SD card of a camera + + - **entity_id** Required: Entity to format +
+
tapo_control.set_motion_detection_mode diff --git a/custom_components/tapo_control/__init__.py b/custom_components/tapo_control/__init__.py index 8263186..b8958ed 100644 --- a/custom_components/tapo_control/__init__.py +++ b/custom_components/tapo_control/__init__.py @@ -1,369 +1,36 @@ from pytapo import Tapo -from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD) +from homeassistant.const import (CONF_IP_ADDRESS, CONF_USERNAME, CONF_PASSWORD) +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady import logging -import re -import voluptuous as vol -import homeassistant.helpers.config_validation as cv -import unidecode -from homeassistant.helpers.event import track_time_interval -from datetime import timedelta +from .const import * _LOGGER = logging.getLogger(__name__) -DOMAIN = "tapo_control" -ALARM_MODE = "alarm_mode" -PRESET = "preset" -LIGHT = "light" -SOUND = "sound" -PRIVACY_MODE = "privacy_mode" -LED_MODE = "led_mode" -NAME = "name" -DISTANCE = "distance" -TILT = "tilt" -PAN = "pan" -ENTITY_ID = "entity_id" -MOTION_DETECTION_MODE = "motion_detection_mode" -AUTO_TRACK_MODE = "auto_track_mode" -DEFAULT_SCAN_INTERVAL = 10 -ENTITY_CHAR_WHITELIST = set('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_') -DEVICE_MODEL_C100 = "C100" -DEVICE_MODEL_C200 = "C200" -DEVICES_WITH_NO_PRESETS = [DEVICE_MODEL_C100] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) - -tapo = {} -tapoData = {} - -def setup(hass, config): - def update(event_time): - for entity_id in tapo: - tapoConnector = tapo[entity_id] - manualUpdate(entity_id, tapoConnector) - - def manualUpdate(entity_id, tapoConnector): - basicInfo = tapoConnector.getBasicInfo() - attributes = basicInfo['device_info']['basic_info'] - if(not basicInfo['device_info']['basic_info']['device_model'] in DEVICES_WITH_NO_PRESETS): - attributes['presets'] = tapoConnector.getPresets() - tapoData[entity_id] = {} - tapoData[entity_id]['state'] = "monitoring" # todo: better state - tapoData[entity_id]['attributes'] = attributes - - hass.states.set(entity_id, tapoData[entity_id]['state'], tapoData[entity_id]['attributes']) - - def getIncrement(entity_id): - lastNum = entity_id[entity_id.rindex('_')+1:] - if(lastNum.isnumeric()): - return int(lastNum)+1 - return 1 - - def addTapoEntityID(requested_entity_id, requested_value): - regex = r"^"+requested_entity_id.replace(".","\.")+"_[0-9]+$" - if(requested_entity_id in tapo): - biggestIncrement = 0 - for id in tapo: - r1 = re.findall(regex,id) - if r1: - inc = getIncrement(requested_entity_id) - if(inc > biggestIncrement): - biggestIncrement = inc - if(biggestIncrement == 0): - oldVal = tapo[requested_entity_id] - tapo.pop(requested_entity_id, None) - tapo[requested_entity_id+"_1"] = oldVal - tapo[requested_entity_id+"_2"] = requested_value - else: - tapo[requested_entity_id+"_"+str(biggestIncrement)] = requested_value - else: - biggestIncrement = 0 - for id in tapo: - r1 = re.findall(regex,id) - if r1: - inc = getIncrement(id) - if(inc > biggestIncrement): - biggestIncrement = inc - if(biggestIncrement == 0): - tapo[requested_entity_id] = requested_value - else: - tapo[requested_entity_id+"_"+str(biggestIncrement)] = requested_value - - def handle_ptz(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if PRESET in call.data: - preset = str(call.data.get(PRESET)) - if(preset.isnumeric()): - tapo[entity_id].setPreset(preset) - else: - foundKey = False - presets = tapoData[entity_id]['attributes']['presets'] - for key, value in presets.items(): - if value == preset: - foundKey = key - if(foundKey): - tapo[entity_id].setPreset(foundKey) - else: - _LOGGER.error("Preset "+preset+" does not exist.") - elif TILT in call.data: - tilt = call.data.get(TILT) - if DISTANCE in call.data: - distance = float(call.data.get(DISTANCE)) - if(distance >= 0 and distance <= 1): - degrees = 68 * distance - else: - degrees = 5 - else: - degrees = 5 - if tilt == "UP": - tapo[entity_id].moveMotor(0,degrees) - elif tilt == "DOWN": - tapo[entity_id].moveMotor(0,-degrees) - else: - _LOGGER.error("Incorrect "+TILT+" value. Possible values: UP, DOWN.") - elif PAN in call.data: - pan = call.data.get(PAN) - if DISTANCE in call.data: - distance = float(call.data.get(DISTANCE)) - if(distance >= 0 and distance <= 1): - degrees = 360 * distance - else: - degrees = 5 - else: - degrees = 5 - if pan == "RIGHT": - tapo[entity_id].moveMotor(degrees,0) - elif pan == "LEFT": - tapo[entity_id].moveMotor(-degrees,0) - else: - _LOGGER.error("Incorrect "+PAN+" value. Possible values: RIGHT, LEFT.") - else: - _LOGGER.error("Incorrect additional PTZ properties. You need to specify at least one of " + TILT + ", " + PAN + ", " + PRESET + ".") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") - - def handle_set_privacy_mode(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(PRIVACY_MODE in call.data): - privacy_mode = call.data.get(PRIVACY_MODE) - if(privacy_mode == "on"): - tapo[entity_id].setPrivacyMode(True) - elif(privacy_mode == "off"): - tapo[entity_id].setPrivacyMode(False) - else: - _LOGGER.error("Incorrect "+PRIVACY_MODE+" value. Possible values: on, off.") - else: - _LOGGER.error("Please specify "+PRIVACY_MODE+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Tapo: Cameras Control component from YAML.""" + return True - def handle_set_alarm_mode(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(ALARM_MODE in call.data): - alarm_mode = call.data.get(ALARM_MODE) - sound = "on" - light = "on" - if(LIGHT in call.data): - light = call.data.get(LIGHT) - if(SOUND in call.data): - sound = call.data.get(SOUND) - if(alarm_mode == "on"): - tapo[entity_id].setAlarm(True, True if sound == "on" else False, True if light == "on" else False) - elif(alarm_mode == "off"): - tapo[entity_id].setAlarm(False, True if sound == "on" else False, True if light == "on" else False) - else: - _LOGGER.error("Incorrect "+ALARM_MODE+" value. Possible values: on, off.") - else: - _LOGGER.error("Please specify "+ALARM_MODE+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") - def handle_set_led_mode(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(LED_MODE in call.data): - led_mode = call.data.get(LED_MODE) - if(led_mode == "on"): - tapo[entity_id].setLEDEnabled(True) - elif(led_mode == "off"): - tapo[entity_id].setLEDEnabled(False) - else: - _LOGGER.error("Incorrect "+LED_MODE+" value. Possible values: on, off.") - else: - _LOGGER.error("Please specify "+LED_MODE+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up the Tapo: Cameras Control component from a config entry.""" + hass.data.setdefault(DOMAIN, {}) - def handle_set_motion_detection_mode(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(MOTION_DETECTION_MODE in call.data): - motion_detection_mode = call.data.get(MOTION_DETECTION_MODE) - if(motion_detection_mode == "high" or motion_detection_mode == "normal" or motion_detection_mode == "low"): - tapo[entity_id].setMotionDetection(True, motion_detection_mode) - elif(motion_detection_mode == "off"): - tapo[entity_id].setMotionDetection(False) - else: - _LOGGER.error("Incorrect "+MOTION_DETECTION_MODE+" value. Possible values: high, normal, low, off.") - else: - _LOGGER.error("Please specify "+MOTION_DETECTION_MODE+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") + host = entry.data.get(CONF_IP_ADDRESS) + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + try: + tapoController = Tapo(host, username, password) - def handle_set_auto_track_mode(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(AUTO_TRACK_MODE in call.data): - auto_track_mode = call.data.get(AUTO_TRACK_MODE) - if(auto_track_mode == "on"): - tapo[entity_id].setAutoTrackTarget(True) - elif(auto_track_mode == "off"): - tapo[entity_id].setAutoTrackTarget(False) - else: - _LOGGER.error("Incorrect "+AUTO_TRACK_MODE+" value. Possible values: on, off.") - else: - _LOGGER.error("Please specify "+AUTO_TRACK_MODE+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") + hass.data[DOMAIN][entry.entry_id] = tapoController - def handle_reboot(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - tapo[entity_id].reboot() - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") - - def handle_save_preset(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(NAME in call.data): - name = call.data.get(NAME) - if(not name == "" and not name.isnumeric()): - tapo[entity_id].savePreset(name) - update(None) - else: - _LOGGER.error("Incorrect "+NAME+" value. It cannot be empty or a number.") - else: - _LOGGER.error("Please specify "+NAME+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") - - def handle_delete_preset(call): - if ENTITY_ID in call.data: - entity_id = call.data.get(ENTITY_ID) - if(isinstance(entity_id, list)): - entity_id = entity_id[0] - if entity_id in tapo: - if(PRESET in call.data): - preset = str(call.data.get(PRESET)) - if(preset.isnumeric()): - tapo[entity_id].deletePreset(preset) - else: - foundKey = False - presets = tapoData[entity_id]['attributes']['presets'] - for key, value in presets.items(): - if value == preset: - foundKey = key - if(foundKey): - tapo[entity_id].deletePreset(foundKey) - else: - _LOGGER.error("Preset "+preset+" does not exist.") - else: - _LOGGER.error("Please specify "+PRESET+" value.") - else: - _LOGGER.error("Entity "+entity_id+" does not exist.") - else: - _LOGGER.error("Please specify "+ENTITY_ID+" value.") - - def generateEntityIDFromName(name): - str = unidecode.unidecode(name.rstrip().replace(".","_").replace(" ", "_").lower()) - str = re.sub("_"+'{2,}',"_",''.join(filter(ENTITY_CHAR_WHITELIST.__contains__, str))) - return DOMAIN+"."+str - - for camera in config[DOMAIN]: - host = camera[CONF_HOST] - username = camera[CONF_USERNAME] - password = camera[CONF_PASSWORD] - - tapoConnector = Tapo(host, username, password) - basicInfo = tapoConnector.getBasicInfo() - - entity_id = generateEntityIDFromName(basicInfo['device_info']['basic_info']['device_alias']) - # handles conflicts if entity_id the same - addTapoEntityID(entity_id,tapoConnector) - - - for entity_id in tapo: - tapoConnector = tapo[entity_id] - manualUpdate(entity_id, tapoConnector) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "camera") + ) - hass.services.register(DOMAIN, "ptz", handle_ptz) - hass.services.register(DOMAIN, "set_privacy_mode", handle_set_privacy_mode) - hass.services.register(DOMAIN, "set_alarm_mode", handle_set_alarm_mode) - hass.services.register(DOMAIN, "set_led_mode", handle_set_led_mode) - hass.services.register(DOMAIN, "set_motion_detection_mode", handle_set_motion_detection_mode) - hass.services.register(DOMAIN, "set_auto_track_mode", handle_set_auto_track_mode) - hass.services.register(DOMAIN, "reboot", handle_reboot) - hass.services.register(DOMAIN, "save_preset", handle_save_preset) - hass.services.register(DOMAIN, "delete_preset", handle_delete_preset) + except Exception as e: + _LOGGER.error("Unable to connect to Tapo: Cameras Control controller: %s", str(e)) + raise ConfigEntryNotReady - track_time_interval(hass, update, timedelta(seconds=DEFAULT_SCAN_INTERVAL)) - return True \ No newline at end of file diff --git a/custom_components/tapo_control/camera.py b/custom_components/tapo_control/camera.py new file mode 100644 index 0000000..d814088 --- /dev/null +++ b/custom_components/tapo_control/camera.py @@ -0,0 +1,315 @@ +from homeassistant.helpers.config_validation import boolean +from .const import * +from homeassistant.core import HomeAssistant +from homeassistant.const import (CONF_IP_ADDRESS, CONF_USERNAME, CONF_PASSWORD) +from typing import Callable +from homeassistant.helpers.entity import Entity +from pytapo import Tapo +from homeassistant.util import slugify +from homeassistant.helpers import entity_platform +from homeassistant.components.camera import SUPPORT_STREAM, Camera +import logging + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, entry: dict, async_add_entities: Callable): + async_add_entities([TapoCamEntity(entry, hass.data[DOMAIN][entry.entry_id],True)]) + async_add_entities([TapoCamEntity(entry, hass.data[DOMAIN][entry.entry_id],False)]) + + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_SET_LED_MODE, SCHEMA_SERVICE_SET_LED_MODE, "set_led_mode", + ) + platform.async_register_entity_service( + SERVICE_SET_PRIVACY_MODE, SCHEMA_SERVICE_SET_PRIVACY_MODE, "set_privacy_mode", + ) + platform.async_register_entity_service( + SERVICE_PTZ, SCHEMA_SERVICE_PTZ, "ptz", + ) + platform.async_register_entity_service( + SERVICE_SET_ALARM_MODE, SCHEMA_SERVICE_SET_ALARM_MODE, "set_alarm_mode", + ) + platform.async_register_entity_service( + SERVICE_SET_MOTION_DETECTION_MODE, SCHEMA_SERVICE_SET_MOTION_DETECTION_MODE, "set_motion_detection_mode", + ) + platform.async_register_entity_service( + SERVICE_SET_AUTO_TRACK_MODE, SCHEMA_SERVICE_SET_AUTO_TRACK_MODE, "set_auto_track_mode", + ) + platform.async_register_entity_service( + SERVICE_REBOOT, SCHEMA_SERVICE_REBOOT, "reboot", + ) + platform.async_register_entity_service( + SERVICE_SAVE_PRESET, SCHEMA_SERVICE_SAVE_PRESET, "save_preset", + ) + platform.async_register_entity_service( + SERVICE_DELETE_PRESET, SCHEMA_SERVICE_DELETE_PRESET, "delete_preset", + ) + platform.async_register_entity_service( + SERVICE_FORMAT, SCHEMA_SERVICE_FORMAT, "format", + ) + + +class TapoCamEntity(Camera): + def __init__(self, entry: dict, controller: Tapo, HDStream: boolean): + super().__init__() + self._attributes = {} + self._motion_detection_enabled = None + self._motion_detection_sensitivity = None + self._privacy_mode = None + self._basic_info = {} + self._mac = "" + self._alarm = None + self._alarm_mode = None + self._led = None + self._auto_track = None + + self._controller = controller + self._entry = entry + self._hdstream = HDStream + self._host = entry.data.get(CONF_IP_ADDRESS) + self._username = entry.data.get(CONF_USERNAME) + self._password = entry.data.get(CONF_PASSWORD) + self.manualUpdate() + + @property + def supported_features(self): + return SUPPORT_STREAM + + @property + def icon(self) -> str: + return "mdi:cctv" + + @property + def name(self) -> str: + return self.getName() + + @property + def unique_id(self) -> str: + return self.getUniqueID() + + @property + def device_state_attributes(self): + return self._attributes + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, slugify(f"{self._mac}_tapo_control"))}, + "name": self._basic_info['device_alias'], + "manufacturer": "TP-Link", + "model": self._basic_info['device_model'], + "sw_version": self._basic_info['sw_version'] + } + + @property + def motion_detection_enabled(self): + return self._motion_detection_enabled + + @property + def brand(self): + return "TP-Link" + + @property + def model(self): + return self._basic_info['device_model'] + + @property + def should_poll(self): + return True + + async def stream_source(self): + if(self._hdstream): + streamType = "stream1" + else: + streamType = "stream2" + streamURL = f"rtsp://{self._username}:{self._password}@{self._host}:554/{streamType}" + return streamURL + + def update(self): + self.manualUpdate() + + def manualUpdate(self): + self._basic_info = self._controller.getBasicInfo()['device_info']['basic_info'] + self._attributes = self._basic_info + self._mac = self._basic_info['mac'] + self._state = "idle" + try: + motionDetectionData = self._controller.getMotionDetection() + self._motion_detection_enabled = motionDetectionData['enabled'] + if(motionDetectionData['digital_sensitivity'] == "20"): + self._motion_detection_sensitivity = "low" + elif(motionDetectionData['digital_sensitivity'] == "50"): + self._motion_detection_sensitivity = "normal" + elif(motionDetectionData['digital_sensitivity'] == "80"): + self._motion_detection_sensitivity = "high" + else: + self._motion_detection_sensitivity = None + except: + self._motion_detection_enabled = None + self._motion_detection_sensitivity = None + self._attributes['motion_detection_sensitivity'] = self._motion_detection_sensitivity + + try: + self._privacy_mode = self._controller.getPrivacyMode()['enabled'] + except: + self._privacy_mode = None + self._attributes['privacy_mode'] = self._privacy_mode + + try: + alarmData = self._controller.getAlarm() + self._alarm = alarmData['enabled'] + self._alarm_mode = alarmData['alarm_mode'] + except: + self._alarm = None + self._alarm_mode = None + self._attributes['alarm'] = self._alarm + self._attributes['alarm_mode'] = self._alarm_mode + + try: + self._led = self._controller.getLED()['enabled'] + except: + self._led = None + self._attributes['led'] = self._led + + try: + self._auto_track = self._controller.getAutoTrackTarget()['enabled'] + except: + self._auto_track = None + self._attributes['auto_track'] = self._auto_track + + + if(self._basic_info['device_model'] in DEVICES_WITH_NO_PRESETS): + self._attributes['presets'] = {} + else: + self._attributes['presets'] = self._controller.getPresets() + + def getName(self): + name = self._basic_info['device_alias'] + if(self._hdstream): + name += " - HD" + else: + name += " - SD" + return name + + def getUniqueID(self): + if(self._hdstream): + streamType = "hd" + else: + streamType = "sd" + return slugify(f"{self._mac}_{streamType}_tapo_control") + + + def ptz(self, tilt = None, pan = None, preset = None, distance = None): + if preset: + if(preset.isnumeric()): + self._controller.setPreset(preset) + else: + foundKey = False + for key, value in self._attributes['presets'].items(): + if value == preset: + foundKey = key + if(foundKey): + self._controller.setPreset(foundKey) + else: + _LOGGER.error("Preset "+preset+" does not exist.") + elif tilt: + if distance: + distance = float(distance) + if(distance >= 0 and distance <= 1): + degrees = 68 * distance + else: + degrees = 5 + else: + degrees = 5 + if tilt == "UP": + self._controller.moveMotor(0,degrees) + else: + self._controller.moveMotor(0,-degrees) + elif pan: + if distance: + distance = float(distance) + if(distance >= 0 and distance <= 1): + degrees = 360 * distance + else: + degrees = 5 + else: + degrees = 5 + if pan == "RIGHT": + self._controller.moveMotor(degrees,0) + else: + self._controller.moveMotor(-degrees,0) + else: + _LOGGER.error("Incorrect additional PTZ properties. You need to specify at least one of " + TILT + ", " + PAN + ", " + PRESET + ".") + + def set_privacy_mode(self, privacy_mode: str): + if(privacy_mode == "on"): + self._controller.setPrivacyMode(True) + else: + self._controller.setPrivacyMode(False) + self.manualUpdate() + + def set_alarm_mode(self, alarm_mode, sound = None, light = None): + if(not light): + light = "on" + if(not sound): + sound = "on" + if(alarm_mode == "on"): + self._controller.setAlarm(True, True if sound == "on" else False, True if light == "on" else False) + else: + self._controller.setAlarm(False, True if sound == "on" else False, True if light == "on" else False) + self.manualUpdate() + + def set_led_mode(self, led_mode: str): + if(led_mode == "on"): + self._controller.setLEDEnabled(True) + else: + self._controller.setLEDEnabled(False) + self.manualUpdate() + + def set_motion_detection_mode(self, motion_detection_mode): + if(motion_detection_mode == "off"): + self._controller.setMotionDetection(False) + else: + self._controller.setMotionDetection(True, motion_detection_mode) + self.manualUpdate() + + def set_auto_track_mode(self, auto_track_mode: str): + if(auto_track_mode == "on"): + self._controller.setAutoTrackTarget(True) + else: + self._controller.setAutoTrackTarget(False) + self.manualUpdate() + + def reboot(self): + self._controller.reboot() + + def save_preset(self, name): + if(not name == "" and not name.isnumeric()): + self._controller.savePreset(name) + self.manualUpdate() + else: + _LOGGER.error("Incorrect "+NAME+" value. It cannot be empty or a number.") + + def delete_preset(self, preset): + if(preset.isnumeric()): + self._controller.deletePreset(preset) + self.manualUpdate() + else: + foundKey = False + for key, value in self._attributes['presets'].items(): + if value == preset: + foundKey = key + if(foundKey): + self._controller.deletePreset(foundKey) + self.manualUpdate() + else: + _LOGGER.error("Preset "+preset+" does not exist.") + + def format(self): + self._controller.format() + \ No newline at end of file diff --git a/custom_components/tapo_control/config_flow.py b/custom_components/tapo_control/config_flow.py new file mode 100644 index 0000000..f2d7bc6 --- /dev/null +++ b/custom_components/tapo_control/config_flow.py @@ -0,0 +1,51 @@ +from pytapo import Tapo +from homeassistant import config_entries +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_USERNAME, + CONF_PASSWORD +) +import voluptuous as vol +import logging + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str + } +) + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 1 + + async def async_step_user(self, user_input=None): + errors = {} + if user_input is not None: + try: + host = user_input[CONF_IP_ADDRESS] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + Tapo(host, username, password) + + return self.async_create_entry( + title=host, + data={CONF_IP_ADDRESS: host, CONF_USERNAME: username, CONF_PASSWORD: password,}, + ) + except Exception as e: + if("Failed to establish a new connection" in str(e)): + errors["base"] = "connection_failed" + elif(str(e) == "Invalid authentication data."): + errors["base"] = "invalid_auth" + else: + errors["base"] = "unknown" + _LOGGER.error(e) + + return self.async_show_form( + step_id="user", data_schema=DEVICE_SCHEMA, errors=errors + ) \ No newline at end of file diff --git a/custom_components/tapo_control/const.py b/custom_components/tapo_control/const.py new file mode 100644 index 0000000..a4d1801 --- /dev/null +++ b/custom_components/tapo_control/const.py @@ -0,0 +1,89 @@ +import voluptuous as vol +from datetime import timedelta +from homeassistant.helpers import config_validation as cv + +DOMAIN = "tapo_control" +ALARM_MODE = "alarm_mode" +PRESET = "preset" +LIGHT = "light" +SOUND = "sound" +PRIVACY_MODE = "privacy_mode" +LED_MODE = "led_mode" +NAME = "name" +DISTANCE = "distance" +TILT = "tilt" +PAN = "pan" +ENTITY_ID = "entity_id" +MOTION_DETECTION_MODE = "motion_detection_mode" +AUTO_TRACK_MODE = "auto_track_mode" +DEFAULT_SCAN_INTERVAL = 10 +ENTITY_CHAR_WHITELIST = set('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_') +DEVICE_MODEL_C100 = "C100" +DEVICE_MODEL_C200 = "C200" +DEVICES_WITH_NO_PRESETS = [DEVICE_MODEL_C100] +SCAN_INTERVAL = timedelta(seconds=30) + +TOGGLE_STATES = ["on", "off"] + +SERVICE_PTZ = "ptz" +SCHEMA_SERVICE_PTZ = { + vol.Required(ENTITY_ID): cv.string, + vol.Optional(TILT): vol.In(["UP", "DOWN"]), + vol.Optional(PAN): vol.In(["RIGHT", "LEFT"]), + vol.Optional(PRESET): cv.string, + vol.Optional(DISTANCE): cv.string +} + +SERVICE_SET_PRIVACY_MODE = "set_privacy_mode" +SCHEMA_SERVICE_SET_PRIVACY_MODE = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(PRIVACY_MODE): vol.In(TOGGLE_STATES) +} + +SERVICE_SET_ALARM_MODE = "set_alarm_mode" +SCHEMA_SERVICE_SET_ALARM_MODE = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(ALARM_MODE): vol.In(TOGGLE_STATES), + vol.Optional(SOUND): vol.In(TOGGLE_STATES), + vol.Optional(LIGHT): vol.In(TOGGLE_STATES) +} + +SERVICE_SET_LED_MODE = "set_led_mode" +SCHEMA_SERVICE_SET_LED_MODE = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(LED_MODE): vol.In(TOGGLE_STATES) +} + +SERVICE_SET_MOTION_DETECTION_MODE = "set_motion_detection_mode" +SCHEMA_SERVICE_SET_MOTION_DETECTION_MODE = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(MOTION_DETECTION_MODE): vol.In(["high","normal","low","off"]) +} + +SERVICE_SET_AUTO_TRACK_MODE = "set_auto_track_mode" +SCHEMA_SERVICE_SET_AUTO_TRACK_MODE = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(AUTO_TRACK_MODE): vol.In(TOGGLE_STATES) +} + +SERVICE_REBOOT = "reboot" +SCHEMA_SERVICE_REBOOT = { + vol.Required(ENTITY_ID): cv.string +} + +SERVICE_SAVE_PRESET = "save_preset" +SCHEMA_SERVICE_SAVE_PRESET = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(NAME): cv.string +} + +SERVICE_DELETE_PRESET = "delete_preset" +SCHEMA_SERVICE_DELETE_PRESET = { + vol.Required(ENTITY_ID): cv.string, + vol.Required(PRESET): cv.string +} + +SERVICE_FORMAT = "format" +SCHEMA_SERVICE_FORMAT = { + vol.Required(ENTITY_ID): cv.string +} \ No newline at end of file diff --git a/custom_components/tapo_control/manifest.json b/custom_components/tapo_control/manifest.json index dbfb2d1..d415b65 100644 --- a/custom_components/tapo_control/manifest.json +++ b/custom_components/tapo_control/manifest.json @@ -5,6 +5,7 @@ "dependencies": [], "issue_tracker": "https://github.com/JurajNyiri/HomeAssistant-Tapo-Control/issues", "codeowners": ["@JurajNyiri"], - "requirements": ["pytapo==0.10","unidecode"] + "requirements": ["pytapo==0.11"], + "config_flow": true } \ No newline at end of file diff --git a/custom_components/tapo_control/services.yaml b/custom_components/tapo_control/services.yaml index f4427fd..971672b 100644 --- a/custom_components/tapo_control/services.yaml +++ b/custom_components/tapo_control/services.yaml @@ -3,7 +3,7 @@ ptz: fields: entity_id: description: "Entity to adjust" - example: "tapo_control.living_room" + example: "camera.living_room" tilt: description: "Tilt direction. Allowed values: UP, DOWN" example: "UP" @@ -21,7 +21,7 @@ set_privacy_mode: fields: entity_id: description: "Entity to set privacy mode for" - example: "tapo_control.living_room" + example: "camera.living_room" privacy_mode: description: "Sets privacy mode for camera. Possible values: on, off" example: "on" @@ -30,7 +30,7 @@ set_alarm_mode: fields: entity_id: description: "Entity to set alarm mode for" - example: "tapo_control.living_room" + example: "camera.living_room" alarm_mode: description: "Sets alarm mode for camera. Possible values: on, off" example: "on" @@ -45,7 +45,7 @@ set_led_mode: fields: entity_id: description: "Entity to set LED mode for" - example: "tapo_control.living_room" + example: "camera.living_room" led_mode: description: "Sets LED mode for camera. Possible values: on, off" example: "on" @@ -54,7 +54,7 @@ set_motion_detection_mode: fields: entity_id: description: "Entity to set motion detection mode for" - example: "tapo_control.living_room" + example: "camera.living_room" motion_detection_mode: description: "Sets motion detection mode for camera. Possible values: high, normal, low, off" example: "normal" @@ -65,7 +65,7 @@ set_auto_track_mode: fields: entity_id: description: "Entity to set auto track mode for" - example: "tapo_control.living_room" + example: "camera.living_room" auto_track_mode: description: "Sets auto track mode for camera. Possible values: on, off" example: "on" @@ -74,13 +74,13 @@ reboot: fields: entity_id: description: "Entity to reboot" - example: "tapo_control.living_room" + example: "camera.living_room" save_preset: description: Saves the current PTZ position to a preset fields: entity_id: description: "Entity to save the preset for" - example: "tapo_control.living_room" + example: "camera.living_room" name: description: "Name of the preset. Cannot be empty or a number" example: "Entry Door" @@ -89,7 +89,13 @@ delete_preset: fields: entity_id: description: "Entity to delete the preset for" - example: "tapo_control.living_room" + example: "camera.living_room" preset: description: "PTZ preset ID or a Name. See possible presets in entity attributes" - example: "1" \ No newline at end of file + example: "1" +format: + description: Formats the SD card of a camera + fields: + entity_id: + description: "Entity to format" + example: "camera.living_room" \ No newline at end of file diff --git a/custom_components/tapo_control/strings.json b/custom_components/tapo_control/strings.json new file mode 100644 index 0000000..12c6304 --- /dev/null +++ b/custom_components/tapo_control/strings.json @@ -0,0 +1,20 @@ +{ + "title": "Tapo: Cameras Control", + "config": { + "step": { + "user": { + "title": "Connect Tapo camera", + "data": { + "ip_address": "IP Address", + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication data", + "unknown": "Unknown error", + "connection_failed": "Connection failed" + } + } +} diff --git a/custom_components/tapo_control/translations/en.json b/custom_components/tapo_control/translations/en.json new file mode 100644 index 0000000..c11a068 --- /dev/null +++ b/custom_components/tapo_control/translations/en.json @@ -0,0 +1,21 @@ +{ + "title": "Tapo: Cameras Control", + "config": { + "step": { + "user": { + "title": "Connect Tapo camera", + "data": { + "ip_address": "IP Address", + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication data", + "unknown": "Unknown error", + "connection_failed": "Connection failed" + } + } + } + \ No newline at end of file diff --git a/info.md b/info.md index 1a71172..6967ea7 100644 --- a/info.md +++ b/info.md @@ -1,12 +1,5 @@ ## Usage: -Add to configuration.yaml: - -``` -tapo_control: - - host: [IP ADDRESS TO TAPO CAMERA] - username: [USERNAME SET IN ADVANCED OPTIONS IN CAMERA APP] - password: [PASSWORD SET IN ADVANCED OPTIONS IN CAMERA APP] -``` +After installation add cameras via Integrations (search for Tapo) in Home Assistant UI. For further information see [Github repository](https://github.com/JurajNyiri/HomeAssistant-Tapo-Control/blob/master/README.md)