APsystems APS ECU R local inverters data pull

Could it be that your contractor closed the port? If nmap did not reveal any port this could be the issue. Do you have a support contract? It is known that registered/offical installers have more options to service the PV set. I’m a self-installer. Only way to find out is indeed try to connect using the button on the ECU-R and find out if the centralized distributed ECUapp wants to connect. Mind that there is also a USB port that might have been used for special purposes like locking the device. Installation manual only says “reserved”. Ask the contractor to re-open the port for the purposes you want. Don’t know if there are special support contracts between official installers and APS that prohibit these actions in the US, local laws might apply. Option I just came up with… when the inverters are online, the ECU-R posts the data to the EMA site. Capture this traffic and use that (reroute the traffic on your LAN). Analysis of data package is needed though.

Notice: Sending commands to the ECU-R can alter the configuration if you don’t know what you’re doing!

i did not self install, service company just setup the ecu and inverterid’s, didnt see any config options to change stuff. I did apply for installer account on EMA, maybe i should try to use that and see if more options come up.

Ok, checked on the device tonight. Here’s what I found. If I power cycle it, it will start broadcasting it’s ECU_R_XXXX SSID. I connect on my phone and the ECU app works, I’m running the 1.2.13 firmware. I double checked in the LAN settings section to make sure the IP address was correct, and it is. Still no response on the 8899 tcp port from my computer with netcat or nmap.

I then proceeded to connect my computer to the ECU_R_XXX SSID and was assigned an IP address in the 10.10.100.X/24 network with a default gateway of 10.10.100.254. I presumed that was the IP of the ECU, and can confirm it is listening on tcp port 8899 on 10.10.100.254 - I get data this way with the sample python program.

But, I’m still getting no response on port 8899 the LAN interface of the ECU. Only it’s internal wifi network in AP mode.

I then used the ECU app to have it join my wifi network, I had been using the ethernet interface only, and then in my router, I see it having 2 IP addresses on my network, one ethernet (192.168.0.198), one wifi (192.168.0.251). If I query the ethernet IP (192.168.0.198) on port 8899 still no go, but if I use the wifi one (192.168.0.251), it does now work!

Are you guys using wifi or ethernet to connect it to your network, maybe it only listens on port 8899 on it’s wifi interface?

Anyway, here’s my raw ascii data from the ECU when sending the command in the first python example.

41505331313031393030303032303030310007202012282040578020001044130030330000006400000000000000000000802000110269003033000000640000000000000000000080200011054900303300000064000000000000000000008020001113140030330000006400000000000000000000802000112342003033000000640000000000000000000080200011330400303300000064000000000000000000008020001135230030330000006400000000000000000000454e440a

There are a lot of zeros since it’s night-time and no sun. I’ll spend some time tomorrow decoding this more, when the sun is out, and I start seeing actual data.

Since I played a lot on the local ssid, I have it in wifi mode connect to network, wanted to switch to ethernet, but if then the port is unavailable, it’s a no go :slight_smile:
Good you have data now

You have 7 inverters? How many panels are installed on those?
Also interested in the 3033 part on each inverter… I have 3031, can’t figure what it is.
THinking you have qs1 inverters, it might be identifier for that.
Or maybe the power grid profile setting. Where do you live?

Also on Wifi, haven’t tried LAN interface on ECU-R (switch full, one broken). Indeed 7 inverters (QS1) makes a total of 28 panels if all channels on the inverters are used. Unfortunately it seems that there are two bytes added per inverter. Logic must be added to serve QS1 owners. Since we haven’t figured out all data yet, it could well be that there is an ID of inverter type (YC600 or QS1) present. For now it seems that panels connected to the YC600 start of with 4080 and panels connected to the QS1 with 8020 but the exact word-length is a wild guess. We simply haven’t enough data examples to prove this. It could well be that Sander is right with 3033(QS1) and 3031(YC600). Also a mix of QS1’s and YC600’s could be connected to the ECU-R, to make things more complicated :slight_smile:

About every 5 minutes data is posted to ecu.apsema.com but I’ve seen ecu2.apsema.com also pass by in the early morning when I was asleep.

@ksheumaker got some better script to work with from other guy that had interest, maybe helps you too.

import socket
import struct
import binascii
import json
import datetime
from struct import unpack
 
myIPadress = "192.168.?.?"
myMACadress = "" #May be search for this if ipadres doesn't react, later
output = {}
 
def APSint(codec,start):
    return int(binascii.b2a_hex(codec[(start):(start+2)]),16)
   
def APSbool(codec,start):
    return bool(binascii.b2a_hex(codec[(start):(start+2)]))
 
def APSuid(codec,start):
    return str(binascii.b2a_hex(codec[(start):(start+12)]))[2:14]
 
def APSstr(codec,start,amount):
    return str(codec[start:(start+amount)])[2:(amount+2)]
 
def APStimestamp(codec,start,amount):
    timestr=str(binascii.b2a_hex(codec[start:(start+amount)]))[2:(amount+2)]
    return timestr[0:4]+"-"+timestr[4:6]+"-"+timestr[6:8]+" "+timestr[8:10]+":"+timestr[10:12]+":"+timestr[12:14]
 
#initialize socket
soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
soc.connect((myIPadress,8899))
 
#get ECUID
ECU_R = 'APS1100160001END'
ECU_Rsend = ECU_R.encode('utf-8')
soc.send(ECU_Rsend)
ECU_Rreceive = soc.recv(1024)
# print ("\nAsking for ECUID number: ")
# print(ECU_Rreceive)
# Decoded explanation
# APS 1100940001 = Open sentence and answer notation
# 216000026497 = ECU_R nummer
# 01 =text unkown
# 00 00 (a7 90)=42896[167,144] 00 00 00 00 00 00 00
# 8x status of UID ca=202 d0=208 d0 d0 d0 d0 d0 d0
# 0008=(aantal UID) maximaal (16)
# 0000=integer unkown
# 10012 = text unkown
# ECU_R_1.2.13009 = Version
# Etc/GMT-8 = ETC ipv UTC Timezone server op lokatie met -8 uur?
# 80 97 1b 01 5d 1e 00 00 00 00 00 00
# END\n
myECUID = APSstr(ECU_Rreceive,13,12) #216000026497
output["ECU_R_ID"] = myECUID
output["maxUID"] = APSint(ECU_Rreceive,46)
output["Version"] = APSstr(ECU_Rreceive,55,15)
output["TimeZone"] = APSstr(ECU_Rreceive,70,9)
#print(output)
 
#get data from UID
ECU_R = 'APS1100280002'+myECUID+"END"  # 2 extra?
ECU_Rsend = ECU_R.encode('utf-8')
soc.send(ECU_Rsend)
ECU_Rreceive = soc.recv(2048)
#print("\nAsking for UID numbers and data: "+str(len(ECU_Rreceive)))
#print(ECU_Rreceive)
#print("\n")
#print(binascii.b2a_hex(ECU_Rreceive))
 
if (len(ECU_Rreceive)>16) :
    #base data valid for all UID
    output["timestamp"] = APStimestamp(ECU_Rreceive,19,14)
    counter = 1
    maxcounter = APSint(ECU_Rreceive,17) #number of inverters
    offset=26
    while counter <= maxcounter:
        # Records will pass for each inverter on this ECU_R (UID)
        # 1-3 APS
        # 4-18 UID
        # 19 number of inverters?
        #------------------------
        # 20-32 Inverter ID (UID)
        # 33 0 or 1 Marks online status of inverter instance
        # 34 unkown "0"
        # 35 unkown "1" Could be country because of 31 (Netherlands)
        # 36-37 Frequency multiplied by 10
        # 38-39 Temperature Celsius Bit 7 of second byte is signbit (1=+, 0=-)?
        # 40-41 Power A Channel A on Inverter
        # 42-43 Voltage A Chanel A on Inverter
        # 44-45 Power B Channel B on Inverter
        # 46-47 Voltage B Chanel B on Inverter
        # 48-51 END or channel C and D till END
        #pick up data for this inverter
        # output={}
        output["UID"+str(counter)] = APSuid(ECU_Rreceive,offset)
        #you can also use UID as index in dictonary
        uid=output["UID"+str(counter)][7:12]
        output["Online"+uid] = APSbool(ECU_Rreceive,offset+6)
        output["Something"+uid] = APSstr(ECU_Rreceive,offset+7,2)
        output["Frequentie"+uid] = APSint(ECU_Rreceive,offset+9)/10.0
        output["Temperature"+uid] = APSint(ECU_Rreceive,offset+11)-100 # check later if sign is bit 7 64 and fahrenheit
        step = 13
        channel = 1
        while (channel <= 2): #QS1 must have 4 times, so C and D but how to see the difference
            output["Power"+uid+"-"+str(channel)] = APSint(ECU_Rreceive,offset+step)
            output["Volt"+uid+"-"+str(channel)] = APSint(ECU_Rreceive,offset+step+2)
            step += 4
            channel += 1
            # print(output)
            #end while channel
 
        offset += step #how big is UID block
        counter += 1
        #print(output)
        #end while UID
    #print(output)
else:
    output["Error"] = "No inverters active"
print(json.dumps(output))

heads up on above, seems to mess up a bit in QS1 data, the bytes per UID are longer for QS1

I’ve taken the above code, and fixed it up a bit, and created the start of a python class that will process data from the ECU:

#!/usr/bin/env python3

import socket
import binascii
import datetime
import json

from pprint import pprint

class APSystemsECU:

    def __init__(self, ipaddr, port=8899, raw_ecu=None, raw_inverter=None):
        self.ipaddr = ipaddr
        self.port = port

        self.recv_size = 2048

        self.ecu_query = 'APS1100160001END'
        self.inverter_query_prefix = 'APS1100280002'
        self.inverter_query_suffix = 'END'
        self.inverter_byte_start = 26

        self.ecu_id = None
        self.qty_of_inverters = 0
        self.inverters = []
        self.firmware = None
        self.timezone = None

        self.ecu_raw_data = raw_ecu
        self.inverter_raw_data = raw_inverter

        self.last_inverter_data = None


    def dump(self):
        print(f"ECU : {self.ecu_id}")
        print(f"Firmware : {self.firmware}")
        print(f"TZ : {self.timezone}")
        print(f"Qty of inverters : {self.qty_of_inverters}")

    def query_ecu(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect((self.ipaddr,self.port))

        sock.send(self.ecu_query.encode('utf-8'))
        self.ecu_raw_data = sock.recv(self.recv_size)

        sock.shutdown(socket.SHUT_RDWR)
        sock.close()

        self.process_ecu_data()

    def query_inverters(self, ecu_id = None):
        if not ecu_id:
            ecu_id = self.ecu_id

        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect((self.ipaddr,self.port))
        cmd = self.inverter_query_prefix + self.ecu_id + self.inverter_query_suffix
        sock.send(cmd.encode('utf-8'))

        self.inverter_raw_data = sock.recv(self.recv_size)

        sock.shutdown(socket.SHUT_RDWR)
        sock.close()

        data = self.process_inverter_data()
        self.last_inverter_data = data

        return(data)
 
    def aps_int(self, codec, start):
        return int(binascii.b2a_hex(codec[(start):(start+2)]),16)
    
    def aps_bool(self, codec, start):
        return bool(binascii.b2a_hex(codec[(start):(start+2)]))
    
    def aps_uid(self, codec, start):
        return str(binascii.b2a_hex(codec[(start):(start+12)]))[2:14]
    
    def aps_str(self, codec, start, amount):
        return str(codec[start:(start+amount)])[2:(amount+2)]
    
    def aps_timestamp(self, codec, start, amount):
        timestr=str(binascii.b2a_hex(codec[start:(start+amount)]))[2:(amount+2)]
        return timestr[0:4]+"-"+timestr[4:6]+"-"+timestr[6:8]+" "+timestr[8:10]+":"+timestr[10:12]+":"+timestr[12:14]

    def process_ecu_data(self, data=None):
        if not data:
            data = self.ecu_raw_data

        if len(data) < 16:
            raise Exception("ECU query didn't return minimum 16 bytes, no inverters active.")

        self.ecu_id = self.aps_str(data, 13, 12)
        self.qty_of_inverters = self.aps_int(data, 46)
        self.firmware = self.aps_str(data, 55, 15)
        self.timezone = self.aps_str(data, 70, 9)

    def process_inverter_data(self, data=None):
        if not data:
            data = self.inverter_raw_data

        output = {}

        timestamp = self.aps_timestamp(data, 19, 14)
        inverter_qty = self.aps_int(data, 17)

        output["timestamp"] = timestamp
        output["inverter_qty"] = inverter_qty
        output["inverters"] = []

        # this is the start of the loop of inverters
        location = self.inverter_byte_start

        inverters = []
        for i in range(0, inverter_qty):

            inv={}

            inverter_uid = self.aps_uid(data, location)
            inv["uid"] = inverter_uid
            location += 6

            inv["online"] = self.aps_bool(data, location)
            location += 1

            inv["unknown"] = self.aps_str(data, location, 2)
            location += 2

            inv["frequency"] = self.aps_int(data, location) / 10
            location += 2

            inv["temperature"] = self.aps_int(data, location) - 100
            location += 2

            # a YC600 starts with 4080
            if inverter_uid.startswith("4080"):
                (channel_data, location) = self.process_yc600(data, location)
                inv.update(channel_data)    

            # a QS1 starts with 8020
            elif inverter_uid.startswith("8020"):
                (channel_data, location) = self.process_qs1(data, location)
                inv.update(channel_data)    

            inverters.append(inv)

        total_power = 0
        for i in inverters:
            for p in i["power"]:
                total_power += p


        output["total_power"] = total_power
        output["inverters"] = inverters
        return (output)

    def process_qs1(self, data, location):

        power = []
        voltages = []

        power.append(self.aps_int(data, location))
        location += 2

        voltage = self.aps_int(data, location)
        location += 2

        power.append(self.aps_int(data, location))
        location += 2

        power.append(self.aps_int(data, location))
        location += 2

        power.append(self.aps_int(data, location))
        location += 2

        voltages.append(voltage)

        output = {
            "model" : "QS1",
            "channel_qty" : 4,
            "power" : power,
            "voltage" : voltages
        }

        return (output, location)


    def process_yc600(self, data, location):
        power = []
        voltages = []

        for i in range(0, 2):
            power.append(self.aps_int(data, location))
            location += 2

            voltages.append(self.aps_int(data, location))
            location += 2

        output = {
            "model" : "YC600",
            "channel_qty" : 2,
            "power" : power,
            "voltage" : voltages,
        }

        return (output, location)


if __name__ == "__main__":

    # supply the correct IP address here
    ecu = APSystemsECU("192.168.0.251")

    # get inverter data by querying the ecu directly
    ecu.query_ecu()
    data = ecu.query_inverters()
    print(json.dumps(data, indent=2))


    # sample_yc600_data = bytes.fromhex('415053313130313736303030323030303100072020112412051040800009401601303101f3006f001400e4001400e440800009562201303101f3006f001300e4001400e440800009182601303101f3006f001400e3001400e340800009293301303101f3006f001400e3001300e340800009191301303101f3006f001500e3001400e340800009243401303101f3006f001400e3001400e340800009184001303101f3006f001400e2001400e2454e440a')
    # data = ecu.process_inverter_data(data = sample_yc600_data)
    # print(json.dumps(data, indent=2))

    # sample_qs1_data = bytes.fromhex('415053313130313930303030323030303100072020122915125380200010441301303302570065000200f100010007000680200011026901303302570064000100f200010006000680200011054901303302570065000100f100010006000680200011131401303302570064000100f100000006000680200011234201303302570065000000ef00010005000680200011330401303302570065000400f000000000000080200011352301303302570066000100f1000100060006454e440a')
    # data = ecu.process_inverter_data(data = sample_qs1_data)
    # print(json.dumps(data, indent=2))

I should be able to use this with a HA custom_component to generate all the appropriate sensors, and run the query_inverters() method at a regular interval to get new data.

Please try this on your systems, as I only have the QS1 to test with, I took the sample data from a previous post and wrote a process_yc600 method to based on that code and data.

My data structure is quite a bit different, but still has all the pertinent data.

Output from my system:

{
  "timestamp": "2020-12-29 15:22:53",
  "inverter_qty": 7,
  "inverters": [
    {
      "uid": "802000104413",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 1,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        2,
        1,
        7,
        6
      ],
      "voltage": [
        242
      ]
    },
    {
      "uid": "802000110269",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 0,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        1,
        1,
        6,
        6
      ],
      "voltage": [
        242
      ]
    },
    {
      "uid": "802000110549",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 2,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        1,
        1,
        5,
        6
      ],
      "voltage": [
        241
      ]
    },
    {
      "uid": "802000111314",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 0,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        1,
        0,
        6,
        5
      ],
      "voltage": [
        242
      ]
    },
    {
      "uid": "802000112342",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 1,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        0,
        0,
        5,
        6
      ],
      "voltage": [
        240
      ]
    },
    {
      "uid": "802000113304",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 1,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        2,
        0,
        0,
        0
      ],
      "voltage": [
        240
      ]
    },
    {
      "uid": "802000113523",
      "online": true,
      "unknown": "03",
      "frequency": 59.9,
      "temperature": 2,
      "model": "QS1",
      "channel_qty": 4,
      "power": [
        1,
        1,
        6,
        6
      ],
      "voltage": [
        242
      ]
    }
  ],
  "total_power": 82
}

very interesting, especiallly because when i use the command line sensor, it is getting exceeded with the 255 character limit in json repsonse.
This above script is also runnable manually on command line to test?
Config for custom component in config .yaml is just ‘APSystemsECU:’ ?

Oh, no I didn’t write it for a command line sensor. I just was trying to get the data in a usable format, for use later into a custom component for home assistant. You should be able to just put this python script somewhere, and run it (after modifying the IP address).

I’m starting work on the component that will actually integrate in HA correctly. That will probably be a few days away before I have something working.

understood, still very promising:

{
  "timestamp": "2020-12-29 22:59:12",
  "inverter_qty": 7,
  "inverters": [
    {
      "uid": "408000094016",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    },
    {
      "uid": "408000095622",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    },
    {
      "uid": "408000091826",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    },
    {
      "uid": "408000092933",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    },
    {
      "uid": "408000091913",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    },
    {
      "uid": "408000092434",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    },
    {
      "uid": "408000091840",
      "online": true,
      "unknown": "01",
      "frequency": 0.0,
      "temperature": 0,
      "model": "YC600",
      "channel_qty": 2,
      "power": [
        0,
        0
      ],
      "voltage": [
        0,
        0
      ]
    }
  ],
  "total_power": 0
}

Just to make sure there isn’t a bug, but based on your timestamp, I’m assuming you aren’t generating any power right now?

Lol, indeed, will try tomorrow with some light on the panels :grin:

I’m a noob in Home Assistant and hope there will be some sort of ECU-R integration to make the install possible for me. I updated the first command to be send to the ECU-R to determen the ECU-ID which is needed for following commands you might want to send.
status
Not all fields are known right now… work in progress. @ksheumaker we are also active on this forumdiscussion (in Dutch) https://gathering.tweakers.net/forum/list_messages/2032302/1

Hey Kevin, could you change this logic to be determined on byte 35? Not all inverters in same type have those start numbers.

35 1=YC600 and 3=QS1

(the 31 and 33 values discussed before)

Inverter ID is officially three digits (conform APSystems info)

QS1 = 802 (US and Canada)
QS1 = 801 (Europe, Middle East and Africa)
YC600-T = 407 (US and Canada)
YC600-Y = 409 (US and Canada)
YC600 = 406 or 408 (Europe, Middle East and Africa)
YC1000-3 = 503 or 504 (US and Canada)
YC1000 = 501 or 502 (Europe, Middle East and Africa)

This is great. I was hoping to get the kWh numbers, I’ll implement that as well.

My plan is to make the integration (it will be pretty easy to install), I’m gonna start working on that today. I’ve made one a couple of years ago, so I have some experience, but I know the APIs have changed a bit. I’ll keep you updated on the progress.

I will also check out the forum, we will see how well google translate does, since I don’t speak Dutch.

Anything need explained, just ask here we’’ ll notice and reply

Has anyone figured out what bits hold this? I’d be happy to add support for all the models. Do we have output from all them to test with? Hopefully at least all the QS1 and YC600 variants use the same data format. :slight_smile:

Hey Kyle! Today I made some improvement on the datasheets (I posted new versions of them). In there you’ll find the bits’n’pieces.

I’m trying to keep up with all info and correct where applicable. Sander is playing with random commands, beware you can mess up the configuration! We need to do more sniffing to retreive other commands and responses (like getting historical data from the ECU-R). Basically we almost have it to start of with! Good practice is not to start too complicated. There’s always room for expansion.:slight_smile:

I should do other stuff at home now but I really want to expand the response datasheet when we ask the ECU-R to return Inverter data. You allready provided most of the info in earlier responses.

I’ve been reading through the forum, google translate does a pretty good job. I should be able to implement all the changes from the datasheet easily enough.

I’m not as concerned with additional commands since once we have the data in home assistant, things like historical data can be pulled from it - since HA will now be logging everything. I don’t think additional commands to the ECU would end up in any integration (unless you find something interesting).

It’s only 9AM here, and sun is actually shining today, so I should make some progress today.