├── .gitignore ├── DahuaVTO.py ├── Dockerfile ├── MQTTEvents.MD ├── README.md └── SupportedModels.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv/ 3 | 4 | *.pyc -------------------------------------------------------------------------------- /DahuaVTO.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import struct 5 | import sys 6 | import logging 7 | import json 8 | import asyncio 9 | import hashlib 10 | from threading import Timer 11 | from time import sleep 12 | from typing import Optional, Callable 13 | import paho.mqtt.client as mqtt 14 | import requests 15 | from requests.auth import HTTPDigestAuth 16 | 17 | DEBUG = str(os.environ.get('DEBUG', False)).lower() == str(True).lower() 18 | 19 | PROTOCOLS = { 20 | True: "https", 21 | False: "http" 22 | } 23 | 24 | log_level = logging.DEBUG if DEBUG else logging.INFO 25 | 26 | root = logging.getLogger() 27 | root.setLevel(log_level) 28 | 29 | stream_handler = logging.StreamHandler(sys.stdout) 30 | stream_handler.setLevel(log_level) 31 | formatter = logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s') 32 | stream_handler.setFormatter(formatter) 33 | root.addHandler(stream_handler) 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | DAHUA_DEVICE_TYPE = "deviceType" 38 | DAHUA_SERIAL_NUMBER = "serialNumber" 39 | DAHUA_VERSION = "version" 40 | DAHUA_BUILD_DATE = "buildDate" 41 | 42 | DAHUA_GLOBAL_LOGIN = "global.login" 43 | DAHUA_GLOBAL_KEEPALIVE = "global.keepAlive" 44 | DAHUA_EVENT_MANAGER_ATTACH = "eventManager.attach" 45 | DAHUA_CONFIG_MANAGER_GETCONFIG = "configManager.getConfig" 46 | DAHUA_MAGICBOX_GETSOFTWAREVERSION = "magicBox.getSoftwareVersion" 47 | DAHUA_MAGICBOX_GETDEVICETYPE = "magicBox.getDeviceType" 48 | 49 | DAHUA_ALLOWED_DETAILS = [ 50 | DAHUA_DEVICE_TYPE, 51 | DAHUA_SERIAL_NUMBER 52 | ] 53 | 54 | ENDPOINT_ACCESS_CONTROL = "accessControl.cgi?action=openDoor&UserID=101&Type=Remote&channel=" 55 | ENDPOINT_MAGICBOX_SYSINFO = "magicBox.cgi?action=getSystemInfo" 56 | 57 | MQTT_ERROR_DEFAULT_MESSAGE = "Unknown error" 58 | 59 | MQTT_ERROR_MESSAGES = { 60 | 1: "MQTT Broker failed to connect: incorrect protocol version", 61 | 2: "MQTT Broker failed to connect: invalid client identifier", 62 | 3: "MQTT Broker failed to connect: server unavailable", 63 | 4: "MQTT Broker failed to connect: bad username or password", 64 | 5: "MQTT Broker failed to connect: not authorised" 65 | } 66 | 67 | 68 | class DahuaVTOClient(asyncio.Protocol): 69 | requestId: int 70 | sessionId: int 71 | keep_alive_interval: int 72 | username: str 73 | password: str 74 | realm: Optional[str] 75 | random: Optional[str] 76 | messages: [] 77 | mqtt_client: mqtt.Client 78 | dahua_details: {} 79 | base_url: str 80 | hold_time: int 81 | lock_status: {} 82 | auth: HTTPDigestAuth 83 | data_handlers: {} 84 | 85 | def __init__(self): 86 | self.dahua_details = {} 87 | self.host = os.environ.get('DAHUA_VTO_HOST') 88 | self.is_ssl = str(os.environ.get('DAHUA_VTO_SSL', False)).lower() == str(True).lower() 89 | 90 | self.base_url = f"{PROTOCOLS[self.is_ssl]}://{self.host}/cgi-bin/" 91 | 92 | self.username = os.environ.get('DAHUA_VTO_USERNAME') 93 | self.password = os.environ.get('DAHUA_VTO_PASSWORD') 94 | self.auth = HTTPDigestAuth(self.username, self.password) 95 | 96 | self.mqtt_broker_host = os.environ.get('MQTT_BROKER_HOST') 97 | self.mqtt_broker_port = os.environ.get('MQTT_BROKER_PORT') 98 | self.mqtt_broker_username = os.environ.get('MQTT_BROKER_USERNAME') 99 | self.mqtt_broker_password = os.environ.get('MQTT_BROKER_PASSWORD') 100 | 101 | self.mqtt_broker_topic_prefix = os.environ.get('MQTT_BROKER_TOPIC_PREFIX') 102 | self.mqtt_open_door_topic = f"{self.mqtt_broker_topic_prefix}/Command/Open" 103 | 104 | self.realm = None 105 | self.random = None 106 | self.request_id = 1 107 | self.sessionId = 0 108 | self.keep_alive_interval = 0 109 | self.transport = None 110 | self.hold_time = 0 111 | self.lock_status = {} 112 | self.data_handlers = {} 113 | 114 | self.mqtt_client = mqtt.Client() 115 | self._loop = asyncio.get_event_loop() 116 | 117 | def initialize_mqtt_client(self): 118 | _LOGGER.info("Initializing MQTT Broker") 119 | connected = False 120 | self.mqtt_client.user_data_set(self) 121 | 122 | self.mqtt_client.username_pw_set(self.mqtt_broker_username, self.mqtt_broker_password) 123 | 124 | self.mqtt_client.on_connect = self.on_mqtt_connect 125 | self.mqtt_client.on_message = self.on_mqtt_message 126 | self.mqtt_client.on_disconnect = self.on_mqtt_disconnect 127 | 128 | while not connected: 129 | try: 130 | _LOGGER.info("MQTT Broker is trying to connect...") 131 | 132 | self.mqtt_client.connect(self.mqtt_broker_host, int(self.mqtt_broker_port), 60) 133 | self.mqtt_client.loop_start() 134 | 135 | connected = True 136 | 137 | except Exception as ex: 138 | exc_type, exc_obj, exc_tb = sys.exc_info() 139 | error_details = f"error: {ex}, Line: {exc_tb.tb_lineno}" 140 | 141 | _LOGGER.error(f"Failed to connect to broker, retry in 60 seconds, {error_details}") 142 | 143 | sleep(60) 144 | 145 | @staticmethod 146 | def on_mqtt_connect(client, userdata, flags, rc): 147 | if rc == 0: 148 | _LOGGER.info(f"MQTT Broker connected with result code {rc}") 149 | 150 | client.subscribe(userdata.mqtt_open_door_topic) 151 | 152 | else: 153 | error_message = MQTT_ERROR_MESSAGES.get(rc, MQTT_ERROR_DEFAULT_MESSAGE) 154 | 155 | _LOGGER.error(error_message) 156 | 157 | asyncio.get_event_loop().stop() 158 | 159 | @staticmethod 160 | def on_mqtt_message(client, userdata, msg): 161 | payload = None if msg.payload is None else msg.payload.decode("utf-8") 162 | 163 | _LOGGER.debug(f"MQTT Message {msg.topic}: {payload}") 164 | 165 | if msg.topic == userdata.mqtt_open_door_topic: 166 | data = {} 167 | 168 | if payload is not None and len(payload) > 0: 169 | data = json.loads(payload) 170 | 171 | door_id = data.get("Door", 1) 172 | 173 | userdata.access_control_open_door(door_id) 174 | 175 | @staticmethod 176 | def on_mqtt_disconnect(client, userdata, rc): 177 | connected = False 178 | 179 | while not connected: 180 | try: 181 | _LOGGER.info(f"MQTT Broker got disconnected, trying to reconnect...") 182 | 183 | client.connect(userdata.mqtt_broker_host, int(userdata.mqtt_broker_port), 60) 184 | client.loop_start() 185 | 186 | connected = True 187 | 188 | except Exception as ex: 189 | exc_type, exc_obj, exc_tb = sys.exc_info() 190 | 191 | _LOGGER.error(f"Failed to reconnect, retry in 60 seconds, error: {ex}, Line: {exc_tb.tb_lineno}") 192 | 193 | sleep(60) 194 | 195 | def connection_made(self, transport): 196 | _LOGGER.debug("Connection established") 197 | 198 | try: 199 | self.transport = transport 200 | 201 | self.initialize_mqtt_client() 202 | self.pre_login() 203 | 204 | except Exception as ex: 205 | exc_type, exc_obj, exc_tb = sys.exc_info() 206 | 207 | _LOGGER.error(f"Failed to handle message, error: {ex}, Line: {exc_tb.tb_lineno}") 208 | 209 | def data_received(self, data): 210 | try: 211 | message = self.parse_response(data) 212 | _LOGGER.debug(f"Data received: {message}") 213 | 214 | message_id = message.get("id") 215 | 216 | handler: Callable = self.data_handlers.get(message_id, self.handle_default) 217 | handler(message) 218 | 219 | except Exception as ex: 220 | exc_type, exc_obj, exc_tb = sys.exc_info() 221 | 222 | _LOGGER.error(f"Failed to handle message, error: {ex}, Line: {exc_tb.tb_lineno}") 223 | 224 | def handle_notify_event_stream(self, params): 225 | try: 226 | event_list = params.get("eventList") 227 | 228 | for message in event_list: 229 | code = message.get("Code") 230 | 231 | for k in self.dahua_details: 232 | if k in DAHUA_ALLOWED_DETAILS: 233 | message[k] = self.dahua_details.get(k) 234 | 235 | topic = f"{self.mqtt_broker_topic_prefix}/{code}/Event" 236 | 237 | _LOGGER.debug(f"Publishing MQTT message {topic}: {message}") 238 | 239 | self.mqtt_client.publish(topic, json.dumps(message, indent=4)) 240 | 241 | except Exception as ex: 242 | exc_type, exc_obj, exc_tb = sys.exc_info() 243 | 244 | _LOGGER.error(f"Failed to handle event, error: {ex}, Line: {exc_tb.tb_lineno}") 245 | 246 | def handle_default(self, message): 247 | _LOGGER.info(f"Data received without handler: {message}") 248 | 249 | def eof_received(self): 250 | _LOGGER.info('Server sent EOF message') 251 | 252 | self._loop.stop() 253 | 254 | def connection_lost(self, exc): 255 | _LOGGER.error('server closed the connection') 256 | 257 | self._loop.stop() 258 | 259 | def send(self, action, handler, params=None): 260 | if params is None: 261 | params = {} 262 | 263 | self.request_id += 1 264 | 265 | message_data = { 266 | "id": self.request_id, 267 | "session": self.sessionId, 268 | "magic": "0x1234", 269 | "method": action, 270 | "params": params 271 | } 272 | 273 | self.data_handlers[self.request_id] = handler 274 | 275 | if not self.transport.is_closing(): 276 | message = self.convert_message(message_data) 277 | 278 | self.transport.write(message) 279 | 280 | @staticmethod 281 | def convert_message(data): 282 | message_data = json.dumps(data, indent=4) 283 | 284 | header = struct.pack(">L", 0x20000000) 285 | header += struct.pack(">L", 0x44484950) 286 | header += struct.pack(">d", 0) 287 | header += struct.pack(" 3 | 4 | WORKDIR /app 5 | 6 | COPY *.py ./ 7 | 8 | RUN apk update && \ 9 | apk upgrade && \ 10 | pip install paho-mqtt requests 11 | 12 | ENV DAHUA_VTO_HOST=vto-host 13 | ENV DAHUA_VTO_USERNAME=Username 14 | ENV DAHUA_VTO_PASSWORD=Password 15 | ENV MQTT_BROKER_HOST=mqtt-host 16 | ENV MQTT_BROKER_PORT=1883 17 | ENV MQTT_BROKER_USERNAME=Username 18 | ENV MQTT_BROKER_PASSWORD=Password 19 | ENV MQTT_BROKER_TOPIC_PREFIX=DahuaVTO 20 | 21 | RUN chmod +x /app/DahuaVTO.py 22 | 23 | ENTRYPOINT ["python3", "/app/DahuaVTO.py"] -------------------------------------------------------------------------------- /MQTTEvents.MD: -------------------------------------------------------------------------------- 1 | # MQTT Message (Dahua VTO Event Payload) 2 | 3 | ## Actions 4 | Publish MQTT Message to perform action 5 | 6 | ### Open door 7 | ``` 8 | TOPIC: [MQTT_BROKER_TOPIC_PREFIX]/Command/Open 9 | ``` 10 | 11 | ## Lock State 12 | Topic `[MQTT_BROKER_TOPIC_PREFIX]/MagneticLock/Status` represents the locking status, 13 | Works only if the lock released by the DahuaVTO2MQTT 14 | 15 | Since there is not real indication, it pulls from the configuration of the unit the interval allowed between unlocks (as defined in the Web Manager of the unit), 16 | 17 | It will also protect duplicate attempts while the magnetic lock is in unlock interval 18 | 19 | ```json 20 | { 21 | "door":"Door ID", 22 | "isLocked":"true/false" 23 | } 24 | ``` 25 | 26 | ## Events (With dedicated additional data) 27 | Topic will be always [MQTT_BROKER_TOPIC_PREFIX]/[EVENT NAME]/Event 28 | Message represent an event 29 | 30 | ```json 31 | { 32 | "deviceType":"Device Model", 33 | "serialNumber":"Device Serial Number" 34 | } 35 | ``` 36 | 37 | 38 | ### CallNoAnswered: Call from VTO 39 | ```json 40 | { 41 | "Action": "Start", 42 | "Data": { 43 | "CallID": "1", 44 | "IsEncryptedStream": false, 45 | "LocaleTime": "2020-03-02 20:11:13", 46 | "LockNum": 2, 47 | "SupportPaas": false, 48 | "TCPPort": 37777, 49 | "UTC": 1583172673 50 | }, 51 | "deviceType":"Device Model", 52 | "serialNumber":"Device Serial Number" 53 | } 54 | ``` 55 | 56 | ### IgnoreInvite: VTH answered call from VTO 57 | 58 | 59 | ### VideoMotion: Video motion detected 60 | ```json 61 | { 62 | "Action": "Start", 63 | "Data": { 64 | "LocaleTime": "2020-03-02 20:44:28", 65 | "UTC": 1583174668 66 | }, 67 | "deviceType":"Device Model", 68 | "serialNumber":"Device Serial Number" 69 | } 70 | ``` 71 | 72 | ### RtspSessionDisconnect: Rtsp-Session connection connection state changed 73 | Action: Represented whether event Start or Stop 74 | Data.Device: IP of the device connected / disconnected 75 | 76 | ### BackKeyLight: BackKeyLight with State 77 | ```json 78 | { 79 | "Action": "Pulse", 80 | "Data": { 81 | "LocaleTime": "2020-03-02 20:24:07", 82 | "State": 8, 83 | "UTC": 1583173447 84 | }, 85 | "deviceType":"Device Model", 86 | "serialNumber":"Device Serial Number" 87 | } 88 | ``` 89 | 90 | ### TimeChange: Time changed 91 | ```json 92 | { 93 | "Action": "Pulse", 94 | "Data": { 95 | "BeforeModifyTime": "02-03-2020 21:41:40", 96 | "LocaleTime": "2020-03-02 21:41:40", 97 | "ModifiedTime": "02-03-2020 21:41:39", 98 | "UTC": 1583178100 99 | }, 100 | "deviceType":"Device Model", 101 | "serialNumber":"Device Serial Number" 102 | } 103 | ``` 104 | 105 | ### NTPAdjustTime: NTP Adjusted time 106 | ```json 107 | { 108 | "Action": "Pulse", 109 | "Data": { 110 | "Address": "time.windows.com", 111 | "Before": "02-03-2020 21:41:38", 112 | "LocaleTime": "2020-03-02 21:41:40", 113 | "UTC": 1583178100, 114 | "result": true 115 | }, 116 | "deviceType":"Device Model", 117 | "serialNumber":"Device Serial Number" 118 | } 119 | ``` 120 | 121 | ### KeepLightOn: Keep light state changed 122 | Data.Status: Repesents whether the state changed to On or Off 123 | 124 | ### VideoBlind: Video got blind state changed 125 | Action: Represents whether event Start or Stop 126 | 127 | ### FingerPrintCheck: Finger print check status 128 | Data.FingerPrintID: Finger print ID, if 0, check failed 129 | 130 | ### SIPRegisterResult: SIP Device registration status 131 | ```json 132 | { 133 | "Action": "Pulse", 134 | "Data": { 135 | "Date": "02-03-2020 21:42:59", 136 | "LocaleTime": "2020-03-02 21:42:59", 137 | "Success": true, 138 | "UTC": 1583178179 139 | }, 140 | "deviceType":"Device Model", 141 | "serialNumber":"Device Serial Number" 142 | } 143 | ``` 144 | 145 | ### AccessControl: Someone opened the door 146 | ```json 147 | { 148 | "Action": "Pulse", 149 | "Data": { 150 | "CardNo": "", 151 | "CardType": null, 152 | "LocaleTime": "2020-03-02 20:24:08", 153 | "Method": 4, // 4=Remote/WebIf/SIPext | 6=FingerPrint 154 | "Name": "OpenDoor", // Access control action name 155 | "Password": "", 156 | "ReaderID": "1", 157 | "RecNo": 691, 158 | "SnapURL": "", 159 | "Status": 1, 160 | "Type": "Entry", 161 | "UTC": 1583173448, 162 | "UserID": "" // By FingerprintManager / Room Number / SIPext 163 | }, 164 | "deviceType":"Device Model", 165 | "serialNumber":"Device Serial Number" 166 | } 167 | ``` 168 | 169 | 170 | ### CallSnap: Call 171 | ```json 172 | { 173 | "Action": "Pulse", 174 | "Data": { 175 | "DeviceType": "Which device type", 176 | "RemoteID": "UserID", 177 | "RemoteIP": "IP of VTH / SIP device", 178 | "ChannelStates": "Status" 179 | }, 180 | "deviceType":"Device Model", 181 | "serialNumber":"Device Serial Number" 182 | } 183 | ``` 184 | 185 | ### Invite: Invite for a call (calling) 186 | ```json 187 | { 188 | "Action": "Pulse", 189 | "Data": { 190 | "CallID": "1", 191 | "IsEncryptedStream": false, 192 | "LocaleTime": "2020-03-02 20:11:13", 193 | "LockNum": 2, 194 | "SupportPaas": false, 195 | "TCPPort": 37777, 196 | "UTC": 1583172673 197 | }, 198 | "deviceType":"Device Model", 199 | "serialNumber":"Device Serial Number" 200 | } 201 | ``` 202 | 203 | ### AccessSnap: ? 204 | ```json 205 | { 206 | "Action": "?", 207 | "Data": { 208 | "FtpUrl": "FTP uploaded to", 209 | }, 210 | "deviceType":"Device Model", 211 | "serialNumber":"Device Serial Number" 212 | } 213 | ``` 214 | 215 | ### RequestCallState: ? 216 | ```json 217 | { 218 | "Action": "?", 219 | "Data": { 220 | "LocaleTime": "2020-03-02 20:24:07", 221 | "Index": 1 222 | }, 223 | "deviceType":"Device Model", 224 | "serialNumber":"Device Serial Number" 225 | } 226 | ``` 227 | 228 | ### PassiveHungup: Call was dropped 229 | ```json 230 | { 231 | "Action": "?", 232 | "Data": { 233 | "LocaleTime": "2020-03-02 20:24:07", 234 | "Index": 1 235 | }, 236 | "deviceType":"Device Model", 237 | "serialNumber":"Device Serial Number" 238 | } 239 | ``` 240 | 241 | ### ProfileAlarmTransmit: Alarm triggered 242 | ```json 243 | { 244 | "Action": "?", 245 | "Data": { 246 | "AlarmType": "ALARM TYPE", 247 | "SenseMethod": "What triggered the alarm", 248 | "DevSrcType": "Device triggered the alarm" 249 | }, 250 | "deviceType":"Device Model", 251 | "serialNumber":"Device Serial Number" 252 | } 253 | ``` 254 | 255 | ### BackLightOn: Back light turned-on 256 | ```json 257 | { 258 | "Action": "Pulse", 259 | "Data": { 260 | "LocaleTime": "2020-03-02 20:24:07", 261 | "UTC": 1583173447 262 | }, 263 | "deviceType":"Device Model", 264 | "serialNumber":"Device Serial Number" 265 | } 266 | ``` 267 | 268 | ### BackLightOff: Back light turned-on 269 | ```json 270 | { 271 | "Action": "Pulse", 272 | "Data": { 273 | "LocaleTime": "2020-03-02 20:23:39", 274 | "UTC": 1583173419 275 | }, 276 | "deviceType":"Device Model", 277 | "serialNumber":"Device Serial Number" 278 | } 279 | ``` 280 | 281 | ### AlarmLocal: Alarm triggered by the VTO unit 282 | ```json 283 | { 284 | "Action": "Stop", //Represents whether event for Start or Stop 285 | "Data": { 286 | "LocaleTime": "2020-03-02 20:11:16", 287 | "UTC": 1583172676 288 | }, 289 | "deviceType":"Device Model", 290 | "serialNumber":"Device Serial Number" 291 | } 292 | ``` 293 | 294 | ### APConnect: AccessPoint got connected (Stop) or disconnected (Start) 295 | ```json 296 | { 297 | "Action": "Stop", 298 | "Data": { 299 | "Error": "SSIDNotValid", 300 | "LocaleTime": "2020-03-02 19:20:07", 301 | "Result": false, 302 | "Type": "Timerconnect", 303 | "UTC": 1583158807 304 | }, 305 | "deviceType":"Device Model", 306 | "serialNumber":"Device Serial Number" 307 | } 308 | ``` 309 | 310 | ## Generic structure 311 | ```json 312 | { 313 | "id": "MESSAGE ID", 314 | "method":"client.notifyEventStream", 315 | "params":{ 316 | "SID":513, 317 | "eventList": [ 318 | { 319 | "Action":"[EVENT ACTION]", 320 | "Code":"[EVENT NAME]", 321 | "Data":{ 322 | "LocaleTime":"YYYY-MM-DD HH:mm:SS", 323 | "UTC": "EPOCH TIMESTAMP" 324 | }, 325 | "Index": "EVENT ID IN MESSAGE", 326 | "Param":[] 327 | } 328 | ] 329 | }, 330 | "session": "SESSION IDENTIFIER", 331 | "deviceType":"Device Model", 332 | "serialNumber":"Device Serial Number" 333 | } 334 | ``` 335 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DahuaVTO2MQTT 2 | Listens to events from Dahua VTO, Camera, NVR unit and publishes them via MQTT Message 3 | 4 | ## Repository moved to GitLab 5 | Latest code and docker image available under the GitLab repo: 6 | 7 | https://gitlab.com/elad.bar/DahuaVTO2MQTT 8 | -------------------------------------------------------------------------------- /SupportedModels.md: -------------------------------------------------------------------------------- 1 | # Supported Models 2 | 3 | - VTO1220BW 4 | - VTO2111d-WP 5 | - VTO2111D-P-S2 6 | - VTO3211D-P2-S1 7 | - VTO2000A(-2) 8 | - VTO2202F-P 9 | - VTO3221E 10 | --------------------------------------------------------------------------------