├── CC2530ZNP-cc2591-with-SBL.hex ├── Demo-ESP32 ├── README.md ├── _creds.py.rename ├── domoticz.py ├── graphite.py ├── influxdb.py ├── main.py ├── pair.py └── poll.py ├── README.md ├── aps_yc600.py └── cc2530.jpg /Demo-ESP32/README.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | This folder is my current 'application' for retrieving inverter stats from 3 | the YC600 and pushing the data to Domoticz, InfluxDB and Graphite. 4 | 5 | The current micropython version which I'm running on my ESP32 is 1.15. 6 | 7 | # Wiring 8 | The wiring is briefly mentioned in poll.py: 9 | ``` 10 | RX -> GPIO09 11 | TX -> GPIO10 12 | RST -> GPIO13 13 | ``` 14 | # Setup 15 | Copy the aps_yc600.py file to this folder and fill _creds.py with actual 16 | values. 17 | 18 | # Not too pretty 19 | The Graphite metric path's are static, not too pretty I know... 20 | This goes for the IDX values for Domoticz too... 21 | 22 | These values should be adjusted to your own needs. 23 | 24 | -------------------------------------------------------------------------------- /Demo-ESP32/_creds.py.rename: -------------------------------------------------------------------------------- 1 | ''' 2 | File containing all secret data 3 | ''' 4 | secrets = { 5 | 'ssid': "mywifi", 6 | 'psk': 'secret', 7 | 'inv_serial': '408000123456', 8 | 'inv_id': '0000', 9 | 'graphite_host': '192.168.0.1', 10 | 'graphite_port': 2003, 11 | 'domoticz_url': 'http://domoticz.mydomain.tld:8080', 12 | 'domoticz_user': 'domo-user', 13 | 'domoticz_pass': '123secrets', 14 | 'influx_url': 'http://192.168.0.1:8086', 15 | 'influx_db': 'solar-db', 16 | 'influx_user': 'influx-user', 17 | 'influx_pass': '456secrets', 18 | 'influx_bucket': 'solar-data'} 19 | -------------------------------------------------------------------------------- /Demo-ESP32/domoticz.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Module to send data to domoticz 3 | ''' 4 | # pylint: disable=E0401 5 | import urequests 6 | import ubinascii 7 | 8 | class Domoticz: 9 | ''' 10 | Domoticz Client 11 | ''' 12 | headers = "" 13 | url = "" 14 | 15 | def __init__(self, url, username, password): 16 | ''' 17 | Set local vars 18 | ''' 19 | base64pass = ubinascii.b2a_base64(username+":"+password).decode("utf-8").rstrip() 20 | self.headers = {'Authorization':'Basic '+base64pass} 21 | self.url = url 22 | 23 | def send_data(self, data): 24 | ''' 25 | Send data 26 | data = {'idx': value, 'idx': value} 27 | ''' 28 | result = [] 29 | try: 30 | for key, val in data.items(): 31 | url = self.url + "/json.htm?type=command¶m=udevice&idx=" 32 | url += key + "&nvalue=0&svalue=" + str(val) 33 | client = urequests.get(url, headers=self.headers) 34 | result.append(client.status_code) 35 | client.close() 36 | except Exception as domoticz_error: 37 | print("Error in domo", domoticz_error) 38 | result.append('err') 39 | return result 40 | -------------------------------------------------------------------------------- /Demo-ESP32/graphite.py: -------------------------------------------------------------------------------- 1 | ''' 2 | class for communication with Graphite 3 | ''' 4 | import socket 5 | import time 6 | 7 | class Graphite(): 8 | ''' 9 | Graphite Client 10 | ''' 11 | hostname = "" 12 | port = 0 13 | 14 | def __init__(self, hostname, port): 15 | ''' 16 | Set hostname and port 17 | ''' 18 | self.hostname = hostname 19 | self.port = port 20 | 21 | def send_data(self, data, timestamp=False): 22 | ''' 23 | Send data to plaintext input 24 | ''' 25 | if time.time() < 692284226: 26 | raise Exception('Time not set!') 27 | if not timestamp: 28 | timestamp = time.time() + 946684800 29 | g_sock = socket.socket() 30 | try: 31 | g_sock.connect((self.hostname, self.port)) 32 | g_msg = "" 33 | for key, val in data.items(): 34 | g_msg += key+' '+str(val)+' '+str(timestamp)+'\n' 35 | g_sock.sendall(g_msg) 36 | g_sock.close() 37 | except Exception as graphite_error: 38 | print("Error in graphite", graphite_error) 39 | return "err" 40 | return True 41 | -------------------------------------------------------------------------------- /Demo-ESP32/influxdb.py: -------------------------------------------------------------------------------- 1 | ''' 2 | InfluxDB Client 3 | ''' 4 | # pylint: disable=E0401 5 | import urequests 6 | 7 | 8 | class InfluxDBClient: 9 | ''' 10 | InfluxDB Client 11 | ''' 12 | url = '' 13 | 14 | def __init__(self, url, db_name, username, token): 15 | ''' 16 | Set local var for url 17 | ''' 18 | self.url = url+'/write?db='+db_name+'&u='+username+'&p='+token 19 | # To be implemented: 20 | # https://docs.influxdata.com/influxdb/v1.8/tools/api/#apiv2write-http-endpoint 21 | 22 | def write(self, bucket, data): 23 | ''' 24 | Post data 25 | ''' 26 | influx_data = "" 27 | result = False 28 | http_client = "" 29 | for key, val in data.items(): 30 | influx_data += key+'='+str(val)+',' 31 | try: 32 | http_client = urequests.post( 33 | self.url, 34 | data=bucket+' '+influx_data[:-1]) 35 | result = http_client.status_code 36 | http_client.close() 37 | except Exception as influx_error: 38 | print("Error in influx", influx_error) 39 | result = "err" 40 | 41 | return result 42 | 43 | def set_url(self, url, db_name, username, token): 44 | ''' 45 | Set local var for url 46 | ''' 47 | self.url = url+'/write?db='+db_name+'&u='+username+'&p='+token 48 | -------------------------------------------------------------------------------- /Demo-ESP32/main.py: -------------------------------------------------------------------------------- 1 | ''' 2 | When first starting this application we need the inverter_id. 3 | This inverter id is retrieved by pairing. 4 | 5 | After pairing the inverter id is displayed, please store this value 6 | in the _creds.py 7 | 8 | To pair: only import pair 9 | To poll: only import poll 10 | ''' 11 | #import pair 12 | import poll 13 | -------------------------------------------------------------------------------- /Demo-ESP32/pair.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Pair APS inverter and retrieve Inverter ID 3 | ''' 4 | import time 5 | # pylint: disable=E0401 6 | from machine import UART 7 | from machine import Pin 8 | from aps_yc600 import ApsYc600 9 | from _creds import secrets 10 | 11 | # RX = GPIO09 / SD2 12 | # TX = GPIO10 / SD3 13 | # Reset pin = GPIO13 14 | 15 | serial = UART(1, 115200) 16 | 17 | # Reset cc2530 module 18 | reset_pin = Pin(13, Pin.OUT) 19 | reset_pin.off() 20 | reset_pin.on() 21 | time.sleep(1) 22 | 23 | # Start pairing 24 | inverter = ApsYc600(serial, serial) 25 | INV_INDEX = inverter.add_inverter(secrets['inv_serial'], '0000', 2) 26 | inverter_id = inverter.pair_inverter(INV_INDEX) 27 | 28 | # Echo information and store in object 29 | print("Inverter ID found:", inverter_id) 30 | inverter.set_inverter_id(INV_INDEX, inverter_id) 31 | -------------------------------------------------------------------------------- /Demo-ESP32/poll.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Poll inverter from ESP32 3 | ''' 4 | # Prevent errors from uPython modules 5 | # pylint: disable=E0401 6 | import time 7 | import gc 8 | import _thread 9 | import network 10 | from machine import UART 11 | from machine import Pin 12 | import ntptime 13 | from aps_yc600 import ApsYc600 14 | from influxdb import InfluxDBClient 15 | from graphite import Graphite 16 | from domoticz import Domoticz 17 | from _creds import secrets 18 | # RX = GPIO09 / SD2 19 | # TX = GPIO10 / SD3 20 | # Reset pin = GPIO13 21 | 22 | # Enable WiFi 23 | sta = network.WLAN(network.STA_IF) 24 | sta.active(True) 25 | time.sleep(0.5) 26 | try: 27 | sta.connect(secrets['ssid'], secrets['psk']) 28 | except: 29 | pass 30 | 31 | # Enable serial UART for communication with cc2530 32 | serial = UART(1, 115200) 33 | 34 | # Reset cc2530 module 35 | reset_pin = Pin(13, Pin.OUT) 36 | reset_pin.off() 37 | reset_pin.on() 38 | time.sleep(1) 39 | 40 | # Init inverter 41 | inverter = ApsYc600(serial, serial) 42 | inverter.add_inverter(secrets['inv_serial'], secrets['inv_id'], 2) 43 | print("Coordinator starting", inverter.start_coordinator()) 44 | 45 | # Graphite logger 46 | graphite_client = Graphite(secrets['graphite_host'], secrets['graphite_port']) 47 | 48 | domo_client = Domoticz( 49 | secrets['domoticz_url'], 50 | secrets['domoticz_user'], 51 | secrets['domoticz_pass']) 52 | 53 | influx_client = InfluxDBClient( 54 | secrets['influx_url'], 55 | secrets['influx_db'], 56 | secrets['influx_user'], 57 | secrets['influx_pass']) 58 | 59 | # Send data to all destinations 60 | def push_data(data): 61 | ''' 62 | Send data out 63 | ''' 64 | result = [] 65 | # Influx output 66 | data_xlate = { 67 | 'acv': data['voltage_ac'], 68 | 'dcc0': data['current_dc1'], 69 | 'dcc1': data['current_dc2'], 70 | 'dcv0': data['voltage_dc1'], 71 | 'dcv1': data['voltage_dc2'], 72 | 'energy': round((data['energy_panel1'] + data['energy_panel2']) / 1000, 3), 73 | 'freqac': data['freq_ac'], 74 | 'pow_p0': data['watt_panel1'], 75 | 'pow_p1': data['watt_panel2'], 76 | 'power': round(data['watt_panel1'] + data['watt_panel2'], 2), 77 | 'temp': data['temperature']} 78 | result.append(influx_client.write(secrets['influx_bucket'], data_xlate)) 79 | gc.collect() 80 | 81 | # Domoticz output 82 | data_energy = str(round(data['watt_panel1'] + data['watt_panel2'], 2)) 83 | data_energy += ';' 84 | data_energy += str(data['energy_panel1'] + data['energy_panel2']) 85 | data_xlate = { 86 | '217': data_energy, 87 | '218': data['voltage_dc1'], 88 | '219': data['voltage_dc2'], 89 | '220': data['watt_panel1'], 90 | '221': data['watt_panel2'], 91 | '222': data['temperature'], 92 | '223': data['voltage_ac']} 93 | result.append(domo_client.send_data(data_xlate)) 94 | gc.collect() 95 | 96 | # Graphite output 97 | data_xlate = { 98 | 'energy.data.aps.acv': data['voltage_ac'], 99 | 'energy.data.aps.dcc0': data['current_dc1'], 100 | 'energy.data.aps.dcc1': data['current_dc2'], 101 | 'energy.data.aps.dcv0': data['voltage_dc1'], 102 | 'energy.data.aps.dcv1': data['voltage_dc2'], 103 | 'energy.data.kwh.aps': round((data['energy_panel1'] + data['energy_panel2']) / 1000, 3), 104 | 'energy.data.aps.freq': data['freq_ac'], 105 | 'energy.data.aps.pow0': data['watt_panel1'], 106 | 'energy.data.aps.pow1': data['watt_panel2'], 107 | 'energy.data.solar_aps': round(data['watt_panel1'] + data['watt_panel2'], 2), 108 | 'energy.data.aps.temp': data['temperature']} 109 | result.append(graphite_client.send_data(data_xlate)) 110 | gc.collect() 111 | 112 | return result 113 | 114 | def reset_data(): 115 | ''' 116 | Send all zeros to reset stats for new day 117 | ''' 118 | # check if time is correct! 119 | if time.time() < 692284226: 120 | raise Exception('Time not set!') 121 | 122 | push_data({ 123 | 'voltage_ac': 0, 124 | 'freq_ac': 0, 125 | 'temperature': 0, 126 | 'current_dc1': 0, 127 | 'current_dc2': 0, 128 | 'voltage_dc1': 0, 129 | 'voltage_dc2': 0, 130 | 'energy_panel1': 0, 131 | 'energy_panel2': 0, 132 | 'watt_panel1': 0, 133 | 'watt_panel2': 0}) 134 | 135 | def ntp_update(): 136 | ''' 137 | Do ntp update 138 | ''' 139 | update = False 140 | while not update: 141 | try: 142 | print("Updating NTP") 143 | ntptime.settime() 144 | update = True 145 | except Exception as ntp_error: 146 | print('NTP not updated!', ntp_error) 147 | time.sleep(5) 148 | 149 | def do_poll(): 150 | ''' 151 | Poll inverter and write to influx client 152 | ''' 153 | current_day = -1 154 | last_ntp_update = time.time() 155 | while True: 156 | # Do we need to update via NTP? 157 | if last_ntp_update + 7200 < time.time() or time.time() < 692284226: 158 | print("NTP Update needed") 159 | ntp_update() 160 | last_ntp_update = time.time() 161 | # Was this the initial ntp update? 162 | if current_day == -1: 163 | # If so; set current_day without resetting counters 164 | current_day = time.gmtime()[2] 165 | 166 | try: 167 | if inverter.ping_radio(): 168 | success = False 169 | tries = 5 170 | while not success and tries > 0: 171 | result = inverter.poll_inverter(0) 172 | if not 'error' in result: 173 | success = True 174 | tries = tries - 1 175 | if success: 176 | if result['crc']: 177 | print("Data written:", push_data(result['data'])) 178 | else: 179 | print("No reading", result) 180 | else: 181 | print('radio not healthy') 182 | # If day changed, reset counters 183 | if current_day != time.gmtime()[2] and current_day > 0: 184 | reset_data() 185 | current_day = time.gmtime()[2] 186 | except Exception as global_error: 187 | print("Oh noes", global_error) 188 | gc.collect() 189 | time.sleep(30) 190 | 191 | # Start poll thread 192 | _thread.start_new_thread(do_poll, ()) 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | This is my attempt at creating a Python library for interfacing with my APSystems YC600 inverter. 3 | My work is based on the cc2531/cc2530 firmware created by Kadzsol (https://github.com/Koenkk/zigbee2mqtt/files/6797510/discord-11-7-2021.zip / https://github.com/Koenkk/zigbee2mqtt/issues/4221) and the ESP12 firmware created by patience4711 (https://github.com/patience4711/read-APS-inverters-YC600-QS1) 4 | 5 | # About the cc253x firmware 6 | As stated above, the cc253x firmware is created by Kadzsol. 7 | Flashing the custom firmware is mandatory to be able to communicate with APS inverters. 8 | After flashing your module it will not be zigbee compatible until you reflash with the original firmware. 9 | 10 | The author states that: 11 | - use of this firmware for any commercial purposes is *not* allowed 12 | - Use at your own risk 13 | 14 | The reason I include the firmware is specifically to have a single bundle of all required files to be able to use this library 15 | 16 | # Preparing a cc2531/cc2530 17 | Flash using: https://github.com/jmichault/flash_cc2531 18 | 19 | ![](https://github.com/No13/ApsYc600-Pythonlib/blob/master/cc2530.jpg?raw=true) 20 | 21 | Connect the CC2530 module to a Raspberry Pi: 22 | ``` 23 | RPI Pin Name CC2530 Pin Name 24 | 39 GND 15 GND 25 | 38 GPIO20 5 P2_1 (DD) 26 | 36 GPIO16 4 P2_2 (DC) 27 | 35 GPIO19 17 Reset 28 | 01 VCC 16 VCC 29 | ``` 30 | Check if chip is recognised: 31 | ``` 32 | ./cc_chipid 33 | ``` 34 | Backup current firmware: 35 | ``` 36 | ./cc_read backup.hex 37 | ``` 38 | Write custom firmware for APS connectivity: 39 | ``` 40 | ./cc_write CC2530ZNP-cc2591-with-SBL.hex 41 | ``` 42 | Flashing is done using DD and DC Pin. After flashing the TX and RX pins for communication with ESP32 and/or 43 | Raspberry PI at the cc2530 module are: 44 | ``` 45 | CC2530 Pin Name 46 | 21 P0_2 (RX) 47 | 22 P0_3 (TX) 48 | ``` 49 | Communication with the cc2530 module is done using serial communication at 50 | 115200 baud. 51 | 52 | # Pairing an Inverter 53 | Before an inverter is usable it needs to be paired. After the pairing process you will get the inverter ID. 54 | This ID is needed to communicate with the inverter. 55 | Using a 'clean' CC2530 module without pairing (when inverter ID is already known) does not seem to work. 56 | When paired once, the inverter ID should suffice for further communication. 57 | 58 | # Using a Raspberry PI 59 | ## Pairing 60 | import time 61 | import serial 62 | import RPi.GPIO as GPIO 63 | from aps_yc600 import ApsYc600 64 | 65 | SER_PORT = serial.serial_for_url('/dev/ttyS0') 66 | # RPi pins for UART are GPIO 14,15 67 | 68 | SER_PORT.baudrate = 115200 69 | 70 | RESET_PIN = 22 71 | GPIO.setmode(GPIO.BOARD) 72 | GPIO.setup(RESET_PIN, GPIO.OUT) 73 | 74 | def reset_modem(): 75 | ''' 76 | Reset modem by toggling reset pin 77 | ''' 78 | GPIO.output(RESET_PIN, 0) 79 | time.sleep(1) 80 | GPIO.output(RESET_PIN, 1) 81 | 82 | reset_modem() 83 | time.sleep(1) 84 | INVERTER = ApsYc600(SER_PORT, SER_PORT) 85 | # The serial is required for pairing, inverter ID is unknown (0000) 86 | INDEX = INVERTER.add_inverter('123456789012', '0000', 2) 87 | print("Inverter ID is", INVERTER.pair_inverter(INDEX)) 88 | # The inverter ID needs to be stored for future communications 89 | GPIO.cleanup() 90 | 91 | ## Polling inverter 92 | import time 93 | import serial 94 | import RPi.GPIO as GPIO 95 | from aps_yc600 import ApsYc600 96 | 97 | SER_PORT = serial.serial_for_url('/dev/ttyS0') 98 | # RPi pins for UART are GPIO 14,15 99 | SER_PORT.baudrate = 115200 100 | 101 | RESET_PIN = 22 102 | GPIO.setmode(GPIO.BOARD) 103 | GPIO.setup(RESET_PIN, GPIO.OUT) 104 | 105 | def reset_modem(): 106 | ''' 107 | Reset modem by toggling reset pin 108 | ''' 109 | GPIO.output(RESET_PIN, 0) 110 | time.sleep(1) 111 | GPIO.output(RESET_PIN, 1) 112 | 113 | reset_modem() 114 | time.sleep(1) 115 | INVERTER = ApsYc600(SER_PORT, SER_PORT) 116 | # Inverter ID in this example is 9988 117 | INVERTER.add_inverter('123456789012', '9988', 2) 118 | print(INVERTER.start_coordinator()) 119 | print(INVERTER.ping_radio()) 120 | print(INVERTER.poll_inverter(0)) 121 | GPIO.cleanup() 122 | 123 | # Using an ESP32 124 | ## Pairing 125 | from aps_yc600 import ApsYc600 126 | from machine import UART 127 | from machine import Pin 128 | import time 129 | 130 | # RX = GPIO09 / SD2 131 | # TX = GPIO10 / SD3 132 | # Reset pin = GPIO13 133 | 134 | serial = UART(1,115200) # Default pins for UART 1 are 9 / 10 135 | 136 | reset_pin = Pin(13,Pin.OUT) 137 | reset_pin.off() 138 | reset_pin.on() 139 | time.sleep(1) 140 | 141 | inverter = ApsYc600(serial,serial) 142 | # The serial is required for pairing, inverter ID is unknown (0000) 143 | index = inverter.add_inverter('123456789012', '0000', 2) 144 | print(inverter.pair_inverter(index)) 145 | # The inverter ID needs to be stored for future communications 146 | 147 | ## Polling inverter 148 | from aps_yc600 import ApsYc600 149 | from machine import UART 150 | from machine import Pin 151 | import time 152 | 153 | # RX = GPIO09 / SD2 154 | # TX = GPIO10 / SD3 155 | # Reset pin = GPIO13 156 | 157 | serial = UART(1,115200) # Default pins for UART 1 are 9 / 10 158 | 159 | reset_pin = Pin(13,Pin.OUT) 160 | reset_pin.off() 161 | reset_pin.on() 162 | time.sleep(1) 163 | 164 | inverter = ApsYc600(serial,serial) 165 | # Inverter ID in this example is 9988 166 | inverter.add_inverter('123456789012', '9988', 2) 167 | print(inverter.start_coordinator()) 168 | print(inverter.ping_radio()) 169 | print(inverter.poll_inverter(0)) 170 | -------------------------------------------------------------------------------- /aps_yc600.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Library to communicate with YC600 inverters 3 | Should work with QS1 too (num_panels = 4). 4 | Based on work from: 5 | - https://github.com/patience4711 6 | - https://github.com/kadzsol 7 | 8 | I'm trying to keep this compatible with micropython for ESP32 & python3-serial 9 | ''' 10 | import time 11 | 12 | class ApsYc600: 13 | ''' 14 | Class to communicate with YC600 inverters 15 | ''' 16 | 17 | # struct to store all inverter data 18 | inv_data = [] 19 | 20 | # identification for this ECU / controller 21 | controller_id = "" 22 | 23 | # Serial handles 24 | reader = None 25 | writer = None 26 | system_type = None 27 | 28 | # History data to detect inverter resets 29 | energy_data = [] 30 | 31 | ### Internal helper fuctions 32 | 33 | def __init__(self, reader, writer, controller_id='D8A3011B9780'): 34 | ''' 35 | Create controller, default controller ID is supplied 36 | ''' 37 | if len(controller_id) != 12: 38 | raise Exception('Controller ID must be 12 hex characters') 39 | self.controller_id = controller_id 40 | self.reader = reader 41 | self.writer = writer 42 | 43 | # Try and test the reader for 'in_waiting' function 44 | if 'in_waiting' in dir(self.reader): 45 | print('Found python3-serial module') 46 | self.system_type = 'python3-serial' 47 | else: 48 | print('Only using read/write (micropython)') 49 | self.system_type = 'micropython' 50 | 51 | @staticmethod 52 | def __reverse_byte_str(in_str): 53 | ''' 54 | Reverse input bytes (0123 -> 2301) 55 | Needs even number of characters 56 | ''' 57 | if len(in_str) % 2 > 0: 58 | raise Exception('Input is not an even number of characters') 59 | out_str = "" 60 | for i in range(len(in_str), 1, -2): 61 | out_str = ''.join((out_str, in_str[i-2], in_str[i-1])) 62 | return out_str 63 | 64 | @staticmethod 65 | def __crc(in_str): 66 | ''' 67 | Calculate CRC 68 | Input: string containing hex characters 69 | Output: hex value 70 | ''' 71 | if len(in_str) % 2 > 0: 72 | raise Exception('Input is not an even number of characters') 73 | crc_res = 0 74 | for i in range(0, len(in_str), 2): 75 | # Calculate crc 76 | crc_res = crc_res ^ int(in_str[i:i+2], 16) 77 | # Return as hex value 78 | return hex(crc_res) 79 | 80 | def __crc_check(self, in_str): 81 | ''' 82 | Check CRC in message 83 | ''' 84 | # Convert message to upper-case for string compare later 85 | in_str = in_str.upper() 86 | # Only messages starting with FE are valid 87 | if in_str[:2] != 'FE': 88 | raise Exception('Data corrupt') 89 | # Strip CRC and FE header 90 | data_check = in_str[2:][:-2] 91 | # Assemble CRC in message 92 | crc_to_check = ''.join(('0X', in_str[-2:])) 93 | # Calculate CRC For message 94 | crc_calc = str(self.__crc(data_check)).upper() 95 | # Return compare 96 | return crc_calc == crc_to_check 97 | 98 | def __send_cmd(self, cmd): 99 | ''' 100 | Send cmd 101 | ''' 102 | # All commands are prefixed with 0xFE 103 | prefix_cmd = 'FE' 104 | # Length of message is total length / 2 minus cmd (2 bytes) (and strip '0x') 105 | cmdlen = hex(int(len(cmd) / 2) - 2)[2:] 106 | # Pad length to 2 bytes 107 | if len(cmdlen) == 1: 108 | cmdlen = ''.join(('0', str(cmdlen))) 109 | # Assemble and add CRC 110 | cmd = ''.join((prefix_cmd, cmdlen, cmd, self.__crc(cmdlen+cmd)[2:])) 111 | # Send each set of two characters as byte 112 | for i in range(0, len(cmd), 2): 113 | new_char = int(cmd[i:i+2], 16).to_bytes(1, 'big') 114 | self.writer.write(new_char) 115 | 116 | def __listen(self, timeout=1000): 117 | ''' 118 | Listen for serial output 119 | When serial buffer is empty after timeout, return '' 120 | 121 | When reader has no in_waiting function assume read() is non-blocking. 122 | TODO: Convert str to bytearray 123 | ''' 124 | out_str = "" 125 | # micropython has no float for time... 126 | end_time_ms = (time.time_ns() // 1000000) + timeout 127 | if self.system_type == 'python3-serial': 128 | while (self.reader.in_waiting == 0) and ((time.time() * 1000) < end_time_ms): 129 | time.sleep(0.01) 130 | time.sleep(0.1) 131 | # Only read characters when serial buffer is not empty 132 | while self.reader.in_waiting > 0: 133 | new_char = self.reader.read() 134 | hex_char = new_char.hex() 135 | if len(hex_char) == 1: 136 | hex_char = '0'+hex_char 137 | out_str += hex_char 138 | else: 139 | # micropython seems to be slow; 140 | time.sleep(0.5) 141 | # Read current buffer 142 | buffer = self.reader.read() 143 | # While buffer is empty, retry until buffer is not, or timeout expires 144 | while (buffer is None) and ((time.time_ns() // 1000000) < end_time_ms): 145 | time.sleep(0.1) 146 | buffer = self.reader.read() 147 | # After first characters are received, wait short time and read the rest. 148 | time.sleep(0.2) 149 | temp_str = b"" 150 | while buffer is not None: 151 | temp_str += buffer 152 | time.sleep(0.1) 153 | buffer = self.reader.read() 154 | # Convert binary string to hex-string 155 | for i in temp_str: 156 | temp_char = hex(i)[2:] 157 | if len(temp_char) == 1: 158 | temp_char = '0'+temp_char 159 | out_str += temp_char 160 | return out_str 161 | 162 | def __decode(self, in_str, inverter_index): 163 | ''' 164 | Decode message type, start decoding of received information 165 | Called by: parse 166 | ''' 167 | known_cmds = { 168 | '2101': 'ZigbeePing', 169 | '2400': 'AF_REGISTER', 170 | '2401': 'AF_DATA_REQ', 171 | '2600': 'ZB_START_REQUEST', 172 | '2605': 'ZB_WRITE_CONFIGURATION', 173 | '2700': 'StartCoordinator', 174 | '4081': 'StartupRadio', 175 | '4100': 'SYS_RESET_REQ', 176 | '4180': 'SYS_RESET_INT', 177 | '4481': 'AF_INCOMING_MSG', 178 | '6101': 'ZigbeePingResp', 179 | '6400': 'AF_REG_Resp', 180 | '6401': 'AF_DATA_REQ_Resp', 181 | '6605': 'ZB_WR_CONF_Resp', 182 | '6700': 'StartCoordinatorResp'} 183 | 184 | # Replace code with string when available 185 | cmd_code = in_str[4:8] 186 | if cmd_code in known_cmds: 187 | cmd_code = known_cmds.get(cmd_code) 188 | 189 | data = in_str[8:-2] 190 | 191 | # Check CRC 192 | crc = self.__crc_check(in_str) 193 | 194 | if cmd_code == 'AF_INCOMING_MSG' and crc: 195 | # Can be answer to poll request or pair request 196 | pair = False 197 | if len(in_str) < 222: 198 | for inverter in self.inv_data: 199 | if inverter['serial'] in in_str: 200 | # Decode pair request is done in pair_inverter function 201 | data = in_str 202 | pair = True 203 | break 204 | else: 205 | if not pair and (inverter_index >= 0): 206 | # Decode inverter poll response 207 | data = self.__decode_inverter_values(in_str, inverter_index) 208 | return {'cmd': cmd_code, 'crc': crc, 'data': data} 209 | 210 | def __decode_inverter_values(self, in_str, inverter_index): 211 | ''' 212 | Transform byte string of poll response to values 213 | called by: __decode 214 | ''' 215 | # We do not need the first 38 bytes apparently 216 | data = in_str[38:] 217 | voltdc = [] 218 | currdc = [] 219 | en_pan = [] 220 | num_panels = self.inv_data[inverter_index]['panels'] 221 | invtemp = -258.7 + (int(data[24:28], 16) * 0.2752) # Inverter temperature 222 | freq_ac = 50000000 / int(data[28:34], 16) # AC Fequency 223 | # DC Current for panel 1 224 | currdc.append((int(data[48:50], 16) + (int(data[51], 16) * 256)) * (27.5 / 4096)) 225 | # DC Volts for panel 1 226 | voltdc.append((int(data[52:54], 16) * 16 + int(data[50], 16)) * (82.5 / 4096)) 227 | currdc.append((int(data[54:56], 16) + (int(data[57], 16) * 256)) * (27.5 / 4096)) 228 | voltdc.append((int(data[58:60], 16) * 16 + int(data[56], 16)) * (82.5 / 4096)) 229 | volt_ac = (int(data[60:64], 16) * (1 / 1.3277)) / 4 230 | # Energy counter (daily reset), swapped panel 1 and 2 as reported in 231 | # https://github.com/No13/ApsYc600-Pythonlib/issues/1 232 | en_pan.append(int(data[88:94], 16) * (8.311 / 3600)) 233 | en_pan.append(int(data[78:84], 16) * (8.311 / 3600)) 234 | if num_panels == 4: 235 | currdc.append((int(data[34:36], 16) + (int(data[37], 16) * 256)) *(27.5 / 4096)) 236 | voltdc.append((int(data[38:40], 16) * 16 + int(data[36], 16)) * (82.5 / 4096)) 237 | currdc.append((int(data[28:30], 16) + (int(data[31], 16) * 256)) *(27.5 / 4096)) 238 | voltdc.append((int(data[32:34], 16) * 16 + int(data[30], 16)) * (82.5 / 4096)) 239 | en_pan.append(int(data[98:104], 16) * (8.311 / 3600)) 240 | en_pan.append(int(data[108:114], 16) * (8.311 / 3600)) 241 | return { 242 | 'temperature': round(invtemp, 2), 243 | 'freq_ac': round(freq_ac, 2), 244 | 'current_dc1': round(currdc[0], 2), 245 | 'current_dc2': round(currdc[1], 2), 246 | 'current_dc3': round(currdc[2], 2), 247 | 'current_dc4': round(currdc[3], 2), 248 | 'voltage_dc1': round(voltdc[0], 2), 249 | 'voltage_dc2': round(voltdc[1], 2), 250 | 'voltage_dc3': round(voltdc[2], 2), 251 | 'voltage_dc4': round(voltdc[3], 2), 252 | 'voltage_ac': round(volt_ac, 2), 253 | 'energy_panel1': round(en_pan[0], 3), 254 | 'energy_panel2': round(en_pan[1], 3), 255 | 'energy_panel3': round(en_pan[2], 3), 256 | 'energy_panel4': round(en_pan[3], 3), 257 | 'watt_panel1': round(currdc[0] * voltdc[0], 2), 258 | 'watt_panel2': round(currdc[1] * voltdc[1], 2), 259 | 'watt_panel3': round(currdc[2] * voltdc[2], 2), 260 | 'watt_panel4': round(currdc[3] * voltdc[3], 2)} 261 | 262 | return { 263 | 'temperature': round(invtemp, 2), 264 | 'freq_ac': round(freq_ac, 2), 265 | 'current_dc1': round(currdc[0], 2), 266 | 'current_dc2': round(currdc[1], 2), 267 | 'voltage_dc1': round(voltdc[0], 2), 268 | 'voltage_dc2': round(voltdc[1], 2), 269 | 'voltage_ac': round(volt_ac, 2), 270 | 'energy_panel1': round(en_pan[0], 3), 271 | 'energy_panel2': round(en_pan[1], 3), 272 | 'watt_panel1': round(currdc[0] * voltdc[0], 2), 273 | 'watt_panel2': round(currdc[1] * voltdc[1], 2)} 274 | 275 | def __parse(self, in_str, inverter_index=-1): 276 | ''' 277 | Parse incoming messages 278 | Split multiple messages, decode them and return the output 279 | ''' 280 | in_str = in_str.upper() 281 | decoded_cmd = [] 282 | while in_str[:2] == 'FE': 283 | # New command found 284 | str_len = int(in_str[2:4], 16) # Decode cmd len 285 | min_len = int((len(in_str) - 10) / 2) # FE XX XXXX ... XX 286 | if str_len > min_len: # Check if data is sufficient for found str_len 287 | raise Exception('Data corrupt, length field does not match actual length') 288 | cmd = in_str[:(10 + str_len * 2)] # Copy command to str 289 | in_str = in_str[10 + (str_len * 2):] 290 | decoded_cmd.append(self.__decode(cmd, inverter_index)) 291 | return decoded_cmd 292 | 293 | # Public functions 294 | 295 | def add_inverter(self, inv_serial, inv_id, num_panels): 296 | ''' 297 | Add inverter to struct, 298 | inv_id is required for polling 299 | serial is required for pairing 300 | num_panels is required to determine inverter type (YC600 / QS1) 301 | ''' 302 | if num_panels not in (2, 4): 303 | raise Exception("Only 2 or 4 panels supported") 304 | inverter = { 305 | 'serial': inv_serial, 306 | 'inv_id': inv_id, 307 | 'panels': num_panels} 308 | self.inv_data.append(inverter) 309 | inverter_index = len(self.inv_data) -1 310 | if num_panels == 2: 311 | self.energy_data.append( 312 | { 313 | 'last_energy_p1': 0, 314 | 'last_energy_p2': 0, 315 | 'energy_offset_p1': 0, 316 | 'energy_offset_p2': 0}) 317 | else: 318 | self.energy_data.append( 319 | { 320 | 'last_energy_p1': 0, 321 | 'last_energy_p2': 0, 322 | 'last_energy_p3': 0, 323 | 'last_energy_p4': 0, 324 | 'energy_offset_p1': 0, 325 | 'energy_offset_p2': 0, 326 | 'energy_offset_p3': 0, 327 | 'energy_offset_p4': 0}) 328 | return inverter_index # Return index for last inverter 329 | 330 | def set_inverter_id(self, inv_index, inv_id): 331 | ''' 332 | Set inverter ID for existing inverter 333 | ''' 334 | if len(self.inv_data) <= inv_index: 335 | raise Exception('Invalid inverter index') 336 | self.inv_data[inv_index]['inv_id'] = inv_id 337 | return True 338 | 339 | def reset_counters(self, inverter_index): 340 | ''' 341 | Reset historical data 342 | ''' 343 | self.energy_data[inverter_index] = { 344 | 'last_energy_p1': 0, 345 | 'last_energy_p2': 0, 346 | 'energy_offset_p1': 0, 347 | 'energy_offset_p2': 0} 348 | 349 | def poll_inverter(self, inverter_index): 350 | ''' 351 | Get values from inverter. 352 | 353 | Uses previous values to determine inverter restarts. 354 | In case of inverter restart the energy counters will continue (using offset) 355 | instead of restarting from 0. 356 | 357 | This will require you to reset_counters every day to begin a new day at 0. 358 | ''' 359 | # Clear serial buffer 360 | self.clear_buffer() 361 | if inverter_index > len(self.inv_data) -1: 362 | raise Exception('Invalid inverter') 363 | num_panels = self.inv_data[inverter_index]['panels'] 364 | # Send poll request 365 | self.__send_cmd(''.join( 366 | ('2401', self.__reverse_byte_str(self.inv_data[inverter_index]['inv_id']), 367 | '1414060001000F13', self.__reverse_byte_str(self.controller_id), 368 | 'FBFB06BB000000000000C1FEFE'))) 369 | time.sleep(1) 370 | # Check poll response 371 | return_str = self.__listen() 372 | response_data = self.__parse(return_str, inverter_index) 373 | # Check if correct response is found... 374 | for response in response_data: 375 | if response['cmd'] == '4480' and 'CD' in response['data']: 376 | return {'error': 'NoRoute'} 377 | if response['cmd'] == 'AF_INCOMING_MSG': 378 | # Calculate energy 379 | if not 'data' in response: 380 | return {'error': 'incomplete'} 381 | if not 'energy_panel1' in response['data']: 382 | return {'error': 'incomplete', 'data': response} 383 | if response['data']['voltage_dc1'] + response['data']['voltage_dc2'] < 0.1: 384 | return {'error': 'data error', 'data': response} 385 | # Retrieve last energy values 386 | last_energy = self.energy_data[inverter_index]['last_energy_p1'] 387 | last_energy += self.energy_data[inverter_index]['last_energy_p2'] 388 | # Retrieve current energy values 389 | curr_energy = response['data']['energy_panel1'] 390 | curr_energy += response['data']['energy_panel2'] 391 | # Retrieve offset energy 392 | offset_energy = self.energy_data[inverter_index]['energy_offset_p1'] 393 | offset_energy += self.energy_data[inverter_index]['energy_offset_p2'] 394 | if num_panels == 4: 395 | last_energy += self.energy_data[inverter_index]['last_energy_p3'] 396 | last_energy += self.energy_data[inverter_index]['last_energy_p4'] 397 | curr_energy += response['data']['energy_panel3'] 398 | curr_energy += response['data']['energy_panel4'] 399 | offset_energy += self.energy_data[inverter_index]['energy_offset_p3'] 400 | offset_energy += self.energy_data[inverter_index]['energy_offset_p4'] 401 | # If offset + current becomes smaller than last value then update offsets 402 | if (curr_energy + offset_energy) < last_energy: 403 | # Reset of inverter: update offset 404 | new_offset = self.energy_data[inverter_index]['last_energy_p1'] 405 | self.energy_data[inverter_index]['energy_offset_p1'] = new_offset 406 | new_offset = self.energy_data[inverter_index]['last_energy_p2'] 407 | self.energy_data[inverter_index]['energy_offset_p2'] = new_offset 408 | if num_panels == 4: 409 | new_offset = self.energy_data[inverter_index]['last_energy_p3'] 410 | self.energy_data[inverter_index]['energy_offset_p3'] = new_offset 411 | new_offset = self.energy_data[inverter_index]['last_energy_p4'] 412 | self.energy_data[inverter_index]['energy_offset_p4'] = new_offset 413 | # Return offset + current value & update last value 414 | new_energy = self.energy_data[ 415 | inverter_index]['energy_offset_p1'] + response['data']['energy_panel1'] 416 | self.energy_data[ 417 | inverter_index]['last_energy_p1'] = new_energy 418 | response['data']['energy_panel1'] = new_energy 419 | 420 | new_energy = self.energy_data[ 421 | inverter_index]['energy_offset_p2'] + response['data']['energy_panel2'] 422 | self.energy_data[ 423 | inverter_index]['last_energy_p2'] = new_energy 424 | response['data']['energy_panel2'] = new_energy 425 | 426 | if num_panels == 4: 427 | new_energy = self.energy_data[ 428 | inverter_index]['energy_offset_p3'] + response['data']['energy_panel3'] 429 | self.energy_data[ 430 | inverter_index]['last_energy_p3'] = new_energy 431 | response['data']['energy_panel3'] = new_energy 432 | 433 | new_energy = self.energy_data[ 434 | inverter_index]['energy_offset_p4'] + response['data']['energy_panel4'] 435 | self.energy_data[ 436 | inverter_index]['last_energy_p4'] = new_energy 437 | response['data']['energy_panel4'] = new_energy 438 | 439 | return response 440 | return {'error': 'timeout', 'data': response_data} 441 | 442 | def ping_radio(self): 443 | ''' 444 | Check if radio module is ok 445 | ''' 446 | self.__send_cmd('2101') 447 | str_resp = self.__listen() 448 | if str_resp is None: 449 | print("Ping reply empty") 450 | return False 451 | cmd_output = self.__parse(str_resp) 452 | 453 | for cmd in cmd_output: 454 | if cmd['cmd'] == 'ZigbeePingResp' and cmd['crc'] and cmd['data'] == '7907': 455 | return True 456 | print("Ping failed", cmd_output) 457 | return False 458 | 459 | def start_coordinator(self, pair_mode=False): 460 | ''' 461 | Start coordinator proces in Zigbee radio. 462 | Resets modem 463 | ''' 464 | rev_controll_id = self.__reverse_byte_str(self.controller_id) 465 | init_cmd = [] 466 | expect_response = [] 467 | init_cmd.append('2605030103') # 20 ms 468 | expect_response.append(['fe0166050062']) 469 | init_cmd.append('410000') # 500 ms 470 | expect_response.append(['fe064180020202020702c2']) 471 | init_cmd.append('26050108FFFF'+rev_controll_id) # 15 ms 472 | expect_response.append(['fe0166050062']) 473 | init_cmd.append('2605870100') # 10 ms 474 | expect_response.append(['fe0166050062']) 475 | init_cmd.append('26058302'+self.controller_id[:4]) # 20 ms 476 | expect_response.append(['fe0166050062']) 477 | init_cmd.append('2605840400000100') # 20 ms 478 | expect_response.append(['fe0166050062']) 479 | init_cmd.append('240014050F00010100020000150000') # 10 ms 480 | expect_response.append(['fe0164000065']) 481 | init_cmd.append('2600') # 1000 ms 482 | expect_response.append(['fe00660066', 'fe0145c0088c']) # second is optional 483 | init_cmd.append('6700') 484 | expect_response.append(['fe0e670000ffff']) 485 | 486 | if not pair_mode: 487 | init_cmd.append(''.join( 488 | ('2401FFFF1414060001000F1E', rev_controll_id, 'FBFB11', 489 | '00000D6030FBD3000000000000000004010281FEFE'))) # 20 ms 490 | expect_response.append( 491 | ['fe0164010064', 492 | 'fe0145c0088c', 493 | 'fe0145c0088c', 494 | 'fe0145c0098d']) 495 | 496 | all_verified = True 497 | for cmd in init_cmd: 498 | self.__send_cmd(cmd) 499 | result_str = self.__listen(1100) 500 | cmd_index = init_cmd.index(cmd) 501 | try: 502 | if not expect_response[cmd_index][0] in result_str: 503 | all_verified = False 504 | print('Verify failed', cmd, result_str) 505 | finally: 506 | pass 507 | # Final commands need more time to process 508 | if init_cmd.index(cmd) > 6: 509 | time.sleep(1.5) 510 | return all_verified 511 | 512 | def check_coordinator(self): 513 | ''' 514 | Send 2700 message to modem, show response 515 | Result should contain 0709 (??) 516 | ''' 517 | self.clear_buffer() 518 | self.__send_cmd('2700') 519 | print('check_coord', self.__listen(500)) 520 | 521 | def clear_buffer(self): 522 | ''' 523 | Return serial buffer after waiting 100 msec 524 | ''' 525 | return self.__listen(100) 526 | 527 | def pair_inverter(self, inverter_index): 528 | ''' 529 | Pair with inverter at index inv_index 530 | ''' 531 | if inverter_index > len(self.inv_data) -1: 532 | raise Exception('Invalid inverter') 533 | self.start_coordinator(True) 534 | init_cmd = [] 535 | inverter_serial = self.inv_data[inverter_index]['serial'] 536 | pair_cmd = ''.join( 537 | ("24020FFFFFFFFFFFFFFFFF14FFFF140D0200000F1100", 538 | inverter_serial, "FFFF10FFFF", 539 | self.__reverse_byte_str(self.controller_id))) 540 | init_cmd.append(pair_cmd) 541 | pair_cmd = ''.join( 542 | ("24020FFFFFFFFFFFFFFFFF14FFFF140C0201000F0600", 543 | inverter_serial)) 544 | init_cmd.append(pair_cmd) 545 | pair_cmd = ''.join( 546 | ("24020FFFFFFFFFFFFFFFFF14FFFF140F0102000F1100", 547 | inverter_serial, 548 | self.__reverse_byte_str(self.controller_id)[-4:], 549 | "10FFFF", self.__reverse_byte_str(self.controller_id))) 550 | init_cmd.append(pair_cmd) 551 | pair_cmd = ''.join( 552 | ("24020FFFFFFFFFFFFFFFFF14FFFF14010103000F0600", 553 | self.__reverse_byte_str(self.controller_id))) 554 | init_cmd.append(pair_cmd) 555 | 556 | found = False 557 | for cmd in init_cmd: 558 | self.__send_cmd(cmd) 559 | result_str = self.__listen(1100) 560 | # no check in place to verify responses from pair commands 561 | time.sleep(1.5) 562 | result = self.__parse(result_str) 563 | 564 | for result_obj in result: 565 | if inverter_serial in result_obj['data']: 566 | inv_id_start = 12 + result_obj['data'].index(inverter_serial) 567 | inv_id = result_obj['data'][inv_id_start:inv_id_start+4] 568 | if inv_id not in ( 569 | '0000', 'FFFF', 570 | self.__reverse_byte_str(self.controller_id)[-4:]): 571 | 572 | found = inv_id[2:]+inv_id[:2] 573 | print('Inverter ID Found', found) 574 | return found 575 | 576 | return found 577 | -------------------------------------------------------------------------------- /cc2530.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/No13/ApsYc600-Pythonlib/bfdac088a91b344e726fe1b2d5985a152d682328/cc2530.jpg --------------------------------------------------------------------------------