"""TLV configuration parser for OpenDisplay devices.""" from __future__ import annotations import logging import struct from ..exceptions import ConfigParseError from ..models.config import ( BinaryInputs, DataBus, DisplayConfig, GlobalConfig, LedConfig, ManufacturerData, PassiveBuzzer, PowerOption, SecurityConfig, SensorData, SystemConfig, TouchController, WifiConfig, ) _LOGGER = logging.getLogger(__name__) # TLV packet type IDs PACKET_TYPE_SYSTEM = 0x01 PACKET_TYPE_MANUFACTURER = 0x02 PACKET_TYPE_POWER = 0x04 PACKET_TYPE_DISPLAY = 0x20 PACKET_TYPE_LED = 0x21 PACKET_TYPE_SENSOR = 0x23 PACKET_TYPE_DATABUS = 0x24 PACKET_TYPE_BINARY_INPUT = 0x25 PACKET_TYPE_WIFI_CONFIG = 0x26 PACKET_TYPE_SECURITY_CONFIG = 0x27 PACKET_TYPE_TOUCH_CONTROLLER = 0x28 PACKET_TYPE_PASSIVE_BUZZER = 0x29 WIFI_CONFIG_SIZE = 160 WIFI_CONFIG_LEGACY_SIZE = 65 SECURITY_CONFIG_SIZE = 64 def parse_config_response(raw_data: bytes) -> GlobalConfig: """Parse complete TLV config response from device. Firmware sends config data with a wrapper: [length:2][version:1][packets...][crc:2] This function strips the wrapper and passes clean packet data to the TLV parser. Args: raw_data: Complete TLV data assembled from all BLE chunks Returns: Parsed GlobalConfig Raises: ConfigParseError: If data is too short or invalid """ if len(raw_data) < 5: # Min: 2 (length) + 1 (version) + 0 (packets) + 2 (crc) raise ConfigParseError(f"Config data too short: {len(raw_data)} bytes (need at least 5)") # Parse TLV wrapper header config_length = int.from_bytes(raw_data[0:2], "little") config_version = raw_data[2] _LOGGER.debug( "TLV wrapper: length=%d bytes, version=%d", config_length, config_version, ) # Extract packet data (skip 3-byte header, ignore 2-byte CRC at end) if len(raw_data) > 5: packet_data = raw_data[3:-2] # Skip header, ignore CRC else: packet_data = raw_data[3:] # Skip header only _LOGGER.debug("Packet data after wrapper strip: %d bytes", len(packet_data)) # Parse TLV packets return parse_tlv_config(packet_data, version=config_version) def parse_tlv_config(data: bytes, version: int = 1) -> GlobalConfig: """Parse complete TLV configuration from device response. BLE format: [TLV packets...] (raw TLV data, no header) Each TLV packet: [packet_number:1][packet_type:1][data:fixed_size] Args: data: Raw TLV data from device (after echo bytes stripped) version: Config version from wrapper (default 1 if called directly) Returns: GlobalConfig with all parsed configuration Raises: ConfigParseError: If parsing fails """ if len(data) < 2: raise ConfigParseError(f"TLV data too short: {len(data)} bytes (need at least 2)") _LOGGER.debug("Parsing TLV config, %d bytes", len(data)) # Parse TLV packets (OEPL format: [packet_number:1][packet_id:1][fixed_data]) offset = 0 packets = {} while offset < len(data) - 1: if offset + 2 > len(data): break # Not enough data for packet header packet_number = data[offset] packet_type = data[offset + 1] offset += 2 # Determine packet size based on type packet_size = _get_packet_size(packet_type) if packet_size is None: _LOGGER.warning("Unknown packet type 0x%02x at offset %d, skipping", packet_type, offset - 2) break # Extract packet data if offset + packet_size > len(data): remaining = len(data) - offset # Legacy firmware can return a short 0x26 payload (SSID/password/encryption only). if packet_type == PACKET_TYPE_WIFI_CONFIG and remaining == WIFI_CONFIG_LEGACY_SIZE: _LOGGER.debug( "Detected legacy wifi_config packet size (%d bytes)", WIFI_CONFIG_LEGACY_SIZE, ) packet_size = WIFI_CONFIG_LEGACY_SIZE else: raise ConfigParseError( f"Packet type 0x{packet_type:02x} truncated: need {packet_size} bytes, have {remaining}" ) packet_data = data[offset : offset + packet_size] offset += packet_size # Store packet key = (packet_type, packet_number) packets[key] = packet_data _LOGGER.debug("Parsed packet: type=0x%02x, num=%d, size=%d", packet_type, packet_number, packet_size) # Parse packets in a single pass # Note: Firmware uses global sequential numbering across all packet types system = None manufacturer = None power = None displays = [] leds = [] sensors = [] data_buses = [] binary_inputs = [] wifi_config = None security_config = None touch_controllers = [] buzzers = [] for (packet_type, packet_number), packet_data in packets.items(): if packet_type == PACKET_TYPE_SYSTEM: system = _parse_system_config(packet_data) elif packet_type == PACKET_TYPE_MANUFACTURER: manufacturer = _parse_manufacturer_data(packet_data) elif packet_type == PACKET_TYPE_POWER: power = _parse_power_option(packet_data) elif packet_type == PACKET_TYPE_DISPLAY: displays.append(_parse_display_config(packet_data)) elif packet_type == PACKET_TYPE_LED: leds.append(_parse_led_config(packet_data)) elif packet_type == PACKET_TYPE_SENSOR: sensors.append(_parse_sensor_data(packet_data)) elif packet_type == PACKET_TYPE_DATABUS: data_buses.append(_parse_data_bus(packet_data)) elif packet_type == PACKET_TYPE_BINARY_INPUT: binary_inputs.append(_parse_binary_inputs(packet_data)) elif packet_type == PACKET_TYPE_WIFI_CONFIG: wifi_config = _parse_wifi_config(packet_data) elif packet_type == PACKET_TYPE_SECURITY_CONFIG: security_config = SecurityConfig.from_bytes(packet_data) elif packet_type == PACKET_TYPE_TOUCH_CONTROLLER: touch_controllers.append(TouchController.from_bytes(packet_data)) elif packet_type == PACKET_TYPE_PASSIVE_BUZZER: buzzers.append(PassiveBuzzer.from_bytes(packet_data)) missing_required = [ name for name, present in [ ("system", system), ("manufacturer", manufacturer), ("power", power), ("display", displays), ] if not present ] if missing_required: raise ConfigParseError("Missing required packet(s): " + ", ".join(missing_required)) assert system is not None assert manufacturer is not None assert power is not None return GlobalConfig( system=system, manufacturer=manufacturer, power=power, displays=displays, leds=leds, sensors=sensors, data_buses=data_buses, binary_inputs=binary_inputs, wifi_config=wifi_config, security_config=security_config, touch_controllers=touch_controllers, buzzers=buzzers, version=version, # From firmware wrapper minor_version=1, # Not stored in device (only single version byte exists) loaded=True, ) def _get_packet_size(packet_type: int) -> int | None: """Get expected size for a packet type. Args: packet_type: TLV packet type ID Returns: Expected packet size in bytes, or None if unknown type """ sizes = { PACKET_TYPE_SYSTEM: 22, PACKET_TYPE_MANUFACTURER: 22, PACKET_TYPE_POWER: 30, # Fixed: was 32 PACKET_TYPE_DISPLAY: 46, # Fixed: was 66 PACKET_TYPE_LED: 22, PACKET_TYPE_SENSOR: 30, PACKET_TYPE_DATABUS: 30, # Fixed: was 28 PACKET_TYPE_BINARY_INPUT: 30, # Fixed: was 29 PACKET_TYPE_WIFI_CONFIG: 160, PACKET_TYPE_SECURITY_CONFIG: SECURITY_CONFIG_SIZE, PACKET_TYPE_TOUCH_CONTROLLER: 32, PACKET_TYPE_PASSIVE_BUZZER: 32, } return sizes.get(packet_type) def _parse_system_config(data: bytes) -> SystemConfig: """Parse SystemConfig packet (0x01, 22 bytes).""" if len(data) < 22: raise ConfigParseError(f"SystemConfig too short: {len(data)} bytes (need 22)") ic_type, comm_modes, dev_flags, pwr_pin = struct.unpack_from(" ManufacturerData: """Parse ManufacturerData packet (0x02, 22 bytes).""" if len(data) < 22: raise ConfigParseError(f"ManufacturerData too short: {len(data)} bytes (need 22)") mfg_id, board_type, board_rev = struct.unpack_from(" PowerOption: """Parse PowerOption packet (0x04, 30 bytes).""" if len(data) < 30: raise ConfigParseError(f"PowerOption too short: {len(data)} bytes (need 30)") power_mode = data[0] # Battery capacity is 3 bytes (little-endian) battery_capacity_bytes = data[1:4] ( sleep_timeout, tx_power, sleep_flags, battery_sense_pin, battery_sense_enable_pin, battery_sense_flags, capacity_estimator, voltage_scaling_factor, deep_sleep_current_ua, deep_sleep_time_seconds, ) = struct.unpack_from(" DisplayConfig: """Parse DisplayConfig packet (0x20, 66 bytes).""" if len(data) < 46: raise ConfigParseError(f"DisplayConfig too short: {len(data)} bytes (need 46)") ( instance_num, display_tech, panel_ic, pixel_width, pixel_height, active_width_mm, active_height_mm, tag_type, rotation, reset_pin, busy_pin, dc_pin, cs_pin, data_pin, partial_update, color_scheme, trans_modes, clk_pin, ) = struct.unpack_from("= 33 else 0 reserved = data[33:] if len(data) >= 33 else data[31:] return DisplayConfig( instance_number=instance_num, display_technology=display_tech, panel_ic_type=panel_ic, pixel_width=pixel_width, pixel_height=pixel_height, active_width_mm=active_width_mm, active_height_mm=active_height_mm, tag_type=tag_type, rotation=rotation, reset_pin=reset_pin, busy_pin=busy_pin, dc_pin=dc_pin, cs_pin=cs_pin, data_pin=data_pin, partial_update_support=partial_update, color_scheme=color_scheme, transmission_modes=trans_modes, clk_pin=clk_pin, reserved_pins=reserved_pins, full_update_mC=full_update_mC, reserved=reserved, ) def _parse_led_config(data: bytes) -> LedConfig: """Parse LedConfig packet (0x21, 22 bytes).""" if len(data) < 22: raise ConfigParseError(f"LedConfig too short: {len(data)} bytes (need 22)") instance_num, led_type, led_1, led_2, led_3, led_4, led_flags = struct.unpack_from(" SensorData: """Parse SensorData packet (0x23, 30 bytes).""" if len(data) < 30: raise ConfigParseError(f"SensorData too short: {len(data)} bytes (need 30)") instance_num, sensor_type, bus_id = struct.unpack_from(" DataBus: """Parse DataBus packet (0x24, 30 bytes).""" if len(data) < 30: raise ConfigParseError(f"DataBus too short: {len(data)} bytes (need 30)") ( instance_num, bus_type, pin_1, pin_2, pin_3, pin_4, pin_5, pin_6, pin_7, bus_speed_hz, bus_flags, pullups, pulldowns, ) = struct.unpack_from(" BinaryInputs: """Parse BinaryInputs packet (0x25, 30 bytes).""" if len(data) < 30: raise ConfigParseError(f"BinaryInputs too short: {len(data)} bytes (need 30)") instance_num, input_type, display_as = struct.unpack_from(" WifiConfig: """Parse WifiConfig packet (0x26, legacy 65 bytes or current 160 bytes).""" if len(data) >= WIFI_CONFIG_SIZE: return WifiConfig.from_bytes(data[:WIFI_CONFIG_SIZE]) if len(data) == WIFI_CONFIG_LEGACY_SIZE: ssid = data[0:32] password = data[32:64] encryption_type = data[64] return WifiConfig( ssid=ssid, password=password, encryption_type=encryption_type, server_url=b"\x00" * 64, server_port=2446, reserved=b"\x00" * 29, ) raise ConfigParseError( f"WifiConfig too short: {len(data)} bytes (need {WIFI_CONFIG_LEGACY_SIZE} or {WIFI_CONFIG_SIZE})" )