#!/usr/bin/python

from __future__ import print_function
import gc
import os
import sys
import json
import time
import base64
import signal
import OpenOPC
import subprocess
import paho.mqtt.client as mqtt

gl_loop = True
gl_devsn = None
gl_maxnum = 128
def find_template_id(tplt, tpid):
    tcfg = tplt.get("template_cfg")
    if tcfg is None or not isinstance(tcfg, list):
        return None, None
    for svrcfg in tcfg:
        if not isinstance(svrcfg, dict):
            continue
        tid = svrcfg.get("template_id")
        if tid is None or tid != tpid:
            continue
        scfgs = svrcfg.get("service_cfg")
        if scfgs is None or not isinstance(scfgs, list):
            continue
        for scfg in scfgs:
            if not isinstance(scfg, dict):
                continue
            direct = scfg.get("direction")
            if direct is None or direct != 0:
                continue
            ident = scfg.get("identifier")
            period = scfg.get("server_period")
            if period is None or ident is None:
                continue
            return ident, period
    return None, None
def get_openopc_period(perlist):
    try:
        fcfg = open("/app/node/nodes_cfg.json", "rb")
        nodecfg = json.load(fcfg); fcfg.close()
        ftem = open("/app/node/templates_cfg.json", "rb")
        tempcfg = json.load(ftem); ftem.close()
    except (IOError, ValueError) as ioe:
        print(ioe)
        return False
    nodes_cfg = nodecfg.get("nodes_cfg")
    if nodes_cfg is None or not isinstance(nodes_cfg, list):
        print("Error, cannot get nodes_cfg")
        return False
    for node in nodes_cfg:
        if not isinstance(node, dict):
            print("Error, not dictionary: " + str(type(node)))
            continue
        app_key = node.get("app_key")
        if app_key is None or app_key != "openopc":
            print("Error, app_key not found")
            continue
        tid = node.get("template_id")
        if tid is None:
            print("Error, template_id not found for " + app_key)
            continue
        hostip = node.get("tcp_ip_addr")
        if hostip is None or len(hostip) == 0:
            print("Error, tcp_ip_addr not found for " + tid)
            continue
        sn = node.get("sn")
        if sn is None or len(sn) == 0:
            print("Error, invalid sn for " + app_key)
            continue
        identifier, period = find_template_id(tempcfg, tid)
        if identifier is None:
            print("Error, period not found for " + tid)
            continue
        print("{} => identifier: {}, period: {}".format(tid, identifier, period))
        if period == 0:
            continue
        period = int(period * 1000)
        perinfo = dict()
        perinfo["topic"] = "ipc/{}/openopc/device/{}/data/Set_Rglt".format(gl_devsn, sn)
        payload = dict()
        payload["sn"] = sn
        payload["identifier"] = identifier
        payload["server_period"] = period
        perinfo["period"] = period
        perinfo["payload"] = json.dumps(payload, indent=4)
        perlist[hostip] = perinfo
    return True
# Simple OpenOPC/MQTT client class
class OPC2MQTT:
    def __init__(self):
        self.opchosts = dict()
        self.client = None
        # load nodes_cfg.json & templates_cfg.json
        # to instantiate periodic read of OPC DA
        self.periods = dict()
        get_openopc_period(self.periods)
    def on_connect(self, client, userdat, flags, rc):
        print("MQTT connection established: " + str(rc))
        topi = "ipc/{}/openopc/device/+/data/Set_Rglt_Raw".format(gl_devsn)
        ret = client.subscribe(topi)
        print("Subscribing topic \"{}\": {}".format(topi, ret))
        return True
    def check_connect(self, ipaddr):
        host = self.opchosts.get(ipaddr)
        if host is None:
            return False
        opchdl = host.get("OPENOPC")
        if opchdl and not opchdl.ping():
            del host["OPENOPC"]
            opchdl.close(); opchdl = None
        if opchdl is not None:
            return True
        daname = None; cmdbuf = host.get("cmdjson")
        if cmdbuf is not None:
            daname = cmdbuf.get("daname")
        if daname is None or len(daname) == 0:
            print("Failed to find OPCDA name for " + ipaddr)
            return False
        print("Connecting to OpenOPC server: " + ipaddr)
        try: # the connection might fail
            opchdl = OpenOPC.open_client(ipaddr)
            opchdl.connect(daname) # connect to the specific DA
        except Exception as exval:
            print("Failed to connect to OpenOPC server: " + ipaddr)
            print(exval)
            if opchdl is not None:
                opchdl.close(); opchdl = None
            return False
        # save the OpenOPC client handle
        host["OPENOPC"] = opchdl
        return True
    def reportopc(self, grplen, items, hInfo):
        reply = dict(); nitems = 0
        for ite in items:
            if not isinstance(ite, tuple) or len(ite) != 4:
                print("Error, invalid type item: " + str(type(ite)))
                continue
            itemn = str(ite[0]); itemv = ite[1]; good = ite[2]
            if itemv is None or good != 'Good':
                print("Error, bad status for " + itemn)
                continue
            if isinstance(itemv, float):
                itemv = round(itemv, 4)
            itemn = itemn[grplen:]
            nitems += 1; reply[itemn] = itemv
        if nitems == 0:
            return False
        reply = json.dumps(reply)
        pload = hInfo["payload"]
        pload["len"] = len(reply)
        pload["data_b64"] = base64.b64encode(reply)
        self.client.publish(hInfo["newtopic"], json.dumps(pload, indent=1))
        return True
    def readopc(self, hostip, update=False):
        if not self.check_connect(hostip):
            return False
        hinfo = self.opchosts.get(hostip)
        if update:
            hinfo["lastread"] = time.time()
        cmdjson = hinfo["cmdjson"]
        # get the group name, which must exist
        grpname = cmdjson.get("grpname")
        if grpname is None:
            print(type(grpname))
            print("Error, no valid grpname found")
            return False
        grpname += "."
        # get the OPC UA item list string
        items = cmdjson.get("itemname")
        if items is None or len(items) == 0:
            print("Error, no valid itemname found")
            return False
        # transform the item list string to an array
        itemlst = items.split(",")
        if len(itemlst) == 1 and os.access(itemlst[0], os.R_OK):
            itemhdl = None # try to read from file for a list of items
            try:
                itemhdl = open(itemlst[0], mode='rb')
                items = itemhdl.read()
                itemhdl.close(); itemhdl = None
            except IOError as ioe:
                print(ioe)
                if itemhdl is not None:
                    itemhdl.close()
                    itemhdl = None
            else:
                itemlst = items.split(",")
        items = None # for the good of garbage collector
        if not isinstance(itemlst, list) or len(itemlst) == 0:
            print("Error, not a single item found")
            return False
        itemlst = [ grpname + xval for xval in itemlst ]
        opchdl = hinfo["OPENOPC"]
        try: # invoke the OpenOPC read method
            rval = opchdl.read(itemlst, group=grpname)
        except Exception as exvalue:
            rval = None
            print(exvalue)
        if not isinstance(rval, list):
            print("Error, OpenOPC read has failed: " + str(type(rval)))
            return False
        rlen = len(rval); clen = 0; lengrp = len(grpname)
        while clen < rlen:
            left = rlen - clen
            if left > gl_maxnum:
                left = gl_maxnum
            coff = left + clen
            self.reportopc(lengrp, rval[clen:coff], hinfo)
            clen = coff
        return True
    def new_host(self, hipaddr, topic, payload, cmdjson):
        hinfo = self.opchosts.get(hipaddr)
        if hinfo is None:
            hinfo = dict()
        hinfo["topic"] = topic
        hinfo["newtopic"] = topic.replace("Set_Rglt_Raw", "raw_data")
        hinfo["payload"] = payload
        hinfo["cmdjson"] = cmdjson
        hinfo["lastread"] = 0.0
        hinfo["ipaddr"] = hipaddr
        self.opchosts[hipaddr] = hinfo
        return self.check_connect(hipaddr)
    def on_message(self, client, userdata, msg):
        pload = msg.payload # MQTT payload
        try: # decode the payload as string
            pld = json.loads(pload)
        except (TypeError, ValueError, StopIteration) as valerr:
            print(valerr); print(pload)
            return False
        ipaddr = pld.get("tcp_ip_addr")
        if ipaddr is None or len(ipaddr) == 0:
            print("Error, invalid tcp_ip_addr given")
            return False
        opccmd = pld.get("data_b64")
        if opccmd is None or len(opccmd) == 0:
            print("Error, invalid data_b64 given")
            return False
        try: # decode data_b64 as json
            cmdat = json.loads(opccmd)
        except (TypeError, ValueError, StopIteration) as errval:
            print(errval); print(opccmd)
            return False
        # pld["period"] = 120000 # force periodic test
        if self.new_host(ipaddr, msg.topic, pld, cmdat):
            return self.readopc(ipaddr, True)
        return False
    def period_check(self):
        hosts = list(self.opchosts.keys())
        if len(hosts) == 0:
            return True
        nowt = time.time()
        for ipaddr in hosts:
            hinfo = self.opchosts.get(ipaddr)
            if hinfo is None:
                continue
            savedp = self.periods.get(ipaddr)
            if savedp is None:
                continue
            pload = hinfo.get("payload")
            if pload is None:
                continue
            period = savedp.get("period")
            if period is None or not isinstance(period, int):
                continue
            period = period / 1000.0; # process as seconds
            lastread = hinfo["lastread"]
            if nowt >= (lastread + period):
                self.readopc(ipaddr)
                if nowt >= (lastread + period * 2):
                    hinfo["lastread"] = time.time()
                else:
                    hinfo["lastread"] = lastread + period
                gc.collect()
        return True
    def on_disconnect(self, client, userdata, rc):
        self.connected = client.is_connected()
        print("MQTT connection down: " + str(rc))
    def init_periods(self, last):
        nowt = time.time()
        if (nowt - last) < 5.0:
            return last
        hosts = list(self.periods.keys())
        hlen = len(hosts); count = 0
        if hlen == 0:
            return None
        for ipaddr in hosts:
            if ipaddr in self.opchosts:
                count += 1
                continue
            pinfo = self.periods.get(ipaddr)
            if pinfo is None:
                continue
            print("Periodic read from {}, interval: {}".format(ipaddr, pinfo["period"]))
            self.client.publish(pinfo["topic"], pinfo["payload"])
        if count == hlen:
            return None
        return time.time()
    def main(self):
        global gl_loop
        self.client = mqtt.Client()
        self.client.on_connect = self.on_connect
        self.client.on_message = self.on_message
        self.client.on_disconnect = self.on_disconnect
        self.client.connect("127.0.0.1")
        self.client.loop(timeout=2)
        lasttim = time.time()
        while gl_loop:
            if not self.client.is_connected():
                self.client.reconnect()
                self.client.loop(timeout=3)
                continue
            self.period_check()
            self.client.loop(timeout=3)
            if lasttim is not None:
                lasttim = self.init_periods(lasttim)
        self.client.disconnect()
        return False
    def __del__(self):
        hostlst = list(self.opchosts.keys())
        print("number of hosts: " + str(len(hostlst)))
        for hlst in hostlst:
            print("Deleting host " + hlst)
            hinfo = self.opchosts.get(hlst)
            if hinfo is None:
                continue
            del self.opchosts[hlst]
            opchdl = hinfo.get("OPENOPC")
            if opchdl is not None:
                opchdl.close()
                del hinfo["OPENOPC"]
            del hinfo["topic"]
            del hinfo["newtopic"]
            del hinfo["payload"]
            del hinfo["cmdjson"]
            del hinfo["lastread"]
        self.opchosts = None
        if self.client is not None:
            self.client.disconnect()
            self.client = None
        return True
# Signal handler function for SIGINT & SIGTERM
def opcsighandler(signo, sigfrm):
    global gl_loop
    gl_loop = False
    print("Received a signal: {}".format(signo))
    return True
# function to fetch gateway SN
def get_devsn(argv):
    global gl_devsn
    try:
        pop = subprocess.Popen(argv, stdout=subprocess.PIPE, shell=False)
        pop.wait()
        dev_sn = pop.stdout.read().decode()
    except Exception as exval:
        print(exval)
        return False
    dev_sn = dev_sn.strip("\r\n\t ")
    if "=" in dev_sn:
        dlist = dev_sn.split("=")
        if len(dlist) != 2:
            print("Invalid output for SN: " + dev_sn)
            return False
        dev_sn = dlist[1]
        dev_sn = dev_sn.strip("'")
    if len(dev_sn) == 0:
        return False
    gl_devsn = dev_sn
    return True
if not get_devsn([ 'fw_printenv', 'SN' ]) and not get_devsn(["uci", "-X", "show", "system.system.hostname"]):
    print("Error, cannot get SN")
    sys.exit(1)
# Disable cloud_mqtt to avoid data upload:
os.system("CMFILE=/etc/init.d/cloud_mqtt; ${CMFILE} disable ; ${CMFILE} stop 2>/dev/null")
# Create an OPC-To-MQTT instance
opc2m = OPC2MQTT()
# Register signals: SIGINT & SIGTERM
signal.signal(signal.SIGINT, opcsighandler)
signal.signal(signal.SIGTERM, opcsighandler)
# invoke main function
opc2m.main()
del opc2m; gc.collect()
# terminate script with non-zero status
opc2m = None; sys.exit(1)
