Example driver for use of TCP socket based device

I have a Raspberry PI that can be queried to report solar inverter performance data. The queries are performed using a TCP socket. I want to implement a polling driver that updates a group of solar inverter measurements that can be displayed on the dashboard. I am trying to find an example driver that might use a similar interface or documentation on how to use use a TCP socket device. Any help would be very appreciated.

I'm not sure Hubitat has an API to work directly with TCP sockets. But it supports httpGet/HttpPost and websockets (both usually provided by web servers). I use both in this driver

Looks like the raw socket interface may be what you are after. I can't claim any experience myself or know of an example, hopefully one of the other Dev's will chime in.

https://docs2.hubitat.com/developer/interfaces/raw-socket-interface

Personally I am more used to http calls, so it may also be worth looking at options for exposing the data in this way from the rpi, e.g. using Node or Node RED.

1 Like

Uses the raw socket. Just check out the panel driver.

2 Likes

Is there a reference that describes the network interface provided by your Pi setup?

1 Like

Thank you sburke781. For what ever reason I did not find that link when I was searching the documentation. This does look like the API I am looking for.

1 Like

Thank you FiredCheese2006. I found the driver sources at QOLSYS drivers github. Very useful information.

1 Like

Thank you everyone for the quick response. This is very helpful. I will post what I find as I work on my driver implementation.

//
// TCP Socket interface to RPI-SPR-4000m app on Rasberyy PI (RPI)
//
// The RPI-SPR-4000m is a custom app that can collect measurement and diagnostic information
// by sniffing the serial interface between the Sunpower SPR-PMR-DLOG datalogger and the SPR-4000m
// solar panel power inverter. The primary purpose of the app is to collect and store solar panel
// stats independent of Sunpower company and eliminating the need to always be connected via internet
// to the Sunpower monitoring site. The Sunpower datalogger (SPR-PMR-DLOG) collects inverter startup
// each time the inverter starts up (typically once a day at the start of the day). After that the
// Sunpower datalogger queries the inverter for measurements and status of various parameters every
// 23 seconds. The app recognizes the regular queries return by the inverter and display and logs them
// to local storage. The app supports being queried through a TCP socket interface for various key
// measurements and status result. This allows the app to report the inverter operation to any locally
// connected network device such as a computer or smart hub.
//
// TCP socket usage is very straight forward. The RPI app is a socket server. You need to know the
// TCIP address of the RPI and the port number used to communicate. The Port number is only allowed
// to be in the ranage 49152-65535 as per IANA port assignements.
//
// Example sequence of operation for socket communication:
//
// Connect("192.168.aaa.bbb",portnum); // Connect to socket server as a client
// Send("PowerNow");
// Recv(ResultString);
// Send("PowerToday");
// Recv(ResultString);
// Send("Status");
// Recv(ResultString);
// Send("TimeStamp");
// Recv(ResultString);
// Disconnect(); // Disconnect from the socket server so other can make a queries
//
// Query strings are case insensitive
//
// Query: Result
// ACSII string Measurenemt/Result (Ascii string)
// PowerNow Current solar power in Watts
// PowerToday Current total power generated so far today in Watts
// PowerTotal Total amount of power generated since the was installed
// Status Current system status
// 7 - normal operation, solar power is being generated
// 6 - normal operation, solar power not being generated (TBD)
// 3 - system is waiting to sync to grid, not enough power
// This is typically the last status received before powering down at the end of a day
// This is typically the first status received at the start of a day
// 2 - system is trying to sync to grid
// other - invert/ solar system problem/error
// GridV Grid voltage (xxx.x)
// GridHZ Grid Frequency (xx.xx)
// UpTime Total number of seconds power was generated so far today.
// TimeStamp date and time yyyy-mm-dd hh:min:sec of the last update from the inverter.
// Note: the inverter is only on when there is power from the solar panels.
// When the inverter power is on the parameters/data are updated every every 23 seconds.
// close NO RETURN STRING IS SENT. The socket client has asked the TCP socket to be disconnected
// by the server. The client must reconnect for further queries. This is not required by
// the TCP socket server and was included only to support some client implementations
// that don't have a socket disconnect easily accessible in their code.
//
// return strinf format:
// success: return value
// error: invalid query
//
// example:
// send("powernow");
// recv(ResultString);
// // ResultString = "success: PowerNow 2984"
//
// send("unknowncommand");
// recv(ResultString);
// // ResultString = "error: invalid query"
//

Cool, that seems relatively straightforward. Post back here if you have any trouble using the RawSocket interface.

Using the following driver code I am able to query and gets results from my solar inverter monitor. This uses raw socket interface provided by HE. I am able to create a polling app that runs every 5 minutes that calls the poll() method in the driver. On the dashboard I am using the energy meter and the power meter templates to display the results in the dashboard. I would really like to make a customer template that would display all 4 attributes collected but I am new to this and do not understand how to o this yet (hopeful).

The hub can not sync send/recv socket requests. The receive, parse(), runs independent from the sendmessage.

Doing the following 3 things make coding easier in both the HE driver and the server socket (assuming you have control of the server socket).

1.) identifier in return message from server

Socket server should include an identifier in the message response and a end of message character (such as '\n') so that parse() properly identifies what the returned message means. For example:
sendMessage("power\n") should return something like "power dddd\n" rather than "dddd\n"

2.) use end of message delimiters in responses from the server.

parse(), does not by default use an end of message marker. The message passed to parse returns all results until a break of specified amount to time occurs (default is 120 ms). This means that you should either specify an end of message character (the option is called eol) or handle messages possibly being split into multiple parse calls.
so if you did:
sendMessage("power\n")
sendMessage("energy\n")
The parser may return "power dddd\nenergy x.xxx\n" if the server uses '\n' as the end of message marker
or "power ddddenergy x.xxx" if the server doesn't use a end of message marker in the message.
or "power ddd" and a separate message "d\nenergy x.xxx"
If you specify the end of message marker in the connect call using the parameter option eol:'\n' then you would get 2 separate messages
"power dddd"
"energy x.xxx"
which makes parsing much easier.

3.) message delimiters includes in messages to the server.

Series of sendMessages are all queued to the socket without waiting for any response. This means if the socket server is busy it may receive a packet with multiple messages:
sendMessage("power\n")
sendMessage("energy\n")
The socket server might receive "power\nenergy\n" instead of 2 separate messages. The messages sent to the socket should include a delimiter, in many cases '\n' so the server can separate the messages correctly and not end up with something like "powerenergy"

//
//	RPI-SPR-4000m driver
//	2023 GolBend
//  GNU GENERAL PUBLIC LICENSE
//  Version 3, 29 June 2007
//	Author: GolBend (Mark)
//
//	Implementation based on Hubitat Elevation Developer documentation and
//	community feedback
//
//	https://docs2.hubitat.com/developer/driver documenation
//	https://docs2.hubitat.com/developer/driver/driver-object
//	https://docs2.hubitat.com/en/developer/interfaces/raw-socket-interface
//
//	https://community.hubitat.com/t/example-driver-for-use-of-tcp-socket-based-device/120979/4
//
//	Purpose: Provide status information for power generation/operation of the Sunpower SPR-4000m
//	The information is collected using a Raspberry PI (RPI) and made available through a TCP socket
//	interface.  Whlie a number of meaurements are available from the RPI, the only data needed
//	in this driver are:
//		PowerNow		Solar power generated in Watts at last timestamp
//		PowerToday	Today power generated today in Wh
//		Timestamp	yyyy/mm/dd hh:mm:ss of last inverter report
//		Status			7 - normal operation, generating power
//						6 - normal operation, not generating power
//						3 - standby, no power generation
//						2 - standby, syncing to grid
//
//	HE's raw socket interface has implemented the following methods:
//		Hubitat-Provided Methods (already implemented)
//		interfaces.rawSocket.connect(String IP, int port, [options (name/value pairs)])
//            [options (name/value pairs)]
//            There are only 3 options that can be specified, 'eol' and 'readDelay' are mutually exclusive
//            i.e. If you specify one you can't specify the other
//                eol:'\n'            (eol is inactive by default, you must use this to enable)
//                readDelay:300       (readDelay:120 is active by default, eol is inactive)
//                byteInterface:true  (default is false)
//                    This is used for binary communication.  It uses hexadecimal ascii strings that
//                    represent the binary values.
//      
//		interfaces.rawSocket.close()
//		interfaces.rawSocket.sendMessage(String message)  // There is no method on the HE hub to do a paired sendrecv.
//                                                        // Any reposnse from the socket server is returned
//                                                        // by parse as a hexadecimal string.
//
//	Since the driver uses HE's raw socket interface the following methods must be user implemented:
//		parse(String message)        //This method is called with any incoming messages from the socket server.
//                                   //If EOL is defined then parse() receives one string from the socket at a time
//                                   //marked by the EOL character selected. If there are 4 packets in the queue
//                                   //then there will be 4 calls to parse.  If EOL is not used then a packet returned
//                                   //by parse() is anything received that doesn't have a gap greater than readDelay.
//                                   //This means that the parse() calls may have split packets that should be concactenated
//                                   //for the correct full packet.
//                                   //Note: 'message' is hexadecimal string that needs to be converted to byte array
//                                   //then back to string:
//                                   // bytemessage = hubitat.helper.HexUtils.hexStringToByteArray(hexmessage)
//                                   // String message = new String(bytemessage)
//
//		socketStatus(String message) // This method is called with any status message
//                                   // This appears to return some socket status but not all
//                                   // error handlers show most real connection and timeout errors
//
//	Capabilties required for this driver:
//		EnergyMeter
//			Attribute used energy - NUMBER, unit:KWh
//		PowerMeter
//			Attribute used power - NUMBER, unit:W
//
//		Polling    // this allows an app to poll the driver every 'some specified' period of time
//		Refresh
//
//  Attributes in driver:
//      Status, type:"string"
//      TimeStamp, type:"string"
//      power", "number"       
//      energy", "number"       
//
//	All drivers need these methods:
//		installed()
//		initialize()
//		updated()
//		uninstalled()
//
//	Additional methods needed:
//		poll()
//		refresh()
//
//	V1.0.0	2023-07-07	Initial Release
//
import hubitat.helper.HexUtils
//************************************************************
//
// Driver metadata definitions
//
//************************************************************
metadata {
   definition (name: "RPI-SPR-4000m",
               namespace: "GolBend",
               author: "GolBend") // Hubitat community user name
   {
       // these capabilities will be replaced by the ones specific to this device
       capability "Refresh"
       capability "Polling"
       capability "EnergyMeter"
       capability "PowerMeter"
       
       attribute "Status", "string"
       attribute "TimeStamp", "string"
       attribute "power", "number"       
       attribute "energy", "number"       
   }

   preferences {
       input("RPIaddress", "text",  title: "RPI TCIP address", description: "", required: true)
       input name:"RPIport", type: "integer", title: "RPI port #", description: "", required: true, range: 49152..65535
       input name:"EnableLogging", type: "bool", title:"Enable debug logging", default:false
   }
}

//************************************************************
//
//methods implementation
//
//************************************************************

def installed() {
   if(EnableLogging) log.debug "installed()"
}

def initialize() {
   if(EnableLogging) log.debug "initialize()"
}

def updated() {
   // save prefernces was clicked
   if(EnableLogging) log.debug "updated()"
   interfaces.rawSocket.close()    // ensure that the socket is closed
    // just verifying connection can be made
    // This could throw an error if it fails
   interfaces.rawSocket.connect(RPIaddress, RPIport.toInteger(), eol:'\n')
   interfaces.rawSocket.close()
}

def uninstalled() {
   if(EnableLogging) log.debug "uninstalled()"
   interfaces.rawSocket.close()
}

def poll() {
    refresh()
}

def refresh() {
	if(EnableLogging) log.debug "refresh()"
    interfaces.rawSocket.close()
    interfaces.rawSocket.connect(RPIaddress, RPIport.toInteger(), eol:'\n')
    interfaces.rawSocket.sendMessage("PowerNow\n")
    interfaces.rawSocket.sendMessage("PowerToday\n")
    interfaces.rawSocket.sendMessage("Status\n")
    interfaces.rawSocket.sendMessage("TimeStamp\n")
    // important not to close the socket here
    // closing it may result in partial or no string returned in parse()
    // since there doesn't seem to be a way to wait for the reposonse
    // to the queries
}

def socketStatus (message) {
    if(EnableLogging) log.debug "SocketStatus: "+message
}

def parse (hexmessage) {
    // 'hexmessage' received is hexadecimal string that needs to be converted to byte array then back to string
    bytemessage = hubitat.helper.HexUtils.hexStringToByteArray(hexmessage)
    String message = new String( bytemessage )
    if(EnableLogging) log.debug "parse: "+hexmessage+'\n'+message
    if (message.length() > 0) {
        // check if success:
        slen = message.length()
        if (slen >= 8 && message.substring(0,8) == 'success:') {
            if(slen >= 18 && message.substring(9,18) == 'PowerNow ') {
                if(EnableLogging) log.debug "parse() updating PowerNow"
                StatusString = message.substring(18,message.length());
                sendEvent([name: "power", value: StatusString.toInteger(), unit: "W"])
            } else if(slen >= 20 && message.substring(9,20) == 'PowerToday ') {
                if(EnableLogging) log.debug "parse() updating PowerToday"
                StatusString = message.substring(20,message.length());
                sendEvent([name: "energy", value: StatusString.toInteger()/1000.0, unit: "KWh"])
            } else if(slen >= 16 && message.substring(9,16) == 'Status ') {
                StatusString = message.substring(16,message.length());
                StatusCode = StatusString.toInteger();
                if(StatusCode == 7) {
                    if(EnableLogging) log.debug "Status: Normal Generating"
                    Status = 'Normal, generating power'   // update Status attribute with Normal Generating
                } else if(StatusCode == 3) {
                    if(EnableLogging) log.debug "Status: Standby"
                    // update Status attribute with Standby
                    Status = 'Standby'   // update Status attribute with Normal Generating
                } else if(StatusCode == 0) {
                    if(EnableLogging) log.debug "Status: none, ${StatusString}"
                    // update Status attribute with no status yet
                    Status = 'None'   // update Status attribute with Normal Generating
                } else if(StatusCode == 2) {
                    if(EnableLogging) log.debug "Status: Standby syncing to grid"
                    // update Status attribute with Standby syncing to grid
                    Status = 'Standby, syncing to grid'   // update Status attribute with Normal Generating
                } else if(StatusCode == 6) {
                    if(EnableLogging) log.debug "Status: Normal not Generating"
                    // update Status attribute with Normal not Generating
                    Status = 'Normal, not generating power'   // update Status attribute with Normal Generating
                } else {
                    Status = 'Failure, code  ${StatusString}'   // update Status attribute with Normal Generating
                }
                sendEvent([name: "Status", value: Status])                
            } else if(slen >= 19 && message.substring(9,19) == 'TimeStamp ') {
                if(EnableLogging) log.debug "parse() updating TimeStamp"
                // Timeframe for polling this device polling is every 5 minutes
                // This is the response to the last query sent in the refresh() or the results of a poll()
                // so closing the socket here keeps the parse() method's read of the socket from generating
                // a timeout error every 30 seconds
                interfaces.rawSocket.close()
                StatusString = message.substring(19,message.length());
                TimeStamp = StatusString
                sendEvent([name: "TimeStamp", value: TimeStamp])                
            } else {
                Status = 'Error, result failed to decode'   // update Status attribute with Normal Generating
                sendEvent([name: "Status", value: Status])                
            }
        }
    }
}
1 Like