3 Commits
Author SHA1 Message Date
Rene Nulsch 28bf64baad Update: message parser, trip handling 2026-04-30 13:00:56 +02:00
Rene Nulsch 52178a219b Add x20 parser logic 2026-04-30 10:00:59 +02:00
Rene Nulsch 0afd8243bc Discover HUS-prefixed BLE devices alongside iWoc 2026-04-30 07:40:52 +02:00
6 changed files with 506 additions and 57 deletions
+3 -3
View File
@@ -90,10 +90,10 @@ The integration is configured through the Home Assistant UI:
1. Go to **Settings****Devices & Services** 1. Go to **Settings****Devices & Services**
2. Click **+ Add Integration** 2. Click **+ Add Integration**
3. Search for **MySmartBike BLE** 3. Search for **MySmartBike BLE**
4. Select your iWoc device from the list 4. Select your iWoc/HUS device from the list
5. Click **Submit** 5. Click **Submit**
The integration will automatically discover iWoc devices in range via Bluetooth. The integration will automatically discover iWoc and HUS devices in range via Bluetooth.
## Troubleshooting ## Troubleshooting
@@ -101,7 +101,7 @@ The integration will automatically discover iWoc devices in range via Bluetooth.
- Make sure your E-Bike is turned on and in range - Make sure your E-Bike is turned on and in range
- Check that Bluetooth is enabled on your Home Assistant host - Check that Bluetooth is enabled on your Home Assistant host
- Verify that the device name starts with "iWoc" (please report other device names) - Verify that the device name starts with "iWoc" or "HUS" (please report other device names)
### Connection issues ### Connection issues
@@ -99,8 +99,7 @@ class MySmartBikeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
): ):
continue continue
# Check if device name starts with "iWoc" if discovery_info.name and discovery_info.name.startswith(("iWoc", "HUS")):
if discovery_info.name and discovery_info.name.startswith("iWoc"):
self._discovered_devices[discovery_info.address] = discovery_info self._discovered_devices[discovery_info.address] = discovery_info
if not self._discovered_devices: if not self._discovered_devices:
@@ -4,6 +4,9 @@
"bluetooth": [ "bluetooth": [
{ {
"local_name": "iWoc*" "local_name": "iWoc*"
},
{
"local_name": "HUS*"
} }
], ],
"codeowners": [ "codeowners": [
+163 -32
View File
@@ -12,12 +12,25 @@ _LOGGER = logging.getLogger(__name__)
def read16(data: bytes, offset: int) -> int: def read16(data: bytes, offset: int) -> int:
"""Read 16-bit value from data at offset (big-endian, as per Mahle protocol).""" """Read 16-bit big-endian value from data at offset."""
return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF) return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF)
def read16_signed(data: bytes, offset: int) -> int:
"""Read 16-bit big-endian value as signed int."""
value = read16(data, offset)
if value & 0x8000:
value -= 0x10000
return value
def read_signed_byte(byte_val: int) -> int:
"""Read byte as signed int."""
return byte_val - 256 if byte_val & 0x80 else byte_val
def read24(data: bytes, offset: int) -> int: def read24(data: bytes, offset: int) -> int:
"""Read 24-bit value from data at offset (big-endian, as per Mahle protocol).""" """Read 24-bit big-endian value from data at offset."""
return ( return (
((data[offset] & 0xFF) << 16) ((data[offset] & 0xFF) << 16)
| ((data[offset + 1] & 0xFF) << 8) | ((data[offset + 1] & 0xFF) << 8)
@@ -26,7 +39,7 @@ def read24(data: bytes, offset: int) -> int:
def read32(data: bytes, offset: int) -> int: def read32(data: bytes, offset: int) -> int:
"""Read 32-bit value from data at offset (big-endian, as per Mahle protocol).""" """Read 32-bit big-endian value from data at offset."""
return ( return (
((data[offset] & 0xFF) << 24) ((data[offset] & 0xFF) << 24)
| ((data[offset + 1] & 0xFF) << 16) | ((data[offset + 1] & 0xFF) << 16)
@@ -57,11 +70,15 @@ class BikeDataParser:
self.protocol_version: Optional[str] = None self.protocol_version: Optional[str] = None
def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]: def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse battery message and update state.""" """Parse battery frame; dispatch by length to the right layout."""
if len(message) < BATTERY_MESSAGE_LENGTH: if len(message) == 20:
return self._parse_battery_x20(message)
if len(message) >= BATTERY_MESSAGE_LENGTH:
return self._parse_battery_ebm(message)
return None return None
# Read values def _parse_battery_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 19-byte battery frame (X25 / X35+ / ebikemotion)."""
voltage = read16(message, 5) / 10.0 voltage = read16(message, 5) / 10.0
soc = read_unsigned_byte(message[7]) soc = read_unsigned_byte(message[7])
temp_status = message[8] temp_status = message[8]
@@ -69,66 +86,102 @@ class BikeDataParser:
nominal_capacity = read16(message, 11) / 10.0 nominal_capacity = read16(message, 11) / 10.0
remaining_wh = read16(message, 13) / 10.0 remaining_wh = read16(message, 13) / 10.0
# Get battery number and cycles from combined field at offset 15
# Format: value = (battery_number * 10000) + cycles
# e.g., 10036 means battery 1, 36 cycles
combined_raw = read16(message, 15) if len(message) >= 19 else None combined_raw = read16(message, 15) if len(message) >= 19 else None
battery_number = (combined_raw // 10000) if combined_raw else 1 battery_number = (combined_raw // 10000) if combined_raw else 1
cycles = (combined_raw % 10000) if combined_raw else None cycles = (combined_raw % 10000) if combined_raw else None
# Construct battery data dictionary
data = { data = {
"voltage": voltage, "voltage": voltage,
"soc": soc, "soc": soc,
"temperature": temp_status, "temperature": temp_status,
"temperature_mos": None,
"current": current, "current": current,
"nominal_capacity": nominal_capacity, "nominal_capacity": nominal_capacity,
"remaining_wh": remaining_wh, "remaining_wh": remaining_wh,
"cycles": cycles, "cycles": cycles,
"is_charging": False,
} }
self._store_battery(data, battery_number)
return data
# Handle secondary vs primary battery def _parse_battery_x20(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 20-byte battery frame (X20 / HUS-prefixed devices)."""
voltage = read16(message, 5) / 100.0
soc_raw = read_unsigned_byte(message[7])
# Bit 7 of the SOC byte signals charging on the newer firmware variant;
# safe to read unconditionally — real SOC is always ≤ 100, so the bit
# would never be set by accident on older firmwares.
is_charging = bool(soc_raw & 0x80)
soc = soc_raw & 0x7F
temp_status = read_signed_byte(message[8])
current = read16_signed(message, 9) / 10.0
nominal_capacity = read16(message, 11) / 10.0
remaining_wh = read16(message, 13) / 10.0
temperature_mos = read_signed_byte(message[15])
combined_raw = read16(message, 16)
battery_number = combined_raw // 10000
cycles = combined_raw % 10000
data = {
"voltage": voltage,
"soc": soc,
"temperature": temp_status,
"temperature_mos": temperature_mos,
"current": current,
"nominal_capacity": nominal_capacity,
"remaining_wh": remaining_wh,
"cycles": cycles,
"is_charging": is_charging,
}
self._store_battery(data, battery_number)
return data
def _store_battery(self, data: Dict[str, Any], battery_number: int) -> None:
"""Update primary/secondary battery slots and the consecutive-primary counter."""
if battery_number == 2: if battery_number == 2:
# Secondary battery detected
self.battery_packet_counter = 0 self.battery_packet_counter = 0
self.state["battery_secondary"] = data self.state["battery_secondary"] = data
elif battery_number == 1: return
# Primary battery
# Anything that isn't an explicit secondary battery (number == 2) is
# treated as primary — a missing/zero battery_number on a single-battery
# bike would otherwise leave all sensors unavailable.
self.battery_packet_counter += 1 self.battery_packet_counter += 1
self.state["battery_primary"] = data self.state["battery_primary"] = data
# After 4 consecutive primary battery packets, reset secondary battery
if self.battery_packet_counter >= 4: if self.battery_packet_counter >= 4:
self.state["battery_secondary"] = { self.state["battery_secondary"] = {
"voltage": 0.0, "voltage": 0.0,
"soc": 0.0, "soc": 0.0,
"temperature": 0, "temperature": 0,
"temperature_mos": None,
"current": 0.0, "current": 0.0,
"nominal_capacity": 0.0, "nominal_capacity": 0.0,
"remaining_wh": 0.0, "remaining_wh": 0.0,
"cycles": None, "cycles": None,
"is_charging": False,
} }
return data
def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]: def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse motor message and update state.""" """Parse motor frame; dispatch by length to the right layout."""
if len(message) < MOTOR_MESSAGE_LENGTH: if len(message) >= 20:
return self._parse_motor_x20(message)
if len(message) >= MOTOR_MESSAGE_LENGTH:
return self._parse_motor_ebm(message)
return None return None
# Extract values from message def _parse_motor_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 18-byte motor frame (X25 / X35+ / ebikemotion)."""
assist_level = message[5] assist_level = message[5]
temperature_celsius = message[6] temperature_celsius = message[6]
power_amp = float(read16(message, 7)) / 10.0 power_amp = float(read16(message, 7)) / 10.0
speed_kmh = float(read16(message, 9)) / 10.0 speed_kmh = float(read16(message, 9)) / 10.0
# Additional values
wheel_speed = read_unsigned_byte(message[11]) wheel_speed = read_unsigned_byte(message[11])
torque_pct = message[12] torque_pct = message[12]
power_max = float(read16(message, 13)) / 10.0 power_max = float(read16(message, 13)) / 10.0
max_torque_pct = message[15] max_torque_pct = message[15]
# Update state with motor data
data = { data = {
"assist_level": assist_level, "assist_level": assist_level,
"temperature_celsius": temperature_celsius, "temperature_celsius": temperature_celsius,
@@ -138,8 +191,38 @@ class BikeDataParser:
"torque_motor_pct": torque_pct, "torque_motor_pct": torque_pct,
"power_max_amp": power_max, "power_max_amp": power_max,
"max_torque_motor_pct": max_torque_pct, "max_torque_motor_pct": max_torque_pct,
"motor_power_watts": None,
"rider_power_watts": None,
} }
self.state["motor"] = data
return data
def _parse_motor_x20(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 20-byte motor frame (X20 / HUS-prefixed devices)."""
assist_level = read_signed_byte(message[5])
# Temperature is signed: 0xD8 (= -40 °C) is the "no sensor data" sentinel
# the bike reports during the first packets after connect.
temperature_celsius = read_signed_byte(message[6])
motor_power_watts = read16(message, 7) / 100.0
speed_kmh = read16(message, 9) / 10.0
wheel_speed_raw = read_unsigned_byte(message[11])
rider_power_watts = read16(message, 12) / 10.0
power_max_amp = read16(message, 14) / 10.0
# max_torque doubles as a validity flag for wheel_speed (0 → no data).
max_torque = read16(message, 16)
data = {
"assist_level": assist_level,
"temperature_celsius": temperature_celsius,
"power_amp": None,
"speed_kmh": speed_kmh,
"wheel_speed_rpm": wheel_speed_raw if max_torque != 0 else None,
"torque_motor_pct": None,
"power_max_amp": power_max_amp,
"max_torque_motor_pct": max_torque,
"motor_power_watts": motor_power_watts,
"rider_power_watts": rider_power_watts,
}
self.state["motor"] = data self.state["motor"] = data
return data return data
@@ -214,29 +297,77 @@ class BikeDataParser:
return None return None
def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]: def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse EBM (E-Bike Management) message.""" """Parse EBM (E-Bike Management) frame; dispatch by length."""
if len(message) < EBM_MESSAGE_LENGTH: if len(message) >= 20:
return None return self._parse_ebm_x20(message)
if len(message) >= EBM_MESSAGE_LENGTH:
# EbmParserEbm format: 32-bit reads directly from message (big-endian) return self._parse_ebm_ebm(message)
# Raw values are in decimeters, divide by 10000 to get km
# (Mahle code divides by 10 to get meters, then displays as km by /1000)
if len(message) < 15:
return None return None
def _parse_ebm_ebm(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 17-byte EBM frame (X25 / X35+ / ebikemotion)."""
odometry_km = read32(message, 5) / 10000.0 odometry_km = read32(message, 5) / 10000.0
autonomy_km = read32(message, 9) / 10000.0 autonomy_km = read32(message, 9) / 10000.0
is_light_on = message[13] == 1 is_light_on = message[13] == 1
status = read_unsigned_byte(message[14]) status = read_unsigned_byte(message[14])
# EbmParserEbm only parses bytes 5-14, bytes 15-16 are suffix #@
data = { data = {
"odometry": odometry_km, "odometry": odometry_km,
"autonomy": autonomy_km, "autonomy": autonomy_km,
"trip_odometry": None,
"trip_autonomy": None,
"is_light_on": is_light_on, "is_light_on": is_light_on,
"status": status, "status": status,
"accel_y": None,
"accel_z": None,
}
self.state["ebm"] = data
return data
def _parse_ebm_x20(self, message: bytes) -> Optional[Dict[str, Any]]:
"""Parse 20-byte EBM frame (X20 / HUS-prefixed devices).
The bike alternates between two slot indicators in byte 14:
- slot == 1 → bytes 5-9 carry the LIFETIME odometer & range
- slot == 2 → same bytes carry the current TRIP A distance & range
Bytes 15-17 are a fixed `HIJ` (`0x48 0x49 0x4A`) marker before `#@`. A
device using protocol v200 puts an MPlatform error code and remote-SOC
info there instead — not yet supported.
"""
odometry_km = read24(message, 5) / 10.0
autonomy_km = read16(message, 8) / 10.0
is_light_on = message[10] == 1
status = read_unsigned_byte(message[11])
accel_z = read_signed_byte(message[12])
accel_y = read_signed_byte(message[13])
slot = read_unsigned_byte(message[14])
prev = self.state.get("ebm") or {}
if slot == 2:
data = {
"odometry": prev.get("odometry"),
"autonomy": prev.get("autonomy"),
"trip_odometry": odometry_km,
"trip_autonomy": autonomy_km,
}
else:
data = {
"odometry": odometry_km,
"autonomy": autonomy_km,
"trip_odometry": prev.get("trip_odometry"),
"trip_autonomy": prev.get("trip_autonomy"),
} }
data.update(
{
"is_light_on": is_light_on,
"status": status,
"accel_y": accel_y,
"accel_z": accel_z,
}
)
self.state["ebm"] = data self.state["ebm"] = data
return data return data
@@ -119,6 +119,25 @@ SENSORS: tuple[MySmartBikeSensorEntityDescription, ...] = (
icon="mdi:map-marker-distance", icon="mdi:map-marker-distance",
value_fn=lambda data: safe_get(data, "ebm", "autonomy"), value_fn=lambda data: safe_get(data, "ebm", "autonomy"),
), ),
MySmartBikeSensorEntityDescription(
key="trip_distance",
name="Trip A Distance",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:bike",
value_fn=lambda data: safe_get(data, "ebm", "trip_odometry"),
),
MySmartBikeSensorEntityDescription(
key="trip_range",
name="Trip A Range",
native_unit_of_measurement=UnitOfLength.KILOMETERS,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:map-marker-distance",
entity_registry_enabled_default=False,
value_fn=lambda data: safe_get(data, "ebm", "trip_autonomy"),
),
MySmartBikeSensorEntityDescription( MySmartBikeSensorEntityDescription(
key="light", key="light",
name="Light", name="Light",
@@ -4,8 +4,10 @@ import pytest
from custom_components.mysmartbike_ble.parsers import ( from custom_components.mysmartbike_ble.parsers import (
BikeDataParser, BikeDataParser,
read16, read16,
read16_signed,
read24, read24,
read32, read32,
read_signed_byte,
read_unsigned_byte, read_unsigned_byte,
) )
@@ -35,6 +37,24 @@ class TestReadFunctions:
assert read_unsigned_byte(0x00) == 0 assert read_unsigned_byte(0x00) == 0
assert read_unsigned_byte(0x7F) == 127 assert read_unsigned_byte(0x7F) == 127
def test_read_signed_byte(self):
"""Test signed byte read."""
assert read_signed_byte(0x00) == 0
assert read_signed_byte(0x7F) == 127
assert read_signed_byte(0x80) == -128
assert read_signed_byte(0xFF) == -1
def test_read16_signed(self):
"""Test 16-bit signed read (big-endian)."""
# Positive: 0x0001 → 1
assert read16_signed(bytes([0x00, 0x01]), 0) == 1
# Boundary: 0x7FFF → 32767
assert read16_signed(bytes([0x7F, 0xFF]), 0) == 32767
# Negative: 0x8000 → -32768
assert read16_signed(bytes([0x80, 0x00]), 0) == -32768
# -1: 0xFFFF
assert read16_signed(bytes([0xFF, 0xFF]), 0) == -1
class TestEbmParser: class TestEbmParser:
"""Test EBM message parsing with real data.""" """Test EBM message parsing with real data."""
@@ -110,6 +130,183 @@ class TestMotorParser:
assert result["temperature_celsius"] == 23 assert result["temperature_celsius"] == 23
class TestMotorParserX20:
"""20-byte motor frame parsing (X20 / HUS-prefixed devices)."""
# Real frame at rest: assist 1, 22 °C, zero power/speed, max_torque 0x03FF
# (the bike's idle sentinel), power_max_amp 9.0 A.
MOTOR_MESSAGE = bytes.fromhex("246d245a23011600000000000000005a03ff2340")
# First packet after connect: temp byte 0xD8 = -40 signed (no-sensor sentinel).
MOTOR_MESSAGE_BOOT = bytes.fromhex("246d245a2301d800000000000000005a03ff2340")
def test_recognition(self):
parser = BikeDataParser()
assert parser.recognize_message_type(self.MOTOR_MESSAGE) == "motor"
def test_assist_level_and_temperature(self):
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["assist_level"] == 1
assert result["temperature_celsius"] == 22
def test_signed_temperature_handles_no_sensor_sentinel(self):
"""0xD8 must decode as -40 °C (signed), not 216 °C (unsigned)."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE_BOOT)
assert result["temperature_celsius"] == -40
def test_speed_and_power(self):
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["speed_kmh"] == 0.0
assert result["motor_power_watts"] == 0.0
assert result["rider_power_watts"] == 0.0
# power_max_amp = 0x005A / 10 = 9.0 A
assert abs(result["power_max_amp"] - 9.0) < 0.01
def test_max_torque_uses_offset_16(self):
"""max_torque is a 16-bit raw value at offset 16-17 (= 0x03FF)."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["max_torque_motor_pct"] == 0x03FF
def test_wheel_speed_returned_when_max_torque_nonzero(self):
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
# max_torque = 0x03FF != 0 → wheel_speed byte (0x00) is returned
assert result["wheel_speed_rpm"] == 0
def test_wheel_speed_nulled_when_max_torque_zero(self):
"""When max_torque == 0, wheel_speed must be None."""
msg = bytearray(self.MOTOR_MESSAGE)
msg[16] = 0x00
msg[17] = 0x00
parser = BikeDataParser()
result = parser.parse_motor_message(bytes(msg))
assert result["wheel_speed_rpm"] is None
def test_x20_specific_fields_replace_legacy(self):
"""The X20 frame doesn't carry power_amp / torque_motor_pct."""
parser = BikeDataParser()
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
assert result["power_amp"] is None
assert result["torque_motor_pct"] is None
assert "motor_power_watts" in result
assert "rider_power_watts" in result
class TestEbmParserX20:
"""20-byte EBM frame parsing (X20 / HUS-prefixed devices).
Field offsets verified against an app-confirmed capture:
- Odometer 0x000084 / 10 = 13.2 km (app shows 8.08 mi = 13.005 km)
- Range 0x02E1 / 10 = 73.7 km (app shows 45 mi = 72.42 km)
"""
# Slot 1 frame = lifetime values
EBM_LIFETIME = bytes.fromhex("246a245a2300008402e1000001f50148494a2340")
# Slot 2 frame = trip values; same bike, same odometer/autonomy bytes →
# trip A == lifetime (no reset since first ride).
EBM_TRIP = bytes.fromhex("246a245a2300008402e1000001f50248494a2340")
def test_message_length(self):
assert len(self.EBM_LIFETIME) == 20
def test_recognition(self):
parser = BikeDataParser()
assert parser.recognize_message_type(self.EBM_LIFETIME) == "ebm"
def test_lifetime_odometer(self):
"""Odometer is a 24-bit field at offset 5 with /10 km scaling."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# 0x000084 / 10 = 13.2 km (app: 8.08 mi = 13.005 km)
assert abs(result["odometry"] - 13.2) < 0.05
def test_lifetime_autonomy(self):
"""Range is a 16-bit field at offset 8 with /10 km scaling."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# 0x02E1 / 10 = 73.7 km (app: 45 mi = 72.42 km)
assert abs(result["autonomy"] - 73.7) < 0.05
def test_lights_off_at_offset_10(self):
"""Lights flag moved from offset 13 to offset 10 in the X20 layout."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# message[10] = 0x00 → off
assert result["is_light_on"] is False
def test_lights_on(self):
msg = bytearray(self.EBM_LIFETIME)
msg[10] = 0x01
parser = BikeDataParser()
result = parser.parse_ebm_message(bytes(msg))
assert result["is_light_on"] is True
def test_status_byte(self):
"""Status moved from offset 14 to offset 11."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# message[11] = 0x00
assert result["status"] == 0
def test_accelerometer_axes(self):
"""Bytes 12-13 carry accelerometer Z/Y as signed bytes."""
parser = BikeDataParser()
result = parser.parse_ebm_message(self.EBM_LIFETIME)
# message[12] = 0x01, message[13] = 0xF5 (signed = -11)
assert result["accel_z"] == 1
assert result["accel_y"] == -11
def test_slot_2_updates_trip_only(self):
"""Slot 2 frames carry trip A values; lifetime fields stay at previous."""
parser = BikeDataParser()
# First ingest a slot 1 frame so we have a previous lifetime
parser.parse_ebm_message(self.EBM_LIFETIME)
prev_odo = parser.state["ebm"]["odometry"]
# Now a slot 2 frame with different bytes (mock a real trip distance)
msg = bytearray(self.EBM_TRIP)
# Set trip odometer to 0x000050 = 80 → 8.0 km
msg[5], msg[6], msg[7] = 0x00, 0x00, 0x50
result = parser.parse_ebm_message(bytes(msg))
assert result["trip_odometry"] == 8.0
assert result["odometry"] == prev_odo # lifetime preserved
def test_slot_1_updates_lifetime_preserves_trip(self):
"""A slot 1 frame must not clobber the previously seen trip values."""
parser = BikeDataParser()
# First a slot 2 frame to populate trip_*
msg2 = bytearray(self.EBM_TRIP)
msg2[5], msg2[6], msg2[7] = 0x00, 0x00, 0x50 # trip = 8.0 km
parser.parse_ebm_message(bytes(msg2))
assert parser.state["ebm"]["trip_odometry"] == 8.0
# Now a slot 1 frame
result = parser.parse_ebm_message(self.EBM_LIFETIME)
assert result["trip_odometry"] == 8.0 # preserved
assert abs(result["odometry"] - 13.2) < 0.05
class TestBatteryParser: class TestBatteryParser:
"""Test battery message parsing with real data.""" """Test battery message parsing with real data."""
@@ -195,6 +392,106 @@ class TestBatteryParser:
assert parser.state["battery_primary"]["cycles"] == 36 assert parser.state["battery_primary"]["cycles"] == 36
class TestBatteryParserX20:
"""20-byte battery frame parsing (X20 / HUS-prefixed devices)."""
# Real frame from a HUS device: voltage 37.78 V, SOC 57 %, temp 22 °C,
# current 0 A, nominal 352.8 Wh, remaining 200.3 Wh, MOSFET temp 24 °C,
# combined cycles 0x2715 = 10005 → battery 1, 5 cycles.
BATTERY_MESSAGE = bytes.fromhex("2462245a230ec2391600000dc807d31827152340")
def test_message_length(self):
assert len(self.BATTERY_MESSAGE) == 20
def test_recognition(self):
parser = BikeDataParser()
assert parser.recognize_message_type(self.BATTERY_MESSAGE) == "battery"
def test_voltage_uses_centi_volt_scaling(self):
"""Voltage on the 20-byte frame is encoded as raw / 100 (vs raw / 10 on 19-byte)."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result is not None
assert abs(result["voltage"] - 37.78) < 0.01
def test_soc(self):
"""SOC is the unsigned byte at offset 7 with bit 7 masked off."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result["soc"] == 57
assert result["is_charging"] is False
def test_temperature(self):
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result["temperature"] == 22
def test_current_is_zero_at_rest(self):
"""Current is signed read16 / 10."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert result["current"] == 0.0
def test_capacity_and_remaining(self):
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
assert abs(result["nominal_capacity"] - 352.8) < 0.1
assert abs(result["remaining_wh"] - 200.3) < 0.1
def test_temperature_mos(self):
"""A BMS MOSFET temperature byte sits at offset 15."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
# 0x18 = 24°C
assert result["temperature_mos"] == 24
def test_cycles_at_offset_16(self):
"""(battery_number * 10000 + cycles) is read at offset 16-17, not 15-16."""
parser = BikeDataParser()
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
# 0x2715 = 10005 → battery 1, 5 cycles
assert result["cycles"] == 5
def test_primary_state_is_set(self):
"""Regression: reading the combined field at offset 15 would compute
battery_number == 0 here and silently drop the update."""
parser = BikeDataParser()
parser.parse_battery_message(self.BATTERY_MESSAGE)
assert parser.state["battery_primary"] is not None
assert parser.state["battery_primary"]["soc"] == 57
def test_signed_current_when_charging(self):
"""A negative raw current value should decode as negative amps."""
# Replace bytes 9-10 with 0xFFEC (= -20 raw → -2.0 A)
msg = bytearray(self.BATTERY_MESSAGE)
msg[9] = 0xFF
msg[10] = 0xEC
parser = BikeDataParser()
result = parser.parse_battery_message(bytes(msg))
assert abs(result["current"] - (-2.0)) < 0.001
def test_charging_bit_in_soc_byte(self):
"""When the SOC byte's bit 7 is set, is_charging is True and SOC is masked."""
msg = bytearray(self.BATTERY_MESSAGE)
msg[7] = 0x80 | 57 # charging flag + 57% SOC
parser = BikeDataParser()
result = parser.parse_battery_message(bytes(msg))
assert result["is_charging"] is True
assert result["soc"] == 57
class TestVinParser: class TestVinParser:
"""Test VIN/serial number message parsing.""" """Test VIN/serial number message parsing."""