├── images └── hass-example.png ├── README.md ├── alarm.yaml └── paradox_combus_src └── paradox_combus_esphome.h /images/hass-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Margriko/Paradox-ESPHome/HEAD/images/hass-example.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paradox-ESPHome 2 | 3 | Connect Paradox COMBUS (green-yellow wires which connect alarm system to the keypad) alarm interface to Home Assistant using esp8266 device and ESPHome library. 4 | 5 | Currently the implementation is read-only, it shows motion, window/door, smoke sensor and alarm state status in Home Assistant (with a slight delay). 6 | 7 | ## Example in Home Assistant 8 | ![Image of HASS example](https://github.com/Margriko/Paradox-ESPHome/blob/master/images/hass-example.png) 9 | 10 | ## Wiring 11 | Because Combus operates at ~12v, we need to step down voltage to levels suitable for esp2866. 12 | 13 | Wiring example: 14 | 15 | Alarm Aux(+) --- Voltage regulator (5v for Wemos, NodeMCU, 3.3V for generic esp8266) --- VIN pin on esp8266 16 | 17 | Alarm Aux(-) --- esp8266 Ground 18 | 19 | +--- clock pin (Wemos, NodeMCU: D1, D2, D8) 20 | Alarm Yellow --- 15k ohm resistor ---| 21 | +--- 10k ohm resistor --- Ground 22 | 23 | +--- data read pin (Wemos, NodeMCU: D1, D2, D8) 24 | Alarm Green ---- 15k ohm resistor ---| 25 | +--- 10k ohm resistor --- Ground 26 | 27 | When using different pins, be sure to modify sources to match your configuration. 28 | 29 | ## OTA updates 30 | In order to make OTA updates, connection switch in frontend must be switched to OFF. 31 | 32 | ## Compatibility 33 | Tested with "Trikdis SP231" alarm system which uses Paradox-compatible green-yellow data bus (COMBUS). 34 | Should work with other Paradox alarm systems. 35 | 36 | ## References 37 | * Most of the code is taken from https://github.com/liaan/paradox_esp8266 38 | * Wiring and some ideas taken from https://github.com/taligentx/dscKeybusInterface 39 | * General knowledge about decoding COMBUS and a source for future improvements https://github.com/0ki/paradox 40 | * ESPHome library https://esphome.io 41 | 42 | ## Suggestions 43 | This is a rough implementation, stability is not guaranteed. If you want a stable solution with read/write capability and your alarm system is compatible, take a look at [Paradox Alarm Interface](https://github.com/ParadoxAlarmInterface/pai), which connects to alarm system by using serial port. In my case my alarm system was not compatible and had to use my own solution. 44 | -------------------------------------------------------------------------------- /alarm.yaml: -------------------------------------------------------------------------------- 1 | esphome: 2 | name: alarm 3 | platform: ESP8266 4 | board: nodemcuv2 5 | 6 | includes: 7 | - paradox_combus_src/ 8 | 9 | wifi: 10 | ssid: !secret wifi_name 11 | password: !secret wifi_password 12 | 13 | # Enable fallback hotspot (captive portal) in case wifi connection fails 14 | ap: 15 | ssid: "alarm" 16 | password: !secret wifi_password 17 | 18 | #captive_portal: 19 | 20 | # Enable logging 21 | logger: 22 | baud_rate: 0 23 | 24 | # Enable Home Assistant API 25 | api: 26 | 27 | ota: 28 | 29 | status_led: 30 | pin: 31 | number: D4 32 | inverted: yes 33 | 34 | custom_component: 35 | - lambda: |- 36 | auto combusEsp = new ParadoxCombusEsphome(); 37 | 38 | combusEsp->onAlarmStatusChange([&](std::string statusCode) { 39 | if (id(alarm_status).state != statusCode) { 40 | id(alarm_status).publish_state(statusCode); 41 | } 42 | }); 43 | 44 | combusEsp->onZoneStatusChange([&](uint8_t zone, bool open) { 45 | switch (zone) { 46 | case 1: id(z1).publish_state(open); break; 47 | case 2: id(z2).publish_state(open); break; 48 | case 3: id(z3).publish_state(open); break; 49 | case 4: id(z4).publish_state(open); break; 50 | case 5: id(z5).publish_state(open); break; 51 | case 6: id(z6).publish_state(open); break; 52 | case 7: id(z7).publish_state(open); break; 53 | case 8: id(z8).publish_state(open); break; 54 | case 9: id(z9).publish_state(open); break; 55 | } 56 | }); 57 | return {combusEsp}; 58 | 59 | binary_sensor: 60 | - platform: template 61 | id: z1 62 | name: "Entrance motion" 63 | device_class: motion 64 | - platform: template 65 | id: z2 66 | name: "Living room motion" 67 | device_class: motion 68 | - platform: template 69 | id: z3 70 | name: "Terrace door" 71 | device_class: door 72 | - platform: template 73 | id: z4 74 | name: "Service room motion" 75 | device_class: motion 76 | - platform: template 77 | id: z5 78 | name: "Service room door" 79 | device_class: door 80 | - platform: template 81 | id: z6 82 | name: "Child room motion" 83 | device_class: motion 84 | - platform: template 85 | id: z7 86 | name: "Bedroom motion" 87 | device_class: motion 88 | - platform: template 89 | id: z8 90 | name: "Smoke detector" 91 | device_class: smoke 92 | - platform: template 93 | id: z9 94 | name: "Entrance door" 95 | device_class: door 96 | 97 | text_sensor: 98 | - platform: template 99 | id: alarm_status 100 | name: "Alarm Status" 101 | icon: "mdi:shield" 102 | 103 | switch: 104 | - platform: template 105 | name: "Alarm Connection" 106 | id: connection_status_switch 107 | lambda: |- 108 | return getCombusConnectionStatus(); 109 | icon: "mdi:shield-link-variant" 110 | turn_on_action: 111 | - switch.toggle: restart_switch 112 | turn_off_action: 113 | - lambda: |- 114 | disconnectCombus(); 115 | - platform: restart 116 | id: restart_switch 117 | 118 | -------------------------------------------------------------------------------- /paradox_combus_src/paradox_combus_esphome.h: -------------------------------------------------------------------------------- 1 | #include "esphome.h" 2 | 3 | #define CLK 5 // Keybus Yellow 4 | #define DTA 4 // Keybus Green 5 | 6 | String BusMessage = ""; 7 | unsigned long LastClkSignal = 0; 8 | bool ClkPinTriggered = 0; 9 | bool combusConnectionStatus = false; 10 | 11 | void ICACHE_RAM_ATTR interuptClockFalling() 12 | { 13 | 14 | //## Set last Clock time 15 | LastClkSignal = micros(); 16 | //Set pin triggered 17 | ClkPinTriggered = true; 18 | //Trigger data read timer 19 | timer1_write(750); // 750 / 5 ticks per us from TIM_DIV16 == 150microseconds interval (ithink) 20 | } 21 | 22 | void ICACHE_RAM_ATTR readDataPin() 23 | { 24 | 25 | //If not triggered, just return .. this should never happen, but who knows 26 | if (ClkPinTriggered == false) 27 | { 28 | ESP_LOGD("custom", "never"); 29 | return; 30 | } 31 | 32 | //Serial.println("Reading Data Pin"); 33 | 34 | /* 35 | * Code need to be updated to ignore the response from the keypad (Rising Edge Comms). 36 | * 37 | * Panel to other is on Clock Falling edge, Reply is after keeping DATA low (it seems) and then reply on Rising edge 38 | */ 39 | //delayMicroseconds(150); 40 | //Just add small delay to make sure DATA is already set, each clock is 500 ~microseconds, Seem to have about 50ms delay before Data goes high when keypad responce and creating garbage . 41 | 42 | //Just wait 150ms since last clk signal to stabilise .. should not happen as we triggered via timer 43 | while (micros() - LastClkSignal < 150) 44 | { 45 | } 46 | 47 | //Append pin state to bus message .. probably should make it binary, but we have enough memory to make debugging simpler 48 | if (!digitalRead(DTA)) 49 | BusMessage += "1"; 50 | else 51 | BusMessage += "0"; 52 | 53 | //Set pin to not triggered 54 | ClkPinTriggered = false; 55 | 56 | if (BusMessage.length() > 200) 57 | { 58 | //Serial.println("String to long"); 59 | //Serial.println((String) BusMessage); 60 | BusMessage = ""; 61 | // printSerialHex(BusMessage); 62 | return; // Do not overflow the arduino's little ram 63 | } 64 | } 65 | 66 | void disconnectCombus() { 67 | timer1_disable(); 68 | timer1_detachInterrupt(); 69 | detachInterrupt(CLK); 70 | combusConnectionStatus = false; 71 | } 72 | 73 | void connectCombus() { 74 | pinMode(CLK, INPUT); 75 | pinMode(DTA, INPUT); 76 | //## Interupt pin 77 | attachInterrupt(CLK, &interuptClockFalling, FALLING); 78 | //##Interupt timer 79 | timer1_attachInterrupt(&readDataPin); // Add ISR Function 80 | timer1_enable(TIM_DIV16,TIM_EDGE,TIM_SINGLE); 81 | combusConnectionStatus = true; ESP_LOGD("Status", "Init successful"); 82 | 83 | } 84 | 85 | bool getCombusConnectionStatus() { 86 | return combusConnectionStatus; 87 | } 88 | 89 | class ParadoxCombusEsphome : public Component { 90 | public: 91 | std::function zoneStatusChangeCallback; 92 | std::function alarmStatusChangeCallback; 93 | 94 | const std::string STATUS_UNAVAILABLE = "unavailable"; 95 | const std::string STATUS_ARM = "armed_away"; 96 | const std::string STATUS_SLEEP = "armed_night"; 97 | const std::string STATUS_STAY = "armed_home"; 98 | const std::string STATUS_OFF = "disarmed"; 99 | 100 | /** 101 | * Zone status changed. 102 | * 103 | * @param callback callback to notify 104 | * @param zone zone number [0..MAX] 105 | * @param isOpen true if zone open (disturbed), false if zone closed 106 | */ 107 | void onZoneStatusChange(std::function callback) { zoneStatusChangeCallback = callback; } 108 | 109 | /** 110 | * Alarm status changed. 111 | * 112 | * @param callback callback to notify 113 | * @param statusCode status code 114 | */ 115 | void onAlarmStatusChange(std::function callback) { alarmStatusChangeCallback = callback; } 116 | 117 | void setup() override { 118 | connectCombus(); 119 | } 120 | 121 | void loop() override { 122 | if (!getCombusConnectionStatus()) { 123 | alarmStatusChangeCallback(STATUS_UNAVAILABLE); 124 | 125 | for (int i = 0; i < 32; i++) { 126 | zoneStatusChangeCallback(i + 1, false); 127 | } 128 | return; 129 | } 130 | 131 | // ## Check if there anything new on the Bus 132 | if (!checkClockIdle() or BusMessage.length() < 2) 133 | { 134 | return; 135 | } 136 | 137 | // ## Save message and clear buffer 138 | String Message = BusMessage; 139 | // ## Clear old message 140 | BusMessage = ""; 141 | 142 | decodeMessage(Message); 143 | } 144 | 145 | /*******************************************************************************************************/ 146 | /****************************** Process Zone Status connect *****************************************/ 147 | /** 148 | * All ok 149 | * 11010000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 01100011 150 | * Zone 1 151 | * 11010000 00000000 01000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 10111001 152 | * Zone 1 and 2 and 3 153 | * 11010000 00000000 01010100 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 1010 0001 154 | */ 155 | void processZoneStatus(String &msg) 156 | { 157 | //Zone 1 = bit 17 158 | //Zone 2 = bit 19 159 | //Zone 3 = bit 21 160 | 161 | // Serial.println("ProcessingZones"); 162 | 163 | for (int i = 0; i < 32; i++) 164 | { 165 | bool open = msg[17 + (i * 2)] == '1'; 166 | zoneStatusChangeCallback(i + 1, open); 167 | } 168 | } 169 | 170 | /****************************** Process Alarm (D1) Status connect *****************************************/ 171 | /* 172 | * Might be first 5 bytes are Partition1 and 2nd 5 bytes is partion 2?? 173 | * Alarm Not set: 11010001 00000000 00000000 00010001 00000000 00000000 00000000 01000100 00000000 00000000 00000001 01001111 174 | * Alarm Set: 11010001 00000000 01000000 00010001 00000000 00000000 00000000 00000100 00000000 00000000 00000001 01110101 175 | * Alarm STay 11010001 00000000 00000000 00010001 00000000 00000000 00000100 00000100 00000000 00000000 00000001 10110000 176 | * Alarm Sleep 11010001 00000000 00000100 00010001 00000000 00000000 00000000 00000100 00000000 00000000 00000001 00001101 177 | * 178 | * 179 | */ 180 | 181 | void processAlarmStatus(String &msg) 182 | { 183 | //If set , then get status 184 | if (msg[((8 * 7) + 1)] == '0') 185 | { 186 | //Sleep 187 | if (msg[((8 * 2) + 5)] == '1') 188 | { 189 | //AlarmStatus.status = 20; 190 | alarmStatusChangeCallback(STATUS_STAY); 191 | } 192 | //Stay 193 | if (msg[((8 * 6) + 5)] == '1') 194 | { 195 | //AlarmStatus.status = 30; 196 | alarmStatusChangeCallback(STATUS_SLEEP); 197 | } 198 | //Full Arm 199 | if (msg[((8 * 2) + 1)] == '1') 200 | { 201 | //Exit Delay 202 | if (msg[((8 * 2) + 0)] == '1') 203 | { 204 | //AlarmStatus.status = 40; 205 | alarmStatusChangeCallback("exit"); 206 | } 207 | //Full Alarm 208 | else if (msg[((8 * 2) + 0)] == '0') 209 | { 210 | //AlarmStatus.status = 49; 211 | alarmStatusChangeCallback("fullalarm"); 212 | } 213 | else 214 | { 215 | //AlarmStatus.status = 45; 216 | alarmStatusChangeCallback(STATUS_ARM); 217 | } 218 | } 219 | } 220 | else 221 | { 222 | //Not Set 223 | //AlarmStatus.status = 10; 224 | alarmStatusChangeCallback(STATUS_OFF); 225 | } 226 | // alarmStatusChangeCallback(STATUS_OFF); 227 | } 228 | /*********************************************************************************************************/ 229 | 230 | /****************************** decodeMessage **************************************** 231 | * 232 | * Check Command type and call approriate function 233 | * 234 | */ 235 | void decodeMessage(String &msg) 236 | { 237 | 238 | int cmd = GetIntFromString(msg.substring(0, 8)); 239 | 240 | //Some commands have trailing "00 00 00 00" so remove 241 | if (cmd == 0xD0 || cmd == 0xD1) 242 | { 243 | //Strip last 00's 244 | msg = msg.substring(0, msg.length() - (4 * 8) - 1); 245 | 246 | if (!check_crc(msg)) 247 | { 248 | // Serial.println("CRC Faied:"); 249 | // printSerial(msg, HEX); 250 | return; 251 | } 252 | } 253 | else 254 | { 255 | 256 | if (!check_crc(msg)) 257 | { 258 | // Serial.println("CRC Faied:"); 259 | // printSerial(msg, HEX); 260 | return; 261 | } 262 | } 263 | 264 | switch (cmd) 265 | { 266 | case 0xd0: //Zone Status Message 267 | 268 | processZoneStatus(msg); 269 | 270 | break; 271 | case 0xD1: //Seems like Alarm status 272 | processAlarmStatus(msg); 273 | 274 | break; 275 | case 0xD2: //Action Message; 276 | 277 | break; 278 | 279 | case 0x20: // 280 | 281 | break; 282 | case 0xE0: // Status 283 | 284 | break; 285 | default: 286 | //Do Nothing 287 | break; 288 | ; 289 | } 290 | //Serial.print("Cmd="); 291 | //Serial.println(cmd,HEX); 292 | } 293 | 294 | /** 295 | * CRC8 296 | * 297 | * Do Maxim crc8 calc 298 | */ 299 | uint8_t crc8(uint8_t *addr, uint8_t len) 300 | { 301 | uint8_t crc = 0; 302 | 303 | for (uint8_t i = 0; i < len; i++) 304 | { 305 | uint8_t inbyte = addr[i]; 306 | for (uint8_t j = 0; j < 8; j++) 307 | { 308 | uint8_t mix = (crc ^ inbyte) & 0x01; 309 | crc >>= 1; 310 | if (mix) 311 | crc ^= 0x8C; 312 | 313 | inbyte >>= 1; 314 | } 315 | } 316 | return crc; 317 | } 318 | 319 | /** 320 | * Check CRC 321 | * Check if CRC is valid 322 | * 323 | */ 324 | uint8_t check_crc(String &st) 325 | { 326 | 327 | // printSerial(st); 328 | // 329 | 330 | String val = ""; 331 | int Bytes = (st.length()) / 8; 332 | uint8_t calcCRCByte; 333 | 334 | //Serial.print("Bytes :");Serial.println(Bytes); 335 | //printSerialHex(st); 336 | 337 | //Make byte array 338 | uint8_t *BinnaryStr = strToBinArray(st); 339 | 340 | uint8_t CRC = BinnaryStr[Bytes - 1]; 341 | 342 | calcCRCByte = crc8(BinnaryStr, (int)Bytes - 1); 343 | 344 | //Serial.print("Crc :");Serial.print((int)CRC,HEX); 345 | 346 | //Serial.print("CrcCalc :");Serial.print((int)calcCRCByte,HEX); 347 | //Serial.println(""); 348 | 349 | return calcCRCByte == CRC; 350 | } 351 | 352 | /* 353 | * Check if clock idle, means end of message 354 | * Each messasge is split by 10 Millisecond (10 000 microsecond) delay, 355 | * So assume message been send if 4 clock signals (500us x 4 x 2 = 4000) is low 356 | * 357 | */ 358 | bool checkClockIdle() 359 | { 360 | 361 | unsigned long currentMicros = micros(); 362 | //time diff in 363 | long idletime = (currentMicros - LastClkSignal); 364 | 365 | if (idletime > 8000) 366 | { 367 | 368 | //Serial.println("Idle:"+(String)idletime +":"+currentMicros+":"+ LastClkSignal); 369 | return true; 370 | } 371 | else 372 | { 373 | 374 | return false; 375 | } 376 | } 377 | 378 | /** 379 | * Get int from String 380 | * 381 | * Convert the Binary 10001000 to a int 382 | * 383 | */ 384 | unsigned int GetIntFromString(String str) 385 | { 386 | int r = 0; 387 | // Serial.print("Received:"); Serial.println(str); 388 | int length = str.length(); 389 | 390 | for (int j = 0; j < length; j++) 391 | { 392 | if (str[length - j - 1] == '1') 393 | { 394 | r |= 1 << j; 395 | } 396 | } 397 | 398 | return r; 399 | } 400 | 401 | /** 402 | * Convert str to binary array 403 | * 404 | */ 405 | uint8_t *strToBinArray(String &st) 406 | { 407 | int Bytes = (st.length()) / 8; 408 | uint8_t Data[Bytes]; 409 | 410 | String val = ""; 411 | 412 | for (int i = 0; i < Bytes; i++) 413 | { 414 | //String kk = "012345670123456701234567"; 415 | val = st.substring((i * 8), ((i * 8)) + 8); 416 | 417 | Data[i] = GetIntFromString(val); 418 | } 419 | 420 | return Data; 421 | } 422 | 423 | }; --------------------------------------------------------------------------------