File: /volume1/docker/homeassistant2025/custom_components/tapo_control/__init__.py
import datetime
import hashlib
import asyncio
from aiohttp import ClientError
from homeassistant.core import HomeAssistant, callback
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_USERNAME,
CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.exceptions import (
ConfigEntryNotReady,
ConfigEntryAuthFailed,
DependencyError,
)
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt
from homeassistant.components.media_source.error import Unresolvable
import homeassistant.helpers.entity_registry
from .const import (
CONF_RTSP_TRANSPORT,
CONTROL_PORT,
ENABLE_MEDIA_SYNC,
ENABLE_SOUND_DETECTION,
CONF_CUSTOM_STREAM,
ENABLE_WEBHOOKS,
IS_KLAP_DEVICE,
LOGGER,
DOMAIN,
ENABLE_MOTION_SENSOR,
CLOUD_PASSWORD,
ENABLE_STREAM,
ENABLE_TIME_SYNC,
MEDIA_CLEANUP_PERIOD,
MEDIA_SYNC_COLD_STORAGE_PATH,
MEDIA_SYNC_HOURS,
MEDIA_VIEW_DAYS_ORDER,
MEDIA_VIEW_RECORDINGS_ORDER,
REPORTED_IP_ADDRESS,
RTSP_TRANS_PROTOCOLS,
SOUND_DETECTION_DURATION,
SOUND_DETECTION_PEAK,
SOUND_DETECTION_RESET,
TIME_SYNC_DST,
TIME_SYNC_DST_DEFAULT,
TIME_SYNC_NDST,
TIME_SYNC_NDST_DEFAULT,
TIME_SYNC_PERIOD,
UPDATE_CHECK_PERIOD,
PYTAPO_REQUIRED_VERSION,
UPDATE_INTERVAL_BATTERY,
UPDATE_INTERVAL_BATTERY_DEFAULT,
UPDATE_INTERVAL_MAIN,
UPDATE_INTERVAL_MAIN_DEFAULT,
)
from .utils import (
convert_to_timestamp,
deleteDir,
getColdDirPathForEntry,
getDataForController,
getEntryStorageFile,
getHotDirPathForEntry,
getIP,
isUsingHTTPS,
mediaCleanup,
registerController,
getCamData,
setupOnvif,
setupEvents,
update_listener,
initOnvifEvents,
syncTime,
getLatestFirmwareVersion,
findMedia,
getRecordings,
scheduleAll,
)
from pytapo import Tapo
from pytapo.version import PYTAPO_VERSION
from homeassistant.helpers.event import async_track_time_interval
from datetime import timedelta
from .utils import getRecording
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Tapo: Cameras Control component from YAML."""
return True
async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entry."""
LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
new = {**config_entry.data}
new[ENABLE_MOTION_SENSOR] = True
new[CLOUD_PASSWORD] = ""
config_entry.data = {**new}
config_entry.version = 2
if config_entry.version == 2:
new = {**config_entry.data}
new[CLOUD_PASSWORD] = ""
config_entry.data = {**new}
config_entry.version = 3
if config_entry.version == 3:
new = {**config_entry.data}
new[ENABLE_STREAM] = True
config_entry.data = {**new}
config_entry.version = 4
if config_entry.version == 4:
new = {**config_entry.data}
new[ENABLE_TIME_SYNC] = False
config_entry.data = {**new}
config_entry.version = 5
if config_entry.version == 5:
new = {**config_entry.data}
new[ENABLE_SOUND_DETECTION] = False
new[SOUND_DETECTION_PEAK] = -30
new[SOUND_DETECTION_DURATION] = 1
new[SOUND_DETECTION_RESET] = 10
config_entry.data = {**new}
config_entry.version = 6
if config_entry.version == 6:
new = {**config_entry.data}
new[CONF_EXTRA_ARGUMENTS] = ""
config_entry.data = {**new}
config_entry.version = 7
if config_entry.version == 7:
new = {**config_entry.data}
new[CONF_CUSTOM_STREAM] = ""
config_entry.data = {**new}
config_entry.version = 8
if config_entry.version == 8:
new = {**config_entry.data}
new[CONF_RTSP_TRANSPORT] = RTSP_TRANS_PROTOCOLS[0]
config_entry.data = {**new}
config_entry.version = 9
if config_entry.version == 9:
new = {**config_entry.data}
new[ENABLE_WEBHOOKS] = True
config_entry.data = {**new}
config_entry.version = 10
if config_entry.version == 10:
new = {**config_entry.data}
new[ENABLE_MEDIA_SYNC] = False
config_entry.data = {**new}
config_entry.version = 11
if config_entry.version == 11:
new = {**config_entry.data}
new[MEDIA_SYNC_HOURS] = ""
config_entry.data = {**new}
config_entry.version = 12
if config_entry.version == 12:
new = {**config_entry.data}
new[MEDIA_SYNC_COLD_STORAGE_PATH] = ""
config_entry.data = {**new}
config_entry.version = 13
if config_entry.version == 13:
new = {**config_entry.data}
new[MEDIA_VIEW_DAYS_ORDER] = "Ascending"
new[MEDIA_VIEW_RECORDINGS_ORDER] = "Ascending"
config_entry.data = {**new}
config_entry.version = 14
if config_entry.version == 14:
host = config_entry.data.get(CONF_IP_ADDRESS)
username = config_entry.data.get(CONF_USERNAME)
password = config_entry.data.get(CONF_PASSWORD)
cloud_password = config_entry.data.get(CLOUD_PASSWORD)
try:
if cloud_password != "":
tapoController = await hass.async_add_executor_job(
registerController,
host,
443,
"admin",
cloud_password,
cloud_password,
)
else:
tapoController = await hass.async_add_executor_job(
registerController, host, 443, username, password
)
camData = await getCamData(hass, tapoController)
macAddress = camData["basic_info"]["mac"].lower()
@callback
def update_unique_id(entity_entry):
if (
macAddress not in entity_entry.unique_id
and macAddress.replace("-", "_") not in entity_entry.unique_id
):
return {
"new_unique_id": "{}-{}".format(
macAddress, entity_entry.unique_id
).lower()
}
await homeassistant.helpers.entity_registry.async_migrate_entries(
hass, config_entry.entry_id, update_unique_id
)
except Exception as e:
LOGGER.error(
"Unable to connect to Tapo: Cameras Control controller: %s", str(e)
)
if "Invalid authentication data" in str(e):
raise ConfigEntryAuthFailed(e)
elif "Temporary Suspension:" in str(
e
): # keep retrying to authenticate eventually, or throw
# ConfigEntryAuthFailed on invalid auth eventually
raise ConfigEntryNotReady
# Retry for anything else
raise ConfigEntryNotReady
config_entry.version = 15
if config_entry.version == 15:
new = {**config_entry.data}
new[UPDATE_INTERVAL_MAIN] = UPDATE_INTERVAL_MAIN_DEFAULT
new[UPDATE_INTERVAL_BATTERY] = UPDATE_INTERVAL_BATTERY_DEFAULT
hass.config_entries.async_update_entry(config_entry, data=new, version=16)
if config_entry.version == 16:
new = {**config_entry.data}
entry_storage = Store(hass, version=1, key=getEntryStorageFile(config_entry))
if ENABLE_MEDIA_SYNC in new:
await entry_storage.async_save({ENABLE_MEDIA_SYNC: new[ENABLE_MEDIA_SYNC]})
del new[ENABLE_MEDIA_SYNC]
else:
await entry_storage.async_save({ENABLE_MEDIA_SYNC: False})
hass.config_entries.async_update_entry(config_entry, data=new, version=17)
if config_entry.version == 17:
new = {**config_entry.data}
new[CONTROL_PORT] = 443
hass.config_entries.async_update_entry(config_entry, data=new, version=18)
if config_entry.version == 18:
new = {**config_entry.data}
new[CONTROL_PORT] = int(new[CONTROL_PORT])
hass.config_entries.async_update_entry(config_entry, data=new, version=19)
if config_entry.version == 19:
new = {**config_entry.data}
new[IS_KLAP_DEVICE] = False
hass.config_entries.async_update_entry(config_entry, data=new, version=20)
if config_entry.version == 20:
new = {**config_entry.data}
try:
host = config_entry.data.get(CONF_IP_ADDRESS)
controlPort = config_entry.data.get(CONTROL_PORT)
isKlapDevice = config_entry.data.get(IS_KLAP_DEVICE)
cloud_password = config_entry.data.get(CLOUD_PASSWORD)
username = config_entry.data.get(CONF_USERNAME)
password = config_entry.data.get(CONF_PASSWORD)
if cloud_password != "":
LOGGER.debug("Setting up controller using cloud password.")
tapoController = await hass.async_add_executor_job(
registerController,
host,
controlPort,
"admin",
cloud_password,
cloud_password,
"",
None,
isKlapDevice,
hass,
)
else:
LOGGER.debug("Setting up controller using username and password.")
tapoController = await hass.async_add_executor_job(
registerController,
host,
controlPort,
username,
password,
"",
"",
None,
isKlapDevice,
hass,
)
camData = await getCamData(hass, tapoController)
reported_ip_address = getIP(camData)
LOGGER.debug(f"Detected IP: {reported_ip_address}")
new[REPORTED_IP_ADDRESS] = reported_ip_address
hass.config_entries.async_update_entry(
config_entry,
data=new,
version=21,
unique_id=DOMAIN
+ (reported_ip_address if reported_ip_address else host),
)
except Exception as e:
LOGGER.error(
"Unable to connect to Tapo: Cameras Control controller: %s", str(e)
)
if "Invalid authentication data" in str(e):
raise ConfigEntryAuthFailed(e)
elif "Temporary Suspension:" in str(
e
): # keep retrying to authenticate eventually, or throw
# ConfigEntryAuthFailed on invalid auth eventually
raise ConfigEntryNotReady
# Retry for anything else
raise ConfigEntryNotReady
if config_entry.version == 21:
new = {**config_entry.data}
new[TIME_SYNC_DST] = TIME_SYNC_DST_DEFAULT
new[TIME_SYNC_NDST] = TIME_SYNC_NDST_DEFAULT
hass.config_entries.async_update_entry(config_entry, data=new, version=22)
LOGGER.info("Migration to version %s successful", config_entry.version)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.debug("Unloading tapo_control...")
await hass.config_entries.async_unload_platforms(
entry,
[
"binary_sensor",
"sensor",
"button",
"camera",
"light",
"number",
"select",
"siren",
"switch",
"update",
],
)
if hass.data[DOMAIN][entry.entry_id]["events"]:
LOGGER.debug("Stopping events...")
try:
async with asyncio.timeout(3):
await hass.data[DOMAIN][entry.entry_id]["events"].async_stop()
except (asyncio.TimeoutError, ClientError):
LOGGER.warning(
"Timed out waiting for onvif connection to close, proceeding."
)
LOGGER.debug("Events stopped.")
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
LOGGER.debug("async_remove_entry")
entry_id = entry.entry_id
coldDirPath = getColdDirPathForEntry(hass, entry_id)
hotDirPath = getHotDirPathForEntry(hass, entry_id)
entry_storage = Store(hass, version=1, key=getEntryStorageFile(entry))
await entry_storage.async_remove()
# Delete all media stored in cold storage for entity
if coldDirPath:
LOGGER.debug("Deleting cold storage files for entity " + entry_id + "...")
await deleteDir(hass, coldDirPath)
else:
LOGGER.warning(
"No cold storage path found for entity"
+ entry_id
+ ". Not deleting anything."
)
# Delete all media stored in hot storage for entity
if hotDirPath:
LOGGER.debug("Deleting hot storage files for entity " + entry_id + "...")
await deleteDir(hass, hotDirPath)
else:
LOGGER.warning(
"No hot storage path found for entity"
+ entry_id
+ ". Not deleting anything."
)
# Remove the entry data
hass.data[DOMAIN].pop(entry_id, None)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
if PYTAPO_REQUIRED_VERSION != PYTAPO_VERSION:
raise DependencyError(
[
f"Incorrect pytapo version installed: {PYTAPO_VERSION}. Required: {PYTAPO_REQUIRED_VERSION}."
]
)
LOGGER.debug("Starting setup of Tapo: Cameras Control")
"""Set up the Tapo: Cameras Control component from a config entry."""
hass.data.setdefault(DOMAIN, {})
host = entry.data.get(CONF_IP_ADDRESS)
controlPort = entry.data.get(CONTROL_PORT)
username = entry.data.get(CONF_USERNAME)
password = entry.data.get(CONF_PASSWORD)
isKlapDevice = entry.data.get(IS_KLAP_DEVICE)
motionSensor = entry.data.get(ENABLE_MOTION_SENSOR)
enableTimeSync = entry.data.get(ENABLE_TIME_SYNC)
# Disable onvif related capabilities if rtsp data not provided
if len(username) == 0 or len(password) == 0:
motionSensor = False
enableTimeSync = False
cloud_password = entry.data.get(CLOUD_PASSWORD)
updateIntervalMain = entry.data.get(UPDATE_INTERVAL_MAIN)
updateIntervalBattery = entry.data.get(UPDATE_INTERVAL_BATTERY)
timeSyncDST = entry.data.get(TIME_SYNC_DST)
timeSyncNDST = entry.data.get(TIME_SYNC_NDST)
if entry.entry_id not in hass.data[DOMAIN]:
hass.data[DOMAIN][entry.entry_id] = {}
if "setup_retries" not in hass.data[DOMAIN][entry.entry_id]:
hass.data[DOMAIN][entry.entry_id]["setup_retries"] = 0
LOGGER.debug("Checking for HTTPS on HA")
if isUsingHTTPS(hass):
LOGGER.warning(
"Home Assistant is running on HTTPS or it was not able to detect base_url schema. Disabling webhooks."
)
else:
LOGGER.debug("HA is not using HTTPS.")
try:
LOGGER.debug(isKlapDevice)
if cloud_password != "":
LOGGER.debug("Setting up controller using cloud password.")
tapoController = await hass.async_add_executor_job(
registerController,
host,
controlPort,
"admin",
cloud_password,
cloud_password,
"",
None,
isKlapDevice,
hass,
)
else:
LOGGER.debug("Setting up controller using username and password.")
tapoController = await hass.async_add_executor_job(
registerController,
host,
controlPort,
username,
password,
"",
"",
None,
isKlapDevice,
hass,
)
LOGGER.debug("Controller has been set up.")
def getAllEntities(entry):
# Gather all entities, including of children devices
allEntities = entry["entities"].copy()
for childDevice in entry["childDevices"]:
allEntities.extend(childDevice["entities"])
return allEntities
async def async_update_data():
LOGGER.debug("async_update_data - entry")
tapoController = hass.data[DOMAIN][entry.entry_id]["controller"]
host = entry.data.get(CONF_IP_ADDRESS)
username = entry.data.get(CONF_USERNAME)
password = entry.data.get(CONF_PASSWORD)
motionSensor = entry.data.get(ENABLE_MOTION_SENSOR)
enableTimeSync = entry.data.get(ENABLE_TIME_SYNC)
# Disable onvif related capabilities if rtsp data not provided
if len(username) == 0 or len(password) == 0:
motionSensor = False
enableTimeSync = False
ts = datetime.datetime.utcnow().timestamp()
# motion detection retries
if motionSensor or enableTimeSync:
LOGGER.debug("Motion sensor or time sync is enabled.")
if (
not hass.data[DOMAIN][entry.entry_id]["isChild"]
and not hass.data[DOMAIN][entry.entry_id]["isParent"]
):
if (
not hass.data[DOMAIN][entry.entry_id]["eventsDevice"]
or not hass.data[DOMAIN][entry.entry_id]["onvifManagement"]
):
# retry if connection to onvif failed
LOGGER.debug("Setting up subscription to motion sensor...")
onvifDevice = await initOnvifEvents(
hass, host, username, password
)
if onvifDevice:
LOGGER.debug(onvifDevice)
hass.data[DOMAIN][entry.entry_id]["eventsDevice"] = (
onvifDevice["device"]
)
hass.data[DOMAIN][entry.entry_id]["onvifManagement"] = (
onvifDevice["device_mgmt"]
)
if motionSensor:
await setupOnvif(hass, entry)
elif (
not hass.data[DOMAIN][entry.entry_id]["eventsSetup"]
and motionSensor
):
LOGGER.debug(
"Setting up subscription to motion sensor events..."
)
# retry if subscription to events failed
try:
hass.data[DOMAIN][entry.entry_id]["eventsSetup"] = (
await setupEvents(hass, entry)
)
except AssertionError as e:
if str(e) != "PullPoint manager already started":
raise AssertionError(e)
else:
LOGGER.debug("Motion sensor: OK")
else:
LOGGER.debug(
"Not updating motion sensor because device is child or parent."
)
if (
hass.data[DOMAIN][entry.entry_id]["onvifManagement"]
and enableTimeSync
):
if (
ts - hass.data[DOMAIN][entry.entry_id]["lastTimeSync"]
> TIME_SYNC_PERIOD
):
try:
await syncTime(hass, entry.entry_id)
except Exception as e:
LOGGER.error(
f"Failed to sync time for {host}: {e}",
exc_info=True,
)
ts = datetime.datetime.utcnow().timestamp()
else:
debugMsg = "Both motion sensor and time sync are disabled."
if len(username) == 0 or len(password) == 0:
debugMsg += " This is because RTSP username or password is empty."
LOGGER.debug(debugMsg)
if (
ts - hass.data[DOMAIN][entry.entry_id]["lastFirmwareCheck"]
> UPDATE_CHECK_PERIOD
):
LOGGER.debug("Getting latest firmware...")
hass.data[DOMAIN][entry.entry_id]["latestFirmwareVersion"] = (
await getLatestFirmwareVersion(
hass,
entry,
hass.data[DOMAIN][entry.entry_id],
tapoController,
)
)
LOGGER.debug(hass.data[DOMAIN][entry.entry_id]["latestFirmwareVersion"])
for childDevice in hass.data[DOMAIN][entry.entry_id]["childDevices"]:
childDevice["latestFirmwareVersion"] = (
await getLatestFirmwareVersion(
hass,
entry,
hass.data[DOMAIN][entry.entry_id],
childDevice["controller"],
)
)
# cameras state
LOGGER.debug("async_update_data - before someEntityEnabled check")
someEntityEnabled = False
allEntities = getAllEntities(hass.data[DOMAIN][entry.entry_id])
for entity in allEntities:
LOGGER.debug(entity["entity"])
if entity["entity"]._enabled:
LOGGER.debug("async_update_data - enabling someEntityEnabled check")
someEntityEnabled = True
break
if (
someEntityEnabled
and hass.data[DOMAIN][entry.entry_id]["refreshEnabled"]
):
# Update data for all controllers
updateDataForAllControllers = {}
for controller in hass.data[DOMAIN][entry.entry_id]["allControllers"]:
controllerData = getDataForController(hass, entry, controller)
LOGGER.debug(
f"{controllerData['name']} running on battery: {controllerData['isRunningOnBattery']}"
)
if (
controllerData["isRunningOnBattery"] is False
and ts - controllerData["lastUpdate"] > updateIntervalMain
) or (
controllerData["isRunningOnBattery"] is True
and ts - controllerData["lastUpdate"] > updateIntervalBattery
):
timeForAnUpdate = True
LOGGER.debug(f"Updating {controllerData['name']}...")
else:
timeForAnUpdate = False
LOGGER.debug(f"Skipping update for {controllerData['name']}...")
if timeForAnUpdate:
try:
updateDataForAllControllers[controller] = await getCamData(
hass, controller
)
controllerData["isRunningOnBattery"] = (
True
if (
"basic_info"
in updateDataForAllControllers[controller]
and (
(
"power"
in updateDataForAllControllers[controller][
"basic_info"
]
and (
(
updateDataForAllControllers[
controller
]["basic_info"]["power"]
== "BATTERY"
)
or (
updateDataForAllControllers[
controller
]["basic_info"]["power"]
== "SOLAR"
)
)
)
or (
"power_mode"
in updateDataForAllControllers[controller][
"basic_info"
]
and (
(
updateDataForAllControllers[
controller
]["basic_info"]["power_mode"]
== "BATTERY"
)
or (
updateDataForAllControllers[
controller
]["basic_info"]["power_mode"]
== "SOLAR"
)
)
)
)
)
else False
)
controllerData["lastUpdate"] = (
datetime.datetime.utcnow().timestamp()
)
controllerData["reauth_retries"] = 0
except Exception as e:
updateDataForAllControllers[controller] = False
if str(e) == "Invalid authentication data":
if controllerData["reauth_retries"] < 3:
controllerData["reauth_retries"] += 1
raise e
else:
controllerData["refreshEnabled"] = False
raise ConfigEntryAuthFailed(e)
LOGGER.error(e)
if tapoController in updateDataForAllControllers:
hass.data[DOMAIN][entry.entry_id]["camData"] = (
updateDataForAllControllers[tapoController]
)
LOGGER.debug("Updating entities...")
# Gather all entities, including of children devices
allEntities = getAllEntities(hass.data[DOMAIN][entry.entry_id])
for entity in allEntities:
if (
entity["entity"]._enabled
and entity["entry"]["controller"]
in updateDataForAllControllers
):
LOGGER.debug("Updating entity...")
LOGGER.debug(entity["entity"])
entity["camData"] = updateDataForAllControllers[
entity["entry"]["controller"]
]
entity["entity"].updateTapo(
updateDataForAllControllers[
entity["entry"]["controller"]
]
)
entity["entity"].async_schedule_update_ha_state(True)
# start noise detection
if (
not hass.data[DOMAIN][entry.entry_id][
"noiseSensorStarted"
]
and entity["entity"]._is_noise_sensor
and entity["entity"]._enable_sound_detection
):
await entity["entity"].startNoiseDetection()
if ("updateEntity" in hass.data[DOMAIN][entry.entry_id]) and hass.data[
DOMAIN
][entry.entry_id]["updateEntity"]._enabled:
hass.data[DOMAIN][entry.entry_id]["updateEntity"].updateTapo(
camData
)
hass.data[DOMAIN][entry.entry_id][
"updateEntity"
].async_schedule_update_ha_state(True)
if (
ts - hass.data[DOMAIN][entry.entry_id]["lastMediaCleanup"]
> MEDIA_CLEANUP_PERIOD
):
LOGGER.debug(
"Initiating media cleanup for "
+ hass.data[DOMAIN][entry.entry_id]["name"]
+ "..."
)
await mediaCleanup(hass, entry, hass.data[DOMAIN][entry.entry_id])
if hass.data[DOMAIN][entry.entry_id]["isParent"]:
for child in hass.data[DOMAIN][entry.entry_id]["childDevices"]:
if ts - child["lastMediaCleanup"] > MEDIA_CLEANUP_PERIOD:
LOGGER.debug(
"Initiating media cleanup for " + child["name"] + "..."
)
await mediaCleanup(hass, entry, child)
if hass.is_running:
await scheduleAll(
hass, hass.data[DOMAIN][entry.entry_id], entry, mediaSync
)
if hass.data[DOMAIN][entry.entry_id]["isParent"]:
for child in hass.data[DOMAIN][entry.entry_id]["childDevices"]:
await scheduleAll(hass, child, entry, mediaSync)
LOGGER.debug("Setting up data update coordinator.")
tapoCoordinator = DataUpdateCoordinator(
hass,
LOGGER,
name="Tapo resource status",
update_method=async_update_data,
)
LOGGER.debug("Retrieving initial device data.")
camData = await getCamData(hass, tapoController)
LOGGER.debug("Retrieved initial device data.")
LOGGER.debug("Retrieving camera time.")
cameraTime = await hass.async_add_executor_job(tapoController.getTime)
if not tapoController.isKLAP:
cameraTS = cameraTime["system"]["clock_status"]["seconds_from_1970"]
else:
cameraTS = cameraTime["timestamp"]
LOGGER.debug("Retrieved camera time.")
currentTS = dt.as_timestamp(dt.now())
timezoneOffset = cameraTS - currentTS
LOGGER.debug(f"Timezone offset is {timezoneOffset}.")
LOGGER.debug("Setting up entry data.")
hass.data[DOMAIN][entry.entry_id] = {
"setup_retries": 0,
"reauth_retries": 0,
"runningMediaSync": False,
TIME_SYNC_DST: timeSyncDST,
TIME_SYNC_NDST: timeSyncNDST,
"controller": tapoController,
"entry": entry,
"usingCloudPassword": cloud_password != "",
"allControllers": [tapoController],
"update_listener": entry.add_update_listener(update_listener),
"coordinator": tapoCoordinator,
"camData": camData,
"lastTimeSync": 0,
"lastMediaCleanup": 0,
"lastUpdate": 0,
"lastFirmwareCheck": 0,
"latestFirmwareVersion": False,
"mediaSyncColdDir": False,
"mediaSyncHotDir": False,
"motionSensorCreated": False,
"eventsDevice": False,
"onvifManagement": False,
"eventsSetup": False,
"events": False,
"eventsListener": False,
"entities": [],
"noiseSensorStarted": False,
"name": camData["basic_info"]["device_alias"],
"childDevices": [],
"isRunningOnBattery": (
True
if (
"basic_info" in camData
and "power" in camData["basic_info"]
and camData["basic_info"]["power"] == "BATTERY"
)
else False
),
"isChild": False,
"uuid": hashlib.md5(
(
str(host) + str(username) + str(password) + str(cloud_password)
).encode()
).hexdigest(),
"isParent": False,
"isDownloadingStream": False,
"downloadedStreams": {}, # keeps track of all videos downloaded
"downloadProgress": False,
"initialMediaScanDone": False,
ENABLE_MEDIA_SYNC: None,
"mediaSyncScheduled": False,
"mediaSyncRanOnce": False,
"mediaSyncAvailable": True,
"initialMediaScanRunning": False,
"mediaScanResult": {}, # keeps track of all videos currently on camera
"timezoneOffset": timezoneOffset,
"refreshEnabled": True,
}
LOGGER.debug("Entry data has been set up.")
if tapoController.isKLAP is False:
LOGGER.debug("Controller is not KLAP device.")
if not (
camData["childDevices"] is False or camData["childDevices"] is None
):
LOGGER.debug("Device is a parent.")
hass.data[DOMAIN][entry.entry_id]["isParent"] = True
for childDevice in camData["childDevices"]["child_device_list"]:
LOGGER.debug("Setting up child controller.")
tapoChildController = await hass.async_add_executor_job(
registerController,
host,
controlPort,
"admin",
cloud_password,
cloud_password,
"",
childDevice["device_id"],
)
LOGGER.debug("Child controller set up.")
hass.data[DOMAIN][entry.entry_id]["allControllers"].append(
tapoChildController
)
LOGGER.debug("Getting initial child device data.")
childCamData = await getCamData(hass, tapoChildController)
LOGGER.debug("Retrieved initial child device data.")
hass.data[DOMAIN][entry.entry_id]["childDevices"].append(
{
"controller": tapoChildController,
"coordinator": tapoCoordinator,
"entry": entry,
"usingCloudPassword": cloud_password != "",
"timezoneOffset": hass.data[DOMAIN][entry.entry_id][
"timezoneOffset"
],
"camData": childCamData,
"lastTimeSync": 0,
"lastMediaCleanup": 0,
"lastUpdate": 0,
"lastFirmwareCheck": 0,
"latestFirmwareVersion": False,
"motionSensorCreated": False,
"isDownloadingStream": False,
"downloadedStreams": {}, # keeps track of all videos downloaded
"downloadProgress": False,
"initialMediaScanDone": False,
ENABLE_MEDIA_SYNC: None,
"mediaSyncScheduled": False,
"mediaSyncRanOnce": False,
"mediaSyncAvailable": True,
"initialMediaScanRunning": False,
"runningMediaSync": False,
"mediaScanResult": {}, # keeps track of all videos currently on camera
"entities": [],
"name": childCamData["basic_info"]["device_alias"],
"childDevices": [],
"isChild": True,
"isRunningOnBattery": (
True
if (
"basic_info" in childCamData
and "power" in childCamData["basic_info"]
and childCamData["basic_info"]["power"] == "BATTERY"
)
else False
),
"isParent": False,
}
)
LOGGER.debug("Setting up camera entities.")
await hass.async_create_task(
hass.config_entries.async_forward_entry_setups(entry, ["camera"])
)
LOGGER.debug("Setting up entities.")
await hass.async_create_task(
hass.config_entries.async_forward_entry_setups(
entry,
[
"switch",
"button",
"light",
"number",
"select",
"siren",
"update",
"binary_sensor",
"sensor",
],
)
)
LOGGER.debug("Entities set up.")
# Needs to execute AFTER binary_sensor creation!
if (
tapoController.isKLAP is False
and camData["childDevices"] is None
and (motionSensor or enableTimeSync)
):
onvifDevice = await initOnvifEvents(hass, host, username, password)
hass.data[DOMAIN][entry.entry_id]["eventsDevice"] = onvifDevice["device"]
hass.data[DOMAIN][entry.entry_id]["onvifManagement"] = onvifDevice[
"device_mgmt"
]
if motionSensor:
LOGGER.debug("Setting up motion sensor for the first time.")
await setupOnvif(hass, entry)
else:
debugMsg = "Motion sensor is disabled."
if len(username) == 0 or len(password) == 0:
debugMsg += " This is because RTSP username or password is empty."
LOGGER.debug(debugMsg)
if enableTimeSync:
try:
await syncTime(hass, entry.entry_id)
except Exception as e:
LOGGER.error(
f"Failed to sync time for {host}: {e}",
exc_info=True,
)
# Media sync
timeCorrection = await hass.async_add_executor_job(
tapoController.getTimeCorrection
)
# todo move to utils
async def mediaSync(now, entry, device):
LOGGER.debug("mediaSync")
device["mediaSyncRanOnce"] = True
enableMediaSync = device[ENABLE_MEDIA_SYNC]
mediaSyncHours = entry.data.get(MEDIA_SYNC_HOURS)
LOGGER.debug("mediaSync - 2")
if mediaSyncHours == "":
mediaSyncTime = False
else:
mediaSyncTime = (int(mediaSyncHours) * 60 * 60) + timeCorrection
LOGGER.debug("mediaSync - 3")
if (
enableMediaSync
and entry.entry_id in hass.data[DOMAIN]
and "controller" in device
and device["runningMediaSync"] is False
and device["isDownloadingStream"]
is False # prevent breaking user manual upload
):
LOGGER.debug("Running media sync for " + device["name"] + "...")
device["runningMediaSync"] = True
try:
tapoController: Tapo = device["controller"]
LOGGER.debug("getRecordingsList -1")
recordingsList = await hass.async_add_executor_job(
tapoController.getRecordingsList
)
LOGGER.debug("getRecordingsList -2")
ts = datetime.datetime.utcnow().timestamp()
for searchResult in recordingsList:
for key in searchResult:
LOGGER.debug("inside for - 1")
enableMediaSync = device[ENABLE_MEDIA_SYNC]
LOGGER.debug("inside for - 2")
if enableMediaSync and (
(mediaSyncTime is False)
or (
(
mediaSyncTime is not False
and (
(int(ts) - (int(mediaSyncTime) + 86400))
< convert_to_timestamp(
searchResult[key]["date"]
)
)
)
)
):
LOGGER.debug("getRecordings -1")
recordingsForDay = await getRecordings(
hass,
device,
tapoController,
searchResult[key]["date"],
)
LOGGER.debug("getRecordings -2")
totalRecordingsToDownload = 0
for recording in recordingsForDay:
for recordingKey in recording:
if recording[recordingKey]["endTime"] > int(
ts
) - (int(mediaSyncTime)):
totalRecordingsToDownload += 1
recordingCount = 0
for recording in recordingsForDay:
for recordingKey in recording:
if recording[recordingKey]["endTime"] > (
int(ts) - (int(mediaSyncTime))
):
recordingCount += 1
try:
enableMediaSync = device[
ENABLE_MEDIA_SYNC
]
if enableMediaSync:
LOGGER.debug("getRecording -1")
await getRecording(
hass,
tapoController,
entry.entry_id,
device,
searchResult[key]["date"],
recording[recordingKey][
"startTime"
],
recording[recordingKey][
"endTime"
],
recordingCount,
totalRecordingsToDownload,
)
LOGGER.debug("getRecording -2")
else:
LOGGER.debug(
f"Media sync disabled (inside getRecording): {enableMediaSync}"
)
except Unresolvable as err:
if (
str(err)
== "Recording is currently in progress."
):
LOGGER.info(err)
else:
LOGGER.warning(err)
except Exception as err:
device["runningMediaSync"] = False
LOGGER.error(err)
else:
LOGGER.debug(
f"Media sync ignoring {searchResult[key]["date"]}. Media sync: {enableMediaSync}."
)
except Exception as err:
LOGGER.error(err)
LOGGER.debug("runningMediaSync -false")
device["runningMediaSync"] = False
else:
LOGGER.debug(
f"Media sync for {device["name"]} disabled (inside mediaSync): {enableMediaSync}"
)
async def unsubscribe(event):
if hass.data[DOMAIN][entry.entry_id]["events"]:
await hass.data[DOMAIN][entry.entry_id]["events"].async_stop()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsubscribe)
except Exception as e:
if "Invalid authentication data" in str(e):
if hass.data[DOMAIN][entry.entry_id]["setup_retries"] < 3:
hass.data[DOMAIN][entry.entry_id]["setup_retries"] += 1
raise ConfigEntryNotReady(e)
raise ConfigEntryAuthFailed(e)
else:
if "Temporary Suspension:" in str(
e
): # keep retrying to authenticate eventually, or throw
# ConfigEntryAuthFailed on invalid auth eventually
raise ConfigEntryNotReady(e)
# Retry for anything else
LOGGER.error(
"Unable to connect to Tapo: Cameras Control controller: %s", str(e)
)
raise ConfigEntryNotReady(e)
return True