diff --git a/custom_components/imagedirectory/__init__.py b/custom_components/imagedirectory/__init__.py index 7d6747b..c7bc820 100644 --- a/custom_components/imagedirectory/__init__.py +++ b/custom_components/imagedirectory/__init__.py @@ -1,11 +1,10 @@ - import logging -#libraries for homeassistant setup service +# libraries for homeassistant setup service import voluptuous as vol import homeassistant.helpers.config_validation as cv -#libraries need for custom code +# libraries need for custom code from PIL import Image import os import shutil @@ -13,201 +12,258 @@ import datetime import imageio -DOMAIN ='imagedirectory' +DOMAIN = "imagedirectory" _LOGGER = logging.getLogger(__name__) from homeassistant.const import CONF_EXCLUDE -SERVICE_CREATE = 'create_gif_mp4' -SERVICE_DEL= 'delete_files' -SERVICE_MOVE= 'move_files' - -SERVICE_PARAM_SOURCE='sourcepath' -SERVICE_PARAM_DESTINATION='destinationpath' -SERVICE_PARAM_FILENAME='filename' -SERVICE_PARAM_FORMAT='format' -SERVICE_PARAM_EXCLUDE=CONF_EXCLUDE -SERVICE_PARAM_BEGINTIME='begintimestamp' -SERVCE_PARAM_ENDTIME='endtimestamp' -SERVICE_PARAM_LASTHOURS='lasthours' -EPOCH_START='01/01/1970 00:00:00' -EPOCH_END='31/12/2037 23:59:59' - -SNAPTOGIF_CREATE_SCHEMA = vol.Schema( +SERVICE_CREATE = "create_gif_mp4" +SERVICE_DEL = "delete_files" +SERVICE_MOVE = "move_files" + +SERVICE_PARAM_SOURCE = "sourcepath" +SERVICE_PARAM_DESTINATION = "destinationpath" +SERVICE_PARAM_FILENAME = "filename" +SERVICE_PARAM_FORMAT = "format" +SERVICE_PARAM_EXCLUDE = CONF_EXCLUDE +SERVICE_PARAM_BEGINTIME = "begintimestamp" +SERVCE_PARAM_ENDTIME = "endtimestamp" +SERVICE_PARAM_LASTHOURS = "lasthours" +EPOCH_START = "01/01/1970 00:00:00" +EPOCH_END = "31/12/2037 23:59:59" + +SNAPTOGIF_CREATE_SCHEMA = vol.Schema( { vol.Required(SERVICE_PARAM_SOURCE): cv.isdir, - vol.Required(SERVICE_PARAM_DESTINATION): cv.isdir, - vol.Optional(SERVICE_PARAM_FILENAME,default='latest'):cv.matches_regex(r'^[^<>:;,.?"*|/\\]+$'), - vol.Optional(SERVICE_PARAM_FORMAT,default='gif'):vol.In(['gif','mp4']), - vol.Optional(SERVICE_PARAM_EXCLUDE,default=[]):cv.ensure_list_csv, - vol.Optional(SERVICE_PARAM_BEGINTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(SERVCE_PARAM_ENDTIME,default=EPOCH_END):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(SERVICE_PARAM_LASTHOURS, default=0.0):cv.positive_float, + vol.Required(SERVICE_PARAM_DESTINATION): cv.isdir, + vol.Optional(SERVICE_PARAM_FILENAME, default="latest"): cv.matches_regex( + r'^[^<>:;,.?"*|/\\]+$' + ), + vol.Optional(SERVICE_PARAM_FORMAT, default="gif"): vol.In(["gif", "mp4"]), + vol.Optional(SERVICE_PARAM_EXCLUDE, default=[]): cv.ensure_list_csv, + vol.Optional(SERVICE_PARAM_BEGINTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(SERVCE_PARAM_ENDTIME, default=EPOCH_END): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(SERVICE_PARAM_LASTHOURS, default=0.0): cv.positive_float, } - ) +) -SNAPTOGIF_DEL_SCHEMA = vol.Schema( +SNAPTOGIF_DEL_SCHEMA = vol.Schema( { vol.Required(SERVICE_PARAM_SOURCE): cv.isdir, - vol.Optional(SERVICE_PARAM_EXCLUDE,default=[]):cv.ensure_list_csv, - vol.Optional(SERVICE_PARAM_BEGINTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(SERVCE_PARAM_ENDTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(SERVICE_PARAM_LASTHOURS, default=0.0):cv.positive_float, + vol.Optional(SERVICE_PARAM_EXCLUDE, default=[]): cv.ensure_list_csv, + vol.Optional(SERVICE_PARAM_BEGINTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(SERVCE_PARAM_ENDTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(SERVICE_PARAM_LASTHOURS, default=0.0): cv.positive_float, } - ) -SNAPTOGIF_MOVE_SCHEMA = vol.Schema( +) +SNAPTOGIF_MOVE_SCHEMA = vol.Schema( { vol.Required(SERVICE_PARAM_SOURCE): cv.isdir, - vol.Required(SERVICE_PARAM_DESTINATION): cv.string, - vol.Optional(SERVICE_PARAM_EXCLUDE,default=[]):cv.ensure_list_csv, - vol.Optional(SERVICE_PARAM_BEGINTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(SERVCE_PARAM_ENDTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(SERVICE_PARAM_LASTHOURS, default=0.0):cv.positive_float, + vol.Required(SERVICE_PARAM_DESTINATION): cv.string, + vol.Optional(SERVICE_PARAM_EXCLUDE, default=[]): cv.ensure_list_csv, + vol.Optional(SERVICE_PARAM_BEGINTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(SERVCE_PARAM_ENDTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(SERVICE_PARAM_LASTHOURS, default=0.0): cv.positive_float, } - ) - -def Getfileslist(path,exclude,begintime,endtime,extensions,lasthours=0.0): - - def GetTimestampFile(path,file): - return (os.path.getmtime(os.path.join(path, file))) - - #get files in source path - files=os.listdir(path) - #only files with selected extensions and filter out the excludelist - files=[file for file in files if any(x in file for x in extensions ) and file not in exclude] - - #convert timestrings to epoch time - BeginTimeStamp=time.mktime(datetime.datetime.strptime(begintime, "%d/%m/%Y %H:%M:%S").timetuple()) - EndTimeStamp=time.mktime(datetime.datetime.strptime(endtime, "%d/%m/%Y %H:%M:%S").timetuple()) - - #filter files between timestamps - files=[file for file in files if GetTimestampFile(path,file)>=BeginTimeStamp and GetTimestampFile(path,file)<=EndTimeStamp ] - - #sort images on modified date - files.sort(key=lambda x:os.path.getmtime(os.path.join(path, x))) - - #only last xx hours filtering active - if lasthours>0.0 and len(files)>1: - #timestamp latest file in selected range - latest=GetTimestampFile(path,files[-1]) - #Get images defined by lasthours from latest file - files=[file for file in files if GetTimestampFile(path,file)>=(latest-(lasthours*3600)) and GetTimestampFile(path,file)<=EndTimeStamp ] - return files - -def createOutputfile(hass,call,files): - #convert selected range to selected format - inputfolder=call.data[SERVICE_PARAM_SOURCE] - outputfile=f'{call.data[SERVICE_PARAM_FILENAME]}.{call.data[SERVICE_PARAM_FORMAT]}' - outputfolder=call.data[SERVICE_PARAM_DESTINATION] - try: - #sort images on modified date - files.sort(key=lambda x:os.path.getmtime(os.path.join(inputfolder, x))) - #convert frames to destination format (GIF/MP3) - writer = imageio.get_writer(os.path.join(outputfolder,outputfile),mode='I',fps=1) - for file in files: - writer.append_data(imageio.imread(os.path.join(inputfolder,file))) - writer.close() - - _LOGGER.info(f'{outputfile} succesfully generated in: {outputfolder}') - eventdata={ 'type': SERVICE_CREATE, - 'file': outputfile, - 'destinationpath': outputfolder, - 'begintimestamp': call.data[SERVICE_PARAM_BEGINTIME], - 'endtimestamp': call.data[SERVCE_PARAM_ENDTIME], - 'no_files': len(files), - 'sourcepath': inputfolder, - 'sourcefiles': files - } - hass.bus.fire(DOMAIN, eventdata) - except Exception as e: - _LOGGER.warning(f"Not able to store {outputfile} on given destination: {outputfolder} error:{str(e)}") - -def deletefiles(hass,call,files): - #remove selected files - inputfolder=call.data[SERVICE_PARAM_SOURCE] - try: - for file in files: - os.remove(os.path.join(inputfolder,file)) - _LOGGER.info(f'Files succesfully removed from: {inputfolder}') - eventdata={ 'type': SERVICE_DEL, - 'begintimestamp': call.data[SERVICE_PARAM_BEGINTIME], - 'endTtimestamp': call.data[SERVCE_PARAM_ENDTIME], - 'no_files': len(files), - 'sourcepath': inputfolder, - 'sourcefiles': files - } - hass.bus.fire(DOMAIN, eventdata) - except Exception as e: - _LOGGER.warning(f"Error deleting selected files on given destination: {inputfolder}\nerror:{str(e)}") - -def movefiles(hass,call,files): - #move selected files - inputfolder=call.data[SERVICE_PARAM_SOURCE] - outputfolder=call.data[SERVICE_PARAM_DESTINATION] - try: - #create directory if not exist - if not os.path.exists(outputfolder): - os.makedirs(outputfolder) - for file in files: - shutil.move(os.path.join(inputfolder,file),outputfolder) - _LOGGER.info(f'Files succesfully moved from: {inputfolder} to {outputfolder}') - eventdata={ 'type': SERVICE_MOVE, - 'begintimestamp': call.data[SERVICE_PARAM_BEGINTIME], - 'endtimestamp': call.data[SERVCE_PARAM_ENDTIME], - 'no_files': len(files), - 'sourcepath': inputfolder, - 'destinationpath': outputfolder, - 'sourcefiles': files - } - hass.bus.fire(DOMAIN, eventdata) - except Exception as e: - _LOGGER.warning(f"Error moveing selected files on given source: {inputfolder} to destination: {outputfolder}\nerror:{str(e)}") +) + + +def Getfileslist(path, exclude, begintime, endtime, extensions, lasthours=0.0): + def GetTimestampFile(path, file): + return os.path.getmtime(os.path.join(path, file)) + + # get files in source path + files = os.listdir(path) + # only files with selected extensions and filter out the excludelist + files = [ + file + for file in files + if any(x in file for x in extensions) and file not in exclude + ] + + # convert timestrings to epoch time + BeginTimeStamp = time.mktime( + datetime.datetime.strptime(begintime, "%d/%m/%Y %H:%M:%S").timetuple() + ) + EndTimeStamp = time.mktime( + datetime.datetime.strptime(endtime, "%d/%m/%Y %H:%M:%S").timetuple() + ) + + # filter files between timestamps + files = [ + file + for file in files + if GetTimestampFile(path, file) >= BeginTimeStamp + and GetTimestampFile(path, file) <= EndTimeStamp + ] + + # sort images on modified date + files.sort(key=lambda x: os.path.getmtime(os.path.join(path, x))) + + # only last xx hours filtering active + if lasthours > 0.0 and len(files) > 1: + # timestamp latest file in selected range + latest = GetTimestampFile(path, files[-1]) + # Get images defined by lasthours from latest file + files = [ + file + for file in files + if GetTimestampFile(path, file) >= (latest - (lasthours * 3600)) + and GetTimestampFile(path, file) <= EndTimeStamp + ] + return files + + +def createOutputfile(hass, call, files): + # convert selected range to selected format + inputfolder = call.data[SERVICE_PARAM_SOURCE] + outputfile = ( + f"{call.data[SERVICE_PARAM_FILENAME]}.{call.data[SERVICE_PARAM_FORMAT]}" + ) + outputfolder = call.data[SERVICE_PARAM_DESTINATION] + try: + # sort images on modified date + files.sort(key=lambda x: os.path.getmtime(os.path.join(inputfolder, x))) + # convert frames to destination format (GIF/MP3) + writer = imageio.get_writer( + os.path.join(outputfolder, outputfile), mode="I", fps=1 + ) + for file in files: + writer.append_data(imageio.imread(os.path.join(inputfolder, file))) + writer.close() + + _LOGGER.info(f"{outputfile} succesfully generated in: {outputfolder}") + eventdata = { + "type": SERVICE_CREATE, + "file": outputfile, + "destinationpath": outputfolder, + "begintimestamp": call.data[SERVICE_PARAM_BEGINTIME], + "endtimestamp": call.data[SERVCE_PARAM_ENDTIME], + "no_files": len(files), + "sourcepath": inputfolder, + "sourcefiles": files, + } + hass.bus.fire(DOMAIN, eventdata) + except Exception as e: + _LOGGER.warning( + f"Not able to store {outputfile} on given destination: {outputfolder} error:{str(e)}" + ) + + +def deletefiles(hass, call, files): + # remove selected files + inputfolder = call.data[SERVICE_PARAM_SOURCE] + try: + for file in files: + os.remove(os.path.join(inputfolder, file)) + _LOGGER.info(f"Files succesfully removed from: {inputfolder}") + eventdata = { + "type": SERVICE_DEL, + "begintimestamp": call.data[SERVICE_PARAM_BEGINTIME], + "endTtimestamp": call.data[SERVCE_PARAM_ENDTIME], + "no_files": len(files), + "sourcepath": inputfolder, + "sourcefiles": files, + } + hass.bus.fire(DOMAIN, eventdata) + except Exception as e: + _LOGGER.warning( + f"Error deleting selected files on given destination: {inputfolder}\nerror:{str(e)}" + ) + + +def movefiles(hass, call, files): + # move selected files + inputfolder = call.data[SERVICE_PARAM_SOURCE] + outputfolder = call.data[SERVICE_PARAM_DESTINATION] + try: + # create directory if not exist + if not os.path.exists(outputfolder): + os.makedirs(outputfolder) + for file in files: + shutil.move(os.path.join(inputfolder, file), outputfolder) + _LOGGER.info(f"Files succesfully moved from: {inputfolder} to {outputfolder}") + eventdata = { + "type": SERVICE_MOVE, + "begintimestamp": call.data[SERVICE_PARAM_BEGINTIME], + "endtimestamp": call.data[SERVCE_PARAM_ENDTIME], + "no_files": len(files), + "sourcepath": inputfolder, + "destinationpath": outputfolder, + "sourcefiles": files, + } + hass.bus.fire(DOMAIN, eventdata) + except Exception as e: + _LOGGER.warning( + f"Error moveing selected files on given source: {inputfolder} to destination: {outputfolder}\nerror:{str(e)}" + ) + def setup(hass, config): - #Set up is called when Home Assistant is loading our component. - - def GetTimestampFile(path,file): - return (os.path.getmtime(os.path.join(path, file))) - - def Imagedirectory_Services(call): - - #get files in source path - folder=call.data[SERVICE_PARAM_SOURCE] - files=os.listdir(folder) - - 'Allowed extentions for Servive start are jpg or png, for service move and delete also the possibile output extensions (gif, mp4) are allowed ' - if call.service==SERVICE_CREATE: - ext=['.jpg','.png'] - else: - ext=['.jpg','.png','.mp4','gif'] - - #get files in source path and use the defined critera to filter the list - files=Getfileslist(call.data[SERVICE_PARAM_SOURCE],call.data[SERVICE_PARAM_EXCLUDE],call.data[SERVICE_PARAM_BEGINTIME], - call.data[SERVCE_PARAM_ENDTIME],ext,call.data[SERVICE_PARAM_LASTHOURS]) - - _LOGGER.debug(f'No of images/files found for operation {len(files)}') - - #Call the corresponding service - if len(files)>0: - if call.service==SERVICE_CREATE: - createOutputfile(hass,call,files) - elif call.service==SERVICE_DEL: - deletefiles(hass,call,files) - elif call.service==SERVICE_MOVE: - movefiles(hass,call,files) - else: - _LOGGER.warning(f"No files found in the specified time range: [{call.data[SERVICE_PARAM_BEGINTIME]} , {call.data[SERVCE_PARAM_ENDTIME]}] in :{folder}") - - #register services to homeassistant - hass.services.register( - DOMAIN, SERVICE_CREATE, Imagedirectory_Services, - schema=SNAPTOGIF_CREATE_SCHEMA) - hass.services.register( - DOMAIN, SERVICE_DEL, Imagedirectory_Services, - schema=SNAPTOGIF_DEL_SCHEMA) - hass.services.register( - DOMAIN, SERVICE_MOVE, Imagedirectory_Services, - schema=SNAPTOGIF_MOVE_SCHEMA) - # Return boolean to indicate that initialization was successfully. - return True + # Set up is called when Home Assistant is loading our component. + + def GetTimestampFile(path, file): + return os.path.getmtime(os.path.join(path, file)) + + def Imagedirectory_Services(call): + + # get files in source path + folder = call.data[SERVICE_PARAM_SOURCE] + files = os.listdir(folder) + + "Allowed extentions for Servive start are jpg or png, for service move and delete also the possibile output extensions (gif, mp4) are allowed " + if call.service == SERVICE_CREATE: + ext = [".jpg", ".png"] + else: + ext = [".jpg", ".png", ".mp4", "gif"] + + # get files in source path and use the defined critera to filter the list + files = Getfileslist( + call.data[SERVICE_PARAM_SOURCE], + call.data[SERVICE_PARAM_EXCLUDE], + call.data[SERVICE_PARAM_BEGINTIME], + call.data[SERVCE_PARAM_ENDTIME], + ext, + call.data[SERVICE_PARAM_LASTHOURS], + ) + + _LOGGER.debug(f"No of images/files found for operation {len(files)}") + + # Call the corresponding service + if len(files) > 0: + if call.service == SERVICE_CREATE: + createOutputfile(hass, call, files) + elif call.service == SERVICE_DEL: + deletefiles(hass, call, files) + elif call.service == SERVICE_MOVE: + movefiles(hass, call, files) + else: + _LOGGER.warning( + f"No files found in the specified time range: [{call.data[SERVICE_PARAM_BEGINTIME]} , {call.data[SERVCE_PARAM_ENDTIME]}] in :{folder}" + ) + + # register services to homeassistant + hass.services.register( + DOMAIN, SERVICE_CREATE, Imagedirectory_Services, schema=SNAPTOGIF_CREATE_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_DEL, Imagedirectory_Services, schema=SNAPTOGIF_DEL_SCHEMA + ) + hass.services.register( + DOMAIN, SERVICE_MOVE, Imagedirectory_Services, schema=SNAPTOGIF_MOVE_SCHEMA + ) + # Return boolean to indicate that initialization was successfully. + return True diff --git a/custom_components/imagedirectory/camera.py b/custom_components/imagedirectory/camera.py index 61db2e7..6f4307a 100644 --- a/custom_components/imagedirectory/camera.py +++ b/custom_components/imagedirectory/camera.py @@ -1,4 +1,6 @@ """Camera that loads pictures from a local directory""" +import asyncio +from aiohttp import web import logging import mimetypes import os @@ -15,26 +17,49 @@ Camera, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_EXCLUDE, CONF_DELAY_TIME -from . import DOMAIN,EPOCH_START,EPOCH_END,SERVICE_PARAM_SOURCE,SERVICE_PARAM_BEGINTIME,SERVCE_PARAM_ENDTIME,SERVICE_PARAM_LASTHOURS +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_NAME, + CONF_EXCLUDE, + CONF_DELAY_TIME, + SERVICE_TOGGLE, + CONTENT_TYPE_MULTIPART, + STATE_PAUSED, +) +from . import ( + DOMAIN, + EPOCH_START, + EPOCH_END, + SERVICE_PARAM_SOURCE, + SERVICE_PARAM_BEGINTIME, + SERVCE_PARAM_ENDTIME, + SERVICE_PARAM_LASTHOURS, +) from homeassistant.helpers import config_validation as cv from . import Getfileslist -#camera service -SERVICE_UPDATE_IMAGE_FILELIST= "camera_update_image_filelist" -SERVICE_CLEAR_IMAGE_FILELIST='camera_clear_image_filelist' +# camera service +SERVICE_UPDATE_IMAGE_FILELIST = "camera_update_image_filelist" +SERVICE_CLEAR_IMAGE_FILELIST = "camera_clear_image_filelist" +SERVICE_TOGGLE_PAUSE = "Camera_toggle_pause" +SERVICE_NEXT = "Camera_next_image" +SERVICE_PREV = "Camera_prev_image" -#camera config parameters +# camera config parameters CONF_DATA_LOCAL_FILE = "local_filelist_cameras" CONF_DEFAULT_NAME = "Local Filelist" CONF_PATH = SERVICE_PARAM_SOURCE -CONF_PARAM_BEGINTIME=SERVICE_PARAM_BEGINTIME -CONF_PARAM_ENDTIME=SERVCE_PARAM_ENDTIME -CONF_PARAM_LASTHOURS=SERVICE_PARAM_LASTHOURS +CONF_PARAM_BEGINTIME = SERVICE_PARAM_BEGINTIME +CONF_PARAM_ENDTIME = SERVCE_PARAM_ENDTIME +CONF_PARAM_LASTHOURS = SERVICE_PARAM_LASTHOURS + +# camera states +STATE_STREAMING = "streaming" -ALLOWED_EXT=[".jpg",".png"] + +ALLOWED_EXT = [".jpg", ".png"] _LOGGER = logging.getLogger(__name__) @@ -42,80 +67,182 @@ { vol.Required(CONF_PATH): cv.isdir, vol.Optional(CONF_NAME, default=CONF_DEFAULT_NAME): cv.string, - vol.Optional(CONF_DELAY_TIME, default=1.0):cv.positive_float, - vol.Optional(CONF_EXCLUDE,default=[]): cv.ensure_list, - vol.Optional(CONF_PARAM_BEGINTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(CONF_PARAM_ENDTIME,default=EPOCH_END):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(CONF_PARAM_LASTHOURS, default=0.0):cv.positive_float, - + vol.Optional(CONF_DELAY_TIME, default=1.0): cv.positive_float, + vol.Optional(CONF_EXCLUDE, default=[]): cv.ensure_list, + vol.Optional(CONF_PARAM_BEGINTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(CONF_PARAM_ENDTIME, default=EPOCH_END): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(CONF_PARAM_LASTHOURS, default=0.0): cv.positive_float, } ) CAMERA_SERVICE_IMAGE_FILELIST = CAMERA_SERVICE_SCHEMA.extend( { vol.Required(CONF_PATH): cv.isdir, - vol.Optional(CONF_EXCLUDE,default=[]): cv.ensure_list, - vol.Optional(CONF_PARAM_BEGINTIME,default=EPOCH_START):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(CONF_PARAM_ENDTIME,default=EPOCH_END):cv.matches_regex(r'[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]'), - vol.Optional(CONF_PARAM_LASTHOURS, default=0.0):cv.positive_float, + vol.Optional(CONF_EXCLUDE, default=[]): cv.ensure_list, + vol.Optional(CONF_PARAM_BEGINTIME, default=EPOCH_START): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(CONF_PARAM_ENDTIME, default=EPOCH_END): cv.matches_regex( + r"[0-3][0-9]/[0-1][0-9]/\d{4} [0-2][0-9]:[0-5][0-9]:[0-5][0-9]" + ), + vol.Optional(CONF_PARAM_LASTHOURS, default=0.0): cv.positive_float, } ) + def setup_platform(hass, config, add_entities, discovery_info=None): - + """Set up the Camera that works with local files.""" if CONF_DATA_LOCAL_FILE not in hass.data: hass.data[CONF_DATA_LOCAL_FILE] = [] path = config[CONF_PATH] - delaytime= config[CONF_DELAY_TIME] - exclude=config[CONF_EXCLUDE] - begintime=config[CONF_PARAM_BEGINTIME] - endtime=config[CONF_PARAM_ENDTIME] - lasthours=config[CONF_PARAM_LASTHOURS] - camera = LocalFile(config[CONF_NAME], path,delaytime,exclude,begintime,endtime,lasthours) + delaytime = config[CONF_DELAY_TIME] + exclude = config[CONF_EXCLUDE] + begintime = config[CONF_PARAM_BEGINTIME] + endtime = config[CONF_PARAM_ENDTIME] + lasthours = config[CONF_PARAM_LASTHOURS] + camera = LocalFile( + config[CONF_NAME], path, delaytime, exclude, begintime, endtime, lasthours + ) hass.data[CONF_DATA_LOCAL_FILE].append(camera) - def update_image_filelist_service(call): """Update the image files list""" path = call.data[CONF_PATH] entity_ids = call.data[ATTR_ENTITY_ID] - exclude=call.data[CONF_EXCLUDE] - begintime=call.data[CONF_PARAM_BEGINTIME] - endtime=call.data[CONF_PARAM_ENDTIME] - lasthours=call.data[CONF_PARAM_LASTHOURS] + exclude = call.data[CONF_EXCLUDE] + begintime = call.data[CONF_PARAM_BEGINTIME] + endtime = call.data[CONF_PARAM_ENDTIME] + lasthours = call.data[CONF_PARAM_LASTHOURS] cameras = hass.data[CONF_DATA_LOCAL_FILE] for camera in cameras: if camera.entity_id in entity_ids: - camera.update_image_filelist(path,exclude,begintime,endtime,lasthours) + camera.update_image_filelist( + path, exclude, begintime, endtime, lasthours + ) return True - def clear_image_filelist_service(call): - """Clear the image files list""" - entity_ids = call.data[ATTR_ENTITY_ID] + def clear_image_filelist_service(call): + """Clear the image files list""" + entity_ids = call.data[ATTR_ENTITY_ID] cameras = hass.data[CONF_DATA_LOCAL_FILE] for camera in cameras: if camera.entity_id in entity_ids: camera.clear_image_filelist() return True + def toggle_pause_filelist_service(call): + # next image + entity_ids = call.data[ATTR_ENTITY_ID] + cameras = hass.data[CONF_DATA_LOCAL_FILE] + for camera in cameras: + if camera.entity_id in entity_ids: + camera.toggle_pause() + return True + + def next_image_filelist_service(call): + # next image + entity_ids = call.data[ATTR_ENTITY_ID] + cameras = hass.data[CONF_DATA_LOCAL_FILE] + for camera in cameras: + if camera.entity_id in entity_ids: + camera.load_next_image() + return True + + def prev_image_filelist_service(call): + # next image + entity_ids = call.data[ATTR_ENTITY_ID] + cameras = hass.data[CONF_DATA_LOCAL_FILE] + for camera in cameras: + if camera.entity_id in entity_ids: + camera.load_prev_image() + return True + hass.services.register( DOMAIN, SERVICE_UPDATE_IMAGE_FILELIST, update_image_filelist_service, - schema=CAMERA_SERVICE_IMAGE_FILELIST, + schema=CAMERA_SERVICE_IMAGE_FILELIST, ) hass.services.register( DOMAIN, SERVICE_CLEAR_IMAGE_FILELIST, clear_image_filelist_service, - schema={}, + schema={}, + ) + hass.services.register( + DOMAIN, + SERVICE_TOGGLE_PAUSE, + toggle_pause_filelist_service, + schema={}, + ) + hass.services.register( + DOMAIN, + SERVICE_NEXT, + next_image_filelist_service, + schema={}, + ) + hass.services.register( + DOMAIN, + SERVICE_PREV, + prev_image_filelist_service, + schema={}, ) add_entities([camera]) +async def async_get_still_stream(request, image_cb, content_type, interval): + """Generate an HTTP MJPEG stream from camera images. + The original implementation in the HA core camera + component, had some issues in displaying the images + The view was always 1 frame behind + so thats why it was in the original code to send twice at start. + Normally not a problem, but in this component it is essential that the + image that is served is also displayed + """ + response = web.StreamResponse() + response.content_type = CONTENT_TYPE_MULTIPART.format("--frameboundary") + await response.prepare(request) + + async def write_to_mjpeg_stream(img_bytes): + """Write image to stream.""" + await response.write( + bytes( + "--frameboundary\r\n" + "Content-Type: {}\r\n" + "Content-Length: {}\r\n\r\n".format(content_type, len(img_bytes)), + "utf-8", + ) + + img_bytes + + b"\r\n" + ) + + LastImage = None + SameImagecount = 0 + while True: + img_bytes = await image_cb() + if not img_bytes: + break + # Send same image max 2 times, to fix browserproblem + if img_bytes == LastImage: + SameImagecount += 1 + else: + SameImagecount = 0 + + if SameImagecount < 2: + await write_to_mjpeg_stream(img_bytes) + LastImage = img_bytes + + await asyncio.sleep(interval) + return response + + class LocalFile(Camera): """Representation of a local file camera.""" @@ -125,82 +252,152 @@ def __init__(self, name, path, delaytime, exclude, begintime, endtime, lasthours self._name = name self._path = path - self._delaytime=delaytime - self._exclude=exclude - self._begintime=begintime - self._endtime=endtime - self._lasthours=lasthours - self._NoImages=0 - self._fileslist= Getfileslist(self._path,self._exclude,self._begintime,self._endtime,ALLOWED_EXT,self._lasthours) - self._NoImages= len(self._fileslist) - _LOGGER.debug(f'No of images/files found for camera: {self._NoImages}') + self._delaytime = delaytime + self._exclude = exclude + self._begintime = begintime + self._endtime = endtime + self._lasthours = lasthours + self._NoImages = 0 + self._fileslist = Getfileslist( + self._path, + self._exclude, + self._begintime, + self._endtime, + ALLOWED_EXT, + self._lasthours, + ) + self._NoImages = len(self._fileslist) + _LOGGER.debug(f"No of images/files found for camera: {self._NoImages}") _LOGGER.debug(f"{self._fileslist}") - self._imageindex=0 - self._lastImageTimestamp=0.0 - self._lastimage= None - - - @property #Baseclass Camera property override + self._imageindex = -1 + self._lastImageTimestamp = 0.0 + self._lastimage = None + self._pause = False + + @property # Baseclass Camera property override def frame_interval(self): """Return the interval between frames of the mjpeg stream""" - if self._delaytime<0.5: - return 0.05 + if self._delaytime < 0.5: + return 0.05 else: - return super().frame_interval + return super().frame_interval - - def camera_image(self): - - #get new image when delaytime has elapsed - if (time.time()-self._lastImageTimestamp) 0: + self._imageindex = self._imageindex + 1 + """Return image response.""" + # When end of list reached set to begin + if self._imageindex > (self._NoImages - 1): + self._imageindex = 0 + _LOGGER.debug( + f"Load [{self._imageindex}] from file {self._fileslist[self._imageindex]}" + ) + try: + with open( + os.path.join(self._path, self._fileslist[self._imageindex]), "rb" + ) as file: + self._lastimage = file.read() + self._lastImageTimestamp = time.time() + return self._lastimage + except FileNotFoundError: + _LOGGER.warning( + "Could not read camera %s image from file: %s", + self._name, + self._file_path, + ) + else: + return None - _LOGGER.debug(f"Camera_image called {self._imageindex}") - if self._NoImages>0: - _LOGGER.debug(f"Load from file {self._fileslist[self._imageindex%self._NoImages]}") + def load_prev_image(self): + if self._NoImages > 0: """Return image response.""" + self._imageindex = self._imageindex - 1 + # When begining of list reached + if self._imageindex < 0: + self._imageindex = 0 + _LOGGER.debug( + f"Load [{self._imageindex}] from file {self._fileslist[self._imageindex]}" + ) try: - with open(os.path.join(self._path,self._fileslist[self._imageindex%self._NoImages]), "rb") as file: - self._imageindex=self._imageindex+1 - #prevent oveflow from index - if self._imageindex%self._NoImages==0: self._imageindex=0 - self._lastimage=file.read() - self._lastImageTimestamp=time.time() + with open( + os.path.join(self._path, self._fileslist[self._imageindex]), "rb" + ) as file: + self._lastimage = file.read() + self._lastImageTimestamp = time.time() return self._lastimage - - except FileNotFoundError: _LOGGER.warning( "Could not read camera %s image from file: %s", self._name, self._file_path, ) - else: return None + def camera_image(self): + + # reset pause after 1 min + if self._pause and (time.time() - self._lastImageTimestamp) > 60: + self.toggle_pause() - def update_image_filelist(self,path,exclude,begintime,endtime,lasthours): + # get new image when delaytime has elapsed + if ((time.time() - self._lastImageTimestamp) < self._delaytime) or self._pause: + return self._lastimage + else: + return self.load_next_image() + + async def async_camera_image(self): + """Return bytes of camera image.""" + return await self.hass.async_add_executor_job(self.camera_image) + + async def handle_async_still_stream(self, request, interval): + """Generate an HTTP MJPEG stream from camera images.""" + return await async_get_still_stream( + request, self.async_camera_image, self.content_type, interval + ) + + async def handle_async_mjpeg_stream(self, request): + """Serve an HTTP MJPEG stream from the camera.""" + return await self.handle_async_still_stream(request, self.frame_interval) + + def toggle_pause(self): + self._pause = not self._pause + self.schedule_update_ha_state() + + def update_image_filelist(self, path, exclude, begintime, endtime, lasthours): self._path = path - self._exclude=exclude - self._begintime=begintime - self._endtime=endtime - self._lasthours=lasthours - self._NoImages=0 - self._fileslist = Getfileslist(self._path,self._exclude,self._begintime,self._endtime,ALLOWED_EXT,self._lasthours) - self._NoImages= len(self._fileslist) - _LOGGER.debug(f'No of images/files found for camera: {self._NoImages}') + self._exclude = exclude + self._begintime = begintime + self._endtime = endtime + self._lasthours = lasthours + self._NoImages = 0 + self._fileslist = Getfileslist( + self._path, + self._exclude, + self._begintime, + self._endtime, + ALLOWED_EXT, + self._lasthours, + ) + self._NoImages = len(self._fileslist) + _LOGGER.debug(f"No of images/files found for camera: {self._NoImages}") _LOGGER.debug(f"New filelist by service {self._fileslist}") - self._imageindex=0 + self._imageindex = -1 self.schedule_update_ha_state() def clear_image_filelist(self): - self._path='' - self._NoImages=0 - self._imageindex=0 - self._fileslist=[] - self.schedule_update_ha_state() + self._path = "" + self._NoImages = 0 + self._imageindex = -1 + self._fileslist = [] + self.schedule_update_ha_state() + @property + def state(self): + """Return the camera state.""" + if self._pause: + return STATE_PAUSED + return STATE_STREAMING @property def name(self): @@ -211,7 +408,7 @@ def name(self): def device_state_attributes(self): """Return the camera state attributes.""" attrs = {} - attrs["directory"]=self._path - attrs["imagecount"]=self._NoImages - attrs["files"]=self._fileslist + attrs["directory"] = self._path + attrs["imagecount"] = self._NoImages + attrs["files"] = self._fileslist return attrs diff --git a/custom_components/imagedirectory/manifest.json b/custom_components/imagedirectory/manifest.json index 1fe8134..ff0caca 100644 --- a/custom_components/imagedirectory/manifest.json +++ b/custom_components/imagedirectory/manifest.json @@ -1,7 +1,7 @@ { "domain": "imagedirectory", "name": "Local Image Filelist", - "documentation": "https://github.com/jodur/imagedirectory", + "documentation": "https://github.com/jodur/imagedirectory-camera", "requirements": [ "pillow", "imageio", diff --git a/custom_components/imagedirectory/services.yaml b/custom_components/imagedirectory/services.yaml index 5d2f0ae..b412ea9 100644 --- a/custom_components/imagedirectory/services.yaml +++ b/custom_components/imagedirectory/services.yaml @@ -28,6 +28,24 @@ camera_clear_image_filelist: entity_id: description: Name of the entity_id of the camera to update example: 'camera.local_file' +camera_toggle_pause: + description: Use this service to pause the timelapse/slideshow of the camera. + fields: + entity_id: + description: Name of the entity_id of the camera to update + example: 'camera.local_file' +camera_next_image: + description: Use this service to display the next image of the filelist. + fields: + entity_id: + description: Name of the entity_id of the camera to update + example: 'camera.local_file' +camera_prev_image: + description: Use this service to display the previous image of the filelist. + fields: + entity_id: + description: Name of the entity_id of the camera to update + example: 'camera.local_file' create_gif_mp4: description: Create GIF/MP4 from selected snapshots fields: @@ -94,5 +112,4 @@ move_files: lasthours: description: Select only files from last x hours since lastfile (OPTIONAL, 0.0 (DEFAULT) select all files in timerange) example: '1.0' - - + \ No newline at end of file diff --git a/document/camera_example_1.png b/document/camera_example_1.png new file mode 100644 index 0000000..ae8cff9 Binary files /dev/null and b/document/camera_example_1.png differ diff --git a/document/camera_example_2.png b/document/camera_example_2.png new file mode 100644 index 0000000..55177e0 Binary files /dev/null and b/document/camera_example_2.png differ