##################################################################################
#
# Script that controls GPIO change/status on networked devices using TCP/IP sockets.
# The goal is to use cheap versatile computers as stationary decoders (independent of railway network - DCC, loconet, NCE, Lenz, ...).
# This script communication was tested with:
# - raspberry pi 2, zero and 3 (wifi)
# - NodeMCU 1.0 8266 ESP-12E (wifi)
#
# Networked devices will try to reconnect when connection is lost.
#
# JMRI Turnouts and Sensors name definition (always defined as internal):
#
# Internal Turnouts (system name):      [IT].IOT$<gpio>:<id>    (GPIO outputs: THROWN - set output to ground / CLOSED - set output to +V)
# Internal Sensors (system name):       [IS].IOT$<gpio>:<id>    (GPIO inputs: INACTIVE - input is at +V / ACTIVE - input is connected to ground)
# Examples:
# IT.IOT$5:192.168.200 - GPIO 5 as output on device with IP address 192.168.200 listening at port 10000 (default port)
# IS.IOT$13:dev1.mylayout.com - GPIO 13 as input on device with server name 'dev1.mylayout.com' listening at port 10000 (default port)
# IS.IOT$5:192.168.201:12345 - GPIO 5 as input on device with IP address 192.168.201 listening at port 12345
#
# JMRI should manage Sensors debounce delays
#
# This script should be loaded at JMRI startup (preferences).
# After adding or removing these Turnouts and Sensors this script (and JMRI) must be reloaded - before restarting, remember to save the panel.
#
# For testing purposes, you may use the following scripts:
# dummyTcpPeripheral.py - runs on python 2.7 (no JMRI needed) to simulate a networked device (stationary decoder)
# testTcpPeripheral.py - runs on python 2.7 (no JMRI needed) to simulate JMRI running JMRI_TcpPeripheral.py (this script)
#
# For aditional information look at the following files and links:
# (it is important to have some electronic knowledge to get the most of GPIO interfaces - LEDs, buttons, relays, reed switches, ...)
# - dummyTcpPeripheral.py
# - testTcpPeripheral.py
# - JMRI_TcpPeripheral.py (this script)
# - RPi_TcpPeripheral.py (to run at startup on raspberry pi)
# - ESP8266_TcpPeripheral.ino (to upload to NodeMCU 1.0 8266 ESP-12E using arduino IDE)
# https://www.raspberrypi.org/
# https://gpiozero.readthedocs.io/
# https://www.gitbook.com/book/smartarduino/user-manual-for-esp-12e-devkit/details
# https://www.arduino.cc/
# http://www.codeproject.com/Articles/1073160/Programming-the-ESP-NodeMCU-with-the-Arduino-IDE
#
# WARNING:
# Devices GPIOs will be defined as INPUT or OUTPUT from a remote machine.
# Hardware protect (using resistors) each GPIO implemented as INPUT because a remote machine (JMRI) may set it as OUTPUT.
#
# NOTE: to enable logging, see https://www.jmri.org/help/en/html/apps/Debug.shtml
# Add the Logger Category name "jmri.jmrit.jython.exec" at DEBUG Level.
#
# Author: Oscar Moutinho (oscar.moutinho@gmail.com), 2016 - for JMRI
##################################################################################

#:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# imports, module variables and imediate running code

import java
import java.beans
import socket
import threading
import time
from org.slf4j import LoggerFactory
import jmri

TcpPeripheral_log = LoggerFactory.getLogger("jmri.jmrit.jython.exec.TcpPeripheral")

CONN_TIMEOUT = 3.0 # timeout (seconds)
MAX_HEARTBEAT_FAIL = 5 # multiply by CONN_TIMEOUT for maximum time interval (send heartbeat after CONN_TIMEOUT * (MAX_HEARTBEAT_FAIL / 2))

#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# get gpio and id from turnout or sensor system name
def TcpPeripheral_getGpioId(sysName):
    gpio = None
    id = None
    _sysName = sysName.split(":")
    if len(_sysName) == 2 or len(_sysName) == 3:
        _gpio = _sysName[0].split("$")
        if len(_gpio) == 2:
            try:
                gpio = int(_gpio[1])
            except: # invalid GPIO
                gpio = 9999
            id = _sysName[1].strip() + ((":" + _sysName[2].strip()) if len(_sysName) > 2 else "")
    return gpio, id

#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# this is the code to be executed for a new network device
def TcpPeripheral_addDevice(id):
    alias = id.lower()
    _aux = id.split(":")
    host = _aux[0]
    try:
        port = int(_aux[1])
    except: # invalid port
        port = 10000 # default
    if alias not in TcpPeripheral_sockets:
        TcpPeripheral_sockets[alias] = TcpPeripheral_clientTcpThread(alias, TcpPeripheral_clientTcpThread_callback(), host, port)
        TcpPeripheral_sockets[alias].start()
    count = MAX_HEARTBEAT_FAIL # loop n times max (use this constant for convenience)
    while (not TcpPeripheral_sockets[alias].isAtive) and (count > 0): # try to wait for slow connection
        count -= 1
        time.sleep(CONN_TIMEOUT)
    return

#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# this is the code to be executed to close and remove a network device
def TcpPeripheral_removeDevice(id):
    alias = id.lower()
    if alias in TcpPeripheral_sockets:
        TcpPeripheral_sockets[alias].stop()
        del TcpPeripheral_sockets[alias]
    return

#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# this is the code to be executed to send a message to a network device
def TcpPeripheral_sendToDevice(out, gpio, active, id):
    alias = id.lower()
    if out:
        msg = "OUT:" + str(gpio) + ":" + ("1" if active else "0")
    else:
        msg = "IN:" + str(gpio)
    sent = TcpPeripheral_sockets[alias].send(msg)
    return sent

#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# this is the code to be executed when a valid sensor status is received from a network device
def TcpPeripheral_receivedFromDevice(alias, gpio, value):
    sensorSysName = "IS.IOT$" + str(gpio) + ":" + alias.upper()
    sensor = sensors.getBySystemName(sensorSysName)
    if sensor != None: # sensor exists
        if value:
            sensor.setKnownState(jmri.Sensor.ACTIVE)
        else:
            sensor.setKnownState(jmri.Sensor.INACTIVE)
    else: # sensor does not exist
        TcpPeripheral_log.error("'TcpPeripheral' - " + alias + ": Feedback for non-existent Sensor [" + sensorSysName + "]")
    return

#=================================================================================
# define the TCP client callback class
class TcpPeripheral_clientTcpThread_callback(object):

#---------------------------------------------------------------------------------
# this is the code to be executed when a message is received
    def processRecvMsg(self, clientTcpThread, msg):
        TcpPeripheral_log.debug("'TcpPeripheral' - " + clientTcpThread.alias + ": Received [" + msg + "]")
        _msg = msg.split(":")
        alias = clientTcpThread.alias
        if len(_msg) == 3 and _msg[0].upper() == "IN":
            try:
                gpio = int(_msg[1])
            except: # invalid GPIO
                gpio = 9999
            if _msg[2] == "1":
                TcpPeripheral_receivedFromDevice(alias, gpio, True)
            if _msg[2] == "0":
                TcpPeripheral_receivedFromDevice(alias, gpio, False)
        else: # invalid feedback
            TcpPeripheral_log.error("'TcpPeripheral' - " + alias + ": Invalid feedback [" + msg + "]")
        return

#---------------------------------------------------------------------------------
# this is the code to be executed on stop
    def onFinished(self, clientTcpThread, msg):
        TcpPeripheral_log.info("'TcpPeripheral' - " + clientTcpThread.alias + ": " + msg)
        return

#=================================================================================
# define the TCP client thread class
class TcpPeripheral_clientTcpThread(threading.Thread):

#---------------------------------------------------------------------------------
# this is the code to be executed when the class is instantiated
    def __init__(self, alias, callback, ip, port):
        threading.Thread.__init__(self)
        self.alias = alias
        self.callback = callback
        self.ip = ip
        self.port = port
        self.received = ""
        self.isAtive = False
        self.exit = False
        self.sock = None
        return

#---------------------------------------------------------------------------------
# this is the code to be executed on start
    def run(self):
        self.connect() # connect
        heartbeatFailCount = 0
        heartbeatCtrl = time.time() # start heartbeat delay
        while not self.exit:
            if (time.time() - heartbeatCtrl) > (CONN_TIMEOUT * (MAX_HEARTBEAT_FAIL / 2)): # send only after appropriate delay
                self.sock.sendall(" ") # send heartbeat
                heartbeatCtrl = time.time() # restart heartbeat delay
            try:
                received = self.sock.recv(256)
                if received:
                    TcpPeripheral_log.debug("'TcpPeripheral' - " + self.alias + ": Received (including heartbeat) [" + received + "]")
                    heartbeatFailCount = 0
                    self.received += received.replace(" ", "") # remove spaces (heartbeat)
                    cmds = self.received.split("|")
                    if len(cmds) > 0:
                        for cmd in cmds:
                            if cmd: # if not empty
                                self.callback.processRecvMsg(self, cmd)
                        procChars = self.received.rfind("|")
                        self.received = self.received[procChars:]
                else:
                    TcpPeripheral_log.error("'TcpPeripheral' - " + self.alias + ": Connection broken - closing socket")
                    self.sock.close()
                    self.isAtive = False
                    self.connect() # reconnect
                    heartbeatFailCount = 0
            except socket.timeout as e:
                heartbeatFailCount += 1
                if heartbeatFailCount > MAX_HEARTBEAT_FAIL:
                    TcpPeripheral_log.error("'TcpPeripheral' - " + self.alias + ": Heartbeat timeout - closing socket")
                    self.sock.close()
                    self.isAtive = False
                    self.connect() # reconnect
                    heartbeatFailCount = 0
            except:
                TcpPeripheral_log.error("'TcpPeripheral' - " + self.alias + ": Connection reset by peer - closing socket")
                self.sock.close()
                self.isAtive = False
                self.connect() # reconnect
                heartbeatFailCount = 0
        self.callback.onFinished(self, "Finished")
        return

#---------------------------------------------------------------------------------
# this is the code to be executed to connect or reconnect
    def connect(self):
        server_address = (self.ip, self.port)
        while not self.exit:
            TcpPeripheral_log.info("'TcpPeripheral' - " + self.alias + ": Connecting socket thread to '%s' port %s" % server_address)
            try:
                self.sock = socket.create_connection(server_address, CONN_TIMEOUT)
            except socket.error as e:
                TcpPeripheral_log.error("'TcpPeripheral' - " + self.alias + ": ERROR - " + str(e))
                self.sock = None
                time.sleep(CONN_TIMEOUT)
            else:
                TcpPeripheral_log.info("'TcpPeripheral' - " + self.alias + ": Connected to '%s' port %s" % server_address)
                self.isAtive = True
                break # continue because connection is done
        return

#---------------------------------------------------------------------------------
# this is the code to be executed to send a message
    def send(self, msg):
        if self.isAtive:
            TcpPeripheral_log.debug("'TcpPeripheral' - '" + self.alias + "' sending message: " + msg)
            try:
                self.sock.sendall(msg + "|") # add end of command delimiter
            except:
                TcpPeripheral_log.error("'TcpPeripheral' - " + self.alias + ": Error sending - closing socket")
                self.sock.close()
                self.isAtive = False
                self.connect() # reconnect
                heartbeatFailCount = 0
        else:
            TcpPeripheral_log.error("'TcpPeripheral' - '" + self.alias + "' message [" + msg + "] not sent")
        return self.isAtive

#---------------------------------------------------------------------------------
# this is the code to be executed to close the socket and exit
    def stop(self):
        TcpPeripheral_log.info("'TcpPeripheral' - " + self.alias + ": Stop the socket thread - closing socket")
        try:
            self.sock.close()
        except: # ignore possible error if connection not ok
            pass
        finally:
            self.isAtive = False
            self.exit = True
        return

#=================================================================================
# define the listener class for Sensors
class TcpPeripheral_Sensor_Listener(java.beans.PropertyChangeListener):

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def propertyChange(self, event):
        sensor = event.getSource()
        sensorName = sensor.getDisplayName(jmri.NamedBean.DisplayOptions.USERNAME_SYSTEMNAME)
        TcpPeripheral_log.debug("'TcpPeripheral' - Sensor=" + sensorName + " property=" + event.propertyName + "]: oldValue=" + str(event.oldValue) + " newValue=" + str(event.newValue))
        if event.propertyName == "KnownState": # only this property matters
            gpio, id = TcpPeripheral_getGpioId(sensor.getSystemName())
            sent = TcpPeripheral_sendToDevice(False, gpio, None, id)
            if not sent: # set as unknown
                sensor.setKnownState(jmri.Sensor.UNKNOWN)
        return

#=================================================================================
# define the listener class for Turnouts
class TcpPeripheral_Turnout_Listener(java.beans.PropertyChangeListener):

#---------------------------------------------------------------------------------
# this is the code to be executed when the class is instantiated
    def __init__(self):
        self.turnoutCtrl = None # for turnout restore control
        return

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    def propertyChange(self, event):
        turnout = event.getSource()
        turnoutName = turnout.getDisplayName(jmri.NamedBean.DisplayOptions.USERNAME_SYSTEMNAME)
        TcpPeripheral_log.debug("'TcpPeripheral' - Turnout=" + turnoutName + " property=" + event.propertyName + "]: oldValue=" + str(event.oldValue) + " newValue=" + str(event.newValue) + " turnoutCtrl=" + str(self.turnoutCtrl))
        if event.propertyName == "CommandedState": # only this property matters
            if event.newValue != self.turnoutCtrl: # this is a state change request
                gpio, id = TcpPeripheral_getGpioId(turnout.getSystemName())
                sent = True
                if event.newValue == jmri.Turnout.CLOSED:
                    sent = TcpPeripheral_sendToDevice(True, gpio, True, id)
                if event.newValue == jmri.Turnout.THROWN:
                    sent = TcpPeripheral_sendToDevice(True, gpio, False, id)
                if sent: # store the current state
                    self.turnoutCtrl = event.newValue
                else: # restore turnout state
                    self.turnoutCtrl = event.oldValue
                    turnout.setCommandedState(event.oldValue)
        return

#=================================================================================
# define the shutdown task class
class TcpPeripheral_ShutDown(jmri.implementation.AbstractShutDownTask):

#---------------------------------------------------------------------------------
# this is the code to be invoked when the program is shutting down
    def run(self):
        auxList = []
        for alias in TcpPeripheral_sockets:
            auxList.append(alias)
        for alias in auxList:
            TcpPeripheral_removeDevice(alias)
        TcpPeripheral_log.info("Shutting down 'TcpPeripheral'.")
        time.sleep(3) # wait 3 seconds for all sockets to close
        return

#*********************************************************************************

if globals().get("TcpPeripheral_running") != None: # Script already loaded so exit script
    TcpPeripheral_log.warn("'TcpPeripheral' already loaded and running. Restart JMRI before load this script.")
else: # Continue running script
    TcpPeripheral_log.info("'TcpPeripheral' started.")
    TcpPeripheral_running = True
    TcpPeripheral_sockets = {}
    shutdown.register(TcpPeripheral_ShutDown("TcpPeripheral"))
    for sensor in sensors.getNamedBeanSet():
        gpio, id = TcpPeripheral_getGpioId(sensor.getSystemName())
        TcpPeripheral_log.debug("'TcpPeripheral' - Sensor SystemName [" + sensor.getSystemName() + "] GPIO [" + str(gpio) + "] ID [" + str(id) + "]")
        if gpio != None and id != None:
            TcpPeripheral_addDevice(id)
            sensor.setKnownState(jmri.Sensor.INCONSISTENT) # set sensor to inconsistent state (just to detect change to unknown)
            sensor.addPropertyChangeListener(TcpPeripheral_Sensor_Listener())
            sensor.setKnownState(jmri.Turnout.UNKNOWN) # to force send a register request to device
    for turnout in turnouts.getNamedBeanSet():
        gpio, id = TcpPeripheral_getGpioId(turnout.getSystemName())
        TcpPeripheral_log.debug("'TcpPeripheral' - Turnout SystemName [" + turnout.getSystemName() + "] GPIO [" + str(gpio) + "] ID [" + str(id) + "] Kown State [" + str(turnout.getKnownState()) + "]")
        if gpio != None and id != None: # should be a valid network device and GPIO
            TcpPeripheral_addDevice(id)
            currentState = turnout.getCommandedState() # get current turnout state
            turnout.setCommandedState(jmri.Turnout.UNKNOWN) # set turnout to a state that will permit change detection by listener
            turnout.addPropertyChangeListener(TcpPeripheral_Turnout_Listener())
            if currentState == jmri.Turnout.CLOSED:
                turnout.setCommandedState(jmri.Turnout.CLOSED)
            if currentState == jmri.Turnout.THROWN:
                turnout.setCommandedState(jmri.Turnout.THROWN)
