"""Message parsers for MySmartBike BLE integration.""" import logging from typing import Dict, Optional, Any from .const import ( BATTERY_MESSAGE_LENGTH, MOTOR_MESSAGE_LENGTH, EBM_MESSAGE_LENGTH, ) _LOGGER = logging.getLogger(__name__) def read16(data: bytes, offset: int) -> int: """Read 16-bit value from data at offset (big-endian, as per Mahle protocol).""" return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF) def read24(data: bytes, offset: int) -> int: """Read 24-bit value from data at offset (big-endian, as per Mahle protocol).""" return ( ((data[offset] & 0xFF) << 16) | ((data[offset + 1] & 0xFF) << 8) | (data[offset + 2] & 0xFF) ) def read32(data: bytes, offset: int) -> int: """Read 32-bit value from data at offset (big-endian, as per Mahle protocol).""" return ( ((data[offset] & 0xFF) << 24) | ((data[offset + 1] & 0xFF) << 16) | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF) ) def read_unsigned_byte(byte_val: int) -> int: """Read unsigned byte value.""" return byte_val & 0xFF class BikeDataParser: """Parser for bike BLE messages.""" def __init__(self): """Initialize parser.""" self.state: Dict[str, Optional[Dict[str, Any]]] = { "battery_primary": None, "battery_secondary": None, "motor": None, "assist": None, "ebm": None, } self.battery_packet_counter = 0 self.vin: Optional[str] = None self.protocol_version: Optional[str] = None def parse_battery_message(self, message: bytes) -> Optional[Dict[str, Any]]: """Parse battery message and update state.""" if len(message) < BATTERY_MESSAGE_LENGTH: return None # Read values voltage = read16(message, 5) / 10.0 soc = read_unsigned_byte(message[7]) temp_status = message[8] current = read16(message, 9) / 10.0 nominal_capacity = read16(message, 11) / 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 battery_number = (combined_raw // 10000) if combined_raw else 1 cycles = (combined_raw % 10000) if combined_raw else None # Construct battery data dictionary data = { "voltage": voltage, "soc": soc, "temperature": temp_status, "current": current, "nominal_capacity": nominal_capacity, "remaining_wh": remaining_wh, "cycles": cycles, } # Handle secondary vs primary battery if battery_number == 2: # Secondary battery detected self.battery_packet_counter = 0 self.state["battery_secondary"] = data elif battery_number == 1: # Primary battery self.battery_packet_counter += 1 self.state["battery_primary"] = data # After 4 consecutive primary battery packets, reset secondary battery if self.battery_packet_counter >= 4: self.state["battery_secondary"] = { "voltage": 0.0, "soc": 0.0, "temperature": 0, "current": 0.0, "nominal_capacity": 0.0, "remaining_wh": 0.0, "cycles": None, } return data def parse_motor_message(self, message: bytes) -> Optional[Dict[str, Any]]: """Parse motor message and update state.""" if len(message) < MOTOR_MESSAGE_LENGTH: return None # Extract values from message assist_level = message[5] temperature_celsius = message[6] power_amp = float(read16(message, 7)) / 10.0 speed_kmh = float(read16(message, 9)) / 10.0 # Additional values wheel_speed = read_unsigned_byte(message[11]) torque_pct = message[12] power_max = float(read16(message, 13)) / 10.0 max_torque_pct = message[15] # Update state with motor data data = { "assist_level": assist_level, "temperature_celsius": temperature_celsius, "power_amp": power_amp, "speed_kmh": speed_kmh, "wheel_speed_rpm": wheel_speed, "torque_motor_pct": torque_pct, "power_max_amp": power_max, "max_torque_motor_pct": max_torque_pct, } self.state["motor"] = data return data def parse_assist_level_message(self, message: bytes) -> Optional[Dict[str, Any]]: """Parse assist level message and update state.""" if len(message) == 10: data = { "min": int(chr(message[5])), "max": int(chr(message[6])), "current": int(chr(message[7])), } self.state["assist"] = data return data elif len(message) == 9: result = message.decode("utf-8", errors="ignore")[5:7] data = { "sync_result": result, "success": result == "OK", } self.state["assist"] = data return data return None def parse_vin_message(self, message: bytes) -> Optional[str]: """Parse VIN/serial number message. Formats: - $s$V##@ - standard format with 17 char serial - R0@ - alternative format (20 chars total) """ text = message.decode("utf-8", errors="ignore") # Standard format: $s$V##@ if text.startswith("$s$V#") and text.endswith("#@"): vin = text[5:-2] # Extract between $s$V# and #@ if len(vin) == 17: self.vin = vin _LOGGER.info("Parsed VIN/serial number: %s", vin) return vin # Alternative format: R0@ (20 chars total) if len(text) == 20 and text.endswith("@") and text.startswith("R0"): vin = text[2:-1] # Extract between R0 and @ if len(vin) == 17: self.vin = vin _LOGGER.info("Parsed VIN/serial number (R0 format): %s", vin) return vin _LOGGER.debug("Could not parse VIN from message: %s", text) return None def parse_protocol_message(self, message: bytes) -> Optional[str]: """Parse protocol version message. Format: $s$P##@ - e.g., $s$P#1.02#@ Error: $s$P#ER#@ indicates error """ text = message.decode("utf-8", errors="ignore") # Standard format: $s$P##@ if text.startswith("$s$P#") and text.endswith("#@"): version = text[5:-2] # Extract between $s$P# and #@ if version and version != "ER": self.protocol_version = version _LOGGER.info("Parsed protocol version: %s", version) return version elif version == "ER": _LOGGER.warning("Protocol version request returned error") return None _LOGGER.debug("Could not parse protocol from message: %s", text) return None def parse_ebm_message(self, message: bytes) -> Optional[Dict[str, Any]]: """Parse EBM (E-Bike Management) message.""" if len(message) < EBM_MESSAGE_LENGTH: return None # EbmParserEbm format: 32-bit reads directly from message (big-endian) # 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 odometry_km = read32(message, 5) / 10000.0 autonomy_km = read32(message, 9) / 10000.0 is_light_on = message[13] == 1 status = read_unsigned_byte(message[14]) # EbmParserEbm only parses bytes 5-14, bytes 15-16 are suffix #@ data = { "odometry": odometry_km, "autonomy": autonomy_km, "is_light_on": is_light_on, "status": status, } self.state["ebm"] = data return data def recognize_message_type(self, message: bytes) -> str: """Recognize message type from message content.""" text = message.decode("ascii", errors="ignore") # Handle standard format messages ($..#@) if text.startswith("$") and text.endswith("#@"): main_type = text[1] sub_type = text[3] if len(text) > 3 else None if main_type == "b": return "battery" elif main_type == "d": if sub_type == "I": return "diagnosis_init" elif sub_type == "R": return "diagnosis_read" elif sub_type == "E": return "diagnosis_end" elif sub_type == "Z": return "security_session" elif sub_type == "C": return "coding_device" elif sub_type == "V": return "write_vin" elif sub_type == "T": return "status" elif main_type == "j" and sub_type == "Z": return "ebm" elif main_type == "m": if sub_type == "A": return "assist" elif sub_type == "Z": return "motor" elif sub_type == "M": return "engine_maps" elif sub_type == "R": return "reset_trip" elif main_type == "s": if sub_type == "V": return "vin" elif sub_type == "P": return "protocol" elif main_type == "M" and sub_type == "M": return "engine_maps" elif main_type == "i" and sub_type == "C": return "calibrate" # Handle special format messages (ending with @) elif text.endswith("@"): main_type = text[0] if main_type == "T": return "status" elif main_type == "C": return "coding_device" elif main_type == "R": return "vin" elif main_type == "Z": return "security_challenge" return "unknown" def handle_message(self, data: bytes) -> None: """Handle received message data and update state.""" msg_type = self.recognize_message_type(data) # Handle different message types if msg_type == "battery": self.parse_battery_message(data) elif msg_type == "motor": self.parse_motor_message(data) elif msg_type == "assist": self.parse_assist_level_message(data) elif msg_type == "ebm": self.parse_ebm_message(data) elif msg_type == "vin": self.parse_vin_message(data) elif msg_type == "protocol": self.parse_protocol_message(data) elif msg_type in [ "diagnosis_init", "diagnosis_read", "diagnosis_end", "security_session", "coding_device", "write_vin", "status", "engine_maps", "reset_trip", "calibrate", "security_challenge", ]: _LOGGER.debug("Received message of type: %s", msg_type) else: # Enhanced logging for unknown messages prefix = " ".join([f"{b:02x}" for b in data[:5]]) _LOGGER.debug( "Unknown message: type=%s, prefix=[%s], length=%d", msg_type, prefix, len(data), )