├── .gitignore ├── LICENSE ├── README.md ├── neptun.py ├── neptun2mqtt.ini ├── neptun2mqtt.py └── neptun2mqtt.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Windows bat file 6 | *.bat 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | .idea 61 | dev/ 62 | 63 | # Project files 64 | README.html 65 | *.log 66 | *.ini.win -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 ptvo.info 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neptun ProW+ - MQTT bridge 2 | 3 | This script allows you to read data and control state of the "[Neptun ProW+](https://neptun-mcs.ru/catalog/filter/c1/p4/v2/)" water leak detection system. 4 | 5 | The system controls automatically and independently one or more wire or wireless sensors and closes a valve when a water leak is detected. 6 | 7 | With this scritp/library you can: 8 | 9 | * Control one or more devices. 10 | * Get state of the main module and all sensors. 11 | * Open or close a valve. 12 | * Enable or disable the special "Cleaning" mode. 13 | 14 | ## How to configure 15 | 16 | The "neptun2mqtt.ini" file in the program folder contains all settings: 17 | 18 | ### MQTT 19 | **debug** - debug mode (0..2) 20 | 21 | **log** - log file location 22 | 23 | **server** - MQTT server IP address 24 | 25 | **port** - MQTT server IP port 26 | 27 | **username** / password - login and password for the MQTT server 28 | 29 | **mqtt_path** - a base topic for each device. 30 | 31 | **qos** - default QoS for all published data 32 | 33 | **retain** - default "Retain" flag for all published data 34 | 35 | ### Devices 36 | 37 | **discovery** - 1: enable auto-discovery of devices in your local network (if an IP address of a device is dynamic) 38 | 39 | **devices** - the "devices" parameter allows you to specify one or more devices with a static IP address and its friendly name. 40 | 41 | ``` 42 | devices = [ 43 | {"ip": "192.168.1.92", "friendly_name": "Neptun"} 44 | ] 45 | ``` 46 | 47 | ## How to install on Raspberry Pi or Banana Pi 48 | 49 | ```bash 50 | $ sudo su 51 | $ pip3 freeze --local | grep -v '^\-e' | cut -d = -f 1 | xargs -n1 pip3 install -U 52 | $ pip3 install ConfigParser paho-mqtt 53 | $ chmod 0755 neptun2mqtt.sh 54 | $ cp neptun2mqtt.sh /etc/init.d 55 | $ update-rc.d neptun2mqtt.sh defaults 56 | $ service neptun2mqtt start 57 | ``` 58 | 59 | ## How to start/stop the daemon 60 | 61 | $ sudo service neptun2mqtt start 62 | 63 | https://github.com/ptvoinfo/neptun2mqtt 64 | -------------------------------------------------------------------------------- /neptun.py: -------------------------------------------------------------------------------- 1 | from six import string_types 2 | import sys, traceback 3 | import os 4 | import socket, errno 5 | import datetime 6 | import time 7 | import threading 8 | 9 | import _thread as thread 10 | import queue 11 | 12 | """ 13 | UDP on Windows 10 with several networks. You should set a primary network 14 | 15 | 1. Goto Control Panel > Network and Internet > Network Connections 16 | 2. Right click the desired connection (Higher Priority Connection) 17 | 3. Click Properties > Internet Protocol Version 4 18 | 4. Click Properties > Advanced 19 | 5. Uncheck 'Automatic Metric' 20 | 6. Enter 10 in 'Interface Metric' 21 | 7. Click OK 22 | """ 23 | 24 | PACKET_WHOIS = 0x49 25 | PACKET_SYSTEM_STATE = 0x52 26 | PACKET_COUNTER_NAME = 0x63 27 | PACKET_COUNTER_STATE = 0x43 28 | PACKET_SENSOR_NAME = 0x4E 29 | PACKET_SENSOR_STATE = 0x53 30 | PACKET_BACK_STATE = 0x42 31 | PACKET_RECONNECT = 0x57 32 | PACKET_SET_SYSTEM_STATE = 0x57 33 | 34 | BROADCAST_PORT = 6350 35 | BROADCAST_ADDRESS = '255.255.255.255' 36 | 37 | SERVER_PORT = 6350 38 | 39 | SOCKET_BUFSIZE = 1024 40 | 41 | 42 | def time_delta(timestamp): 43 | if timestamp is None: 44 | return 9999999 45 | else: 46 | return (datetime.datetime.now() - timestamp).total_seconds() 47 | 48 | def crc16(data, data_len=0): 49 | ''' 50 | CRC16 51 | ''' 52 | polynom = 0x1021 53 | crc16ret = 0xFFFF 54 | if data_len > 0: 55 | data_len2 = data_len 56 | else: 57 | data_len2 = len(data) 58 | 59 | for j in range(data_len2): 60 | b = data[j] & 0xFF 61 | crc16ret ^= b << 8 62 | crc16ret &= 0xFFFF 63 | for i in range(8): 64 | if (crc16ret & 0x8000): 65 | crc16ret = (crc16ret << 1) ^ polynom 66 | else: 67 | crc16ret = crc16ret << 1 68 | crc16ret &= 0xFFFF 69 | crc_hi = (crc16ret >> 8) & 0xFF 70 | crc_lo = crc16ret & 0xFF 71 | return [crc_hi, crc_lo] 72 | 73 | 74 | def crc16_check(data): 75 | i = len(data) 76 | (crc_hi, crc_lo) = crc16(data, i - 2) 77 | return (data[i - 1] == crc_lo) and (data[i - 2] == crc_hi) 78 | 79 | 80 | def crc16_append(data): 81 | i = len(data) 82 | (crc_hi, crc_lo) = crc16(data) 83 | return data + bytearray([crc_hi, crc_lo]) 84 | 85 | 86 | class NeptunSocket: 87 | 88 | def __init__(self, owner, type=socket.SOCK_STREAM, port=SERVER_PORT): 89 | 90 | self.owner = owner 91 | self.sock = None 92 | self.is_udp = type == socket.SOCK_DGRAM 93 | self.port = port 94 | self.request_time = None 95 | self.request_data = None 96 | self.wait_response = False 97 | self.connected = False 98 | self._request_timeout = 0 99 | self.last_activity = datetime.datetime.now() 100 | self.prepare_socket() 101 | 102 | def prepare_socket(self): 103 | if self.sock is None: 104 | self.owner.log("Allocating socket") 105 | if self.is_udp: 106 | self.sock = self._prepare_socket_udp() 107 | else: 108 | self.sock = self._prepare_socket_tcp() 109 | 110 | return self.sock 111 | 112 | def _prepare_socket_tcp(self): 113 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP 114 | try: 115 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 116 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 117 | sock.settimeout(1) 118 | except AttributeError: 119 | pass 120 | 121 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, SOCKET_BUFSIZE) 122 | sock.bind(('', self.port)) 123 | 124 | return sock 125 | 126 | def _prepare_socket_udp(self): 127 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 128 | try: 129 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 130 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 131 | sock.settimeout(1) 132 | except AttributeError: 133 | pass 134 | 135 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, SOCKET_BUFSIZE) 136 | sock.bind(('', BROADCAST_PORT)) 137 | 138 | return sock 139 | 140 | def _set_keepalive_linux(self, after_idle_sec=1, interval_sec=3, max_fails=5): 141 | """Set TCP keepalive on an open socket. 142 | 143 | It activates after 1 second (after_idle_sec) of idleness, 144 | then sends a keepalive ping once every 3 seconds (interval_sec), 145 | and closes the connection after 5 failed ping (max_fails), or 15 seconds 146 | """ 147 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 148 | self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, after_idle_sec) 149 | self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, interval_sec) 150 | self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, max_fails) 151 | 152 | def _set_keepalive_windows(self, after_idle_sec=1, interval_sec=3, max_fails=5): 153 | """Set TCP keepalive on an open socket. 154 | 155 | It activates after 1 second (after_idle_sec) of idleness, 156 | then sends a keepalive ping once every 3 seconds (interval_sec), 157 | and closes the connection after 5 failed ping (max_fails), or 15 seconds 158 | """ 159 | self.sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, interval_sec * (max_fails + 1) * 1000, interval_sec * 1000)) 160 | 161 | def _connect(self, address): 162 | if self.is_udp: 163 | self.connected = True 164 | return True 165 | 166 | self.owner.log("Connecting to:", address) 167 | if self.sock is None: 168 | self.prepare_socket() 169 | 170 | _error = "Unable to connect" 171 | try: 172 | res = self.sock.connect_ex(address) 173 | if res == 0: 174 | self.owner.log("Connected successfully to:", address) 175 | self.connected = True 176 | else: 177 | self.owner.log(_error, res) 178 | self.last_activity = datetime.datetime.now() 179 | 180 | if not self.is_udp: 181 | if (os.name == "posix"): 182 | self._set_keepalive_linux(1, 3, 3) 183 | else: 184 | self._set_keepalive_windows(1, 3, 3) 185 | 186 | except Exception as e: 187 | self.owner.log_traceback(_error, e) 188 | self.disconnect() 189 | except: 190 | self.owner.log(_error) 191 | self.disconnect() 192 | 193 | return self.connected 194 | 195 | def disconnect(self): 196 | if self.connected: 197 | self.owner.log("Closing connection") 198 | self.connected = False 199 | 200 | _error = "Unable to close connection" 201 | try: 202 | self.owner.log("Shutdowning socket") 203 | try: 204 | self.sock.shutdown(socket.SHUT_WR) 205 | except: 206 | pass 207 | self.owner.log("Closing socket") 208 | if hasattr(self.sock, '_sock'): 209 | self.sock._sock.close() 210 | self.sock.close() 211 | except Exception as e: 212 | self.owner.log_traceback(_error, e) 213 | except: 214 | self.owner.log(_error) 215 | self.sock = None 216 | 217 | def request_send(self, data, addr, port, timeout): 218 | self.request_time = datetime.datetime.now() 219 | self.last_activity = self.request_time 220 | self.request_data = data 221 | self._request_timeout = timeout 222 | self.wait_response = timeout > 0 223 | if not self.connected: 224 | self._connect((addr, port)) 225 | if self.connected: 226 | if self.owner.debug_mode > 1: 227 | self.owner.log("--> (" + addr + ':' + str(port) + "):", self.owner._formatBuffer(data)) 228 | 229 | if self.is_udp: 230 | self.sock.sendto(data, (addr, port)) 231 | else: 232 | res = self.sock.send(data) 233 | if res <= 0: 234 | self.owner.log("Unable to send TCP data") 235 | 236 | def request_check_timeout(self): 237 | if self.request_time is not None: 238 | self.last_activity = datetime.datetime.now() 239 | diff = time_delta(self.request_time) 240 | return diff >= self._request_timeout 241 | else: 242 | return True 243 | 244 | def request_complete(self): 245 | self.request_time = None 246 | self.request_data = None 247 | self.wait_response = False 248 | 249 | def check_close_conn(self): 250 | if (not self.is_udp) and self.connected: 251 | diff = time_delta(self.last_activity) 252 | if diff >= 120: 253 | self.disconnect() 254 | 255 | 256 | class RequestSendPeriodically: 257 | def __init__(self, owner, timeout, method): 258 | """Initialize the connector.""" 259 | self.owner = owner 260 | self.timeout = timeout 261 | self.method = method 262 | self.last_sent = None 263 | self.retry = 0 264 | self.count = 0 265 | 266 | def check_send(self, timeout = None, incCounter = True): 267 | """ 268 | Checks and sends a next request after the specified timeout. 269 | """ 270 | 271 | if self.owner.socket is None: 272 | return False 273 | 274 | if self.owner.socket.wait_response: 275 | return False 276 | 277 | if timeout is None: 278 | timeout_ = self.timeout 279 | else: 280 | timeout_ = timeout 281 | 282 | if self.last_sent is None: 283 | diff = timeout_ + 1 284 | else: 285 | diff = time_delta(self.last_sent) 286 | 287 | if diff >= timeout_: 288 | self.last_sent = datetime.datetime.now() 289 | self.method() 290 | if incCounter: 291 | self.count += 1 292 | return True 293 | return False 294 | 295 | class NeptunConnector(threading.Thread): 296 | """Connector for the Xiaomi Mi Hub and devices on multicast.""" 297 | 298 | SEND_WHOIS_TIMEOUT = 300 # resend whois request every 5 minues (0 - do not resend) 299 | SEND_HEARTBEAT_TIMEOUT = 300 # send heartbeat packets (0 - do not resend) 300 | 301 | def __init__(self, ip, port=SERVER_PORT, data_callback=None, log_callback=None, debug_mode=0): 302 | """Initialize the connector.""" 303 | self.ip = ip 304 | self.port = port 305 | self.debug_mode = debug_mode 306 | self.log_callback = log_callback 307 | self.data_callback = data_callback 308 | 309 | self.whois_request = RequestSendPeriodically(self, NeptunConnector.SEND_WHOIS_TIMEOUT, self.send_whois) 310 | 311 | self.command_queue = queue.Queue() 312 | self.device = {'lines': {}} 313 | self.terminated = False 314 | self.socket = None 315 | 316 | self.command_signal = 0 # used by a higher level 317 | self.last_state_updated = None 318 | self.state_update_interval = 120 # poll the device with this interval is seconds 319 | 320 | self.log_prefix = '[' + ip + ']:' 321 | 322 | threading.Thread.__init__(self) 323 | 324 | def terminate(self): 325 | """ 326 | Signal the thread to terminate. 327 | """ 328 | self.terminated = True 329 | self.command_queue.put(None) 330 | 331 | def run(self): 332 | """ 333 | Thread loop. 334 | """ 335 | self.log('Thread started') 336 | 337 | if self.ip == BROADCAST_ADDRESS: 338 | self.socket = NeptunSocket(self, socket.SOCK_DGRAM, self.port) 339 | else: 340 | self.socket = NeptunSocket(self, port=self.port) 341 | 342 | while not self.terminated: 343 | try: 344 | self.check_incoming() 345 | except Exception as e: 346 | if self.debug_mode > 1: 347 | self.log_traceback("Error in connector's thread", e) 348 | except: 349 | if self.debug_mode > 1: 350 | self.log("Error in connector's thread") 351 | time.sleep(0.5) 352 | 353 | if self.socket is not None: 354 | self.socket.disconnect() 355 | 356 | self.log('Thread terminated') 357 | 358 | def _formatBuffer(self, data: bytes): 359 | """ 360 | Format a buffer to readable format. 361 | """ 362 | res = "" 363 | cnt = 0 364 | for n in data: 365 | res = res + format(n, '02X') + ' ' 366 | cnt = cnt + 1 367 | if cnt >= 32: 368 | cnt = 0 369 | res = res + "\n" 370 | return res 371 | 372 | def _update_timestamp(self, *args): 373 | dt = datetime.datetime.now() 374 | dt = dt.replace(microsecond=0) 375 | self.device['timestamp'] = dt.isoformat(' ') 376 | 377 | def log(self, *args): 378 | if self.log_callback is not None: 379 | self.log_callback(self.log_prefix, *args) 380 | else: 381 | d = datetime.datetime.now() 382 | print(self.log_prefix, d.strftime("%Y-%m-%d %H:%M:%S"), *args) 383 | return 384 | 385 | def log_traceback(self, message, ex, ex_traceback=None): 386 | """ 387 | Log detailed call stack for exceptions. 388 | """ 389 | if self.debug_mode: 390 | if ex_traceback is None: 391 | ex_traceback = ex.__traceback__ 392 | tb_lines = [line.rstrip('\n') for line in 393 | traceback.format_exception(ex.__class__, ex, ex_traceback)] 394 | self.log(message + ':', tb_lines) 395 | else: 396 | self.log(message + ':', ex) 397 | 398 | return 399 | 400 | def get_line_info(self, idx): 401 | """ 402 | Get an information set for the specified line index. 403 | """ 404 | line_id = 'line' + str(idx) 405 | if line_id not in self.device['lines']: 406 | self.device['lines'][line_id] = {} 407 | return self.device['lines'][line_id] 408 | 409 | def set_line_info(self, idx, info): 410 | """ 411 | Set an information set for the specified line index. 412 | """ 413 | line_id = 'line' + str(idx) 414 | self.device['lines'][line_id] = info 415 | 416 | def decode_status(self, status): 417 | """ 418 | Decode status bit mask to a string. 419 | """ 420 | if(status == 0x00): 421 | return 'NORMAL' 422 | s = [] 423 | if(status & 0x01): 424 | s.append('ALARM') 425 | if(status & 0x02): 426 | s.append('MAIN BATTERY') 427 | if(status & 0x04): 428 | s.append('SENSOR BATTERY') 429 | if(status & 0x08): 430 | s.append('SENSOR OFFLINE') 431 | return ','.join(s) 432 | 433 | def check_incoming(self): 434 | """ 435 | Check incoming data, close unused TCP connections. 436 | """ 437 | if self.socket is None: 438 | return 439 | 440 | if self.socket.wait_response: 441 | if self.socket.request_check_timeout(): 442 | self.log('Request timeout') 443 | self.socket.request_complete() 444 | self.socket.disconnect() 445 | else: 446 | self.send_from_queue() 447 | 448 | self.socket.check_close_conn() 449 | 450 | data = None 451 | try: 452 | if self.socket.connected: 453 | data, addr = self.socket.sock.recvfrom(SOCKET_BUFSIZE) 454 | if self.debug_mode > 1: 455 | if self.socket.is_udp: 456 | addr = addr[0] 457 | else: 458 | addr = self.ip 459 | self.log('<--', addr, ":", self._formatBuffer(data)) 460 | if data is not None: 461 | self.handle_incoming_data(self.socket, addr, data) 462 | 463 | return True 464 | except socket.timeout as e: 465 | pass 466 | 467 | except socket.error as e: 468 | if e.errno == errno.ECONNRESET: 469 | self.log("Disconnected by peer (%r)" % (e)) 470 | self.socket.disconnect() 471 | else: 472 | self.log("Other socket error (%r)" % (e)) 473 | 474 | except Exception as e: 475 | self.log_traceback("Can't incoming data %r" % (data), e) 476 | raise 477 | 478 | def handle_incoming_data(self, sock, ip, data): 479 | """ 480 | Handle an incoming data packet (control checksum, decode to a readable format) 481 | """ 482 | if len(data) < 4: 483 | self.log("Invalid length of a data packet") 484 | return False 485 | 486 | if not crc16_check(data): 487 | self.log("Invalid checksum of a data packet") 488 | return False 489 | 490 | callback_data = {} 491 | if sock.is_udp: 492 | if (data == sock.request_data): 493 | # this is our request 494 | return False 495 | 496 | try: 497 | if sock.wait_response: 498 | sock.wait_response = False 499 | sock.request_data = None 500 | sock.request_time = None 501 | 502 | if self.data_callback is not None: 503 | data = bytearray(data) 504 | data_len = len(data) - 2 505 | del data[data_len:] # remove CRC 506 | packet_type = data[3] 507 | 508 | callback_data['type'] = packet_type 509 | 510 | if sock.is_udp: 511 | callback_data['ip'] = ip 512 | if packet_type == PACKET_WHOIS: 513 | offset = 6 514 | callback_data['type'] = chr(data[offset]) + chr(data[offset+1]) 515 | offset += 2 516 | callback_data['version'] = chr(data[offset]) + '.' + \ 517 | chr(data[offset+1]) + '.' + chr(data[offset+2]) 518 | offset += 3 519 | data = data.split(b':', 2) 520 | data = data[1] 521 | callback_data['mac'] = data 522 | 523 | elif packet_type == PACKET_SYSTEM_STATE: 524 | # system state 525 | self.device['lines'] = {} 526 | offset = 6 527 | while(offset < data_len): 528 | tag = data[offset] 529 | offset += 1 530 | tag_size = data[offset] * 0x100 + data[offset + 1] 531 | offset += 2 532 | offset2 = offset 533 | if tag == 73: # 0x 49 534 | # type and version 535 | self.device['type'] = chr(data[offset2]) + chr(data[offset2+1]) 536 | offset2 += 2 537 | self.device['version'] = chr(data[offset2]) + '.' + \ 538 | chr(data[offset2+1]) + '.' + chr(data[offset2+2]) 539 | elif tag == 78: # 0x4E 540 | # name 541 | str_data = data[offset2:offset2+tag_size] 542 | self.device['name'] = str_data.decode('ascii') 543 | elif tag == 77: # 0x4D 544 | # MAC 545 | str_data = data[offset2:offset2+tag_size] 546 | self.device['mac'] = str_data.decode('ascii') 547 | elif tag == 65: # 0x41 548 | # access 549 | access = False 550 | if (tag_size > 0) and (data[offset2] > 0): 551 | access = True 552 | self.device['access'] = access 553 | elif tag == 83: # 0x53 554 | # main valve state: open/closed 555 | self._update_timestamp() 556 | self.device['valve_state_open'] = data[offset2] == 1 557 | offset2 += 1 558 | # number of wireless sensors 559 | self.device['sensor_count'] = data[offset2] 560 | offset2 += 1 561 | self.device['relay_count'] = data[offset2] 562 | offset2 += 1 563 | # cleaning mode (ignore sensors alarms) 564 | self.device['flag_dry'] = data[offset2] == 1 565 | offset2 += 1 566 | # close valve is wireless sensors are offline 567 | self.device['flag_cl_valve'] = data[offset2] == 1 568 | offset2 += 1 569 | # wired line mode: sensor/counter (bit mask) 570 | self.device['line_in_config'] = data[offset2] 571 | offset2 += 1 572 | # bitmask 573 | # 0x00 - no events (normal mode) 574 | # 0x01 - alarm 575 | # 0x02 - battery on main module is low 576 | # 0x04 - battery on sensor is low 577 | # 0x08 - sensor (offline) 578 | self.device['status'] = data[offset2] 579 | self.device['status_name'] = self.decode_status(data[offset2]) 580 | 581 | elif tag == 115: # 0x73 582 | # state of wired lines 583 | for idx in range(4): 584 | sensor_info = self.get_line_info(idx) 585 | sensor_info['state'] = data[offset2] 586 | self.set_line_info(idx, sensor_info) 587 | offset2 += 1 588 | 589 | offset += tag_size 590 | 591 | self.send_get_counter_names() 592 | 593 | elif packet_type == PACKET_COUNTER_NAME: 594 | # counter name response 595 | offset = 4 596 | tag_size = data[offset] * 0x100 + data[offset + 1] 597 | offset += 2 598 | str_data = data[offset:] 599 | sensor_names = str_data.split(b'\x00') 600 | sensor_names.pop(-1) 601 | idx = 0 602 | mode = self.device['line_in_config'] 603 | mask = 1 604 | for sensor_name in sensor_names: 605 | if (mode & mask) != 0: 606 | line_type = 'counter' 607 | else: 608 | line_type = 'sensor' 609 | sensor_info = self.get_line_info(idx) 610 | sensor_info['name'] = sensor_name.decode('cp1251') 611 | sensor_info['type'] = line_type 612 | sensor_info['wire'] = True 613 | self.set_line_info(idx, sensor_info) 614 | idx += 1 615 | 616 | self.send_get_counter_value() 617 | 618 | elif packet_type == PACKET_COUNTER_STATE: 619 | # counter value response 620 | 621 | offset = 4 622 | tag_size = data[offset] * 0x100 + data[offset + 1] 623 | offset += 2 624 | 625 | idx = 0 626 | while(offset < data_len): 627 | sensor_info = self.get_line_info(idx) 628 | value = (data[offset] << 24) + (data[offset] << 16) + (data[offset] << 8) + (data[offset] << 24) 629 | sensor_info['value'] = value 630 | sensor_info['step'] = data[offset + 4] 631 | self.set_line_info(idx, sensor_info) 632 | # self.log('Wired sensor or counter:', sensor_info) 633 | offset += 5 634 | idx += 1 635 | 636 | self.send_get_sensor_names() 637 | 638 | elif packet_type == PACKET_SENSOR_NAME: 639 | # sensor names response 640 | offset = 4 641 | tag_size = data[offset] * 0x100 + data[offset + 1] 642 | offset += 2 643 | str_data = data[offset:] 644 | sensor_names = str_data.split(b'\x00') 645 | sensor_names.pop(-1) 646 | idx = 4 647 | for sensor_name in sensor_names: 648 | sensor_info = self.get_line_info(idx) 649 | sensor_info['name'] = sensor_name.decode('cp1251') 650 | sensor_info['type'] = 'sensor' 651 | sensor_info['wire'] = False 652 | self.set_line_info(idx, sensor_info) 653 | idx += 1 654 | 655 | self.send_get_sensor_state() 656 | 657 | elif packet_type == PACKET_SENSOR_STATE: 658 | # sensor state response 659 | self.last_state_updated = datetime.datetime.now() 660 | offset = 4 661 | tag_size = data[offset] * 0x100 + data[offset + 1] 662 | offset += 2 663 | 664 | idx = 4 665 | while(offset < data_len): 666 | sensor_info = self.get_line_info(idx) 667 | sensor_info['signal'] = data[offset] 668 | sensor_info['line'] = data[offset + 1] 669 | sensor_info['battery'] = data[offset + 2] 670 | sensor_info['state'] = data[offset + 3] 671 | self.set_line_info(idx, sensor_info) 672 | # self.log('Wireless sensor:', sensor_info) 673 | offset += 4 674 | idx += 1 675 | 676 | elif packet_type == PACKET_BACK_STATE: 677 | # background status 678 | offset = 4 679 | tag_size = data[offset] * 0x100 + data[offset + 1] 680 | offset += 2 681 | if tag_size > 0: 682 | self._update_timestamp() 683 | self.device['status'] = data[offset] 684 | self.device['status_name'] = self.decode_status(data[offset]) 685 | 686 | try: 687 | self.data_callback(self, sock, ip, callback_data) 688 | except Exception as e: 689 | self.log_traceback('Unhandled exception id data_callback', e) 690 | 691 | except Exception as e: 692 | self.log_traceback('Unhandled exception', e) 693 | 694 | return True 695 | 696 | def send_from_queue(self): 697 | """ 698 | Send a message from a queue. 699 | """ 700 | command = None 701 | try: 702 | if not self.command_queue.empty(): 703 | command = self.command_queue.get_nowait() 704 | except: 705 | pass 706 | 707 | if command is not None: 708 | _error = 'Unable to process command' 709 | try: 710 | data = command['data'] 711 | ip = command['ip'] 712 | port = command['port'] 713 | timeout = command['timeout'] 714 | 715 | self.socket.request_send(data, ip, port, timeout) 716 | 717 | except Exception as e: 718 | self.log_traceback(_error, e) 719 | except: 720 | self.log(_error) 721 | 722 | # signals to queue job is done 723 | self.command_queue.task_done() 724 | 725 | def send_command(self, data, ip, port, timeout): 726 | """ 727 | Add a command to a queue. 728 | """ 729 | self.log("++Q (" + ip + ':' + str(port) + ") :", data) 730 | self.command_queue.put({'data': data, 'ip': ip, 'port': port, 'timeout': timeout}) 731 | 732 | def send_whois(self): 733 | """ 734 | Whois command: for the broadcast (UDP) connector only. 735 | """ 736 | data = bytearray([2, 84, 81, PACKET_WHOIS, 0, 0]) 737 | # crc must be 0x99, 0xD7 738 | data = crc16_append(data) 739 | self.whois_request.last_sent = datetime.datetime.now() 740 | self.send_command(data, BROADCAST_ADDRESS, BROADCAST_PORT, 0) 741 | return self 742 | 743 | def send_reconnect(self): 744 | """ 745 | Reconnect data packet. 746 | """ 747 | data = bytearray([2, 84, 81, PACKET_RECONNECT, 0, 3, 82]) 748 | data = crc16_append(data) 749 | self.send_command(data, self.ip, self.port, 0) 750 | return self 751 | 752 | def send_get_counter_names(self): 753 | """ 754 | Get counter or wired sensor names. 755 | """ 756 | data = bytearray([2, 84, 81, PACKET_COUNTER_NAME, 0, 0]) 757 | data = crc16_append(data) 758 | self.send_command(data, self.ip, self.port, 5) 759 | return self 760 | 761 | def send_get_counter_value(self): 762 | """ 763 | Get counter values. 764 | """ 765 | data = bytearray([2, 84, 81, PACKET_COUNTER_STATE, 0, 0]) 766 | data = crc16_append(data) 767 | self.send_command(data, self.ip, self.port, 5) 768 | return self 769 | 770 | def send_get_sensor_names(self): 771 | """ 772 | Get wireless sensor names. 773 | """ 774 | data = bytearray([2, 84, 81, PACKET_SENSOR_NAME, 0, 0]) 775 | data = crc16_append(data) 776 | self.send_command(data, self.ip, self.port, 5) 777 | return self 778 | 779 | def send_get_sensor_state(self): 780 | """ 781 | Get wireless sensor info and state. 782 | """ 783 | data = bytearray([2, 84, 81, PACKET_SENSOR_STATE, 0, 0]) 784 | data = crc16_append(data) 785 | self.send_command(data, self.ip, self.port, 5) 786 | return self 787 | 788 | def send_get_system_state(self): 789 | """ 790 | Get detailed device info and wired sensors state. 791 | """ 792 | data = bytearray([2, 84, 81, PACKET_SYSTEM_STATE, 0, 0]) 793 | data = crc16_append(data) 794 | self.send_command(data, self.ip, self.port, 15) 795 | return self 796 | 797 | def send_get_background_status(self): 798 | """ 799 | Get main (overall) status. 800 | """ 801 | data = bytearray([2, 84, 81, PACKET_BACK_STATE, 0, 0]) 802 | data = crc16_append(data) 803 | self.send_command(data, self.ip, self.port, 30) 804 | return self 805 | 806 | def send_settings(self, valve_state_open, flag_dry, flag_cl_valve, line_in_config): 807 | """ 808 | Change device status bits. 809 | valve_state_open - valve is opened/closed. 810 | flag_dry - cleaning flag. 811 | flag_cl_valve - close a valve if wireless sensor(s) is offline. 812 | line_in_config - (bitmask) mode of wired sensors (1 - counter, 0 - sensor). 813 | """ 814 | data = bytearray([2, 84, 81, PACKET_SET_SYSTEM_STATE, 0, 7, 83, 0, 4, 0, 0, 0, 0]) 815 | if valve_state_open: 816 | data[9] = 1 817 | if flag_dry: 818 | data[10] = 1 819 | if flag_cl_valve: 820 | data[11] = 1 821 | data[12] = line_in_config 822 | data = crc16_append(data) 823 | self.send_command(data, self.ip, self.port, 5) 824 | return self 825 | 826 | def send_set_valve_state(self, is_open): 827 | """ 828 | Open/close valve. 829 | """ 830 | self.send_settings(is_open, self.device['flag_dry'], 831 | self.device['flag_cl_valve'], self.device['line_in_config']) 832 | return self 833 | 834 | def send_set_cleaning_mode(self, is_enabled): 835 | """ 836 | Set/unset a cleaning mode flag. 837 | """ 838 | self.send_settings(self.device['valve_state_open'], is_enabled, 839 | self.device['flag_cl_valve'], self.device['line_in_config']) 840 | return self 841 | -------------------------------------------------------------------------------- /neptun2mqtt.ini: -------------------------------------------------------------------------------- 1 | [MQTT] 2 | debug=2 3 | log=/var/log/neptun2mqtt.log 4 | server: 192.168.1.13 5 | port: 1883 6 | username: 7 | password: 8 | mqtt_path = home/{friendly_name} 9 | qos=1 10 | retain=1 11 | 12 | [devices] 13 | 14 | discovery=0 15 | devices = [ 16 | {"ip": "192.168.1.92", "friendly_name": "Neptun"} 17 | ] -------------------------------------------------------------------------------- /neptun2mqtt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from neptun import * 5 | import os 6 | import signal 7 | import paho.mqtt.client as mqtt 8 | import json 9 | import sys, traceback 10 | import time 11 | import datetime 12 | import binascii 13 | import logging 14 | import traceback 15 | 16 | import configparser as ConfigParser # Python 3+ 17 | import _thread as thread 18 | 19 | devices = None 20 | 21 | exitSignal = False 22 | debug_mode = 0 23 | logger = None 24 | mqtt_client = None 25 | MQTT_QOS = 0 26 | MQTT_RETAIN = False 27 | MQTT_PATH = 'neptun/{friendly_name}' 28 | connected_devices_info = {} 29 | connected_devices = {} 30 | subscribed_devices = {} # maintain this list independently because MQTT client may connect/disconnect 31 | 32 | def signal_handler(signal, frame): 33 | """ 34 | Captures the "Ctrl+C" event in a console window and signals to exit 35 | """ 36 | log('SIGINT') 37 | global exitSignal 38 | exitSignal = True 39 | 40 | 41 | def printf(*args): 42 | together = ' '.join(map(str, args)) # avoid the arg is not str 43 | return together 44 | 45 | 46 | def log(*args): 47 | if logger is not None: 48 | logger.info(printf(*args)) 49 | else: 50 | d = datetime.datetime.now() 51 | print(d.strftime("%Y-%m-%d %H:%M:%S"), *args) 52 | return 53 | 54 | 55 | def log_traceback(message, ex, ex_traceback=None): 56 | """ 57 | Log detailed call stack for exceptions. 58 | """ 59 | if ex_traceback is None: 60 | ex_traceback = ex.__traceback__ 61 | tb_lines = [line.rstrip('\n') for line in 62 | traceback.format_exception(ex.__class__, ex, ex_traceback)] 63 | log(message + ':', tb_lines) 64 | 65 | def on_connect(client, userdata, flags, rc): 66 | """ 67 | The callback for when the client receives a CONNACK response from the server. 68 | """ 69 | log("Connected with result code:", rc) 70 | subscribed_devices = {} # we (re)subscribe when data will be received 71 | 72 | def str_to_bool(value): 73 | data = str(value) 74 | if (data == '1') or (data == 'True'): 75 | return True 76 | else: 77 | return False 78 | 79 | def on_message(client, userdata, msg): 80 | """ 81 | The callback for when a PUBLISH message is received from the server. 82 | path_to_command/command - receives a json encoded command and allows you to change the valve and cleaning mode states at the same time 83 | path_to_command/command/valve - receives 0/1 or False/True and allows you to change the valve state individually 84 | path_to_command/command/cleaning - receives 0/1 or False/True and allows you to change the cleaning mode state 85 | """ 86 | if msg is None or msg.payload is None: 87 | return 88 | 89 | log("Topic:", msg.topic, "\nMessage:", msg.payload) 90 | parts = msg.topic.split('/') 91 | last = parts.pop(-1) 92 | msg_topic = None 93 | 94 | valve_state_open = None 95 | flag_dry = None 96 | 97 | if last == 'command': 98 | # json command 99 | msg_topic = msg.topic 100 | try: 101 | data = json.loads(msg.payload) 102 | if 'cleaning' in data: 103 | flag_dry = str_to_bool(data['cleaning']) 104 | if 'valve' in data: 105 | valve_state_open = str_to_bool(data['valve']) 106 | except: 107 | log('Invalid JSON data:', msg.payload) 108 | return 109 | elif (last == 'valve') or (last == 'cleaning'): 110 | msg_data = msg.payload.decode("utf-8") 111 | 112 | parts.pop(-1) # remove 'command' 113 | msg_topic = '/'.join(parts) 114 | if last == 'valve': 115 | valve_state_open = str_to_bool(msg_data) 116 | if last == 'cleaning': 117 | flag_dry = str_to_bool(msg_data) 118 | else: 119 | return 120 | 121 | """ 122 | we can serve several devices 123 | trying to find a device by the topic name 124 | """ 125 | found = None 126 | for ip, topic in subscribed_devices.items(): 127 | if topic == msg_topic: 128 | found = ip 129 | break 130 | 131 | if (found is None) or (found not in connected_devices): 132 | log('Unable to execute command. Device not found') 133 | return 134 | 135 | connector = connected_devices[found] 136 | 137 | if 'line_in_config' not in connector.device: 138 | # connector not ready yet 139 | log('Unable to execute command. Device not ready yet') 140 | return 141 | 142 | if valve_state_open is None: 143 | valve_state_open = connector.device['valve_state_open'] 144 | else: 145 | connector.device['valve_state_open'] = valve_state_open 146 | if flag_dry is None: 147 | flag_dry = connector.device['flag_dry'] 148 | else: 149 | connector.device['flag_dry'] = flag_dry 150 | 151 | flag_cl_valve = connector.device['flag_cl_valve'] 152 | line_in_config = connector.device['line_in_config'] 153 | 154 | log('Sending command to the device: Valve:', valve_state_open, 'Cleaning:', flag_dry) 155 | connector.send_settings(valve_state_open, flag_dry, flag_cl_valve, line_in_config) 156 | connector.command_signal = 1 # re-read device state after the command 157 | return True 158 | 159 | def prepare_mqtt(MQTT_SERVER, MQTT_PORT=1883): 160 | """ 161 | Prepare and connect to a MQTT server. 162 | """ 163 | client = mqtt.Client() 164 | client.on_connect = on_connect 165 | client.on_message = on_message 166 | client.connect(MQTT_SERVER, MQTT_PORT, 60) 167 | return client 168 | 169 | 170 | def push_data(client, path, data): 171 | """ 172 | Publish prepared data on the server. 173 | """ 174 | 175 | if client is None: 176 | return 177 | 178 | client.publish(path, payload=str(data), qos=MQTT_QOS, retain=MQTT_RETAIN) 179 | 180 | 181 | def ConfigSectionMap(Config, section): 182 | """ 183 | Load settings from a INI file section to a dict. 184 | """ 185 | dict1 = {} 186 | options = Config.options(section) 187 | for option in options: 188 | if option.startswith(';'): 189 | pass 190 | else: 191 | try: 192 | dict1[option] = Config.get(section, option) 193 | if dict1[option] == -1: 194 | log("skip: %s" % option) 195 | except: 196 | log("Exception on %s!" % option) 197 | dict1[option] = None 198 | return dict1 199 | 200 | def get_device_topic(ip): 201 | """ 202 | Make a device topic name string from a template. 203 | """ 204 | info = connected_devices_info.get(ip) 205 | if info is not None: 206 | friendly_name = info.get('friendly_name', '') 207 | if friendly_name == '': 208 | friendly_name = info.get('name', '') 209 | path = MQTT_PATH.format( 210 | friendly_name=friendly_name, 211 | ip=ip, 212 | name=info.get('name', '')) 213 | return path 214 | return None 215 | 216 | def check_subscription(ip): 217 | """ 218 | Check subscription for a device topic name. 219 | """ 220 | if ip in subscribed_devices: 221 | return 222 | 223 | if mqtt_client is not None: 224 | path = get_device_topic(ip) 225 | if path is None: 226 | # device is not connected yet 227 | return 228 | _error = "Unable to subscribe" 229 | try: 230 | path1 = path + "/command/+" 231 | log("Subscribing to:", path1) 232 | mqtt_client.subscribe(path1) 233 | path2 = path + "/command" 234 | log("Subscribing to:", path2) 235 | mqtt_client.subscribe(path2) 236 | subscribed_devices[ip] = path 237 | except Exception as e: 238 | log_traceback(_error, e) 239 | except: 240 | log(_error) 241 | 242 | def prepare_and_publish_value(path, data, value, topic): 243 | if value in data: 244 | if data[value]: 245 | str_value = '1' 246 | else: 247 | str_value = '0' 248 | push_data(mqtt_client, path + '/' + topic, str_value) 249 | 250 | def prepare_and_publish_data(connector): 251 | """ 252 | Prepare device data for publishing. 253 | """ 254 | #log('Device info:', connector.device) 255 | 256 | # select data to publish 257 | names = ('timestamp', 'name', 'mac', 'ip', 'status', 'status_name', 'valve_state_open', 'flag_dry') 258 | lines2 = dict(connector.device['lines']) 259 | for line_id in lines2: 260 | line_info = lines2[line_id] 261 | name = line_info.get('name', '') 262 | if name == '': 263 | del lines2[line_id] 264 | data2 = {} 265 | for name in names: 266 | if name in connector.device: 267 | data2[name] = connector.device[name] 268 | data2['lines'] = lines2 269 | 270 | data_plain = json.dumps(data2)#.encode("utf-8") 271 | 272 | path = get_device_topic(ip) 273 | log('publishing data', data_plain) 274 | push_data(mqtt_client, path, data_plain) 275 | 276 | prepare_and_publish_value(path, data2, 'valve_state_open', 'valve') 277 | prepare_and_publish_value(path, data2, 'flag_dry', 'cleaning') 278 | 279 | return 280 | 281 | def callback_data(connector, sock, ip, data): 282 | """ 283 | The callback for connectors that read data from devices. 284 | """ 285 | _error = "Unable to process data" 286 | try: 287 | check_subscription(ip) 288 | 289 | if data['type'] == PACKET_SENSOR_STATE: 290 | connector.can_send_background_status_request = True 291 | connected_devices_info[ip].update(connector.device) 292 | return prepare_and_publish_data(connector) 293 | 294 | elif data['type'] == PACKET_BACK_STATE: 295 | return prepare_and_publish_data(connector) 296 | 297 | elif data['type'] == PACKET_SET_SYSTEM_STATE: 298 | if connector.command_signal == 1: 299 | connector.command_signal = 0 300 | connector.send_get_system_state() 301 | except Exception as e: 302 | log_traceback(_error, e) 303 | except: 304 | log(_error) 305 | 306 | return 307 | 308 | 309 | def connect_device(ip, device_info, silent): 310 | """ 311 | Add a device to a list of monitored devices. 312 | """ 313 | check_subscription(ip) 314 | 315 | if ip not in connected_devices: 316 | if not silent: 317 | log('New device found:', ip) 318 | 319 | connected_devices_info[ip] = device_info 320 | 321 | connector = NeptunConnector(ip, 322 | data_callback=callback_data, 323 | log_callback=log, 324 | debug_mode=debug_mode) 325 | 326 | connected_devices[ip] = connector 327 | 328 | connector.setDaemon(True) 329 | connector.whois_request.last_sent = datetime.datetime.now() 330 | connector.start() 331 | 332 | connector.system_state_request = RequestSendPeriodically(connector, 120, connector.send_get_system_state) 333 | connector.system_state_request.last_sent = datetime.datetime.now() 334 | connector.send_get_system_state() 335 | 336 | connector.can_send_background_status_request = False 337 | connector.background_status_request = RequestSendPeriodically(connector, 30, connector.send_get_background_status) 338 | 339 | return connector 340 | return connected_devices[ip] 341 | 342 | 343 | def callback_discovery(connector, sock, ip, data): 344 | """ 345 | The callback for auto discovery connector. 346 | """ 347 | connect_device(ip, data, False) 348 | 349 | connector = connected_devices[ip] 350 | 351 | if not connector.socket.wait_response: 352 | if time_delta(connector.last_state_updated) > connector.state_update_interval: 353 | connector.send_get_system_state() 354 | return 355 | 356 | if __name__ == "__main__": 357 | Config = ConfigParser.ConfigParser() 358 | 359 | script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '') 360 | script_name = os.path.basename(__file__) 361 | script_ini = script_path + os.path.splitext(script_name)[0]+'.ini' 362 | 363 | log('Read settings from:', script_ini) 364 | Config.read(script_ini) 365 | 366 | mqtt_cfg = ConfigSectionMap(Config, "MQTT") 367 | debug_mode = int(mqtt_cfg.get('debug', 0)) 368 | 369 | log_file = mqtt_cfg.get('log', '') 370 | if log_file != '': 371 | if (debug_mode > 1) and os.path.isfile(log_file): 372 | os.remove(log_file) 373 | logger = logging.getLogger('mihome') 374 | hdlr = logging.FileHandler(log_file) 375 | formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') 376 | hdlr.setFormatter(formatter) 377 | logger.addHandler(hdlr) 378 | logger.setLevel(logging.INFO) 379 | 380 | MQTT_QOS = int(mqtt_cfg.get('qos', 0)) 381 | tmp = int(mqtt_cfg.get('retain', 0)) 382 | if tmp > 0: 383 | MQTT_RETAIN = True 384 | else: 385 | MQTT_RETAIN = False 386 | MQTT_SERVER = mqtt_cfg['server'] 387 | MQTT_PORT = int(mqtt_cfg['port']) 388 | MQTT_PATH = mqtt_cfg.get('mqtt_path', 'neptun/{friendly_name}') 389 | 390 | devices_cfg = ConfigSectionMap(Config, "devices") 391 | auto_discovery = int(devices_cfg.get('discovery', '1')) 392 | devices_str = devices_cfg['devices'] 393 | if not sys.version_info >= (3, 0): 394 | devices_str = devices_str.decode('utf-8') 395 | 396 | devices = json.loads(devices_str) 397 | 398 | #mqtt_client = None 399 | mqtt_client = prepare_mqtt(MQTT_SERVER, MQTT_PORT) 400 | 401 | discovery_connector = None 402 | if auto_discovery == 1: 403 | discovery_connector = NeptunConnector(BROADCAST_ADDRESS, 404 | data_callback=callback_discovery, 405 | log_callback=log, 406 | debug_mode=debug_mode) 407 | discovery_connector.setDaemon(True) 408 | discovery_connector.start() 409 | 410 | for device_info in devices: 411 | connector = connect_device(device_info['ip'], device_info, True) 412 | if connector is not None: 413 | connector.state_update_interval = device_info.get('interval', 120) 414 | 415 | _error = "Unable to start thread" 416 | try: 417 | if mqtt_client is not None: 418 | thread.start_new_thread(mqtt_client.loop_forever, ()) 419 | except Exception as e: 420 | log_traceback(_error, e) 421 | except: 422 | log(_error) 423 | 424 | log("Starting main thread") 425 | signal.signal(signal.SIGINT, signal_handler) 426 | 427 | _error = "Error in main thread" 428 | while not exitSignal: 429 | try: 430 | if discovery_connector is not None: 431 | if discovery_connector.whois_request.count < 2: 432 | discovery_connector.whois_request.check_send(5) 433 | else: 434 | discovery_connector.whois_request.check_send(1800, False) 435 | 436 | for ip in connected_devices: 437 | connector = connected_devices[ip] 438 | if not connector.system_state_request.check_send(connector.state_update_interval, False): 439 | if connector.can_send_background_status_request: 440 | connector.background_status_request.check_send(30, False) 441 | 442 | time.sleep(0.5) 443 | except Exception as e: 444 | log_traceback(_error, e) 445 | except: 446 | log(_error) 447 | 448 | if discovery_connector is not None: 449 | log("Stopping discovery connector") 450 | discovery_connector.terminate() 451 | 452 | for ip in connected_devices: 453 | log("Stopping connector for", ip) 454 | connector = connected_devices[ip] 455 | connector.terminate() 456 | 457 | log("Exit") 458 | -------------------------------------------------------------------------------- /neptun2mqtt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | ### BEGIN INIT INFO 5 | # Provides: neptun2mqtt 6 | # Required-Start: $remote_fs $syslog 7 | # Required-Stop: $remote_fs $syslog 8 | # Default-Start: 2 3 4 5 9 | # Default-Stop: 0 1 6 10 | # Short-Description: Neptun ProW - MQTT bridge 11 | # Description: Neptun ProW - MQTT bridge 12 | ### END INIT INFO 13 | 14 | DIR=/home/pi/neptun2mqtt 15 | DAEMON=/usr/bin/python3 16 | DAEMON_NAME=neptun2mqtt 17 | DAEMON_LOG=/var/log/neptun2mqtt.log 18 | USER="root" 19 | 20 | # Add any command line options for your daemon here 21 | DAEMON_OPTS="$DIR/neptun2mqtt.py" 22 | 23 | # This next line determines what user the script runs as. 24 | # Root generally not recommended but necessary 25 | DAEMON_USER=root 26 | 27 | # The process ID of the script when it runs is stored here: 28 | PIDFILE=/var/run/$DAEMON_NAME.pid 29 | 30 | . /lib/lsb/init-functions 31 | 32 | get_pid() { 33 | cat "$PIDFILE" 34 | } 35 | 36 | is_running() { 37 | [ -f "$PIDFILE" ] && ps -p `get_pid` > /dev/null 2>&1 38 | } 39 | 40 | do_start () { 41 | if is_running; then 42 | echo -n "Already started">&2 43 | else 44 | echo -n "Starting "$DAEMON_NAME>&2 45 | echo -n "Starting "$DAEMON_NAME>&1 46 | echo "Starting ">&1 47 | echo "Starting ">&2 48 | log_action_msg "Starting "$DAEMON_NAME 49 | log_daemon_msg "Starting system $DAEMON_NAME daemon" 50 | start-stop-daemon -v -quiet --start --background --pidfile $PIDFILE --make-pidfile --user $DAEMON_USER --chuid $DAEMON_USER --startas $DAEMON -- $DAEMON_OPTS 2>&1 51 | log_end_msg $? 52 | fi 53 | } 54 | do_stop () { 55 | echo -n "Stopping "$DAEMON_NAME>&2 56 | log_daemon_msg "Stopping system $DAEMON_NAME daemon" 57 | start-stop-daemon --stop --pidfile $PIDFILE --retry 10 58 | log_end_msg $? 59 | } 60 | 61 | case "$1" in 62 | 63 | start|stop) 64 | do_${1} 65 | ;; 66 | 67 | restart|reload|force-reload) 68 | do_stop 69 | do_start 70 | ;; 71 | 72 | status) 73 | status_of_proc "$DAEMON_NAME" "$DAEMON" && exit 0 || exit $? 74 | ;; 75 | 76 | *) 77 | echo "Usage: /etc/init.d/$DAEMON_NAME {start|stop|restart|status}" 78 | exit 1 79 | ;; 80 | 81 | esac 82 | exit 0 --------------------------------------------------------------------------------