Skip to content

Commit

Permalink
Merge pull request #49 from stefanor/smart-bms
Browse files Browse the repository at this point in the history
Initial support for Lynx Smart BMS
  • Loading branch information
keshavdv authored May 31, 2024
2 parents cccd995 + 71ec778 commit d6d67d8
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 1 deletion.
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 # ??
assert actual.get_io_status() == 5525 # ??
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",
0xC030: "SmartShunt IP65 500A/50mV",
}
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

0 comments on commit d6d67d8

Please sign in to comment.