diff --git a/.gitignore b/.gitignore index d2a0569..3c2bb7d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ config.ini *.pyc .pytest_cache/* build.config +image.jpg diff --git a/.travis.yml b/.travis.yml index cce0491..1291c7e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python python: - - "2.7" + - "3.7" # command to run tests install: pip install -r requirements.txt -script: pytest \ No newline at end of file +script: pytest test_onreceive.py \ No newline at end of file diff --git a/CameraEvents.py b/CameraEvents.py index 7dad638..869ea8d 100644 --- a/CameraEvents.py +++ b/CameraEvents.py @@ -12,6 +12,12 @@ import requests import datetime import re +import imageio +from PIL import ImageFile +from PIL import Image +from io import BytesIO + +from slacker import Slacker try: #python 3+ from configparser import ConfigParser @@ -28,7 +34,7 @@ import base64 version = "0.1.3" - +ImageFile.LOAD_TRUNCATED_IMAGES = True mqttc = paho.Client("CameraEvents-" + socket.gethostname(), clean_session=True) _LOGGER = logging.getLogger(__name__) @@ -92,6 +98,7 @@ def __init__(self, name, device_cfg, client, basetopic): self.host = device_cfg.get("host") self.port = device_cfg.get("port") self.alerts = device_cfg.get("alerts") + self.token = device_cfg.get("token") self.client = client self.basetopic = basetopic self.snapshotoffset = device_cfg.get("snapshotoffset") @@ -176,7 +183,12 @@ def channelIsMine(self,channelname="",channelid=-1): # print result - def SnapshotImage(self, channel, channelName, message): + def SnapshotImage(self, channel, channelName, message,nopublish=False): + """Takes a snap shot image for the specified channel + channel (index number starts at 1) + channelName if known for messaging + message message to post + nopublish True/False for posting to MQTT""" imageurl = self.SNAPSHOT_TEMPLATE.format( host=self.host, protocol=self.protocol, @@ -191,26 +203,193 @@ def SnapshotImage(self, channel, channelName, message): else: image = requests.get(imageurl, stream=True,auth=requests.auth.HTTPBasicAuth(self.user, self.password)).content - + imagepayload = "" if image is not None and len(image) > 0: + #fp = open("image.jpg", "wb") + #fp.write(image) #r.text is the binary data for the PNG returned by that php script + #fp.close() #construct image payload #{{ \"message\": \"Motion Detected: {0}\", \"imagebase64\": \"{1}\" }}" - imgpayload = base64.encodestring(image) - msgpayload = json.dumps({"message":message,"imagebase64":imgpayload}) + imagepayload = (base64.encodebytes(image)).decode("utf-8") + msgpayload = json.dumps({"message":message,"imagebase64":imagepayload}) #msgpayload = "{{ \"message\": \"{0}\", \"imagebase64\": \"{1}\" }}".format(message,imgpayload) - self.client.publish(self.basetopic +"/{0}/Image".format(channelName),msgpayload) + if not nopublish: + self.client.publish(self.basetopic +"/{0}/Image".format(channelName),msgpayload) except Exception as ex: _LOGGER.error("Error sending image: " + str(ex)) try: - imagepayload = "" + with open("default.png", 'rb') as thefile: imagepayload = thefile.read().encode("base64") msgpayload = json.dumps({"message":"ERR:" + message, "imagebase64": imagepayload}) - self.client.publish(self.basetopic +"/{0}/Image".format(channelName),msgpayload) + if not nopublish: + self.client.publish(self.basetopic +"/{0}/Image".format(channelName),msgpayload) except: pass + + + return image + + + def SearchImages(self,channel,starttime, endtime, events): + """Searches for images for the channel + channel is a numerical channel index (starts at 1) + starttime is the start search time + endtime is the end search time (or now) + events is a list of event types""" + #Create Finder + #http:///cgi-bin/mediaFileFind.cgi?action=factory.create + MEDIA_FINDER="{protocol}://{host}:{port}/cgi-bin/mediaFileFind.cgi?action=factory.create" + MEDIA_START="{protocol}://{host}:{port}/cgi-bin/mediaFileFind.cgi?action=findFile&object={object}&condition.Channel={channel}" + \ + "&condition.StartTime={starttime}&condition.EndTime={endtime}&condition.Types[0]=jpg&condition.Flag[0]=Event" \ + "&condition.Events[0]={events}" + MEDIA_START="{protocol}://{host}:{port}/cgi-bin/mediaFileFind.cgi?action=findFile&object={object}" + \ + "&condition.Channel={channel}" + \ + "&condition.StartTime={starttime}&condition.EndTime={endtime}" + \ + "&condition.Flags%5b0%5d=Event&condition.Types%5b0%5d=jpg" + #&condition.Types=%5b0%5d=jpg" + #&condition.Types[0]=jpg + #&condition.Flags[0]=Event&condition.Types[0]=jpg + #&condition.Flags%5b0%5d=Event&condition.Types%5b0%5d=jpg + #%5B{events}%5D + #MEDIA_START="{protocol}://{host}:{port}/cgi-bin/mediaFileFind.cgi?action=findFile&object={object}" + \ + # "&condition.Channel={channel}" + \ + # "&condition.StartTime=2019-12-05%2006:20:48&condition.EndTime=2019-12-05%2009:28:48&condition.Flags%5b0%5d=Event&condition.Types%5b0%5d=jpg" + + #Start Find + #http:///cgi-bin/mediaFileFind.cgi?action=findFile&object=&condition.Channel=&condition.StartTime= + # &condition.EndTime=&condition.Dirs[0]=&condition.Types[0]=&condition.Flag[0]=&condition.E vents[0]= + #Start to find file wth the above condition. + # If start successfully, return true, else return false. + # object : The object Id is got from interface in 10.1.1 + # Create condition.Channel: in which channel you want to find the file . + # condition.StartTime/condition.EndTime: the start/end time when recording. + # condition.Dirs: in which directories you want to find the file. It is an array. + # The index starts from 0. The range of dir is {“/mnt/dvr/sda0”, “/mnt/dvr/sda1”}. + # This condition can be omitted. If omitted, find files in all the directories. + # condition.Types: which types of the file you want to find. It is an array. + # The index starts from 0. The range of type is {“dav”,“jpg”, “mp4”}. If omitted, + # find files with all the types. + # condition.Flags: which flags of the file you want to find. It is an array. + # The index starts from 0. The range of flag is {“Timing”, “Manual”, “Marker”, “Event”, “Mosaic”, “Cutout”}. + # If omitted, find files with all the flags. + # condition.Event: by which event the record file is triggered. It is an array. + # The index starts from 0. The range of event is {“AlarmLocal”, “VideoMotion”, “VideoLoss”, “VideoBlind”, “Traffic*”}. + # This condition can be omitted. If omitted, find files of all the events. + # Example: Find file in channel 1, in directory “/mnt/dvr/sda0",event type is "AlarmLocal" or + # "VideoMotion", file type is “dav”, and time between 2011-1-1 12:00:00 and 2011-1-10 12:00:00 , + # URL is: http:///cgi-bin/mediaFileFind.cgi?action=findFile&object=08137&condition.Channel=1&conditon.Dir[0]=”/mnt/dvr/sda0”& conditon.Event[0]=AlarmLocal&conditon.Event[1]=VideoMotion&condition.StartTime=2011-1-1%2012:00:00&condition.EndTi me=2011-1-10%2012:00:00 + + #Find next File + # http:///cgi-bin/mediaFileFind.cgi?action=findNextFile&object=&count= Comment + MEDIA_NEXT="{protocol}://{host}:{port}/cgi-bin/mediaFileFind.cgi?action=findNextFile&object={object}&count=50" + + MEDIA_LOADFILE="{protocol}://{host}:{port}/cgi-bin/RPC_Loadfile{file}" + + #Close Finder + #http:///cgi-bin/mediaFileFind.cgi?action=close&object= + MEDIA_CLOSE="{protocol}://{host}:{port}/cgi-bin/mediaFileFind.cgi?action=close&object={object}" + + finderurl = MEDIA_FINDER.format( + host=self.host, + protocol=self.protocol, + port = self.port + ) + objectId = "" + cookies = {} + s = requests.Session() + if self.auth == "digest": + auth = requests.auth.HTTPDigestAuth(self.user, self.password) + else: + auth = auth=requests.auth.HTTPBasicAuth(self.user, self.password) + + _LOGGER.info("Finder Url: " + finderurl) + try: + #first request needs authentication + if self.auth == "digest": + result = s.get(finderurl, stream=True,auth=auth,cookies=cookies).content + else: + result = s.get(finderurl, stream=True,auth=auth,cookies=cookies).content + + #result = b'result=3021795080\r\n' + # Get the object id of the finder request, needed for subsequent searches + objectId = self.ConvertLinesToDict(result.decode())["result"] + + # perform a search. Startime and endtime in the following format: 2011-1-1%2012:00:00 + finderurl = MEDIA_START.format(host=self.host,protocol=self.protocol,port=self.port, + object=objectId,channel=channel, + starttime=starttime.strftime("%Y-%m-%d%%20%H:%M:%S"), + endtime=endtime.strftime("%Y-%m-%d%%20%H:%M:%S"), + events="*") + #finderurl = finderurl + requests.utils.quote("&condition.Types[0]=jpg") + result = s.get(finderurl, stream=True,auth=auth,cookies=cookies) + if result.status_code == 200: + finderurl = MEDIA_NEXT.format(host=self.host,protocol=self.protocol,port=self.port,object=objectId) + result = s.get(finderurl,auth=auth,cookies=cookies) + mediaItem = {} + if result.status_code == 200: + #start downloading the images. + mediaItem = self.ConvertLinesToDict(result.content.decode()) + finderurl = MEDIA_CLOSE.format(host=self.host,protocol=self.protocol,port=self.port, + object=objectId) + + # Close the media find object + result = s.get(finderurl,auth=auth,cookies=cookies).content + images = [] + imagesize = 0 + expectedsize = 0 + for item in mediaItem: + loadurl = MEDIA_LOADFILE.format(host=self.host,protocol=self.protocol,port=self.port, + file=item['FilePath']) + result = s.get(loadurl,auth=auth,cookies=cookies) + imagesize = len(result.content) + expectedsize = int(item['Length']) + expectedsize = int(float(expectedsize) * 0.85) + if imagesize >= expectedsize: + try: + im = Image.open(BytesIO(result.content)) + im.resize((im.size[0] // 2, im.size[1] // 2), Image.ANTIALIAS) + images.append(im) + except Exception as imgEx: + print(str(imgEx)) + pass + #fp = open("image" + str(imagecount) + ".jpg", "wb") + #fp.write(result.content) #r.text is the binary data for the PNG returned by that php script + #fp.close() + + imageio.mimsave('movie.gif',images, duration=1.5) + try: + slack = Slacker(self.token) + with open('movie.gif', 'rb') as f: + slack.files.upload(file_=BytesIO(f.read()), + title="Image'", + channels=slack.channels.get_channel_id('camera'), + filetype='gif') + except Exception as slackEx: + print(str(slackEx)) + #'found': '3', + #'items[0].Channel': '0', + #'items[0].Cluster': '171757', + #'items[0].Disk': '9', + #'items[0].EndTime': '2019-12-06 08:33:29', + #'items[0].FilePath': '/mnt/dvr/2019-12-06...0][0].jpg', + #'items[0].Length': '674816', + #'items[0].Partition': '1', + #'items[0].StartTime': '2019-12-06 08:33:29', + #'items[0].Type': 'jpg', + #'items[0].VideoStream': 'Main', + #result = s.get(finderurl,auth=auth,cookies=cookies) + except Exception as ex: + # if there's been an error and we've got an object id, close the finder. + if len(objectId) > 0: + finderurl = MEDIA_CLOSE.format(host=self.host,protocol=self.protocol,port=self.port, + object=objectId) + result = s.get(finderurl,auth=auth,cookies=cookies).content + pass + + return "" # Connected to camera def OnConnect(self): @@ -262,8 +441,13 @@ def OnReceive(self, data): process = threading.Thread(target=self.SnapshotImage,args=(index+self.snapshotoffset,Alarm["channel"],"Motion Detected: {0}".format(Alarm["channel"]))) process.daemon = True # Daemonize thread process.start() - else: - self.client.publish(self.basetopic +"/" + Alarm["Code"] + "/" + Alarm["channel"] ,"OFF") + #else: + # self.client.publish(self.basetopic +"/" + Alarm["Code"] + "/" + Alarm["channel"] ,"OFF") + # starttime = datetime.datetime.now() - datetime.timedelta(minutes=5) + # endtime = datetime.datetime.now() + # process2 = threading.Thread(target=self.SearchImages,args=(index+self.snapshotoffset,starttime,endtime,"")) + # process2.daemon = True # Daemonize thread + # process2.start() elif Alarm["Code"] == "CrossRegionDetection" or Alarm["Code"] == "CrossLineDetection": if Alarm["action"] == "Start": regionText = Alarm["Code"] @@ -279,19 +463,27 @@ def OnReceive(self, data): region = crossData["Name"] object = crossData["Object"]["ObjectType"] regionText = "{} With {} in {} direction for {} region".format(Alarm["Code"],object,direction,region) - except Exception,ivsExcept: + except Exception as ivsExcept: _LOGGER.error("Error getting IVS data: " + str(ivsExcept)) self.client.publish(self.basetopic +"/IVS/" + Alarm["channel"] ,regionText) if self.alerts: - #possible new process: - #http://192.168.10.66/cgi-bin/snapManager.cgi?action=attachFileProc&Flags[0]=Event&Events=[VideoMotion%2CVideoLoss] - process = threading.Thread(target=self.SnapshotImage,args=(index+self.snapshotoffset,Alarm["channel"],"IVS: {0}: {1}".format(Alarm["channel"],regionText))) - process.daemon = True # Daemonize thread - process.start() + #possible new process: + #http://192.168.10.66/cgi-bin/snapManager.cgi?action=attachFileProc&Flags[0]=Event&Events=[VideoMotion%2CVideoLoss] + process = threading.Thread(target=self.SnapshotImage,args=(index+self.snapshotoffset,Alarm["channel"],"IVS: {0}: {1}".format(Alarm["channel"],regionText))) + process.daemon = True # Daemonize thread + process.start() + #else: + # starttime = datetime.datetime.now() - datetime.timedelta(minutes=5) + # endtime = datetime.datetime.now() + # process2 = threading.Thread(target=self.SearchImages,args=(index+self.snapshotoffset,starttime,endtime,"")) + # process2.daemon = True # Daemonize thread + # process2.start() + else: _LOGGER.info("dahua_event_received: "+ Alarm["name"] + " Index: " + Alarm["channel"] + " Code: " + Alarm["Code"]) self.client.publish(self.basetopic +"/" + Alarm["channel"] + "/" + Alarm["name"],Alarm["Code"]) + #2019-01-27 08:28:19,658 - __main__ - INFO - dahua_event_received: NVR Index: NVR:0 Code: CrossRegionDetection #2019-01-27 08:28:19,674 - __main__ - INFO - dahua_event_received: NVR Index: NVR:0 Code: CrossRegionDetection #2019-01-27 08:28:19,703 - __main__ - INFO - dahua_event_received: NVR Index: NVR:0 Code: CrossLineDetection @@ -300,7 +492,47 @@ def OnReceive(self, data): #mqttc.disconnect() #self.hass.bus.fire("dahua_event_received", Alarm) - + def ConvertLinesToDict(self,Data): + results = dict() + items = [] + currentIndex = '' + indexCount = 0 + for Line in Data.split("\r\n"): + if len(Line) == 0: + if len(items) > 0: + items.append(results) + return items + else: + return results + if Line.find("[") > -1: + if 'found' in results: + indexCount = int(results['found']) + results = dict() + #array value found + #items[0].Channel': '0' + Item, Value = Line.split('=') + Index, Key = Item.split(".") + if currentIndex == '': + currentIndex = Index + #Index= Index.replace("]","") + if Index != currentIndex: + currentIndex = Index + items.append(results) + results = dict() + results[Key] = Value.replace("\r\n","") + else: + + for KeyValue in Line.split(';'): + Key, Value = KeyValue.split('=') + results[Key] = Value.replace("\r\n","") + #else: + # for KeyValue in Data.split(';'): + # Key, Value = KeyValue.split('=') + # results[Key] = Value.replace("\r\n","") + if len(items) > 0: + return items + else: + return results @@ -319,13 +551,14 @@ def __init__(self, mqtt, cameras): self.basetopic = mqtt["basetopic"] self.client = paho.Client("CameraEvents-" + socket.gethostname(), clean_session=True) + if not mqtt["user"] is None and not mqtt["user"] == '': + self.client.username_pw_set(mqtt["user"], mqtt["password"]) self.client.on_connect = self.mqtt_on_connect self.client.on_disconnect = self.mqtt_on_disconnect self.client.message_callback_add(self.basetopic +"/+/picture",self.mqtt_on_picture_message) self.client.message_callback_add(self.basetopic +"/+/alerts",self.mqtt_on_alert_message) self.client.will_set(self.basetopic +"/$online",False,qos=0,retain=True) - self.alerts = True @@ -530,12 +763,20 @@ def mqtt_on_cross_message(self,client, userdata, msg): else: camera["snapshotoffset"] = 0 camera["channels"] = channels + + token = "" + if cp.has_option("Slack","token"): + token = cp.get("Slack","token") + camera["token"] = token + cameras.append(camera) mqtt = {} mqtt["IP"] = cp.get("MQTT Broker","IP") mqtt["port"] = cp.get("MQTT Broker","port") mqtt["basetopic"] = cp.get("MQTT Broker","BaseTopic") + mqtt["user"] = cp.get("MQTT Broker","user",fallback=None) + mqtt["password"] = cp.get("MQTT Broker","password",fallback=None) dahua_event = DahuaEventThread(mqtt,cameras) dahua_event.start() diff --git a/Dockerfile.cross b/Dockerfile.cross index beb782c..9415b7a 100644 --- a/Dockerfile.cross +++ b/Dockerfile.cross @@ -1,4 +1,4 @@ -FROM __BASEIMAGE_ARCH__/python:2.7.15-jessie +FROM __BASEIMAGE_ARCH__/python:3.7.2-stretch __CROSS_COPY qemu/qemu-__QEMU_ARCH__-static /usr/bin/ diff --git a/Tests/test_devices.py b/Tests/test_devices.py new file mode 100644 index 0000000..35680ac --- /dev/null +++ b/Tests/test_devices.py @@ -0,0 +1,83 @@ +import pytest +import CameraEvents +import datetime +try: + #python 3+ + from configparser import ConfigParser +except: + # Python 2.7 + from ConfigParser import ConfigParser + +class dummy_mqtt(object): + pass + def publish(self,topic,payload): + pass + +def create_device(): + device_cfg = {} + channels = {} + device_cfg["channels"] = channels + #device_cfg.set(["channels"] + device_cfg["Name"] = "test" + device_cfg["user"] = "user" + device_cfg["password"] = "pass" + device_cfg["auth"] = "digest" + device_cfg["mqtt"] = "localhsot" + device_cfg["protocol"] = "http" + device_cfg["host"] = "192.168.1.108" + device_cfg["port"] = 80 + device_cfg["alerts"] = False + device_cfg["snapshotoffset"] = 0 + client = dummy_mqtt() + client.connected_flag = True + + basetopic = "CameraEvents" + + device = CameraEvents.DahuaDevice("Camera", device_cfg, client,basetopic) + return device + +def read_config(): + cp = ConfigParser() + filename = {"config.ini","conf/config.ini"} + dataset = cp.read(filename) + + try: + if len(dataset) != 1: + raise ValueError( "Failed to open/find all files") + camera_items = cp.items( "Cameras" ) + for key, camera_key in camera_items: + #do something with path + camera_cp = cp.items(camera_key) + camera = {} + #temp = cp.get(camera_key,"host") + camera["host"] = cp.get(camera_key,'host') + except Exception as ex: + pass + +def test_dahua_create(): + device = create_device() + assert device is not None + +def test_dahua_take_snapshot(): + device = create_device() + device.host = 'cam-nvr.andc.nz' + device.user = 'IOS' + device.password = 'Dragon25' + image = device.SnapshotImage(1,"Garage","message",nopublish=True) + assert image is not None + if len(image) > 600: + sized = True + assert sized is True + +def test_dahua_search_images(): + device = create_device() + device.host = 'cam-nvr.andc.nz' + device.user = 'IOS' + device.password = 'Dragon25' + starttime = datetime.datetime.now() - datetime.timedelta(minutes=25) + endtime = datetime.datetime.now() + result = device.SearchImages(2, starttime,endtime,"") + assert result is not None + #if len(image) > 600: + # sized = True + #assert sized is True diff --git a/config-master.ini b/config-master.ini index e5011d8..fd2aeb6 100644 --- a/config-master.ini +++ b/config-master.ini @@ -4,10 +4,14 @@ IP=mqtt.andc.nz ;MQTT Port Port=1883 ;MQTT username, without qoutes -Mqtt_Username = '' +User= ;MQTT password, without qoutes - ;MQTT password, without qoutes -BaseTopic = 'CameraEvents' +Password= +;Base MQTT Topic +BaseTopic=CameraEvents + +[Slack] +token=api_token_here [Cameras] camera1=Example1 diff --git a/movie.gif b/movie.gif new file mode 100644 index 0000000..16d1195 Binary files /dev/null and b/movie.gif differ diff --git a/requirements.txt b/requirements.txt index 56fe8b4..caee93a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ paho-mqtt pycurl ConfigParser requests +imageio +Slacker