Zelio Integration to OpenHAB via MQTT

So for many years, I used an adaption of the c++ program created by @Jeroen_Ost quite successfully to read from my Zelio Smart Relay, via the exec binding. After I moved to OH3, I experienced an intermittent issue with the exec binding (not the program), which I would probably call an edge case (I won’t bore you with the details here, but see OH3 Exec Binding with Serial Port - Port Locking due to overlapping item reads for more details.

My quick ‘n’ dirty solution was to knock-out a Zelio->MQTT integration using a Python based service. I never got around to cleaning it up, but that said, it has been pretty much rock solid for the past year since I moved to using it.

Future plans could include (in order of importance to me):

  • Adding zelio write operations (current only reads from Zelio)
  • Moving bitwise operations into this program (I currently handle them in OpenHAB rules)
  • Separation of Config and Code (e.g. using yaml)

So rather than waiting years until I get around to getting this all sorted, I will share with you the initial 'Quick ‘n’ dirty version now, along with some key artefacts/info to run it on linux… (Being python, it will also run on windows, but you’ll have a few differences to getting it running as a service etc):

Firstly the ‘zelio.py’ program (I located this at /usr/local/bin/zelio/zelio.py)

zelio.py

#!/usr/bin/env python
# 
# zelio.py
# Draft Zelio to MQTT Interface
# Zelio communications/checksum section inspired & based on works by Pedro Fernández (http://englishdrummer.blogspot.com/2019/02/hacking-crouzet-plc-zelio-schneider.html)
# 
# You will need the following dependencies:
# pip install paho-mqtt
# pip install pyserial

import serial, time, sys, logging
from paho.mqtt import client as mqtt_client


#PARAMS--------------------------------------
#
# Zelio
zelio_port_device = "/dev/ttyZelio"     #Serial device name for Zelio connection. I have used a linked device for the USB Zelio connector (defaults to ttyACMx)
zelioFrameStart = bytes(':','ascii')

# You dont/shouldn't need to edit the following to get this going
zelioUnitId = bytes('01','ascii')       #Do not change - Fixed for Zelio SLIN/SLOUT interface
zelioReadCommand = bytes('03','ascii')
zelioAddressPrefix = bytes('0000FF','ascii')
zelioDefaultReadLength = bytes('02','ascii')

#MQTT
# MQTT Connection Settings
mqtt_broker = 'xxxx'        #MQTT Broker Server Hostname or IP Address
mqtt_port = 1883            #MQTT Broker TCP Port
mqtt_topic = "/zelio"       #MQTT Topic name - Can be anything you choose
mqtt_client_id = "zelio"
mqtt_username = 'xxxx'      #MQTT Username - Consider Setting one up specifically for this vlient
mqtt_password = 'xxxx'      #MQTT Password
retry_wait = 60             #Number of seconds to wait befor retrying connection to Broker
retry_attempts = 10         #Number of retry attempts to reconnect to Broker

#Misc
update_interval = 10     #Interval in seconds for reading Zelio values and updating MQTT

#-----------------------------------------------

class SystemdHandler(logging.Handler):
    PREFIX = {
        logging.CRITICAL: "<2>",
        logging.ERROR: "<3>",
        logging.WARNING: "<4>",
        logging.INFO: "<6>",
        logging.DEBUG: "<7>"
    }
    def __init__(self, stream=sys.stdout):
        self.stream = stream
        logging.Handler.__init__(self)

    def emit(self, record):
        try:
            msg = self.PREFIX[record.levelno] + self.format(record) + "\n"
            self.stream.write(msg)
            self.stream.flush()
        except Exception:
            self.handleError(record)

root_logger = logging.getLogger()
root_logger.setLevel("INFO")
root_logger.addHandler(SystemdHandler())



def modbusAsciiChecksum(frame):
    address = frame[1:3]
    function = frame[3:5]
    data = frame[5::]

    add = 0
    add += int(address, 16)
    add += int(function, 16)

    for i in range(0, len(data), 2):
        add += int(data[i:i+2], 16)

    add = -add
    add &= 0xFF
    add = add + 1
    result = str(hex(add).upper())
    return result[2::]

def zelioRead(readAddress):
    #Routine to send request and read/return response

    #Build read request
    zelioRequest = zelioFrameStart
    zelioRequest += zelioUnitId
    zelioRequest += zelioReadCommand
    zelioRequest += zelioAddressPrefix
    zelioRequest += bytes(readAddress,'ascii')
    zelioRequest += zelioDefaultReadLength
    zelioRequest += bytes(modbusAsciiChecksum(zelioRequest),'ascii')
    zelioRequest += bytes('\r\n','ascii')

    #Send Read Request
    try:
        zelioport.reset_output_buffer()
        zelioport.reset_input_buffer()
        zelioport.write(zelioRequest)
    except serial.SerialException as e:
        raise Exception("Failed to send read request to serial port '{}': {}".format(zelio_port_device, e))
    except Exception as e:
        raise Exception("Failed to send read request to serial port '{}': {}".format(zelio_port_device, e))

    #Read Response
    try:
        time.sleep(0.1)
        if zelioport.in_waiting > 0:
            time.sleep(0.1)
            zelioResponse = zelioport.readline(zelioport.in_waiting)
    except serial.SerialException as e:
        raise Exception("Could not read from serial port '{}': {}".format(zelio_port_device, e))
    except serial.SerialException(TimeoutError) as e:
        raise Exception("Timeout reading from serial port '{}': {}".format(zelio_port_device, e))
    except Exception as e:
        raise Exception("Unknown Exception '{}': {}".format(zelio_port_device, e))

    #Validate return Checksum
    retChecksum = zelioResponse[-4:]
    retCalcChecksum = modbusAsciiChecksum(zelioResponse[0:-4])
    ba = bytearray([int(retChecksum,16)])
    bb = bytearray([int(retCalcChecksum,16)])
    if ba != bb:
        logging.info("Invalid Checksum Returned\n")
        return "Error"

    #Strip out data from Zelio response and return value (Address & Function removed)
    ZelioDataResponse = zelioResponse[7:-4]
    return ZelioDataResponse

def zelioFetch(zelioAddress,mqttTopic):
    try:
            ret_data = zelioRead(zelioAddress)
    except Exception as e:
            logging.warning("Zelio Read Failure - Waiting 5s for retry'{}': {}".format(zelio_port_device, e))
            time.sleep(5)
    else:
        try:
           client.publish(mqttTopic,int(ret_data,16))
        except Exception as e:
            logging.warning("MQTT Publish Failure - Waiting 5s for retry'{}': {}".format(mqtt_topic, e))
            time.sleep(5)
        else:
            return


def mqtt_on_connect(client,userdata,flags,rc):
    if rc == 0:
        logging.info("Connected to broker " + mqtt_broker + " Return Code: " + str(rc) + "\n")
    else:
        logging.error("Failed to connect to broker " + mqtt_broker + " Return Code: " + str(rc) + "\n")

def mqtt_on_disconnect(client,userdata,rc):
    if rc != 0:
        logging.error("Lost Connection to broker " + mqtt_broker + " Return Code: " + str(rc) + "\n")
        for i in (1,retry_attempts):
            try:
                client.reconnect()
            except:
                logging.error("Reconnect failed to broker " + mqtt_broker + " Retrying in " + str(retry_wait) + " seconds")
                client.connected_flag = False
                time.sleep(retry_wait)
            else:
                if client.is_connected() == True: 
                    client.connected_flag = True
                    return


def connect_mqtt():
    client = mqtt_client.Client(mqtt_client_id)
    client.username_pw_set(mqtt_username, mqtt_password)
    client.on_connect = mqtt_on_connect
    client.on_disconnect = mqtt_on_disconnect
    try:
        client.connect(mqtt_broker, mqtt_port)
    except Exception as e:
        client.connected_flag=False
        raise Exception ("ERROR: MQTT Connection Failure:'{}': {}".format(mqtt_broker, e))
    else:
        client.connected_flag=True
        return client

#MAIN-----------------------------------------


#Port initialisation

try:
    zelioport = serial.Serial(port=zelio_port_device, baudrate=115200, bytesize=7, parity='E', stopbits=1, timeout=1)
except serial.SerialException as e:
    logging.error("Could not open serial port '{}': {}".format(zelio_port_device, e))
    sys.exit(1)

try:
    client = connect_mqtt()
except Exception as e:
    logging.error("Could not connect to MQTT Broker '{}': {}".format(mqtt_broker, e))
    sys.exit(1)

# This is the quick & ugly bit here - You need to edit the following lines (and add/remove lines as needed) - To be moved to variables eventually
# these are in the form of:
# zeliofetch('readaddressinhex,'mqtttopicname')

while True:
    client.loop_start()
    if client.is_connected() == True:
        zelioFetch('1B','zelio/read/ZelioSolarTemperature')
        zelioFetch('1C','zelio/read/ZelioTemperatureHWCTop')
        zelioFetch('1D','zelio/read/ZelioTemperatureHWCMid')
        zelioFetch('1E','zelio/read/ZelioTemperatureHWCBot')
        zelioFetch('19','zelio/read/ZelioLivingWBTemperature')
        zelioFetch('1A','zelio/read/ZelioRumpusWBTemperature')
        zelioFetch('18','zelio/read/ZelioFlags')
    time.sleep(update_interval)
    client.loop()

zelioport.close()
client.loop_stop()
sys.exit(0)

Then the service file: zelio.service
(Optional: I have created a specific user to run this service under. If you are going to do this, make sure you add them to the ‘dialout’ group).
This service runs on the same system as OpenHAB and mosquitto MQTT, so that’s why I have the ‘requires’ listed in the service file. If your MQTT is on a remote node, you can drop that.

/etc/systemd/system/zelio.service

[Unit]
Description=zelio_mqtt
Requires=mosquitto.service

[Service]
ExecStart=/usr/local/bin/zelio/zelio.py
User=myzeliouser
WorkingDirectory=/home/myzeliouserdirectory

[Install]
WantedBy=multi-user.target

I also use the USB interface to the Zelio, so its kind-of handy to make sure you get the same device name everytime you reboot, or unplug/replug the Zelio.

I created the following file to achieve this:
/etc/udev/rules.d/98_ttyZelio.rules

KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="7270", SYMLINK="ttyZelio"

Of course if you have the Zelio-Serial interface cable, you can do a similar thing with the serial interface device you are using.

So moving back into the openHAB world, you just grab the data like you would any other MQTT device. The following is the corresponding ‘things’ config for the above example python program:

UID: mqtt:topic:fnlctlmqtt:zelio
label: Zelio Heating Control
thingTypeUID: mqtt:topic
configuration: {}
bridgeUID: mqtt:broker:mymqttbroker
channels:
  - id: th_ZelioSolarTemperature
    channelTypeUID: mqtt:string
    label: Zelio Solar Temperature
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioSolarTemperature
  - id: th_ZelioTemperatureHWCTop
    channelTypeUID: mqtt:string
    label: Zelio HWC Top Temperature
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioTemperatureHWCTop
  - id: th_ZelioTemperatureHWCMid
    channelTypeUID: mqtt:string
    label: Zelio HWC Mid Temperature
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioTemperatureHWCMid
  - id: th_ZelioTemperatureHWCBot
    channelTypeUID: mqtt:string
    label: Zelio HWC Bottom Temperature
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioTemperatureHWCBot
  - id: th_ZelioLivingWBTemperature
    channelTypeUID: mqtt:string
    label: Zelio Living WB Temperature
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioLivingWBTemperature
  - id: th_ZelioRumpusWBTemperature
    channelTypeUID: mqtt:string
    label: Zelio Rumpus WB Temperature
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioRumpusWBTemperature
  - id: th_ZelioFlags
    channelTypeUID: mqtt:string
    label: Zelio Flags
    description: ""
    configuration:
      stateTopic: zelio/read/ZelioFlags

Then all channels are linked to items (Number Items in my case), for example:

The last channel listed in the above things file, links to an item ‘ZelioFlags_Output’ which is actually a number representing individual bits/registers in the Zelio.

In the Zelio itself, it is a CNA (Bit to Word Conversion) connected to a channel on the “slout” function.

I use a OpenHAB rule to break this out to individual items, representing each register, when the ‘ZelioFlags_Output’ item is changed:

configuration: {}
triggers:
  - id: "1"
    configuration:
      itemName: ZelioFlags_Output
    type: core.ItemStateChangeTrigger
conditions: []
actions:
  - inputs: {}
    id: "2"
    configuration:
      type: application/javascript;version=ECMAScript-2021
      script: >-
        zflags = items.getItem("ZelioFlags_Output").state;

        setState("ZelioHWCElement",0,zflags);

        setState("ZelioUnderfloorCirc",1,zflags);

        setState("ZelioRingMainCirc",2,zflags);

        setState("ZelioSolarCirc",3,zflags);

        setState("ZelioWBRumpusCirc",4,zflags);

        setState("ZelioWBLivingCirc",5,zflags);

        setState("ZelioAlarmArmed",6,zflags);


        function setState(item,bitpos,zflags) {

        // Function to update status of items, based on a bit status
          switch(getBit(zflags,bitpos)){
            case "ON" :
              if(items.getItem(item).state !== "ON")
                {
                items.getItem(item).sendCommand("ON");
                //console.log("Changing state to ON for:",items.getItem(item).name);
                }
              break;
            case "OFF" :
              if(items.getItem(item).state !== "OFF")
                {
                items.getItem(item).sendCommand("OFF");
                //console.log("Changing state to OFF for:",items.getItem(item).name);
                }
              break;    
            default:
              console.log("Unknown state for:",items.getItem(item).name);
          } 
        }


        function getBit(number, bitPosition) {

        // Function to read individual bits and return status
          return (number & (1 << bitPosition)) === 0 ? "OFF" : "ON";
        }
    type: script.ScriptAction

So yes, not the greatest/cleanest coding, but it works. Over time I will try and clean it up some more.

If I could spell java, my ideal solution would be a native binding or making the Modbus binding extensible to handle different register address lengths. After all, that is all the Zelio seems to be talking via the slin/slout interface - Modbus with an extended register address field… see https://groups.google.com/g/openhab/c/al5vyXJOqHU)

Good luck, and hope someone finds this of use.

1 Like