Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add additional Pylontech CAN protocol fields #1213

Merged
merged 1 commit into from
Sep 25, 2024

Conversation

ranma
Copy link

@ranma ranma commented Aug 31, 2024

I noticed that these are missing while looking at dissassembly of the Pytes implementation of the protocol. I also found Pylontech sample CAN messages which match the Pytes implementation [1]:

CAN ID – followed by 2 to 8 bytes of data:
0x351 – 14 02 74 0E 74 0E CC 01 – Battery voltage + current limits
                          ^^^^^ discharge cutoff voltage 46.0V
0x355 – 1A 00 64 00 – State of Health (SOH) / State of Charge (SOC)
0x356 – 02 13 00 00 4A 01 – Voltage / Current / Temp
0x359 – 00 00 00 00 0A 50 4E – Protection & Alarm flags
                       ^^^^^ always 0x50 0x59 in Pytes implementation
                    ^^ module count (matches the blog article image)
0x35C – C0 00 – Battery charge request flags
        ^^ two possible additional flags (bit 3 and bit 4)
0x35E – 50 59 4C 4F 4E 20 20 20 – Manufacturer name (“PYLON “)
        ^^^^^^^^^^^^^^ Note: Pytes sends a 5-byte message "PYTES" instead
                       padding with spaces

The extra charge request flag is "bit4: SOC low" (Seems to be SoC < 10% threshold for Pytes), I haven't bothered adding that as it provides little value.

[1] https://www.setfirelabs.com/green-energy/pylontech-can-reading-can-replication

@ranma
Copy link
Author

ranma commented Aug 31, 2024

image showing 10 Pylontech modules

@ranma
Copy link
Author

ranma commented Aug 31, 2024

int can_msg_pylontech_FUN_0802a2c8
              (int param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4)

{
  struct can_battery_state_1 *batteryState;
  int stateOfHealth;
  uint batTemp;
  int tmp;
  int stateOfCharge;
  uint batVolt;
  uint batCurrent;
  byte *pbVar1;
  uint alarms0;
  int iVar2;
  uint alarms1;
  int numModules;
  byte bVar3;
  
  iVar2 = 0;
  batteryState = can_get_ptr_to_battery_state_struct_at_20000194_FUN_080087ec();
  pbVar1 = *(byte **)(param_1 + 0x20);
  alarms0 = batteryState->field12_0x30_alarms0;
  alarms1 = batteryState->field13_0x34_alarms1;
  numModules = batteryState->field2_num_modules;
  switch(param_2) {
  case 0:
                    /* msg 0x359 */
    bVar3 = 0;
    if ((alarms1 & 1 | alarms0 & 1) != 0) {
      bVar3 = 2;
    }
    if (((int)(alarms1 << 0x1b) < 0) || ((int)(alarms0 << 0x1b) < 0)) {
      bVar3 = bVar3 | 4;
    }
    if (((int)(alarms1 << 0x17) < 0) || ((int)(alarms0 << 0x17) < 0)) {
      bVar3 = bVar3 | 8;
    }
    if (((int)(alarms1 << 0x13) < 0) || ((int)(alarms0 << 0x13) < 0)) {
      bVar3 = bVar3 | 0x10;
    }
    if ((alarms0 & 0x60000) != 0) {
      bVar3 = bVar3 | 0x80;
    }
    *pbVar1 = bVar3;
    bVar3 = (alarms0 & 0x180000) != 0;
    if ((int)(alarms0 << 3) < 0) {
      bVar3 = bVar3 | 8;
    }
    pbVar1[1] = bVar3;
    bVar3 = 0;
    if (((int)(alarms1 << 0x1e) < 0) || ((int)(alarms0 << 0x1e) < 0)) {
      bVar3 = 2;
    }
    if (((int)(alarms1 << 0x1c) < 0) || ((int)(alarms0 << 0x1c) < 0)) {
      bVar3 = bVar3 | 4;
    }
    if (((int)(alarms1 << 0x16) < 0) || ((int)(alarms0 << 0x16) < 0)) {
      bVar3 = bVar3 | 8;
    }
    if (((int)(alarms1 << 0x14) < 0) || ((int)(alarms0 << 0x14) < 0)) {
      bVar3 = bVar3 | 0x10;
    }
    if ((int)(alarms0 << 10) < 0) {
      bVar3 = bVar3 | 0x80;
    }
    pbVar1[2] = bVar3;
    if ((int)(alarms0 << 9) < 0) {
      bVar3 = bVar3 | 1;
    }
    if ((int)(batteryState->field14_alarms2 << 0x16) < 0) {
      bVar3 = bVar3 | 8;
    }
    pbVar1[3] = bVar3;
    pbVar1[4] = (byte)numModules;
    pbVar1[5] = 0x50;
    pbVar1[6] = 0x59;
    iVar2 = 7;
    break;
  case 1:
                    /* msg 0x351 */
    alarms0 = (uint)batteryState->field55_charge_end_mV / 100;
    *pbVar1 = (byte)alarms0;
    pbVar1[1] = (byte)(alarms0 >> 8);
    alarms0 = (uint)batteryState->field53_charge_mA / 100;
    pbVar1[2] = (byte)alarms0;
    pbVar1[3] = (byte)(alarms0 >> 8);
    alarms0 = (uint)batteryState->field54_discharge_mA / 100;
    pbVar1[4] = (byte)alarms0;
    pbVar1[5] = (byte)(alarms0 >> 8);
    alarms0 = (uint)batteryState->field56_discharge_end_mV / 100;
    pbVar1[6] = (byte)alarms0;
    pbVar1[7] = (byte)(alarms0 >> 8);
    iVar2 = 8;
    break;
  case 2:
                    /* msg 0x355 */
    stateOfCharge = batteryState->field10_state_of_charge;
    *pbVar1 = (byte)stateOfCharge;
    pbVar1[1] = (byte)((uint)stateOfCharge >> 8);
    stateOfHealth = batteryState->field11_state_of_health;
    pbVar1[2] = (byte)stateOfHealth;
    pbVar1[3] = (byte)((uint)stateOfHealth >> 8);
    iVar2 = 4;
    break;
  case 3:
                    /* msg 0x356 */
    batVolt = (uint)batteryState->field29_battery_voltage_precision5 / 10;
    *pbVar1 = (byte)batVolt;
    pbVar1[1] = (byte)(batVolt >> 8);
    batCurrent = (uint)batteryState->field30_battery_current_precision6 / 100;
    pbVar1[2] = (byte)batCurrent;
    pbVar1[3] = (byte)(batCurrent >> 8);
    batTemp = (uint)batteryState->field41_battery_temperature_precision3 / 100;
    pbVar1[4] = (byte)batTemp;
    pbVar1[5] = (byte)(batTemp >> 8);
    iVar2 = 6;
    break;
  case 4:
                    /* msg 0x35c */
    bVar3 = 0;
    if (batteryState->field44_flag_charge_enabled != 0) {
      bVar3 = 0x80;
    }
    if (batteryState->field45_flag_discharge_enabled != 0) {
      bVar3 = bVar3 | 0x40;
    }
    if (batteryState->field49_flag_charge_immediately != 0) {
      bVar3 = bVar3 | 0x20;
    }
    if (batteryState->field50_flag_soc_below_10percent != 0) {
      bVar3 = bVar3 | 0x10;
    }
    if (batteryState->field51_flag_0xcc_maybe_soc_above_x_threshold != 0) {
      bVar3 = bVar3 | 8;
    }
    *pbVar1 = bVar3;
    pbVar1[1] = 0;
    iVar2 = 2;
    break;
  case 5:
                    /* msg 0x35e */
    for (tmp = 0; tmp < 5; tmp = tmp + 1) {
      *pbVar1 = s__PYTES_0802a4db[tmp + 1];
      pbVar1 = pbVar1 + 1;
    }
    iVar2 = 5;
  }
  return iVar2;
}

@ranma ranma force-pushed the pylontech-can-protocol branch from 22550fd to 4731984 Compare August 31, 2024 14:05
@ranma
Copy link
Author

ranma commented Sep 1, 2024

Liveview test using replayed CAN packets:
Pylontech Liveview

@ranma
Copy link
Author

ranma commented Sep 1, 2024

CAN message simulator (connect Pi GPIO 10 (MOSI) to ESP32 CAN rx input):

#!/usr/bin/env python3

import struct
import time
import pigpio


pi = pigpio.pi()
spi = pi.spi_open(0, 500000, 0)


def addbits(l: bytearray, v: int, n: int):
  for i in range(n):
    mask = 1 << (n - i - 1)
    if (v & mask):
      l.append(1)
    else:
      l.append(0)


def can_crc15(bits: bytearray) -> int:
  crc = 0
  for bit in bits:
    crcout = crc >> 14
    crc = (crc << 1) & 0x7fff
    if bit ^ crcout:
      crc ^= 0x4599
  return crc


def can_stuff(bits: bytearray) -> bytearray:
  stuffed = bytearray()
  ones = 0
  zeroes = 0
  for bit in bits:
    stuffed.append(bit)
    if bit:
      ones += 1
      zeroes = 0
    else:
      zeroes += 1
      ones = 0
    if ones >= 5:
      stuffed.append(0)
      ones = 0
      zeroes = 1
    elif zeroes >= 5:
      stuffed.append(1)
      zeroes = 0
      ones = 1
  return stuffed


def can_msg(msg: int, payload: bytes) -> bytearray:
  if msg >= 0x800:
    raise ValueError('msg id out of range 0x000:0x7ff')
  if len(payload) > 8:
    raise ValueError('payload len out of range 0:7')

  leader = (msg << 7) | len(payload)
  acked_trailer = 0x2ff

  bits = bytearray()
  addbits(bits, leader, 19)
  for b in payload:
    addbits(bits, b, 8)

  crc = can_crc15(bits)
  addbits(bits, crc, 15)
  stuffed = can_stuff(bits)

  addbits(stuffed, acked_trailer, 10)
  return stuffed


def make_bytes(bits: bytearray) -> bytearray:
  x = 0
  i = 0
  data = bytearray()

  def add_bit(bit):
    nonlocal x, i
    x = (x << 1) & 0xff
    x |= bool(bit)
    i += 1
    if i >= 8:
      i = 0
      data.append(x)

  for bit in bits:
    add_bit(bit)

  # pad with ones
  while i > 0:
    add_bit(1)

  return data


def send_msg(msg: int, payload: bytes):
  bits = can_msg(msg, payload)
  data = make_bytes(bits)
  # RPI MOSI line idles at zero in between transactions, add extra padding.
  pad1 = 16 * b'\xff'
  pad2 = 8 * b'\xff'
  pi.spi_write(spi, pad1 + data + pad2)



def send_pylontech_msgs():
  send_msg(0x351, b'\x14\x02\x74\x0e\x74\x0e\xcc\x01') 
  send_msg(0x355, b'\x1a\x00\x64\x00') 
  send_msg(0x356, b'\x02\x13\x00\x00\x4a\x01') 
  send_msg(0x359, b'\x00\x00\x00\x00\x0a\x50\x4e') 
  send_msg(0x35c, b'\xc0\x00') 
  send_msg(0x35e, b'PYLON   ') 


while True:
  send_pylontech_msgs()
  time.sleep(0.1)

Copy link
Member

@schlimmchen schlimmchen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new data points are interpreted and added to the live view, but please also publish them to the MQTT broker and add them to HASS auto-discovery.

src/BatteryStats.cpp Show resolved Hide resolved
src/BatteryStats.cpp Show resolved Hide resolved
@ranma ranma force-pushed the pylontech-can-protocol branch 2 times, most recently from 3d52a4b to 8fc39e1 Compare September 4, 2024 20:52
@ranma ranma requested a review from schlimmchen September 5, 2024 17:53
@AndreasBoehm
Copy link
Member

@DerHavey1994 @Snusme83
Habt ihr Zeit und Lust zu probieren ob diese Änderung die zwei neuen Felder Entladespannungs Limit und Modul Anzahl für eure Pylontech Batterie korrekt anzeigt?

Die entsprechenden builds findet ihr hier: https://github.com/helgeerbe/OpenDTU-OnBattery/actions/runs/10709343648?pr=1213

Danke vorab.

@DerHavey1994
Copy link

@AndreasBoehm läuft!
Screenshot 2024-09-09 144635

@AndreasBoehm
Copy link
Member

AndreasBoehm commented Sep 9, 2024

Danke @DerHavey1994 fürs testen!

Great job @ranma!
Checking the actual code that runs on the batteries was an awesome idea.

@Snusme83
Copy link

Snusme83 commented Sep 9, 2024

@DerHavey1994 @Snusme83 Habt ihr Zeit und Lust zu probieren ob diese Änderung die zwei neuen Felder Entladespannungs Limit und Modul Anzahl für eure Pylontech Batterie korrekt anzeigt?

Die entsprechenden builds findet ihr hier: https://github.com/helgeerbe/OpenDTU-OnBattery/actions/runs/10709343648?pr=1213

Danke vorab.

Servus, soll ich noch testen? Kann ich das hier:
IMG_2346 machen oder muss da wieder neu geflasht werden?

@AndreasBoehm
Copy link
Member

@Snusme83 Genau da wo du gerade bist kannst du das verlinkte build als update hochladen.

@Snusme83
Copy link

Snusme83 commented Sep 9, 2024

Schaut gut aus. Hab zwei US3000C!

IMG_2347

Blöde Frage: Könnte ich auf diese Art und Weise auch wieder auf das Release 2024.08.18 zurückgehen?🤔

@schlimmchen
Copy link
Member

Blöde Frage: Könnte ich auf diese Art und Weise auch wieder auf das Release 2024.08.18 zurückgehen?🤔

@Snusme83 Ja.

@schlimmchen schlimmchen force-pushed the development branch 2 times, most recently from 91cc2fc to 8ff94e7 Compare September 16, 2024 14:10
I noticed that these are missing while looking at dissassembly of the
Pytes implementation of the protocol. I also found Pylontech sample
CAN messages] which match the Pytes implementation [1]:

```
CAN ID – followed by 2 to 8 bytes of data:
0x351 – 14 02 74 0E 74 0E CC 01 – Battery voltage + current limits
                          ^^^^^ discharge cutoff voltage 46.0V
0x355 – 1A 00 64 00 – State of Health (SOH) / State of Charge (SOC)
0x356 – 4e 13 02 03 04 05 – Voltage / Current / Temp
0x359 – 00 00 00 00 0A 50 4E – Protection & Alarm flags
                       ^^^^^ always 0x50 0x59 in Pytes implementation
                    ^^ module count (matches the blog article image)
0x35C – C0 00 – Battery charge request flags
        ^^ two possible additional flags (bit 3 and bit 4)
0x35E – 50 59 4C 4F 4E 20 20 20 – Manufacturer name (“PYLON “)
        ^^^^^^^^^^^^^^ Note: Pytes sends a 5-byte message "PYTES" instead
                       padding with spaces
```

The extra charge request flag is "bit4: SOC low" (Seems to be SoC < 10%
threshold for Pytes), I haven't bothered adding that as it provides
little value.

[1] https://www.setfirelabs.com/green-energy/pylontech-can-reading-can-replication
@ranma ranma force-pushed the pylontech-can-protocol branch from 8fc39e1 to 2aa0607 Compare September 23, 2024 17:03
@schlimmchen schlimmchen merged commit 191cc80 into hoylabs:development Sep 25, 2024
9 checks passed
@ranma ranma deleted the pylontech-can-protocol branch September 26, 2024 18:41
Copy link

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new discussion or issue for related concerns.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Oct 27, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants