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

Initial support for Lynx Smart BMS #49

Merged
merged 1 commit into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions tests/test_lynx_smart_bms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import pytest

from victron_ble.devices.lynx_smart_bms import LynxSmartBMS, LynxSmartBMSData


class TestLynxSmartBMS:
def parse_decrypted(self, decrypted: bytes) -> LynxSmartBMSData:
parsed = LynxSmartBMS(None).parse_decrypted(decrypted)
return LynxSmartBMSData(None, parsed)

def test_parse(self) -> None:
actual = self.parse_decrypted(
b"\x00@8\x8b\n\xfa\xff\x95\x15U\x14\x8c\xcf\x02\x00\xff\xb3\xea\xf1t\xd6\xfczHT\xb8\xec\x00\x86\t\xe9\xca"
)
assert actual.get_battery_temperature() is None
assert actual.get_consumed_ah() == 4.4
assert actual.get_soc() == 99.5
assert actual.get_alarm_flags() == 5205 # ??
Copy link
Contributor

Choose a reason for hiding this comment

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

I also get 5205, with as far as I can tell, no alarms.

assert actual.get_io_status() == 5525 # ??
Copy link
Contributor

Choose a reason for hiding this comment

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

I get 5461 for io_status.

If this matches that dbus/mqtt structure, IO encompasses the following things:

  • Programmable Contact (null in my case)
  • Allowed to Discharge (1 in my case)
  • External Relay (null in my case)
  • Allowed to Charge (1 in my case)

Copy link
Contributor

Choose a reason for hiding this comment

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

16 bits is a lot of space for just these four things. Perhaps this also contains the status of attached Lynx Distributors?

If it's helpful, I have one distributor attached (Distributor A) with all four fuses in the OK state.

Unfortunately it's not so convenient to get to it and start pulling fuses to experiment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm afraid I don't have any distributors to test with.

assert actual.get_current() == -0.6
assert actual.get_voltage() == 26.99
assert actual.get_remaining_mins() == 14400
assert actual.get_error_flags() == 0
5 changes: 4 additions & 1 deletion victron_ble/devices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from victron_ble.devices.battery_sense import BatterySense, BatterySenseData
from victron_ble.devices.dc_energy_meter import DcEnergyMeter, DcEnergyMeterData
from victron_ble.devices.dcdc_converter import DcDcConverter, DcDcConverterData
from victron_ble.devices.lynx_smart_bms import LynxSmartBMS, LynxSmartBMSData
from victron_ble.devices.solar_charger import SolarCharger, SolarChargerData
from victron_ble.devices.vebus import VEBus, VEBusData

Expand All @@ -22,6 +23,8 @@
"DcDcConverterData",
"DcEnergyMeter",
"DcEnergyMeterData",
"LynxSmartBMS",
"LynxSmartBMSData",
"SolarCharger",
"SolarChargerData",
"VEBus",
Expand Down Expand Up @@ -59,7 +62,7 @@ def detect_device_type(data: bytes) -> Optional[Type[Device]]:
elif mode == 0x6: # InverterRS
pass
elif mode == 0xA: # LynxSmartBMS
pass
return LynxSmartBMS
elif mode == 0xB: # MultiRS
pass
elif mode == 0x5: # SmartLithium
Expand Down
1 change: 1 addition & 0 deletions victron_ble/devices/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ class ACInState(Enum):
0xA3CE: "Orion Smart 48V|24V-16A Isolated DC-DC Charger",
0xA3C7: "Orion Smart 48V|48V-6A Isolated DC-DC Charger",
0xA3CF: "Orion Smart 48V|48V-8.5A Isolated DC-DC Charger",
0xA3E6: "Lynx Smart BMS 1000",
0x2780: "Victron Multiplus II 12/3000/120-50 2x120V",
}

Expand Down
104 changes: 104 additions & 0 deletions victron_ble/devices/lynx_smart_bms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from construct import BitsInteger, BitStruct, ByteSwapped, Padding

from victron_ble.devices.base import Device, DeviceData


class LynxSmartBMSData(DeviceData):
def get_error_flags(self) -> int:
"""
Get the raw error_flags field (meaning not documented).
"""
return self._data["error_flags"]

def get_remaining_mins(self) -> float:
"""
Return the number of remaining minutes of battery life in minutes
"""
return self._data["remaining_mins"]

def get_voltage(self) -> float:
"""
Return the voltage in volts
"""
return self._data["voltage"]

def get_current(self) -> float:
"""
Return the current in amps
"""
return self._data["current"]

def get_io_status(self) -> int:
"""
Get the raw io_status field (meaning not documented).
"""
return self._data["io_status"]

def get_alarm_flags(self) -> int:
"""
Get the raw alarm_flags field (meaning not documented).
"""
return self._data["alarm_flags"]

def get_soc(self) -> float:
"""
Return the state of charge in percentage
"""
return self._data["soc"]

def get_consumed_ah(self) -> float:
"""
Return the consumed energy in amp hours
"""
return self._data["consumed_ah"]

def get_battery_temperature(self) -> int:
"""
Return the temperature in Celsius if the aux input is set to temperature
"""
return self._data["battery_temperature"]


class LynxSmartBMS(Device):
data_type = LynxSmartBMSData

# https://community.victronenergy.com/questions/187303/victron-bluetooth-advertising-protocol.html
# Reverse the entire packet, because non-aligned integers are packed
# little-endian
PACKET = ByteSwapped(
BitStruct(
Padding(1), # unused
"battery_temperature" / BitsInteger(7), # -40..86C
"consumed_ah" / BitsInteger(20), # -104857..0Ah
"soc" / BitsInteger(10), # 0..100%
"alarm_flags" / BitsInteger(18),
"io_status" / BitsInteger(16),
"current" / BitsInteger(16, signed=True), # -3276.8..3276.6 A
"voltage" / BitsInteger(16), # -327.68..327.66 V
"remaining_mins" / BitsInteger(16), # 0..45.5 days (in mins)
"error_flags" / BitsInteger(8),
),
)

def parse_decrypted(self, decrypted: bytes) -> dict:
pkt = self.PACKET.parse(decrypted[:20])

parsed = {
"error_flags": pkt.error_flags,
"remaining_mins": (
pkt.remaining_mins if pkt.remaining_mins != 0xFFFF else None
),
"voltage": pkt.voltage / 100 if pkt.voltage != 0x7FFF else None,
"current": pkt.current / 10 if pkt.current != 0x7FFF else None,
"io_status": pkt.io_status,
"alarm_flags": pkt.alarm_flags,
"soc": pkt.soc / 10.0 if pkt.soc != 0x3FFF else None,
"consumed_ah": pkt.consumed_ah / 10 if pkt.consumed_ah != 0xFFFFF else None,
"battery_temperature": (
(pkt.battery_temperature - 40)
if pkt.battery_temperature != 0x7F
else None
),
}

return parsed
Loading