################################################################################## # # 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 outputs: THROWN - set output to ground / CLOSED - set output to +V) # Internal Sensors (system name): [IS].IOT$: (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)