diff --git a/README.md b/README.md index de404ed..73d0795 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,22 @@ Python package to talk to Qolsys Alarm Panels This package was inspired by a conversation on the Home Assistant forum: + + +1. Run main.py (--help for parameters and help) + +2. Publish JSON formatted messages to the mqtt topic (qolsys/requests by default): + +``` +{"event":"INFO", "token":"blah"} +{"event":"ARM", "arm_type":"stay", "partition_id": 0, "token":"blah"} +{"event":"ARM", "arm_type":"away", "partition_id": 0, "token":"blah"} +{"event":"DISARM", "usercode":"0000", "token":"blah"} +``` + +3. Events will publish to the following topics: + - qolsys/INFO + - qolsys/ZONE_EVENT + - qolsys/ZONE_UPDATE + +4. I have a node-red workflow listening to those events creating the sensors diff --git a/demo.py b/demo.py deleted file mode 100644 index 6f7aa83..0000000 --- a/demo.py +++ /dev/null @@ -1,23 +0,0 @@ -from qolsys_client import arm -from qolsys_client import status - -qolsysPanel = "192.168.0.20" -qolsysPort = 12345 -qolsysToken = "abc123" -qolsysTimeout = 20 - -# Status -result = status.qolsysStatus(qolsysPanel, qolsysPort, qolsysToken, qolsysTimeout) -print (result) - -# Arm Away -result = arm.qolsysArm(qolsysPanel, qolsysPort, qolsysToken, qolsysTimeout, 0, "away") -print (result) - -# Arm Stay -result = arm.qolsysArm(qolsysPanel, qolsysPort, qolsysToken, qolsysTimeout, 0, "stay") -print (result) - -# Disarm -result = arm.qolsysArm(qolsysPanel, qolsysPort, qolsysToken, qolsysTimeout, 0, "disarm") -print (result) diff --git a/node-red/node-red.md b/node-red/node-red.md new file mode 100644 index 0000000..8620cfd --- /dev/null +++ b/node-red/node-red.md @@ -0,0 +1 @@ +[{"id":"cbfaf013.0e5e","type":"tab","label":"Monitor QolSys Panel","disabled":false,"info":""},{"id":"20c47d1f.7e57d2","type":"debug","z":"cbfaf013.0e5e","name":"mqtt out","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":980,"y":120,"wires":[]},{"id":"a272ddf7.56ba6","type":"debug","z":"cbfaf013.0e5e","name":"Zone id to zone name mapping","active":false,"tosidebar":true,"console":false,"tostatus":true,"complete":"$flowContext(\"zones\")\t","targetType":"jsonata","statusVal":"$flowContext(\"zones\")","statusType":"auto","x":1030,"y":260,"wires":[]},{"id":"7593a9f9.4b4dc8","type":"function","z":"cbfaf013.0e5e","name":"initialize zone ids and names","func":"class door_window {\n constructor(zoneid, entity_id, name, state=\"Closed\", partition_id=0, device_class=\"door\") {\n this.state = state\n this.id = zoneid\n this.device_class = device_class\n this.name = name\n this.entity_id = entity_id\n this.partition_id = partition_id\n this.payload_on = \"Open\"\n this.payload_off = \"Closed\"\n this.config_topic = \"homeassistant/binary_sensor/\" + entity_id + \"/config\"\n this.state_topic = \"mqtt_states/binary_sensor/\" + entity_id + \"/state\"\n //node.log(\"Name: \" + name + \"\\n\" + \n // \"ZoneID: \" + zoneid + \"\\n\" +\n // \"EntityID: \" + entity_id + \"\\n\" + \n // \"PartitionID: \" + partition_id + \"\\n\" + \n // \"Device Class: \" + device_class + \"\\n\" +\n // \"State: \" + state)\n }\n toString(self){\n return self.name\n } \n}\n\nvar zones = flow.get(\"zones\")\n\nfor (var zone in msg.payload) {\n// node.log(zone)\n var id = msg.payload[zone][\"zone_id\"]\n var name = msg.payload[zone][\"name\"]\n var state = msg.payload[zone][\"status\"]\n var partition_id = msg.payload[zone][\"partition_id\"]\n var entity_id = name.replace(/\\W/g, \"_\").toLowerCase()\n var thisZone = new door_window(zoneid=id, entity_id=entity_id, name=name, state=state, partition_id=partition_id)\n zones[id] = thisZone\n// node.log(thisZone)\n \n}\n//msg.payload = zones\n//node.log(\"Zones:\" + zones)\nflow.set(\"zones\", zones)\nreturn msg\n","outputs":1,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is deployed.\n\nclass door_window {\n constructor(zoneid, entity_id=\"\", name, device_class=\"door\", state=\"Closed\", partition_id=0 ) {\n this.state = state\n this.id = zoneid\n this.name = name\n this.device_class = device_class\n this.entity_id = entity_id\n this.partition_id = partition_id\n this.payload_on = \"Open\"\n this.payload_off = \"Closed\"\n this.config_topic = \"homeassistant/binary_sensor/\" + entity_id + \"/config\"\n this.state_topic = \"mqtt_states/binary_sensor/\" + entity_id + \"/state\" \n }\n \n}\nzones = []\n\n//zones[1] = new door_window(1, \"front_door\", \"Front Door\")\n//zones[2] = new door_window(2, \"trash_gate\", \"Trash Gate\")\n//zones[3] = new door_window(3, \"neela_s_garage_entry\", \"Neela's Garage Entry\")\n//zones[4] = new door_window(4, \"roopesh_s_garage_entry\", \"Roopesh's Garage Entry\")\n//zones[6] = new door_window(6, \"great_room_slider\", \"Great Room Slider\")\n//zones[7] = new door_window(7, \"garage_side_door\", \"Garage Side Door\")\n//zones[9] = new door_window(9, \"kitchen_slider\", \"Kitchen Slider\")\n//zones[10] = new door_window(10, \"game_room_slider\", \"Game Room Slider\")\n//zones[11] = new door_window(11, \"loft_slider\", \"Loft Slider\")\n//zones[12] = new door_window(12, \"guest_room_side_gate\", \"Guest Room Side Gate\")\nflow.set(\"zones\", zones)\n","finalize":"","x":460,"y":260,"wires":[["a272ddf7.56ba6","4e0e16a3.7cd0f8"]]},{"id":"59822226.0248cc","type":"function","z":"cbfaf013.0e5e","name":"Build MQTT Update Payload","func":"var zid = msg.payload[\"zone\"][\"zone_id\"]\nvar zones = flow.get(\"zones\")\nvar state_topic = zones[zid][\"state_topic\"]\nvar state = msg.payload[\"zone\"][\"status\"]\nzones[zid][\"state\"] = state\nflow.set(\"zones\", zones)\nmsg.payload = state\nmsg.topic = state_topic\n\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":580,"y":120,"wires":[["7a8a7809.26cf68","20c47d1f.7e57d2"]]},{"id":"4e0e16a3.7cd0f8","type":"array-loop","z":"cbfaf013.0e5e","name":"loop zones","key":"al4e0e16a37cd0f8","keyType":"msg","reset":true,"resetValue":"value-null","array":"zones","arrayType":"flow","x":430,"y":320,"wires":[["89ebb812.5ea3a8"],["a51867e3.622258"]]},{"id":"89ebb812.5ea3a8","type":"debug","z":"cbfaf013.0e5e","name":"All HA Init'd","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":970,"y":340,"wires":[]},{"id":"a51867e3.622258","type":"switch","z":"cbfaf013.0e5e","name":"","property":"payload","propertyType":"msg","rules":[{"t":"null"},{"t":"empty"},{"t":"else"}],"checkall":"false","repair":false,"outputs":3,"x":230,"y":420,"wires":[["4e0e16a3.7cd0f8"],["4e0e16a3.7cd0f8"],["48744f25.dce7a"]]},{"id":"3f8da6fc.a7238a","type":"catch","z":"cbfaf013.0e5e","name":"Catch","scope":null,"uncaught":false,"x":530,"y":40,"wires":[["cd788dfe.d978"]]},{"id":"cd788dfe.d978","type":"debug","z":"cbfaf013.0e5e","name":"All Uncaught Errors","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":990,"y":40,"wires":[]},{"id":"dfd2c176.68a0e","type":"debug","z":"cbfaf013.0e5e","name":"Initialized in HA","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":980,"y":420,"wires":[]},{"id":"1534a5cd.807cea","type":"mqtt out","z":"cbfaf013.0e5e","name":"","topic":"","qos":"","retain":"","broker":"b1167957.68aa08","x":810,"y":460,"wires":[]},{"id":"48744f25.dce7a","type":"function","z":"cbfaf013.0e5e","name":"Build MQTT topic and payload","func":"//var topic = \"\"\ntry {\n var type = msg.payload[\"type\"]\n var entity_id = msg.payload[\"entity_id\"]\n var name = msg.payload[\"name\"]\n var device_class = msg.payload[\"device_class\"]\n var state_topic = msg.payload[\"state_topic\"]\n var config_topic = msg.payload[\"config_topic\"]\n var payload_on = msg.payload[\"payload_on\"]\n var payload_off = msg.payload[\"payload_off\"]\n var state = msg.payload[\"state\"]\n var config_msg = {\n \"topic\": config_topic,\n \"payload\": {\n \"name\": name,\n \"device_class\": device_class,\n \"state_topic\": state_topic,\n \"payload_on\": payload_on,\n \"payload_off\": payload_off\n }\n }\n}\ncatch(err) {\n node.log(\"failed making variables\", err)\n}\n\n\n//msg.topic = config_topic\n\n//msg.payload = config_msg\ntry {\n var zone_update_msg = {\n \"topic\": state_topic,\n \"payload\": state\n }\n}\ncatch(err) {\n node.log(\"failed making zone_update_msg\")\n}\n//msg.zone_update_msg = zone_update_msg\n//msg.config_msg = config_msg\n//flow.set(\"zone_update_msg\", zone_update_msg)\n//flow.set(\"config_msg\", config_msg)\ntry {\n node.log(\"config msg: \" + config_msg.topic + config_msg.payload.name)\n node.log(\"zone msg: \" + zone_update_msg.topic + zone_update_msg.payload)\n node.log(\"msg: \" + msg.payload.name)\n}\ncatch(err) {\n node.log(\"error logging data\")\n}\n//return(msg, config_msg, zone_update_msg)\n//return(zone_update_msg, config_msg, msg)\nreturn[msg, config_msg, zone_update_msg]","outputs":3,"noerr":0,"initialize":"","finalize":"","x":470,"y":420,"wires":[["4e0e16a3.7cd0f8"],["dfd2c176.68a0e","1534a5cd.807cea"],["94a3b1f.1389e5"]]},{"id":"7a8a7809.26cf68","type":"mqtt out","z":"cbfaf013.0e5e","name":"","topic":"","qos":"","retain":"","broker":"b1167957.68aa08","x":790,"y":160,"wires":[]},{"id":"c1e92bd3.455468","type":"mqtt in","z":"cbfaf013.0e5e","name":"INFO","topic":"qolsys/info","qos":"0","datatype":"auto","broker":"b1167957.68aa08","x":110,"y":100,"wires":[["15befb3c.f83975"]]},{"id":"ec802f9d.c402f","type":"change","z":"cbfaf013.0e5e","name":"Set Zones payload for INFO","rules":[{"t":"set","p":"payload","pt":"msg","to":"$lookup($lookup(payload, \"partition_list\"[0]), \"zone_list\")","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":180,"y":260,"wires":[["ff9d620d.a7c55","7593a9f9.4b4dc8"]]},{"id":"df81c870.4ecda8","type":"mqtt out","z":"cbfaf013.0e5e","name":"Send INFO request","topic":"qolsys/requests","qos":"","retain":"","broker":"b1167957.68aa08","x":330,"y":40,"wires":[]},{"id":"46573582.43dbcc","type":"inject","z":"cbfaf013.0e5e","name":"Start with INFO","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"{\"event\":\"INFO\",\"token\":\"shw9s8\"}","payloadType":"json","x":120,"y":40,"wires":[["df81c870.4ecda8"]]},{"id":"15befb3c.f83975","type":"json","z":"cbfaf013.0e5e","name":"","property":"payload","action":"","pretty":false,"x":230,"y":140,"wires":[["7488361e.748058"]]},{"id":"ff9d620d.a7c55","type":"debug","z":"cbfaf013.0e5e","name":"Zones Payload","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":980,"y":180,"wires":[]},{"id":"7488361e.748058","type":"switch","z":"cbfaf013.0e5e","name":"","property":"payload","propertyType":"msg","rules":[{"t":"jsonata_exp","v":"\"ZONE_EVENT\" = $lookup(payload, \"event\")","vt":"jsonata"},{"t":"jsonata_exp","v":"\"ZONE_UPDATE\" = $lookup(payload, \"event\")","vt":"jsonata"},{"t":"jsonata_exp","v":"\"INFO\" = $lookup(payload, \"event\")","vt":"jsonata"},{"t":"jsonata_exp","v":"\"ARMING\" = $lookup(payload, \"event\")","vt":"jsonata"}],"checkall":"true","repair":false,"outputs":4,"x":350,"y":140,"wires":[["59822226.0248cc"],["59822226.0248cc"],["ec802f9d.c402f"],[]]},{"id":"94a3b1f.1389e5","type":"delay","z":"cbfaf013.0e5e","name":"Zone Update Delay","pauseType":"delay","timeout":"1","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"x":590,"y":500,"wires":[["1534a5cd.807cea","20ab17af.414598"]]},{"id":"e3a9fee7.6b71e","type":"mqtt in","z":"cbfaf013.0e5e","name":"ZONE UPDATE","topic":"qolsys/zone_update","qos":"0","datatype":"auto","broker":"b1167957.68aa08","x":80,"y":180,"wires":[["15befb3c.f83975"]]},{"id":"b49e6dbf.498da","type":"mqtt in","z":"cbfaf013.0e5e","name":"ZONE EVENT","topic":"qolsys/zone_event","qos":"0","datatype":"auto","broker":"b1167957.68aa08","x":90,"y":140,"wires":[["15befb3c.f83975"]]},{"id":"20ab17af.414598","type":"debug","z":"cbfaf013.0e5e","name":"Zone Update in HA","active":false,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"payload","statusType":"auto","x":990,"y":500,"wires":[]},{"id":"b1167957.68aa08","type":"mqtt-broker","name":"HA MQTT","broker":"127.0.0.1","port":"1883","clientid":"","usetls":false,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","closeTopic":"","closeQos":"0","closePayload":"","willTopic":"","willQos":"0","willPayload":""}] \ No newline at end of file diff --git a/qolsys_client/main.py b/qolsys_client/main.py new file mode 100644 index 0000000..65e996f --- /dev/null +++ b/qolsys_client/main.py @@ -0,0 +1,134 @@ +import sys +import time +import json +import logging +from paho.mqtt.client import MQTTMessage +import qolsys_socket, mqtt_client +from mqtt_subscriber import MQTTSubscriber + +################################################################################ +# Code +#args = {} + +def qolsys_data_received(data:dict): + ''' This is where any json data coming from the qolsys panel will be sent. + In this case I have the data being published to a mqtt topic, but you can do what you want. + + Parameters: + data: json object containing the output from the qolsys panel''' + + args = get_command_line_args() + if "mqtt-broker" in args: + mqtt_broker = args["mqtt-broker"] + mqtt_port = args["mqtt-port"] if "mqtt-port" in args else 1883 + topic = "qolsys/" + jdata = json.loads(data) + event_type = jdata["event"] + if event_type == "INFO": + topic += "info" + if event_type == "ZONE_EVENT": + topic += "zone_event" + if event_type == "ZONE_UDPATE": + topic += "zone_update" + + logging.debug(("publishing " + event_type + " event to: " + topic)) + mq = mqtt_client.mqtt(mqtt_broker, mqtt_port) + mq.publish(topic, data) + else: + print(data) + +def main(): + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s: %(levelname)s: %(module)s: %(funcName)s: %(lineno)d: %(message)s') + logging.debug(("Command line arguments:", sys.argv[1:])) + args = get_command_line_args() + + #Deal with some arguments that stop execution + if "help" in args: + help() + if not "token" in args: + print("Qolsys IQ 2 Panel token required") + help()#sys.exit() + if not "host" in args: + print("Qolsys IQ 2 Panel IP address or hostname required") + help()#sys.exit() + + token, host = args["token"], args["host"] + port = int(args["port"]) if "port" in args else 12345 + timeout = int(args["timeout"]) if "timeout" in args else 86400 + mqtt_broker = args["mqtt-broker"] if "mqtt-broker" in args else None + mqtt_port = args["mqtt-port"] if "mqtt-port" in args else 1883 + topics = [] #args["topics"] if "topics" in args else ["qolsys/requests"] + if args["topics"]: + topic_data = str(args["topics"]) + logging.debug(("topics arg:", topic_data)) + if topic_data.find(",") > 0: + topic_array = args["topics"].split(",") + logging.debug(("topic_array:", topic_array)) + for t in topic_array: + logging.debug(("t:", t)) + topics.append(t) + else: + topics.append(topic_data) + else: + raise("No topics") + + + logging.debug("Creating qolsys_socket") + qolsys = qolsys_socket.qolsys() + + qolsys.create_socket(hostname=host, port=port, token=token, cb=qolsys_data_received, timeout=timeout) + + logging.debug("main: qolsys_socket created") + + #logging.debug("doing the info check on startup") + #qolsys_status(qolsys, token) + + #qolsys_arm(qolsys,token,"stay") + if mqtt_broker: + mqtt_sub = MQTTSubscriber(broker=mqtt_broker, port=mqtt_port, qolsys=qolsys, topics=topics) + else: + logging.info("No MQTT Broker. Only getting status events from panel") + + +def get_command_line_args() -> dict: + args = {} + for arg in sys.argv[1:]: + arg_name = "" + arg_value = "" + arg_name = arg.split("=",1)[0] + arg_name = arg_name.split("--",1)[1] + try: + arg_value = arg.split("=",1)[1] + except: + arg_value = "" + logging.debug(("argument name: ", arg_name, " | ", arg_value)) + this_arg = { arg_name: arg_value } + args.update(this_arg) + logging.debug(("all arguments: ", args)) + return args + +def help(): + help_text = """ + Parameters: + Required: + --host IP address or hostname of the Qolsys IQ 2(+) Panel + --port Port to connect on the Qolsys panel. Usually 12345 + --timeout Timeout (seconds) to wait after last data sent/received from the panel before disconnecting. Default will be one day. + --token Token from the Qolsys panel + --usercode (UNUSED) If you want to use disarm, you need to supply a usercode + + Optional: + --mqtt-broker IP address or hostname of the MQTT broker + --mqtt-port MQTT broker port to connect to (default is 1883) + --topics A list (array) of topics to subscribe to for qolsys event requests e.g. --topics=["qolsys/requests"] (Default) + + Usage: + python3 main.py --host=192.168.1.123 --port=12345 --token=yourtoken --timeout=86400 --mqtt-broker=192.168.1.2 --mqtt-port=1883 --topics=["qolsys/requests"] + + + """ + print(help_text) + sys.exit() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/qolsys_client/mqtt_client.py b/qolsys_client/mqtt_client.py new file mode 100644 index 0000000..2f1f6da --- /dev/null +++ b/qolsys_client/mqtt_client.py @@ -0,0 +1,35 @@ +import paho.mqtt.client as pmqtt +import paho.mqtt.subscribe as smqtt +import json +import time +import logging + +class mqtt: + + def __init__(self, broker: str, port=1883): + self.client = "" + self.broker = broker + self.port = port + self.connect() + + def connect(self): + self.client = pmqtt.Client() + self.client.connect(host=self.broker, port=self.port) + + def publish(self, topic:str, message:str): + if topic == "" or message == "": + raise Exception("Topic and Message required") + published = self.client.publish(topic, message) + while not published.is_published(): + time.sleep(0.5) + print("published:", published.rc) + + def subscribe(self, topics:[], cb:callable): + if topics == []: + raise Exception("Need a topic to listen to") + logging.debug("Starting the MQTT subscriber") + smqtt.callback(cb, topics, hostname=self.broker) + #subscribed.callback(cb,) + #self.client.subscribe(topic) + #self.client.on + diff --git a/qolsys_client/mqtt_subscriber.py b/qolsys_client/mqtt_subscriber.py new file mode 100644 index 0000000..3d9eb63 --- /dev/null +++ b/qolsys_client/mqtt_subscriber.py @@ -0,0 +1,138 @@ +import sys +import time +import qolsys_socket, mqtt_client +#import threading +import json +import logging +from paho.mqtt.client import MQTTMessage + +class MQTTSubscriber: + def __init__(self, broker="", qolsys:qolsys_socket.qolsys=None, port=1883, topics=[]): + self.broker = broker + self.qolsys = qolsys + self.port = port + self.topics = topics + self._arming_types = ["away", "stay", "disarm"] + + + logging.debug(("MQTT Subscriber: ", self.broker, ":", self.port, ":", self.topics)) + mqtt_sub = mqtt_client.mqtt(self.broker, self.port) + mqtt_sub.subscribe(topics=self.topics, cb=self.mqtt_request_received) + + def mqtt_request_received(self, client, userdata, message:MQTTMessage): + '''Runs when a MQTT event is received on the request topic + + Parameters: + data: json object containing the request to send to the qolsys panel + + Expected JSON Message: + Required: + event INFO, ARM, DISARM + token Qolsys IQ Panel token + + Optional + usercode Required if disarming + partition_id Required if arming or disarming. 0 is a good value if you don't know what to use + arm_type Required if arming. Options are "away" or "stay" + ''' + + logging.debug(("client:", client)) + logging.debug(("userdata:", userdata)) + logging.debug(("message:", message)) + payload = message.payload + logging.debug(("payload:", payload)) + payload_json = {} + event_type = "" + + try: + if json.loads(payload): + payload_json = json.loads(payload) + logging.debug(("payload_json:", payload_json)) + except: + logging.debug(("Error converting to JSON:", sys.exc_info())) + logging.debug(("Not JSON:", payload)) + + try: + event_type = payload_json["event"] + logging.debug(("event:", event_type)) + except: + logging.error(('Unable to find "event"', payload_json)) + #raise("Unable to receive MQTT requests") + + if event_type != "": + token = payload_json["token"] if "token" in payload_json else None + usercode = payload_json["usercode"] if "usercode" in payload_json else None + partition_id = payload_json["partition_id"] if "partition_id" in payload_json else None + arm_type = payload_json["arm_type"] if "arm_type" in payload_json else None + logging.debug(("event:", event_type,"usercode:", usercode, "partition_id", partition_id, "arm_type", arm_type)) + if token == None: + #raise("Token required for anything you want to do") + logging.error("No token provided. Token is required for anything you want to do with the Qolsys panel") + else: + if event_type == "INFO": + self.qolsys_status(self.qolsys, token) + + if event_type == "ARM": + if partition_id is None or arm_type is None: + logging.error(("arm_type and partition_id are required")) + else: + self.qolsys_arm(self.qolsys, token, arm_type, partition_id) + + if event_type == "DISARM": + arm_type = "disarm" + if partition_id is None or arm_type is None or usercode is None: + logging.error(("arm_type, partition_id, and usercode are required")) + else: + self.qolsys_arm(self.qolsys, token, "disarm", partition_id, usercode) + + def qolsys_arm(self, qolsys, token:str, arming_type:str, partition_id:int, usercode=""): + if not arming_type in self._arming_types: + raise("Invalid arm command") + + arm_type = "" + + if arming_type.lower() == 'away': + arm_type = "ARM_AWAY" + elif arming_type.lower() == 'stay': + arm_type = "ARM_STAY" + elif arming_type.lower() == 'disarm': + arm_type = "DISARM" + else: + raise("Invalid arm command") + + armString = { + "partition_id": partition_id, + "action": "ARMING", + "arming_type": arm_type, + "version": 0, + "nonce": "qolsys", + "source": "C4", + "version_key": 1, + "source_key": "C4", + "token": token + } + + #Disarm requires a usercode + if arming_type.lower() == "disarm": + armString.update({"usercode":usercode}) + + try: + logging.debug(("armString:", armString)) + qolsys.send_to_socket(armString) + except socket.error: + logging.error("Could not send arm command to qolsys socket") + + def qolsys_status(self, qolsys, token): + statusString = { + "nonce": "qolsys", + "action": "INFO", + "info_type": "SUMMARY", + "version": 0, + "source": "C4", + "token": token, + } + + try: + qolsys.send_to_socket(statusString) + except: + logging.error('Could not send status request to qolsys socket') \ No newline at end of file diff --git a/qolsys_client/qolsys_socket.py b/qolsys_client/qolsys_socket.py new file mode 100644 index 0000000..dd38228 --- /dev/null +++ b/qolsys_client/qolsys_socket.py @@ -0,0 +1,132 @@ +import json +import socket +import ssl +import sys +import time +import asyncio +import threading +import logging + +class qolsys: + ################################################################################ + # Code + + def __init__(self): + self._sock = socket.socket + self._wrappedSocket = ssl.SSLContext.wrap_socket + self._listening_thread = threading.Thread() + self._listener_callback = callable + self._hostname = "" + self._port = 12345 + self._token = "" + self._timeout = 60 + + def create_socket(self, hostname, port, token, cb: callable, timeout=60): + self._hostname = hostname + self._port = port + self._token = token + self._listener_callback = cb + self._timeout = timeout + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(timeout) + #Set the listener callback at the instance level so we can restart the listener if needed + except socket.error: + logging.error('Could not create a socket') + raise + + # Wrap SSL + logging.debug("wrapping socket") + self._wrappedSocket = ssl.wrap_socket(self._sock, cert_reqs=ssl.CERT_NONE, ssl_version=ssl.PROTOCOL_TLSv1_2) + + # Connect to server + try: + #The stupid Qolsys panel requires blocking + # wrappedSocket.setblocking(False) + logging.debug("connecting to socket") + self._wrappedSocket.connect((hostname, port)) + logging.debug(("Connected wrappedSocket:", self._wrappedSocket)) + + logging.debug("Starting listener thread") + self._start_listener() + #self.listening_thread = threading.Thread(target=self.listen, args=([cb])) + #self.listening_thread.start() + logging.debug("started listener") + + return True + except socket.error: + logging.error(("Error creating or connecting to socket", sys.exc_info())) + return False + def _start_listener(self): + logging.debug(("Starting listener thread")) + self._listening_thread = threading.Thread(target=self.listen, args=([self._listener_callback])) + self._listening_thread.start() + logging.debug(("started listener thread")) + + def _reset_socket(self): + logging.debug(("Detatching from wrapped socket")) + self._wrappedSocket.detach() + logging.debug(("Closing socket")) + self._sock.close() + time.sleep(2) + #self._listening_thread = threading.Thread(target=self.listen, args=([self._listener_callback])) + logging.debug(("Creating socket")) + self.create_socket(self._hostname, self._port, self._token, self._listener_callback, self._timeout) + + def send_to_socket(self, message: json): + + self._wrappedSocket.send(b'\n') + self._wrappedSocket.send((json.dumps(message)).encode()) + + return True + + def listen(self, cb: callable): + #listening = True + logging.debug("starting listen") + data = "" + #err = "" + while not (self._wrappedSocket._connected): + logging.warning("not connected yet") + logging.debug(self._wrappedSocket._connected) + time.sleep(1) + try: + while self._wrappedSocket._connected: + data = self._wrappedSocket.recv(4096).decode() + if len(data) > 0: + logging.debug(("data received from qolsys panel:", data, "len(data): ", len(data))) + if is_json(data): + try: + cb(data) + except: + logging.error(("Error calling callback:", cb, sys.exc_info())) + #print(data) + else: + if data != 'ACK\n': + pass + #logging.warning(("non json data:", data)) + else: + logging.error(("No data received. Bad token? Detatching.")) + self._wrappedSocket.detach() + raise NoDataError + except socket.timeout: + logging.debug("socket timeout") + except NoDataError: + self._reset_socket() + raise NoDataError + except: + logging.error(("listen failed/stopped:", sys.exc_info())) + + + +def is_json(myjson): + try: + json_object = json.loads(myjson) + if json_object: return True + except: + if myjson != 'ACK\n': + logging.debug(("not json:", myjson)) + logging.debug(("Error:", sys.exc_info())) + return False + +class NoDataError(Exception): + pass \ No newline at end of file diff --git a/qolsys_client/status.py b/qolsys_client/status.py deleted file mode 100644 index 565ecaf..0000000 --- a/qolsys_client/status.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -import socket -import ssl -import sys -import time - -################################################################################ -# Code -def qolsysStatus(hostname, port, token, timeout): - statusString = { - "nonce": "qolsys", - "action": "INFO", - "info_type": "SUMMARY", - "version": 0, - "source": "C4", - "token": token, - } - - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - except socket.error: - print('Could not create a socket') - sys.exit() - - # Wrap SSL - wrappedSocket = ssl.wrap_socket(sock, cert_reqs=ssl.CERT_NONE, ssl_version=ssl.PROTOCOL_TLSv1_2) - - # Connect to server - try: - wrappedSocket.connect((hostname, port)) - except socket.error: - print('Could not connect to server') - sys.exit() - - # Send message and print reply - online = True - while online: - wrappedSocket.send(b'\n') - wrappedSocket.send((json.dumps(statusString)).encode()) - while True: - response = wrappedSocket.recv(4096).decode() - if is_json(response): - online = False - return(response) - break # stop receiving - time.sleep(timeout / 4) - -def is_json(myjson): - try: - json_object = json.loads(myjson) - except ValueError as e: - return False - return True