Serial Control Application Source Code

  Serial Control Source Code [ Oct 06 2020, 6.78 KB, MD5: bb35cb94fc72426bc2e81b8be58ecb2f ]

This post will outline and provide the source code to our Serial Control application.

The application should really be called ASCII Control instead of Serial Control. The application got it’s name because communication was originally only available via the serial ports. Ethernet communication was added in a later release.

The code was rewritten to clean it up and make it available as a sample. We will talk about the features of the application that deal with serial communication, Ethernet communication, I/O control and I/O monitoring. Features such as versioning and logging are parts of this application that are beyond the scope of this post.

To start we are looking at the SerialConrtolMain.java file. After listing the imports, there is a version listed. This is from the Assembly.java file. The versioning of the project would usually be kept there, but we aren’t going over the Assembly.java file because it extends outside the scope of this post.

After this the Class of the file begins. There are three global variables listed there. The validconfiguration variable is to determine if the application should be running or not. This could be determined by weather or not the application is already running on the device. After that is the Input and Output log monitors, which are used to watch the I/O of the JNIORs.

Next comes the main function. Here an object of the application is created, and two function are called. The Init function, which grabs the values needed to establish the connection for the serial control application and the run function which, allows the connection to stay until it needs to end.

The Init function calls the config.init to format the information between connections so the information is readable. Then setUpSerialPort.init setTCPServer.init and setIoLogMonitor are called. The setUpSerialPort.init funciton sets up the port to communicate on, the setTCPServer.init sets the TCP port to communicate on, and the setIoLogMonitor begins to grab alerts about the I/O that are being activated.

For the run function, it confirms if there is a valid configuration. If so the run function doesn’t stop the program, but otherwise the program terminates.

import com.integ.common.iolog.*;
import com.integ.common.logging.AppLog;
import com.integ.common.logging.Logger;
import com.integ.common.logging.RollingFileLog;
import com.integ.common.net.*;
import com.integ.common.system.Application;
import com.integpg.comm.AUXSerialPort;
import com.integpg.comm.COMSerialPort;
import com.integpg.comm.SerialPort;
import com.integpg.system.JANOS;
import java.io.IOException;
import java.net.Socket;

/**
 * The Serial Control application allows people to connect to the JNIOR via Serial or Ethernet.
 * I/O monitoring and Relay control is available with simple ASCII commands.
 */
public class SerialControlMain
        implements IoChannelLogListener {

    /* a flag indicating whether there was a valid configuration.  If there is not a 
       valid configuration then we will let the application exit */
    private boolean _validConfiguration = false;

    /* IO log monitors for both inputs and outputs.  we use these to send unsolicited io alerts */
    private DigitalInputsIoLogMonitor _digitalInputsIoLogMonitor;
    private RelayOutputsIoLogMonitor _relayOutputsIoLogMonitor;



    public static void main(String[] args) throws Exception {

        SerialControlMain serialControlMain = new SerialControlMain();
        serialControlMain.init(args);
        serialControlMain.run();

    }



    public void init(String[] args) throws Exception {
        //
        // Read the configuration.  Configuration is only consumed on application start-up 
        // since we are bringing up tcp listeners and taking over serial ports
        Config.init();

        //
        // set up various features
        setUpSerialPort();
        setTcpServer();
        
        setIoLogMonitors();
    }



    private void setUpSerialPort() {
        try {
            SerialPort serialPort = null;

            //
            // get the serial port configuration and open the port if the name is valid.  we will 
            // use whatever serial settings are in the registry in AUXSerial or COMSerial
            String serialPortName = Config.getSerialPortName();

            if ("aux".equalsIgnoreCase(serialPortName)) serialPort = new AUXSerialPort();
            else if ("rs232".equalsIgnoreCase(serialPortName)
                    || "com".equalsIgnoreCase(serialPortName)) serialPort = new COMSerialPort();

            //
            // if a valid serial port was assigned then open it and start the client
            if (null != serialPort) {
                AppLog.info(String.format("opening %s serial port", serialPortName));
                serialPort.open();

                //
                // get a logger for the serial connection
                Logger serialLog = RollingFileLog.getLogger(
                        String.format("%s_Serial.log", Application.getAppName()));

                //
                // create an serial control client four our serial port
                SerialControlClient serialControlClient
                        = new SerialControlClient(serialPortName + " port",
                                serialPort.getInputStream(), serialPort.getOutputStream());
                serialControlClient.setLog(serialLog);

                //
                /// mark that there is a valid configuration
                _validConfiguration = true;
            } else {
                AppLog.info("serial port not configured to be in use");
            }
        } catch (Exception ex) {
            AppLog.error("error opening serial port", ex);
        }
    }



    private void setTcpServer() {
        try {
            //
            // get the serial port configuration and open the port if the name is valid.  we will 
            // use whatever serial settings are in the registry in AUXSerial or COMSerial
            int tcpServerPortNumber = Config.getTcpServerPortNumber();

            if (-1 != tcpServerPortNumber) {
                TcpServer tcpServer = new TcpServer("SerialControl-TcpServer", tcpServerPortNumber);
                tcpServer.setLog(AppLog.getLog());
                tcpServer.start();

                //
                // get a logger for the serial connection
                Logger tcpServerLog = RollingFileLog.getLogger(
                        String.format("%s_TcpServer.log", Application.getAppName()));

                tcpServer.setTcpServerListener(new TcpServerListener() {
                    @Override
                    public void clientConnected(TcpServerEvent evt) {
                        Socket socket = evt.getSocket();

                        String clientInfo = String.format("%s:%d",
                                socket.getInetAddress().getHostAddress(), socket.getPort());
                        tcpServerLog.info(String.format("%s is connected", clientInfo));

                        try {
                            //
                            // create an serial control client four our serial port
                            SerialControlClient serialControlClient
                                    = new SerialControlClient(clientInfo,
                                            socket.getInputStream(), socket.getOutputStream());
                            serialControlClient.setLog(tcpServerLog);
                        } catch (IOException ex) {
                            tcpServerLog.error(String.format("error setting up ascii command client for %s", clientInfo), ex);
                        }
                    }
                });

                //
                /// mark that there is a valid configuration
                _validConfiguration = true;
            } else {
                AppLog.info("tcp server not configured to be in use");
            }
        } catch (Exception ex) {
            AppLog.error("error setting up tcp server", ex);
        }
    }



    private void setIoLogMonitors() {
        _digitalInputsIoLogMonitor = new DigitalInputsIoLogMonitor();
        _digitalInputsIoLogMonitor.addIoChannelLogEventListener(this);
        _digitalInputsIoLogMonitor.start();

        _relayOutputsIoLogMonitor = new RelayOutputsIoLogMonitor();
        _relayOutputsIoLogMonitor.addIoChannelLogEventListener(this);
        _relayOutputsIoLogMonitor.start();
    }



    @Override
    public void onIoChannelEvent(IoChannelEvent ioEvent) {
        //
        // see if unsolicited io alerts is enabled in the configuration 
        boolean sendUnsolicitedIoAlerts = Config.getSendUnsolicitedIoAlerts();
        if (sendUnsolicitedIoAlerts) {
            //
            // build the string to send
            String outputString = String.format("%s%d=%d",
                    ioEvent.AbbrTypeString, ioEvent.Channel, (ioEvent.State ? 1 : 0));

             if (ioEvent instanceof DigitalInputChannelEvent 
                     && Config.getSendCounts()) {
                int counter = JANOS.getInputCounter(ioEvent.Channel - 1);
                outputString = String.format("%s,%d", outputString, counter);
            }
            
            //
            // broadcast to all connected clients
            SerialControlClient.broadcast(outputString, ioEvent.TransitionTime);
        }
    }



    public void run() throws Exception {
        //
        // see if there is any valid configuration.  if there isnt, then we can let this 
        // application exit as a reboot is needed
        AppLog.info(String.format("Version %s", VERSION));
        if (_validConfiguration) {

            //
            // nothing to do here since the client handlers are being performed in a separate thread.
            Thread.sleep(Integer.MAX_VALUE);
        } else {
            AppLog.warn("there was either not valid configuration or errors occured.  nothing to do, exiting.");
        }
    }

}

Next is the SerialControlClient.java. To start, this file has a few global variables that it uses. It has a QuickDatFormat variable for the date, 3 pattern variables for Inputs, Ouputs, and both together. Then there is an array for clients connected. Lastly is a logger, followed by a string, outputstream, and bytearray for sending data.

The first function of the file is the SerialControlClient function. This function declares an AsciiCommandClient variable which begins listening for clients to connect to. There is a setLog function which sets a logger to record what the AsciiCommandClient object does.

The broadcast function sends a message out to all listening clients. The send function, which after determining which clients returned datestamps back to the JNIOR, sends out to the clients who responded. The bytesRecieved function grabs data that was sent to the JNIOR to be processed and read. The clientStarted function adds a client to the client list. The clientFinished function removes a client from the client list.

Lastly, the processMessage function takes the data received and determines what kind of I/O change occurred to report it.

import com.integ.common.logging.AppLog;
import com.integ.common.logging.Logger;
import com.integ.common.logging.SystemOutLog;
import com.integ.common.net.AsciiCommandClient;
import com.integ.common.net.BytesReceivedEvent;
import com.integ.common.net.ClientListener;
import com.integpg.system.JANOS;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.QuickDateFormat;
import java.util.ArrayList;
import java.util.EventObject;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class SerialControlClient
        implements ClientListener {

    private static final QuickDateFormat QUICK_DATE_FORMAT = new QuickDateFormat("MM/dd/yy HH:mm:ss.fff");

    private static final Pattern IO_CONTROL_PATTERN = Pattern.compile("([copt\\+\\d\\*]+(=\\d+)?)$", Pattern.CASE_INSENSITIVE);
    private static final Pattern DIN_QUERY_PATTERN = Pattern.compile("din(\\d+)\\?$", Pattern.CASE_INSENSITIVE);
    private static final Pattern ROUT_QUERY_PATTERN = Pattern.compile("rout(\\d+)\\?$", Pattern.CASE_INSENSITIVE);

    private static final ArrayList<SerialControlClient> CLIENTS = new ArrayList<>();

    // initially we will log to the system.out stream unless setLog is called
    private Logger _log = SystemOutLog.getLogger();

    private final String _clientNameString;
    private final OutputStream _outputStream;
    private final ByteArrayOutputStream _byteArrayOutputStream = new ByteArrayOutputStream();

    private final AsciiCommandClient _asciiCommandClient;



    public SerialControlClient(String clientNameString, InputStream inputStream, OutputStream outputStream) {
        _clientNameString = clientNameString;
        _outputStream = outputStream;

        //
        // create an ascii command client four our serial port
        _asciiCommandClient = new AsciiCommandClient(clientNameString, inputStream);
        _asciiCommandClient.setTerminationBytes(Config.getIncomingTerminationString());
        _asciiCommandClient.setClientListener(this);
        _asciiCommandClient.start();
    }



    public void setLog(Logger log) {
        _log = log;
        _asciiCommandClient.setLog(_log);
    }



    public static void broadcast(String s, long timestamp) {
        synchronized (CLIENTS) {
            for (SerialControlClient serialControlClient : CLIENTS) {
                try {
                    // send the output string with the acutal transition time from the iolog
                    serialControlClient.send(s, timestamp);
                } catch (Exception ex) {
                    // do nothing
                }
            }
        }
    }



    public synchronized void send(String s) {
        send(s, System.currentTimeMillis());
    }



    public synchronized void send(String s, long timestamp) {
        try {
            // reset the byte output array
            _byteArrayOutputStream.reset();

            // if client should respond with datestamp
            if (Config.getSendDateStamp()) {
                // first send the datestamp
                _byteArrayOutputStream.write(QUICK_DATE_FORMAT.format(timestamp).getBytes());
                _byteArrayOutputStream.write(' ');
            }

            // append text
            _byteArrayOutputStream.write(s.getBytes());

            // append the termination characters
            _byteArrayOutputStream.write(Config.getOutgoingTerminationString().getBytes());

            if (_outputStream != null) {
                _outputStream.write(_byteArrayOutputStream.toByteArray());
                // flush the buffer to make sure it went
                _outputStream.flush();
                // log what was sent. since we are using the timestamp obtained at
                // the beginning of this method we could potentially log out of order
                // with the recieve client.  we can synchronize on a log lock to make
                // sure this doesnt happen or we can let it happen and let it be known
                // that it can happen
                _log.info(_clientNameString + " sent: " + s);
            }
        } catch (IOException ex) {
            _log.error(ex);
            AppLog.error(ex);
        }

    }



    @Override
    public void bytesReceived(BytesReceivedEvent evt) {
        byte[] bytes = evt.getBytes();
        String message = new String(bytes);

        _log.info(String.format("%s received: %s", _clientNameString, message));

        try {
            processMessage(message);
        } catch (IOException ex) {
            _log.error(ex);
            AppLog.error(ex);
        }
    }



    @Override
    public void clientStarted(EventObject evt) {
        _log.info(_clientNameString + " client started");

        //
        // add this serial client to the clients list
        synchronized (CLIENTS) {
            CLIENTS.add(this);
        }
    }



    @Override
    public void clientFinished(EventObject evt) {
        _log.info(_clientNameString + " client finished");

        synchronized (CLIENTS) {
            if (CLIENTS.contains(this)) {
                CLIENTS.remove(this);
            }
        }
    }



    private void processMessage(String message) throws IOException {
        Matcher matcher;
        if ((matcher = IO_CONTROL_PATTERN.matcher(message)).find()) {
            System.out.println("IO_CONTROL_PATTERN.groupCount(): " + matcher.groupCount());
            for (int i = 0; i < matcher.groupCount(); i++) {
                System.out.println("  IO_CONTROL_PATTERN.group[" + i + "]: " + matcher.group(i));
            }

            if (1 < matcher.groupCount()) {
                Jrmon.parseCommand(matcher.group(1));
            }

        } //
        // is it a query for input status?
        else if ((matcher = DIN_QUERY_PATTERN.matcher(message)).find()) {
            System.out.println("DIN_QUERY_PATTERN.groupCount(): " + matcher.groupCount());
            for (int i = 0; i < matcher.groupCount(); i++) {
                System.out.println("  DIN_QUERY_PATTERN.group[" + i + "]: " + matcher.group(i));
            }

            int channelNumber = Integer.parseInt(matcher.group(1));
            int state = ((JANOS.getInputStates() >> (channelNumber - 1)) & 1);
            String response = String.format("din%d=%d", channelNumber, state);
            if (Config.getSendCounts()) {
                int counter = JANOS.getInputCounter(channelNumber - 1);
                response = String.format("%s,%d", response, counter);
            }
            send(response);

        } //
        // is it a query for output status?
        else if ((matcher = ROUT_QUERY_PATTERN.matcher(message)).find()) {
            System.out.println("ROUT_QUERY_PATTERN.groupCount(): " + matcher.groupCount());
            for (int i = 0; i < matcher.groupCount(); i++) {
                System.out.println("  ROUT_QUERY_PATTERN.group[" + i + "]: " + matcher.group(i));
            }

            int channelNumber = Integer.parseInt(matcher.group(1));
            int state = ((JANOS.getOutputStates() >> (channelNumber - 1)) & 1);
            String response = String.format("rout%d=%d", channelNumber, state);
            send(response);


        } else {
            send("unknown command: '" + message + "'");

        }
    }

}

The next file is the Config file, which sets all the information so the port connection properly gets established. The only global variable is the one that gets the applications name form the registry.

The first function in the config file is the init function. It calls all the other functions in the file when its called.

All the functions called in the Init function grab information from the Registry for the serial port name, number, incoming termination string, outgoing termination string, if the JNIOR sends unsolicited I/O Alerts, the Date, and its send counts.

The only function not grabbing from the registry is the getTerminationBytes string which gets called from the getIncomingTerminationString and getOutgoingTerminationString functions. It looks at the message data and determines the Termination value there.

import com.integ.common.system.Application;
import com.integ.common.utils.RegistryUtils;

public class Config {

    private static final String APPDATA_ROOT = "AppData/" + Application.getAppName();



    static void init() {
        getSerialPortName();
        getTcpServerPortNumber();

        getIncomingTerminationString();
        getOutgoingTerminationString();
        getSendUnsolicitedIoAlerts();
        getSendDateStamp();
        getSendCounts();
    }



    public static String getSerialPortName() {
        return RegistryUtils.getRegistryKey(String.format("%s/SerialPort", APPDATA_ROOT), "none");
    }



    public static String getIncomingTerminationString() {
        String incomingTerminationString = RegistryUtils.getRegistryKey(
                String.format("%s/IncomingTerminationString", APPDATA_ROOT), "\\n");
        return new String(getTerminationBytes(incomingTerminationString));
    }



    public static String getOutgoingTerminationString() {
        String outgoingTerminationString = RegistryUtils.getRegistryKey(
                String.format("%s/OutgoingTerminationString", APPDATA_ROOT), "\\n");
        return new String(getTerminationBytes(outgoingTerminationString));
    }



    public static int getTcpServerPortNumber() {
        return RegistryUtils.getRegistryKey(
                String.format("%s/TcpServerPortNumber", APPDATA_ROOT), -1);
    }



    private static byte[] getTerminationBytes(String terminationString) {
        String newTermString = "";
        boolean backslashFound = false;
        for (int i = 0; i < terminationString.length(); i++) {
            if (terminationString.charAt(i) == '\\' && !backslashFound) {
                backslashFound = true;
            } else {

                if (backslashFound) {
                    switch (terminationString.charAt(i)) {
                        case 'r':
                            newTermString += '\r';
                            break;
                        case 'n':
                            newTermString += '\n';
                            break;
                        case 't':
                            newTermString += '\r';
                            break;
                        case 'f':
                            newTermString += '\f';
                            break;
                        case 'b':
                            newTermString += '\b';
                            break;
                        case '0':
                            newTermString += '\0';
                            break;
                    }
                } else {
                    newTermString += terminationString.charAt(i);
                }
                backslashFound = false;
            }
        }
        return newTermString.getBytes();
    }



    public static boolean getSendUnsolicitedIoAlerts() {
        return RegistryUtils.getRegistryKey(
                String.format("%s/SendUnsolicitedIoAlerts", APPDATA_ROOT), true);
    }



    public static boolean getSendDateStamp() {
        return RegistryUtils.getRegistryKey(
                String.format("%s/SendDateStamp", APPDATA_ROOT), true);
    }



    public static boolean getSendCounts() {
        return RegistryUtils.getRegistryKey(
                String.format("%s/SendCounts", APPDATA_ROOT), false);
    }

}

The last file included is the Jrmon file. This file contains only one type of function, the parseCommand function. Depending on the parameters given to the parseCommand function, it will perform differently. The first function grabs a string sent from another device to find the beginning and end of that string. The second instance of parseCommand interprets the string coming in and performs the instructions the string says to do.

import com.integpg.system.JANOS;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;

public class Jrmon {

    public static byte[] parseCommand(String lowerCommand) throws IOException {
        if (0 <= lowerCommand.indexOf(",")) {
            int begin = 0;
            while (begin >= 0) {
                int end = lowerCommand.indexOf(",", begin + 1);
                parseCommand(lowerCommand, begin, end);
                if (end >= 0) {
                    end++;
                }
                begin = end;
            }
        } else {
            return parseCommand(lowerCommand, 0, lowerCommand.length());
        }

        return null;
    }



    public static byte[] parseCommand(String lowerCommand, int beginIndex, int endIndex) throws IOException {
        if (endIndex == -1) {
            endIndex = lowerCommand.length();
        }
        lowerCommand = lowerCommand.substring(beginIndex, endIndex);

        // used for caching the action
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(baos);

        int i, n;
        int states = 0;
        int mask = 0;
        int parameter = 1000;
        boolean close = false;
        boolean open = false;
        boolean toggle = false;
        boolean reset = false;
        boolean setcounters = false;
        boolean dosetcounters = false;
        boolean pulse = false;
        boolean value = false;
        boolean stop = false;
        int shift = 0;

        int len = lowerCommand.length();
        char[] chars = new char[len];
        lowerCommand.getChars(0, len, chars, 0);

        int currentStates = (int) JANOS.getOutputStates();
//        System.out.println("Current States: " + Integer.toBinaryString(currentStates));
        states = currentStates;

        for (i = 0; i < len && !stop; i++) {
            /* each character in the comand */
            switch (chars[i]) {
                case 'c':   // [C]lose sets relay state to 1
                    close = true;
                    open = false;
                    toggle = false;
                    reset = false;
                    setcounters = false;
                    break;  // next character

                case 'o':   // [O]pen sets relay state to 0
                    open = true;
                    close = false;
                    toggle = false;
                    reset = false;
                    setcounters = false;
                    break;  // next character

                case 't':
                    open = false;
                    close = false;
                    toggle = true;
                    reset = false;
                    setcounters = false;
                    break;  // next character

                case 'p':   // [P]ulse indicates that changes are pulsed
                    pulse = true;
                    break;  // next character

                case '=':   // acquire parameter
                    // extract just the content after the '='

                    StringBuffer sb = new StringBuffer();
                    for (n = i + 1; n < lowerCommand.length(); n++) {
                        char c = lowerCommand.charAt(n);
                        if (!Character.isDigit(c)) {
                            break;
                        }
                        sb.append(c);
                        i++;
                    }

                    String cmd = sb.toString(); //command.toString().substring(i+1).trim();
                    // check that only digits are here

                    for (n = 0; n < cmd.length(); n++) {
                        if (!Character.isDigit(cmd.charAt(n))) {
                            return null;
                            // obtain the integer value
                        }
                    }
                    parameter = new Integer(cmd).intValue();
                    value = true;
//                    stop = true;   // ends command interpretation
                    break;

                case 's':   // sets selected counters
                    dosetcounters = true;
                    setcounters = true;
                    open = false;
                    close = false;
                    toggle = false;
                    reset = false;
                    break;

                case '1':   // digit sets up state and mask

                case '2':
                case '3':
                case '4':
                case '5':
                case '6':
                case '7':
                case '8':
                    if (!open && !close && !toggle && !reset && !setcounters) {
                        return null;
                    }
                    n = (int) (lowerCommand.charAt(i) - '1');
                    if (shift == 0) {
                        mask |= (1 << n);
                        if (close) {
                            states |= (1 << n);
                        } else if (open) {
                            states &= ~(1 << n);
                        } else if (toggle) {
                            states ^= (1 << n);
                        }

                    } else {
                        mask |= (1 << (n + 8 * shift));
                        if (close) {
                            states |= (1 << n << (8 * shift));
                        } else if (open) {
                            states &= ~(1 << n << (8 * shift));
                        } else if (toggle) {
                            states ^= (1 << n << (8 * shift));
//                            System.out.println("Toggle States: " + Integer.toBinaryString(states));
                        }
                    }
                    shift = 0;
                    break;  // next character

                case '+':
                    shift++;
                    break;

                case '*':    // means all

                    if (!open && !close && !reset && !setcounters) {
                        return null;
                    }
                    mask |= 0xffffffffL;
                    if (close) {
                        states = 0xffffffff;
                    } else if (open) {
                        states = 0;
                    }

                    break;  // next character

                case ' ':   // white space ignored

                    break;  // next character

                default:    // error in command
            }
        }
        /* each character in the command */

        // extra parameter is an error
        if (value && !pulse && !dosetcounters) {
            return null;
            // parameter conflict - can't use both [S]et and [P]ulse in the same command line.
        }
        if (value && pulse && dosetcounters) {
            return null;
            // [S]et Counters command requires a parameter
        }
        if (dosetcounters && !value) {
            return null;
            // if mask is nonzero then we have changes to make on the way out
        }
        if (mask != 0) {
            if (pulse) {
                /* this action is pulsed */
                if (dos != null) {
                    dos.writeByte(1);
                    dos.writeShort((short) mask);
                    dos.writeShort((short) states);
                    dos.writeInt(parameter);
                    JANOS.setOutputPulsed(states, mask, parameter);
                }

            } /* this action is pulsed */ else if (!toggle) {
                if (dos != null) {
                    dos.writeByte(2);
                    dos.writeShort((short) mask);
                    dos.writeShort((short) states);
                }
                JANOS.setOutputStates(states, mask);
            } else if (toggle) {
                baos = new ByteArrayOutputStream();
                dos = new DataOutputStream(baos);
                JANOS.setOutputStates(states, mask);
            }
        }

        return baos.toByteArray();
    }
}

By | Updated On November 3, 2023 2:27 pm | No Comments | Categories: , | Tags:


INTEG Process Group, inc. © 2023

Real-Time
Mon - Fri, 8am - 4pm EST
P: 724-933-9350
PureChat
Always Available
Contact Form
sales@integpg.com
support@integpg.com

@integpg
@jniordev
4057