From b014b8b1502d358cdf11ae9058d8530e937fdd6b Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sat, 16 May 2026 14:59:56 +0100 Subject: [PATCH 1/6] Ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2d635fd..b549013 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ custom_components/opendisplay/*.jpg custom_components/opendisplay/lastapinteraction.txt tests/drawcustom/test_images/rename_me.png +.worktrees/ From 3ab6ec4f25dd8dee328bc23a9f84d3142779f125 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Mon, 25 May 2026 10:54:04 +0100 Subject: [PATCH 2/6] feat: Add py-opendisplay dependency & create new BLEDeviceMetadata implementation --- custom_components/opendisplay/__init__.py | 4 +- .../opendisplay/ble/image_upload.py | 2 +- custom_components/opendisplay/entity.py | 2 +- .../opendisplay/imagegen/core.py | 2 +- custom_components/opendisplay/manifest.json | 1 + custom_components/opendisplay/metadata.py | 445 ++++++++++++++++++ custom_components/opendisplay/sensor.py | 2 +- custom_components/opendisplay/services.py | 3 +- custom_components/opendisplay/update.py | 2 +- custom_components/opendisplay/upload.py | 3 +- requirements.txt | 1 + tests/test_ble_metadata.py | 146 ++++++ 12 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 custom_components/opendisplay/metadata.py create mode 100644 tests/test_ble_metadata.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index db94cbd..32ca3e5 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry as er, device_registry as dr, storage from homeassistant.const import __version__ as HA_VERSION from homeassistant.helpers.typing import ConfigType -from .ble import BLEDeviceMetadata +from .metadata import BLEDeviceMetadata from .const import DOMAIN from .coordinator import Hub from .runtime_data import OpenDisplayConfigEntry, OpenDisplayBLERuntimeData @@ -114,7 +114,7 @@ async def async_remove_invalid_ble_entities( mac_address = entry.data.get("mac_address", "") # Check power mode - remove battery sensors if not battery/solar powered - from .ble import BLEDeviceMetadata + from .metadata import BLEDeviceMetadata metadata = BLEDeviceMetadata(device_metadata) if metadata.power_mode not in (1, 3): # Not battery (1) or solar (3) for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id): diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py index b239ae4..1007bb3 100644 --- a/custom_components/opendisplay/ble/image_upload.py +++ b/custom_components/opendisplay/ble/image_upload.py @@ -11,7 +11,7 @@ from .exceptions import BLEError from .image_processing import process_image_for_device -from .metadata import BLEDeviceMetadata +from ..metadata import BLEDeviceMetadata _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/opendisplay/entity.py b/custom_components/opendisplay/entity.py index 828b1ea..0ef69ba 100644 --- a/custom_components/opendisplay/entity.py +++ b/custom_components/opendisplay/entity.py @@ -6,7 +6,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity -from .ble import BLEDeviceMetadata +from .metadata import BLEDeviceMetadata from .const import DOMAIN, OPENDISPLAY_CONFIG_URL, ATC_CONFIG_URL from .tag_types import get_hw_string, get_hw_dimensions diff --git a/custom_components/opendisplay/imagegen/core.py b/custom_components/opendisplay/imagegen/core.py index 42b90af..e1b597d 100644 --- a/custom_components/opendisplay/imagegen/core.py +++ b/custom_components/opendisplay/imagegen/core.py @@ -232,7 +232,7 @@ async def get_ble_tag_info(self, hass: HomeAssistant, entity_id: str) -> tuple[i translation_placeholders={"entity_id": entity_id} ) # Wrap metadata for clean access - from ..ble import BLEDeviceMetadata + from ..metadata import BLEDeviceMetadata metadata = BLEDeviceMetadata(device_metadata) # Extract device capabilities diff --git a/custom_components/opendisplay/manifest.json b/custom_components/opendisplay/manifest.json index 7558304..f599755 100644 --- a/custom_components/opendisplay/manifest.json +++ b/custom_components/opendisplay/manifest.json @@ -34,6 +34,7 @@ } }, "requirements": [ + "py-opendisplay==7.3.0", "qrcode[pil]==7.4.2", "requests_toolbelt==1.0.0", "websocket-client==1.7.0", diff --git a/custom_components/opendisplay/metadata.py b/custom_components/opendisplay/metadata.py new file mode 100644 index 0000000..637362f --- /dev/null +++ b/custom_components/opendisplay/metadata.py @@ -0,0 +1,445 @@ +"""New BLE Device Metadata Abstraction using py-opendisplay. + +Provides a clean interface for accessing device metadata that transparently +handles differences between ATC (flat structure) and OpenDisplay (nested config) formats, +utilizing py-opendisplay library configuration models. +""" +from __future__ import annotations + +from typing import Any + +from opendisplay import ColorScheme as ODColorScheme +from opendisplay.models.config import ( + GlobalConfig, + SystemConfig, + ManufacturerData, + PowerOption, + DisplayConfig, + LedConfig, + SensorData, + DataBus, + BinaryInputs, + WifiConfig, + SecurityConfig, + TouchController, + PassiveBuzzer, +) + +def _dict_to_global_config(data: dict[str, Any]) -> GlobalConfig: + """Reconstruct a GlobalConfig dataclass from its nested dict representation.""" + def _to_bytes(val: Any) -> bytes: + if isinstance(val, str): + return bytes.fromhex(val) + return val + + # Extract single instances + sys_data = data.get("system") or {} + system = SystemConfig( + ic_type=sys_data.get("ic_type", 0), + communication_modes=sys_data.get("communication_modes", 0), + device_flags=sys_data.get("device_flags", 0), + pwr_pin=sys_data.get("pwr_pin", 0xFF), + reserved=_to_bytes(sys_data.get("reserved", b"\x00" * 15)), + pwr_pin_2=sys_data.get("pwr_pin_2", 0xFF), + pwr_pin_3=sys_data.get("pwr_pin_3", 0xFF), + ) + + mfr_data = data.get("manufacturer") or {} + manufacturer = ManufacturerData( + manufacturer_id=mfr_data.get("manufacturer_id", 0x2446), + board_type=mfr_data.get("board_type", 0), + board_revision=mfr_data.get("board_revision", 0), + reserved=_to_bytes(mfr_data.get("reserved", b"\x00" * 18)), + ) + + pwr_data = data.get("power") or {} + power = PowerOption( + power_mode=pwr_data.get("power_mode", 1), + battery_capacity_mah=_to_bytes(pwr_data.get("battery_capacity_mah", b"\x00" * 3)), + sleep_timeout_ms=pwr_data.get("sleep_timeout_ms", 0), + tx_power=pwr_data.get("tx_power", 0), + sleep_flags=pwr_data.get("sleep_flags", 0), + battery_sense_pin=pwr_data.get("battery_sense_pin", 0xFF), + battery_sense_enable_pin=pwr_data.get("battery_sense_enable_pin", 0xFF), + battery_sense_flags=pwr_data.get("battery_sense_flags", 0), + capacity_estimator=pwr_data.get("capacity_estimator", 0), + voltage_scaling_factor=pwr_data.get("voltage_scaling_factor", 0), + deep_sleep_current_ua=pwr_data.get("deep_sleep_current_ua", 0), + deep_sleep_time_seconds=pwr_data.get("deep_sleep_time_seconds", 0), + reserved=_to_bytes(pwr_data.get("reserved", b"\x00" * 10)), + ) + + # Extract displays + displays = [] + for d in data.get("displays", []): + displays.append( + DisplayConfig( + instance_number=d.get("instance_number", 0), + display_technology=d.get("display_technology", 0), + panel_ic_type=d.get("panel_ic_type", 0), + pixel_width=d.get("pixel_width", 0), + pixel_height=d.get("pixel_height", 0), + active_width_mm=d.get("active_width_mm", 0), + active_height_mm=d.get("active_height_mm", 0), + tag_type=d.get("open_display_tagtype") or d.get("tag_type") or 0, + rotation=d.get("rotation", 0), + reset_pin=d.get("reset_pin", 0xFF), + busy_pin=d.get("busy_pin", 0xFF), + dc_pin=d.get("dc_pin", 0xFF), + cs_pin=d.get("cs_pin", 0xFF), + data_pin=d.get("data_pin", 0xFF), + partial_update_support=d.get("partial_update_support", 0), + color_scheme=d.get("color_scheme", 0), + transmission_modes=d.get("transmission_modes", 0), + clk_pin=d.get("clk_pin", 0xFF), + reserved_pins=_to_bytes(d.get("reserved_pins", b"\x00" * 7)), + full_update_mC=d.get("full_update_mC", 0), + reserved=_to_bytes(d.get("reserved", b"\x00" * 13)), + ) + ) + + # Extract leds + leds = [] + for l in data.get("leds", []): + leds.append( + LedConfig( + instance_number=l.get("instance_number", 0), + led_type=l.get("led_type", 0), + led_1_r=l.get("led_1_r", 0), + led_2_g=l.get("led_2_g", 0), + led_3_b=l.get("led_3_b", 0), + led_4=l.get("led_4", 0), + led_flags=l.get("led_flags", 0), + reserved=_to_bytes(l.get("reserved", b"\x00" * 15)), + ) + ) + + # Extract sensors + sensors = [] + for s in data.get("sensors", []): + sensors.append( + SensorData( + instance_number=s.get("instance_number", 0), + sensor_type=s.get("sensor_type", 0), + bus_id=s.get("bus_id", 0), + i2c_addr_7bit=s.get("i2c_addr_7bit", 0), + msd_data_start_byte=s.get("msd_data_start_byte", 0), + reserved=_to_bytes(s.get("reserved", b"\x00" * 24)), + ) + ) + + # Extract data buses + data_buses = [] + buses_list = data.get("data_buses") or data.get("buses") or [] + for b in buses_list: + data_buses.append( + DataBus( + instance_number=b.get("instance_number", 0), + bus_type=b.get("bus_type", 0), + pin_1=b.get("pin_1", 0xFF), + pin_2=b.get("pin_2", 0xFF), + pin_3=b.get("pin_3", 0xFF), + pin_4=b.get("pin_4", 0xFF), + pin_5=b.get("pin_5", 0xFF), + pin_6=b.get("pin_6", 0xFF), + pin_7=b.get("pin_7", 0xFF), + bus_speed_hz=b.get("bus_speed_hz", 0), + bus_flags=b.get("bus_flags", 0), + pullups=b.get("pullups", 0), + pulldowns=b.get("pulldowns", 0), + reserved=_to_bytes(b.get("reserved", b"\x00" * 14)), + ) + ) + + # Extract binary inputs + binary_inputs = [] + inputs_list = data.get("binary_inputs") or data.get("inputs") or [] + for bi in inputs_list: + binary_inputs.append( + BinaryInputs( + instance_number=bi.get("instance_number", 0), + input_type=bi.get("input_type", 0), + display_as=bi.get("display_as", 0), + reserved_pins=_to_bytes(bi.get("reserved_pins", b"\x00" * 8)), + input_flags=bi.get("input_flags", 0), + invert=bi.get("invert", 0), + pullups=bi.get("pullups", 0), + pulldowns=bi.get("pulldowns", 0), + button_data_byte_index=bi.get("button_data_byte_index", 0), + reserved=_to_bytes(bi.get("reserved", b"\x00" * 14)), + ) + ) + + # Extract optional configs + wifi_config = None + if "wifi_config" in data and data["wifi_config"] is not None: + w = data["wifi_config"] + wifi_config = WifiConfig( + ssid=_to_bytes(w.get("ssid", b"\x00" * 32)), + password=_to_bytes(w.get("password", b"\x00" * 32)), + encryption_type=w.get("encryption_type", 0), + server_url=_to_bytes(w.get("server_url", b"\x00" * 64)), + server_port=w.get("server_port", 2446), + reserved=_to_bytes(w.get("reserved", b"\x00" * 29)), + ) + + security_config = None + if "security_config" in data and data["security_config"] is not None: + sec = data["security_config"] + security_config = SecurityConfig( + encryption_enabled=sec.get("encryption_enabled", 0), + encryption_key=_to_bytes(sec.get("encryption_key", b"\x00" * 16)), + session_timeout_seconds=sec.get("session_timeout_seconds", 0), + flags=sec.get("flags", 0), + reset_pin=sec.get("reset_pin", 0xFF), + reserved=_to_bytes(sec.get("reserved", b"\x00" * 43)), + ) + + touch_controllers = [] + for tc in data.get("touch_controllers", []): + touch_controllers.append( + TouchController( + instance_number=tc.get("instance_number", 0), + touch_ic_type=tc.get("touch_ic_type", 0), + bus_id=tc.get("bus_id", 0xFF), + i2c_addr_7bit=tc.get("i2c_addr_7bit", 0), + int_pin=tc.get("int_pin", 0xFF), + rst_pin=tc.get("rst_pin", 0xFF), + display_instance=tc.get("display_instance", 0), + flags=tc.get("flags", 0), + poll_interval_ms=tc.get("poll_interval_ms", 0), + touch_data_start_byte=tc.get("touch_data_start_byte", 0), + reserved=_to_bytes(tc.get("reserved", b"\x00" * 21)), + ) + ) + + buzzers = [] + for bz in data.get("buzzers", []): + buzzers.append( + PassiveBuzzer( + instance_number=bz.get("instance_number", 0), + drive_pin=bz.get("drive_pin", 0), + enable_pin=bz.get("enable_pin", 0xFF), + flags=bz.get("flags", 0), + duty_percent=bz.get("duty_percent", 0), + reserved=_to_bytes(bz.get("reserved", b"\x00" * 27)), + ) + ) + + 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=data.get("version", 0), + minor_version=data.get("minor_version", 0), + loaded=data.get("loaded", False), + ) + + +class BLEDeviceMetadata: + """Abstraction for BLE device metadata. + + Wraps raw metadata dictionary and provides clean property-based access + to device capabilities, using py-opendisplay configuration models. + + Args: + raw_metadata: Dictionary containing device metadata + """ + + def __init__(self, raw_metadata: dict[str, Any]) -> None: + """Initialize BLE device metadata wrapper. + + Args: + raw_metadata: Device metadata dictionary from config entry + """ + if "open_display_config" not in raw_metadata and "oepl_config" in raw_metadata: + self._metadata = {**raw_metadata, "open_display_config": raw_metadata["oepl_config"]} + else: + self._metadata = raw_metadata + self._is_open_display = "open_display_config" in self._metadata + + self._config: GlobalConfig | None = None + if self._is_open_display: + try: + self._config = _dict_to_global_config(self._metadata["open_display_config"]) + except Exception: + pass + + @property + def width(self) -> int: + """Get display width in pixels. + + Returns: + Display width, or 0 if not available + """ + if self._is_open_display and self._config and self._config.displays: + return self._config.displays[0].pixel_width + return self._metadata.get("width", 0) + + @property + def height(self) -> int: + """Get display height in pixels. + + Returns: + Display height, or 0 if not available + """ + if self._is_open_display and self._config and self._config.displays: + return self._config.displays[0].pixel_height + return self._metadata.get("height", 0) + + @property + def model_name(self) -> str: + """Get device model name. + + Returns: + Model name string, or "Unknown" if not available + """ + return self._metadata.get("model_name", "Unknown") + + @property + def fw_version(self) -> int | str: + """Get firmware version. + + Returns: + Firmware version number or string, or 0/"" if not available + """ + if self._is_open_display: + # Prefer explicit string/parsed version saved from interrogation + if "fw_version" in self._metadata: + return self._metadata.get("fw_version", "") + major = self._metadata.get("fw_version_major") + minor = self._metadata.get("fw_version_minor") + if major is not None and minor is not None: + return f"{major}.{minor}" + return self._metadata.get("fw_version", 0) + + def formatted_fw_version(self) -> str | None: + """Return firmware version formatted for display.""" + fw = self.fw_version + if fw in (None, ""): + return None + if isinstance(fw, int): + return f"0x{fw:04x}" + return str(fw) + + @property + def rotatebuffer(self) -> int: + """Get rotation setting. + + For OpenDisplay devices, returns the rotation value from display config. + For ATC devices, returns the rotatebuffer flag. + + Returns: + Rotation value (0, 1, 2, or 3) or rotatebuffer flag (0 or 1) + """ + if self._is_open_display and self._config and self._config.displays: + return self._config.displays[0].rotation + return self._metadata.get("rotatebuffer", 0) + + @property + def hw_type(self) -> int: + """Get hardware type identifier. + + Returns: + Hardware type code, or 0 if not available + """ + if self._is_open_display and self._config and self._config.displays: + return self._config.displays[0].tag_type + return self._metadata.get("hw_type", 0) + + @property + def power_mode(self) -> int: + """Get power mode setting. + + Returns: + Power mode: 1=battery, 2=USB, 3=solar + ATC devices always return 1 (battery) + """ + if self._is_open_display and self._config and self._config.power: + return self._config.power.power_mode + return 1 # ATC devices always have batteries + + @property + def is_open_display(self) -> bool: + """Check if this is an OpenDisplay device. + + Returns: + True if OpenDisplay device, False if ATC device + """ + return self._is_open_display + + @property + def color_scheme(self) -> ODColorScheme: + """Get ColorScheme enum for this device.""" + if self._is_open_display and self._config and self._config.displays: + raw_scheme = self._config.displays[0].color_scheme + else: + raw_scheme = self._metadata.get("color_scheme", 0) + return ODColorScheme.from_value(raw_scheme) + + @property + def accent_color(self) -> str: + """Get accent color name. + + Returns: + Accent color name from color scheme palette + """ + return self.color_scheme.accent_color + + @property + def is_multi_color(self) -> bool: + """Check if device supports multiple colors. + + Returns: + True if color scheme has more than 2 colors, False otherwise + """ + return len(self.color_scheme.palette.colors) > 2 + + @property + def transmission_modes(self) -> int: + """Get supported transmission modes (bitfield). + + Bit flags: + - Bit 0 (0x01): raw transfer (block-based uncompressed) + - Bit 1 (0x02): zip compressed transfer (block-based compressed) + - Bit 3 (0x08): direct_write mode + + Returns: + Transmission modes bitfield, or 0 if not available + ATC devices return 0 (assume block-based only for backward compatibility) + """ + if self._is_open_display and self._config and self._config.displays: + return self._config.displays[0].transmission_modes + return 0 # ATC devices don't support direct_write + + @property + def supports_zip_compression(self) -> bool: + """Return true if the device advertises zip-compressed transfer support.""" + return (self.transmission_modes & 0x02) != 0 + + def get_best_upload_method(self) -> str: + """Determine the best upload method based on device capabilities. + + Priority order: + 1. direct_write: If direct_write (0x08) is supported + 2. block: Fallback to block-based upload (always supported) + + Returns: + Upload method string: "direct_write" or "block" + """ + modes = self.transmission_modes + has_direct_write = (modes & 0x08) != 0 + + if has_direct_write: + return "direct_write" + else: + return "block" diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index af87d4e..dad0f82 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -628,7 +628,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry, protocol_type = entry_data.protocol_type # Default to ATC for backward compatibility # Create sensors for each description - from .ble import BLEDeviceMetadata + from .metadata import BLEDeviceMetadata metadata = BLEDeviceMetadata(device_metadata) sensors = [] for description in BLE_SENSOR_TYPES: diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index db5b444..1ff195e 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -10,7 +10,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from .coordinator import Hub -from .ble import BLEConnectionError, BLETimeoutError, BLEProtocolError, BLEDeviceMetadata +from .ble import BLEConnectionError, BLETimeoutError, BLEProtocolError +from .metadata import BLEDeviceMetadata from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE from .imagegen import ImageGen from .tag_types import get_tag_types_manager diff --git a/custom_components/opendisplay/update.py b/custom_components/opendisplay/update.py index 104493f..9b74821 100644 --- a/custom_components/opendisplay/update.py +++ b/custom_components/opendisplay/update.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .ble import BLEDeviceMetadata +from .metadata import BLEDeviceMetadata from .const import DOMAIN from .entity import OpenDisplayBLEEntity from .runtime_data import OpenDisplayBLERuntimeData diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 28a39cb..abf4825 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -17,8 +17,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .runtime_data import OpenDisplayBLERuntimeData from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE -from .ble import BLEConnection, BLEImageUploader, BLEDeviceMetadata, get_protocol_by_name, BLEConnectionError, \ +from .ble import BLEConnection, BLEImageUploader, get_protocol_by_name, BLEConnectionError, \ BLETimeoutError, BLEProtocolError +from .metadata import BLEDeviceMetadata _LOGGER: Final = logging.getLogger(__name__) diff --git a/requirements.txt b/requirements.txt index 49f3bf7..4ac4e9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ requests_toolbelt==1.0.0 websockets websocket-client==1.7.0 python-resize-image==1.1.20 +py-opendisplay==7.3.0 bleak>=1.0.1 diff --git a/tests/test_ble_metadata.py b/tests/test_ble_metadata.py new file mode 100644 index 0000000..b45b49e --- /dev/null +++ b/tests/test_ble_metadata.py @@ -0,0 +1,146 @@ +"""Tests for the new BLEDeviceMetadata implementation.""" +import os +import sys + +# Add metadata paths directly to sys.path to bypass importing custom_components/__init__.py +# which would trigger Home Assistant imports that aren't installed in the test venv. +CURR_DIR = os.path.dirname(os.path.abspath(__file__)) +OPENDISPLAY_DIR = os.path.abspath(os.path.join(CURR_DIR, "../custom_components/opendisplay")) + +sys.path.insert(0, OPENDISPLAY_DIR) +import metadata as new_metadata_mod +NewBLEDeviceMetadata = new_metadata_mod.BLEDeviceMetadata + +sys.path.insert(0, os.path.join(OPENDISPLAY_DIR, "ble")) +import metadata as old_metadata_mod +OldBLEDeviceMetadata = old_metadata_mod.BLEDeviceMetadata + + +def test_atc_metadata_compatibility(): + """Verify that both old and new BLEDeviceMetadata produce identical results for ATC (flat) devices.""" + raw_metadata = { + "width": 296, + "height": 128, + "model_name": "ATC_2.9", + "fw_version": 25, + "rotatebuffer": 1, + "hw_type": 15, + "color_scheme": 1, # BWR + } + + old_metadata = OldBLEDeviceMetadata(raw_metadata) + new_metadata = NewBLEDeviceMetadata(raw_metadata) + + # Core properties + assert new_metadata.width == old_metadata.width == 296 + assert new_metadata.height == old_metadata.height == 128 + assert new_metadata.model_name == old_metadata.model_name == "ATC_2.9" + assert new_metadata.fw_version == old_metadata.fw_version == 25 + assert new_metadata.formatted_fw_version() == old_metadata.formatted_fw_version() == "0x0019" + assert new_metadata.rotatebuffer == old_metadata.rotatebuffer == 1 + assert new_metadata.hw_type == old_metadata.hw_type == 15 + assert new_metadata.power_mode == old_metadata.power_mode == 1 + assert new_metadata.is_open_display == old_metadata.is_open_display is False + + # Color scheme properties + assert new_metadata.color_scheme.value == old_metadata.color_scheme.value == 1 + assert new_metadata.accent_color == old_metadata.accent_color == "red" + assert new_metadata.is_multi_color == old_metadata.is_multi_color is True + + # Transmission / Upload properties + assert new_metadata.transmission_modes == old_metadata.transmission_modes == 0 + assert new_metadata.supports_zip_compression == old_metadata.supports_zip_compression is False + assert new_metadata.get_best_upload_method() == old_metadata.get_best_upload_method() == "block" + + +def test_opendisplay_metadata_compatibility(): + """Verify that both old and new BLEDeviceMetadata produce identical results for OpenDisplay devices.""" + raw_metadata = { + "fw_version": "2.0.2", + "model_name": "OD_7.5_BWR", + "open_display_config": { + "version": 1, + "minor_version": 0, + "loaded": True, + "system": { + "ic_type": 1, + "communication_modes": 1, + "device_flags": 0, + "pwr_pin": 255, + "reserved": "000000000000000000000000000000", + "pwr_pin_2": 255, + "pwr_pin_3": 255, + }, + "manufacturer": { + "manufacturer_id": 9286, + "board_type": 1, + "board_revision": 0, + "reserved": "000000000000000000000000000000000000", + }, + "power": { + "power_mode": 2, # USB power + "battery_capacity_mah": "000000", + "sleep_timeout_ms": 0, + "tx_power": 0, + "sleep_flags": 0, + "battery_sense_pin": 255, + "battery_sense_enable_pin": 255, + "battery_sense_flags": 0, + "capacity_estimator": 0, + "voltage_scaling_factor": 0, + "deep_sleep_current_ua": 0, + "deep_sleep_time_seconds": 0, + "reserved": "00000000000000000000", + }, + "displays": [ + { + "instance_number": 0, + "display_technology": 0, + "panel_ic_type": 0, + "pixel_width": 800, + "pixel_height": 480, + "active_width_mm": 0, + "active_height_mm": 0, + "open_display_tagtype": 12, + "tag_type": 12, + "rotation": 90, + "reset_pin": 255, + "busy_pin": 255, + "dc_pin": 255, + "cs_pin": 255, + "data_pin": 255, + "partial_update_support": 1, + "color_scheme": 3, # BWRY + "transmission_modes": 10, # 0x0A (supports ZIP (0x02) and direct_write (0x08)) + "clk_pin": 255, + "reserved_pins": "00000000000000", + "full_update_mC": 0, + "reserved": "0000000000000000000000000000", + } + ], + } + } + + old_metadata = OldBLEDeviceMetadata(raw_metadata) + new_metadata = NewBLEDeviceMetadata(raw_metadata) + + # Core properties + assert new_metadata.width == old_metadata.width == 800 + assert new_metadata.height == old_metadata.height == 480 + assert new_metadata.model_name == old_metadata.model_name == "OD_7.5_BWR" + assert new_metadata.fw_version == old_metadata.fw_version == "2.0.2" + assert new_metadata.formatted_fw_version() == old_metadata.formatted_fw_version() == "2.0.2" + assert new_metadata.rotatebuffer == old_metadata.rotatebuffer == 90 + assert new_metadata.hw_type == old_metadata.hw_type == 12 + assert new_metadata.power_mode == old_metadata.power_mode == 2 + assert new_metadata.is_open_display == old_metadata.is_open_display is True + + # Color scheme properties + assert new_metadata.color_scheme.value == old_metadata.color_scheme.value == 3 + assert new_metadata.accent_color == old_metadata.accent_color == "red" + assert new_metadata.is_multi_color == old_metadata.is_multi_color is True + + # Transmission / Upload properties + assert new_metadata.transmission_modes == old_metadata.transmission_modes == 10 + assert new_metadata.supports_zip_compression == old_metadata.supports_zip_compression is True + assert new_metadata.get_best_upload_method() == old_metadata.get_best_upload_method() == "direct_write" From d5d61e5478cd2d3648d61bda8aa683867cde9ced Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Mon, 25 May 2026 11:35:28 +0100 Subject: [PATCH 3/6] refactor: Deprecate ATC (OpenEPaperLink) protocol and simplify entity structure --- custom_components/opendisplay/__init__.py | 26 +--- custom_components/opendisplay/config_flow.py | 121 +++++-------------- custom_components/opendisplay/manifest.json | 3 - custom_components/opendisplay/sensor.py | 12 +- custom_components/opendisplay/upload.py | 7 +- 5 files changed, 35 insertions(+), 134 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 32ca3e5..9749ba5 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -308,7 +308,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) mac_address = entry.data.get("mac_address") name = entry.data.get("name") device_metadata = entry.data.get("device_metadata", {}) - protocol_type = entry.data.get("protocol_type", "atc") # Default to ATC for backward compatibility + protocol_type = "open_display" # Get protocol handler for this device protocol = get_protocol_by_name(protocol_type) @@ -378,24 +378,7 @@ def _ble_device_found( _LOGGER.debug("Failed to parse advertising data for %s: %s", mac_address, err, exc_info=True) return - # Dynamically update device attributes (skip OpenDisplay fw to avoid incorrect value) - if advertising_data.fw_version and protocol_type != "open_display": - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, f"ble_{mac_address}")} - ) - new_fw_string = str(advertising_data.fw_version) - if device_entry and device_entry.sw_version != new_fw_string: - _LOGGER.debug( - "Device %s firmware updated from %s to %s", - mac_address, - device_entry.sw_version, - new_fw_string, - ) - device_registry.async_update_device( - device_entry.id, - sw_version=new_fw_string - ) + # Build sensor data sensor_data = { @@ -410,11 +393,6 @@ def _ble_device_found( for sensor in ble_data.sensors.values(): sensor.update_from_advertising_data(sensor_data) - # Remove deprecated clock mode button entities - removed_clock_buttons = await async_remove_clock_mode_buttons(hass, entry) - if removed_clock_buttons: - _LOGGER.info("Removed deprecated clock mode buttons: %s", removed_clock_buttons) - # Remove invalid entities based on the current device config removed_invalid = await async_remove_invalid_ble_entities(hass, entry, device_metadata) if removed_invalid: diff --git a/custom_components/opendisplay/config_flow.py b/custom_components/opendisplay/config_flow.py index 9c73192..d96967a 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -43,8 +43,6 @@ def _format_ble_protocol_label(protocol_type: str) -> str: """Return a user-facing label for a BLE protocol.""" if protocol_type == "open_display": return "OpenDisplay (OD)" - if protocol_type == "atc": - return "OEPL / ATC" return protocol_type @@ -75,15 +73,6 @@ def _bluetooth_description_placeholders( """Build placeholders for the Bluetooth confirmation dialog.""" device = self._discovered_device advertised_details = "" - if device["protocol_type"] == "atc": - battery = f"{device['battery_mv']/1000:.2f}V" if device["battery_mv"] > 0 else "Unknown" - fw_version = str(device["fw_version"]) if device["fw_version"] > 0 else "Unknown" - config_version = str(device["version"]) if device["version"] > 0 else "Unknown" - advertised_details = ( - f"\n- Battery: {battery}" - f"\n- Firmware: {fw_version}" - f"\n- Config Version: {config_version}" - ) placeholders = { "name": device["name"], @@ -223,9 +212,9 @@ async def async_step_bluetooth( manufacturer_id = None manufacturer_data = b'' - # Check for known manufacturer IDs (ATC: 4919, OpenDisplay: 9286) + # Check for known manufacturer IDs (OpenDisplay: 9286) for mfg_id, mfg_data in discovery_info.manufacturer_data.items(): - if mfg_id in (4919, 9286): + if mfg_id == 9286: manufacturer_id = mfg_id manufacturer_data = mfg_data break @@ -285,9 +274,7 @@ async def async_step_bluetooth_confirm( try: # Get protocol handler for this device - protocol = get_protocol_by_manufacturer_id( - 9286 if self._discovered_device["protocol_type"] == "open_display" else 4919 - ) + protocol = get_protocol_by_manufacturer_id(9286) # Interrogate device using protocol-specific method fw_info: dict[str, Any] | None = None @@ -322,93 +309,39 @@ async def async_step_bluetooth_confirm( # Generate model name based on protocol type hw_type = self._discovered_device["hw_type"] - if self._discovered_device["protocol_type"] == "open_display": - # OpenDisplay devices: Store complete config, generate model name from DisplayConfig - from .ble.tlv_parser import config_to_dict, generate_model_name - - if hasattr(protocol, '_last_config') and protocol._last_config: - # Store complete OpenDisplay config for future use - device_metadata = { - "open_display_config": config_to_dict(protocol._last_config), - } - if fw_info: - device_metadata["fw_version"] = fw_info.get("version") - device_metadata["fw_version_raw"] = fw_info.get("raw") - if fw_info.get("sha"): - device_metadata["fw_sha"] = fw_info["sha"] - - # Generate model name from display config - if protocol._last_config.displays: - model_name = generate_model_name(protocol._last_config.displays[0]) - device_metadata["model_name"] = model_name - _LOGGER.debug("Generated model name from config: %s", model_name) - else: - _LOGGER.warning("OpenDisplay config has no display config") + # OpenDisplay devices: Store complete config, generate model name from DisplayConfig + from .ble.tlv_parser import config_to_dict, generate_model_name + + if hasattr(protocol, '_last_config') and protocol._last_config: + # Store complete OpenDisplay config for future use + device_metadata = { + "open_display_config": config_to_dict(protocol._last_config), + } + if fw_info: + device_metadata["fw_version"] = fw_info.get("version") + device_metadata["fw_version_raw"] = fw_info.get("raw") + if fw_info.get("sha"): + device_metadata["fw_sha"] = fw_info["sha"] + + # Generate model name from display config + if protocol._last_config.displays: + model_name = generate_model_name(protocol._last_config.displays[0]) + device_metadata["model_name"] = model_name + _LOGGER.debug("Generated model name from config: %s", model_name) else: - # Fallback if config unavailable (shouldn't happen for OpenDisplay) - model_name = get_hw_string(hw_type) if hw_type else "Unknown" - _LOGGER.warning("OpenDisplay config unavailable, using tagtypes fallback: %s", model_name) - # Store individual fields as fallback - device_metadata = { - "hw_type": hw_type, - "fw_version": self._discovered_device["fw_version"], - "width": capabilities.width, - "height": capabilities.height, - "rotatebuffer": capabilities.rotatebuffer, - "color_scheme": capabilities.color_scheme, - "model_name": model_name, - } + _LOGGER.warning("OpenDisplay config has no display config") else: - # ATC devices: Use tagtypes.json lookup and store individual fields - # Try to get tag types manager, but don't fail if unavailable - tag_types_manager = None - try: - tag_types_manager = await get_tag_types_manager(self.hass) - _LOGGER.debug("Tag types manager loaded successfully") - except Exception as tag_err: - _LOGGER.warning( - "Could not load tag types during config flow, will use fallback values: %s", - tag_err - ) - + # Fallback if config unavailable (shouldn't happen for OpenDisplay) model_name = get_hw_string(hw_type) if hw_type else "Unknown" - _LOGGER.debug("Resolved hw_type %s to model: %s", hw_type, model_name) - - # Refine color_scheme using TagTypes db if available - if tag_types_manager and tag_types_manager.is_in_hw_map(hw_type): - tag_type = await tag_types_manager.get_tag_info(hw_type) - color_table = tag_type.color_table - - if 'yellow' in color_table and 'red' in color_table: - color_scheme = 3 # BWRY - elif 'yellow' in color_table: - color_scheme = 2 # BWY - elif 'red' in color_table: - color_scheme = 1 # BWR - else: - color_scheme = 0 # BW - else: - # Fallback to protocol detection - color_scheme = capabilities.color_scheme - if not tag_types_manager: - _LOGGER.info( - "Tag types not available, using protocol-detected color_scheme: %d", - color_scheme - ) - else: - _LOGGER.warning( - "hw_type %s not in TagTypes, using protocol color_scheme: %d", - hw_type, color_scheme - ) - - # Build device metadata from capabilities + _LOGGER.warning("OpenDisplay config unavailable, using tagtypes fallback: %s", model_name) + # Store individual fields as fallback device_metadata = { "hw_type": hw_type, "fw_version": self._discovered_device["fw_version"], "width": capabilities.width, "height": capabilities.height, "rotatebuffer": capabilities.rotatebuffer, - "color_scheme": color_scheme, + "color_scheme": capabilities.color_scheme, "model_name": model_name, } diff --git a/custom_components/opendisplay/manifest.json b/custom_components/opendisplay/manifest.json index f599755..7ad2973 100644 --- a/custom_components/opendisplay/manifest.json +++ b/custom_components/opendisplay/manifest.json @@ -2,9 +2,6 @@ "domain": "opendisplay", "name": "OpenDisplay", "bluetooth": [ - { - "manufacturer_id": 4919 - }, { "manufacturer_id": 9286 } diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index dad0f82..73c5ec2 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -625,7 +625,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry, mac_address = entry_data.mac_address name = entry_data.name device_metadata = entry_data.device_metadata - protocol_type = entry_data.protocol_type # Default to ATC for backward compatibility + protocol_type = "open_display" # Create sensors for each description from .metadata import BLEDeviceMetadata @@ -634,13 +634,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry, for description in BLE_SENSOR_TYPES: # Handle battery sensors based on device protocol if description.key in ("battery_percentage", "battery_voltage"): - if protocol_type == "atc": - # ATC devices always have batteries - pass # Continue to create sensor - elif protocol_type == "open_display": - # OpenDisplay devices: only create battery sensors for battery/solar power - if metadata.power_mode not in (1, 3): # Not battery (1) or solar (3) - continue # Skip battery sensors + # OpenDisplay devices: only create battery sensors for battery/solar power + if metadata.power_mode not in (1, 3): # Not battery (1) or solar (3) + continue # Skip battery sensors sensor = OpenDisplayBLESensor( hass=hass, diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index abf4825..72692a4 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -372,7 +372,7 @@ async def upload_to_ble_block( try: # Get device metadata from Home Assistant data device_metadata = None - protocol_type = "atc" # Default to ATC for backward compatibility + protocol_type = "open_display" # Find the config entry for this BLE device for entry in hass.config_entries.async_entries(DOMAIN): @@ -380,7 +380,7 @@ async def upload_to_ble_block( if runtime_data is not None and isinstance(runtime_data, OpenDisplayBLERuntimeData): if runtime_data.mac_address.upper() == mac: device_metadata = runtime_data.device_metadata - protocol_type = runtime_data.protocol_type + protocol_type = "open_display" break @@ -417,10 +417,7 @@ async def upload_to_ble_block( ) if processed_image is not None: - # Undo rotation for display (ATC rotation is for device memory, not viewing) display_image = processed_image - if protocol_type == "atc" and metadata.rotatebuffer == 1: - display_image = processed_image.transpose(Image.Transpose.ROTATE_270) jpeg_bytes = await hass.async_add_executor_job( image_to_jpeg_bytes, display_image, 95 From 2262259b713e6fc12c45cf0aeabc904071e84507 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Mon, 25 May 2026 11:43:29 +0100 Subject: [PATCH 4/6] refactor: Migrate BLE device connection and operations to py-opendisplay --- custom_components/opendisplay/button.py | 20 +++++++--------- custom_components/opendisplay/config_flow.py | 25 +++++++------------- custom_components/opendisplay/services.py | 4 ++-- custom_components/opendisplay/upload.py | 8 +++---- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/custom_components/opendisplay/button.py b/custom_components/opendisplay/button.py index ec2c4bd..1ca568a 100644 --- a/custom_components/opendisplay/button.py +++ b/custom_components/opendisplay/button.py @@ -443,22 +443,20 @@ def __init__(self, async def async_press(self) -> None: """Re-interrogate device and update configuration.""" - from .ble import BLEConnection, get_protocol_by_name + import opendisplay from .ble.tlv_parser import config_to_dict, generate_model_name from homeassistant.helpers import device_registry as dr _LOGGER.info("Refreshing configuration for OpenDisplay device %s", self._mac_address) try: - # Get protocol handler - protocol = get_protocol_by_name(self._protocol_type) fw_info = None # Connect and interrogate device - async with BLEConnection(self.hass, self._mac_address, self._service_uuid, protocol) as conn: - capabilities = await protocol.interrogate_device(conn) + async with opendisplay.OpenDisplayDevice(mac_address=self._mac_address) as conn: + capabilities = await conn.interrogate() try: - fw_info = await protocol.read_firmware_version(conn) + fw_info = await conn.read_firmware_version() except Exception as fw_err: _LOGGER.warning( "Failed to read firmware version for %s: %s", @@ -473,7 +471,7 @@ async def async_press(self) -> None: ) # Get updated config from protocol - config = protocol._last_config + config = conn.config if not config: raise HomeAssistantError( @@ -486,10 +484,10 @@ async def async_press(self) -> None: "open_display_config": config_to_dict(config), } if fw_info: - new_metadata["fw_version"] = fw_info.get("version") - new_metadata["fw_version_raw"] = fw_info.get("raw") - if fw_info.get("sha"): - new_metadata["fw_sha"] = fw_info["sha"] + new_metadata["fw_version"] = f"{fw_info['major']}.{fw_info['minor']}" + new_metadata["fw_version_major"] = fw_info["major"] + new_metadata["fw_version_minor"] = fw_info["minor"] + new_metadata["fw_sha"] = fw_info["sha"] elif "fw_version" in self._device_metadata: # Preserve the previously known firmware version if read fails new_metadata["fw_version"] = self._device_metadata.get("fw_version") diff --git a/custom_components/opendisplay/config_flow.py b/custom_components/opendisplay/config_flow.py index d96967a..2ae97ab 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -20,12 +20,10 @@ from .const import DOMAIN from .ble import ( get_protocol_by_manufacturer_id, - BLEConnection, UnsupportedProtocolError, ConfigValidationError, - BLEConnectionError, - BLEProtocolError, ) +import opendisplay from .tag_types import get_tag_types_manager, get_hw_string from .util import is_ble_entry import logging @@ -279,17 +277,12 @@ async def async_step_bluetooth_confirm( # Interrogate device using protocol-specific method fw_info: dict[str, Any] | None = None - async with BLEConnection( - self.hass, - self._discovered_device["address"], - protocol.service_uuid, - protocol - ) as conn: - capabilities = await protocol.interrogate_device(conn) + async with opendisplay.OpenDisplayDevice(mac_address=self._discovered_device["address"]) as conn: + capabilities = await conn.interrogate() # OpenDisplay devices expose firmware version via 0x0043 if self._discovered_device["protocol_type"] == "open_display": try: - fw_info = await protocol.read_firmware_version(conn) + fw_info = await conn.read_firmware_version() except Exception as fw_err: _LOGGER.warning( "Failed to read firmware version for %s: %s", @@ -318,10 +311,10 @@ async def async_step_bluetooth_confirm( "open_display_config": config_to_dict(protocol._last_config), } if fw_info: - device_metadata["fw_version"] = fw_info.get("version") - device_metadata["fw_version_raw"] = fw_info.get("raw") - if fw_info.get("sha"): - device_metadata["fw_sha"] = fw_info["sha"] + device_metadata["fw_version"] = f"{fw_info['major']}.{fw_info['minor']}" + device_metadata["fw_version_major"] = fw_info["major"] + device_metadata["fw_version_minor"] = fw_info["minor"] + device_metadata["fw_sha"] = fw_info["sha"] # Generate model name from display config if protocol._last_config.displays: @@ -365,7 +358,7 @@ async def async_step_bluetooth_confirm( description_placeholders=self._bluetooth_description_placeholders(), ) - except (BLEConnectionError, BLEProtocolError) as e: + except (opendisplay.exceptions.BLEConnectionError, opendisplay.exceptions.ProtocolError) as e: _LOGGER.error("Error during device interrogation: %s", e) return self.async_show_form( step_id="bluetooth_confirm", diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 1ff195e..af98de8 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -10,7 +10,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from .coordinator import Hub -from .ble import BLEConnectionError, BLETimeoutError, BLEProtocolError +import opendisplay from .metadata import BLEDeviceMetadata from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE from .imagegen import ImageGen @@ -360,7 +360,7 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: except ServiceValidationError: raise # User input errors - propagate unchanged - except (HomeAssistantError, BLEConnectionError, BLETimeoutError, BLEProtocolError): + except (HomeAssistantError, opendisplay.exceptions.BLEConnectionError, opendisplay.exceptions.BLETimeoutError, opendisplay.exceptions.ProtocolError): raise # Operational errors - propagate unchanged except Exception as err: # Unexpected errors - wrap as operational error diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 72692a4..c91dc17 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -17,8 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .runtime_data import OpenDisplayBLERuntimeData from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE -from .ble import BLEConnection, BLEImageUploader, get_protocol_by_name, BLEConnectionError, \ - BLETimeoutError, BLEProtocolError +from .ble import BLEConnection, BLEImageUploader, get_protocol_by_name +import opendisplay from .metadata import BLEDeviceMetadata _LOGGER: Final = logging.getLogger(__name__) @@ -430,7 +430,7 @@ async def upload_to_ble_block( except ServiceValidationError: raise # Config/validation errors - propagate unchanged - except (BLEConnectionError, BLETimeoutError, BLEProtocolError) as err: + except (opendisplay.exceptions.BLEConnectionError, opendisplay.exceptions.BLETimeoutError, opendisplay.exceptions.ProtocolError) as err: # BLE-specific errors already inherit from HomeAssistantError raise # Propagate with specific type except Exception as err: @@ -540,7 +540,7 @@ async def upload_to_ble_direct( except ServiceValidationError: raise # Config/validation errors - propagate unchanged - except (BLEConnectionError, BLETimeoutError, BLEProtocolError) as err: + except (opendisplay.exceptions.BLEConnectionError, opendisplay.exceptions.BLETimeoutError, opendisplay.exceptions.ProtocolError) as err: raise # BLE operational errors - propagate unchanged except Exception as err: raise HomeAssistantError( From 7a54e8ac14159dcc39dc9609f9c82fb3113972eb Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Mon, 25 May 2026 11:47:16 +0100 Subject: [PATCH 5/6] refactor: Port image upload pipelines to py-opendisplay client --- custom_components/opendisplay/upload.py | 106 ++++++++---------------- 1 file changed, 36 insertions(+), 70 deletions(-) diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index c91dc17..04f8760 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -17,9 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .runtime_data import OpenDisplayBLERuntimeData from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE -from .ble import BLEConnection, BLEImageUploader, get_protocol_by_name import opendisplay -from .metadata import BLEDeviceMetadata _LOGGER: Final = logging.getLogger(__name__) @@ -370,57 +368,39 @@ async def upload_to_ble_block( _LOGGER.debug("Preparing BLE block-based upload for %s (MAC: %s)", entity_id, mac) try: - # Get device metadata from Home Assistant data - device_metadata = None - protocol_type = "open_display" - # Find the config entry for this BLE device + device_found = False for entry in hass.config_entries.async_entries(DOMAIN): runtime_data = getattr(entry, 'runtime_data', None) if runtime_data is not None and isinstance(runtime_data, OpenDisplayBLERuntimeData): if runtime_data.mac_address.upper() == mac: - device_metadata = runtime_data.device_metadata - protocol_type = "open_display" + device_found = True break - - if not device_metadata: + if not device_found: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="ble_no_metadata", translation_placeholders={"entity_id": entity_id} ) - # Get protocol handler for service UUID - protocol = get_protocol_by_name(protocol_type) - _LOGGER.debug("Using protocol %s for device %s", protocol_type, entity_id) - - # Wrap metadata and create DeviceMetadata object - metadata = BLEDeviceMetadata(device_metadata) - - # Upload via BLE using protocol-specific service UUID - async with BLEConnection(hass, mac, protocol.service_uuid, protocol) as conn: - uploader = BLEImageUploader(conn, mac) - success, processed_image = await uploader.upload_image_block_based( + # Map dither integer to opendisplay.DitherMode + try: + dither_mode = opendisplay.DitherMode(dither) + except ValueError: + dither_mode = opendisplay.DitherMode.ORDERED + + # Upload via BLE using OpenDisplayDevice + async with opendisplay.OpenDisplayDevice(mac_address=mac) as conn: + await conn.interrogate() + processed_image = await conn.upload_image( img, - metadata, - protocol_type, - dither, - render_duration, + dither_mode=dither_mode, ) - if not success: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="ble_upload_failed", - translation_placeholders={"entity_id": entity_id} - ) - if processed_image is not None: - display_image = processed_image - jpeg_bytes = await hass.async_add_executor_job( - image_to_jpeg_bytes, display_image, 95 + image_to_jpeg_bytes, processed_image, 95 ) async_dispatcher_send( hass, @@ -476,58 +456,44 @@ async def upload_to_ble_direct( ) try: - # Get device metadata from Home Assistant data - device_metadata = None - protocol_type = "open_display" # Direct write is OpenDisplay only - # Find the config entry for this BLE device + device_found = False for entry in hass.config_entries.async_entries(DOMAIN): runtime_data = getattr(entry, 'runtime_data', None) if runtime_data is not None and isinstance(runtime_data, OpenDisplayBLERuntimeData): if runtime_data.mac_address.upper() == mac: - device_metadata = runtime_data.device_metadata - protocol_type = runtime_data.protocol_type + device_found = True break - if not device_metadata: + if not device_found: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="ble_no_metadata", translation_placeholders={"entity_id": entity_id} ) - # Verify this is an OpenDisplay device - metadata = BLEDeviceMetadata(device_metadata) - if not metadata.is_open_display: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="ble_direct_write_not_supported", - translation_placeholders={"entity_id": entity_id} - ) - - # Get protocol handler for service UUID - protocol = get_protocol_by_name(protocol_type) - _LOGGER.debug("Using protocol %s for direct write on device %s", protocol_type, entity_id) + # Map dither to DitherMode + try: + dither_mode = opendisplay.DitherMode(dither) + except ValueError: + dither_mode = opendisplay.DitherMode.ORDERED - # Upload via BLE using direct write protocol - async with BLEConnection(hass, mac, protocol.service_uuid, protocol) as conn: - uploader = BLEImageUploader(conn, mac) - success, processed_image = await uploader.upload_direct_write( + # Map refresh_type to RefreshMode + try: + refresh_mode = opendisplay.RefreshMode(refresh_type) + except ValueError: + refresh_mode = opendisplay.RefreshMode.PARTIAL + + # Upload via BLE using OpenDisplayDevice + async with opendisplay.OpenDisplayDevice(mac_address=mac) as conn: + await conn.interrogate() + processed_image = await conn.upload_image( img, - metadata, - allow_compression, - dither, - refresh_type, - render_duration, + refresh_mode=refresh_mode, + dither_mode=dither_mode, + compress=allow_compression, ) - if not success: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="ble_direct_write_failed", - translation_placeholders={"entity_id": entity_id} - ) - if processed_image is not None: jpeg_bytes = await hass.async_add_executor_job( image_to_jpeg_bytes, processed_image, 95 From a922991bedbcf94b4582e3b142dc12204827830c Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Mon, 25 May 2026 11:48:13 +0100 Subject: [PATCH 6/6] cleanup: Remove deprecated ble/ directory and streamline test suite --- custom_components/opendisplay/ble/__init__.py | 53 - .../opendisplay/ble/color_scheme.py | 112 --- .../opendisplay/ble/connection.py | 247 ----- .../opendisplay/ble/exceptions.py | 26 - .../opendisplay/ble/image_processing.py | 276 ----- .../opendisplay/ble/image_upload.py | 952 ------------------ custom_components/opendisplay/ble/metadata.py | 217 ---- .../opendisplay/ble/operations.py | 148 --- .../opendisplay/ble/protocol_atc.py | 216 ---- .../opendisplay/ble/protocol_base.py | 122 --- .../opendisplay/ble/protocol_factory.py | 65 -- .../opendisplay/ble/protocol_open_display.py | 485 --------- .../opendisplay/ble/tlv_parser.py | 758 -------------- tests/test_ble_metadata.py | 82 +- 14 files changed, 38 insertions(+), 3721 deletions(-) delete mode 100644 custom_components/opendisplay/ble/__init__.py delete mode 100644 custom_components/opendisplay/ble/color_scheme.py delete mode 100644 custom_components/opendisplay/ble/connection.py delete mode 100644 custom_components/opendisplay/ble/exceptions.py delete mode 100644 custom_components/opendisplay/ble/image_processing.py delete mode 100644 custom_components/opendisplay/ble/image_upload.py delete mode 100644 custom_components/opendisplay/ble/metadata.py delete mode 100644 custom_components/opendisplay/ble/operations.py delete mode 100644 custom_components/opendisplay/ble/protocol_atc.py delete mode 100644 custom_components/opendisplay/ble/protocol_base.py delete mode 100644 custom_components/opendisplay/ble/protocol_factory.py delete mode 100644 custom_components/opendisplay/ble/protocol_open_display.py delete mode 100644 custom_components/opendisplay/ble/tlv_parser.py diff --git a/custom_components/opendisplay/ble/__init__.py b/custom_components/opendisplay/ble/__init__.py deleted file mode 100644 index f1b03f7..0000000 --- a/custom_components/opendisplay/ble/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -"""BLE protocol abstraction for OpenDisplay devices.""" -from .color_scheme import ColorScheme -# Re-export key classes and functions for backward compatibility -from .connection import BLEConnection -from .image_upload import BLEImageUploader -from .metadata import BLEDeviceMetadata -from .operations import ( - turn_led_on, - turn_led_off, - ping_device, -) -from .protocol_factory import ( - get_protocol_by_manufacturer_id, - get_protocol_by_name, - get_supported_manufacturer_ids, -) -from .protocol_base import AdvertisingData, DeviceCapabilities -from .exceptions import ( - BLEError, - BLEConnectionError, - BLEProtocolError, - BLETimeoutError, - UnsupportedProtocolError, - ConfigValidationError, -) - -__all__ = [ - # Connection - "BLEConnection", - # Image upload - "BLEImageUploader", - # Metadata - "BLEDeviceMetadata", - # Operations - "turn_led_on", - "turn_led_off", - "ping_device", - # Protocol factory - "get_protocol_by_manufacturer_id", - "get_protocol_by_name", - "get_supported_manufacturer_ids", - # Data structures - "AdvertisingData", - "DeviceCapabilities", - "ColorScheme", - # Exceptions - "BLEError", - "BLEConnectionError", - "BLEProtocolError", - "BLETimeoutError", - "UnsupportedProtocolError", - "ConfigValidationError", -] diff --git a/custom_components/opendisplay/ble/color_scheme.py b/custom_components/opendisplay/ble/color_scheme.py deleted file mode 100644 index 57a0432..0000000 --- a/custom_components/opendisplay/ble/color_scheme.py +++ /dev/null @@ -1,112 +0,0 @@ -from dataclasses import dataclass -from enum import Enum -from typing import Tuple, Dict - - -@dataclass(frozen=True) -class ColorPalette: - """Color palette for a display type.""" - colors: Dict[str, Tuple[int, int, int]] # name -> RGB tuple - accent: str - - -class ColorScheme(Enum): - """ - Display color scheme with associated palette. - - Usage: - scheme = ColorScheme.from_int(2) # Get BWY from firmware value - scheme.name # "BWY" - scheme.value # 2 - scheme.accent_color # "yellow" - scheme.palette.colors # {'black': ..., 'white': ..., 'yellow': ...} - """ - MONO = (0, ColorPalette( - colors={ - 'black': (0, 0, 0), - 'white': (255, 255, 255), - }, - accent='black' - )) - - BWR = (1, ColorPalette( - colors={ - 'black': (0, 0, 0), - 'white': (255, 255, 255), - 'red': (255, 0, 0), - }, - accent='red' - )) - - BWY = (2, ColorPalette( - colors={ - 'black': (0, 0, 0), - 'white': (255, 255, 255), - 'yellow': (255, 255, 0), - }, - accent='yellow' - )) - - BWRY = (3, ColorPalette( - colors={ - 'black': (0, 0, 0), - 'white': (255, 255, 255), - 'red': (255, 0, 0), - 'yellow': (255, 255, 0), - }, - accent='red' - )) - - BWGBRY = (4, ColorPalette( - colors={ - 'black': (0, 0, 0), - 'white': (255, 255, 255), - 'green': (0, 255, 0), - 'blue': (0, 0, 255), - 'red': (255, 0, 0), - 'yellow': (255, 255, 0), - }, - accent='red' - )) - - GRAYSCALE_4 = (5, ColorPalette( - colors={ - 'black': (0, 0, 0), - 'gray1': (85, 85, 85), - 'gray2': (170, 170, 170), - 'white': (255, 255, 255) - }, - accent='black' - )) - - def __init__(self, value: int, palette: ColorPalette): - self._value_ = value - self.palette = palette - - @classmethod - def from_int(cls, value: int) -> 'ColorScheme': - """Get ColorScheme from firmware int value.""" - for scheme in cls: - if scheme.value == value: - return scheme - return cls.MONO # Default fallback - - @property - def accent_color(self) -> str: - """Accent color name for this scheme.""" - return self.palette.accent - - @property - def has_red(self) -> bool: - """Check if red color is supported.""" - return 'red' in self.palette.colors - - @property - def has_yellow(self) -> bool: - """Check if yellow color is supported.""" - return 'yellow' in self.palette.colors - - @property - def is_multi_color(self) -> bool: - """Check if the scheme supports multiple colors.""" - return len(self.palette.colors) > 2 diff --git a/custom_components/opendisplay/ble/connection.py b/custom_components/opendisplay/ble/connection.py deleted file mode 100644 index 6bc4ed0..0000000 --- a/custom_components/opendisplay/ble/connection.py +++ /dev/null @@ -1,247 +0,0 @@ -"""BLE connection management.""" -import asyncio -import logging -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant -from homeassistant.components import bluetooth -from bleak import BleakClient -from bleak.exc import BleakError -from bleak_retry_connector import ( - BleakClientWithServiceCache, - BleakOutOfConnectionSlotsError, - establish_connection, -) - -from .exceptions import BLEConnectionError, BLEProtocolError, BLETimeoutError -from ..const import DOMAIN - - -if TYPE_CHECKING: - from .protocol_base import BLEProtocol - -_LOGGER = logging.getLogger(__name__) - -# Protocol initialization command for ATC protocol -CMD_INIT = bytes([0x01, 0x01]) - -INIT_DELAY_SECONDS = 2.0 - - -class BLEConnection: - """Context manager for BLE connections with protocol-specific service UUID. - - Manages BLE connection lifecycle including: - - Connection establishment with retry logic - - Service/characteristic resolution - - Notification handling - - Protocol initialization - - Graceful disconnection - """ - - def __init__(self, hass: HomeAssistant, mac_address: str, service_uuid: str, protocol: "BLEProtocol"): - """Initialize BLE connection manager. - - Args: - hass: Home Assistant instance - mac_address: Device MAC address - service_uuid: Protocol-specific BLE service UUID - protocol: Protocol instance for this device - """ - self.hass = hass - self.mac_address = mac_address - self.service_uuid = service_uuid - self.protocol = protocol - self.client: BleakClient | None = None - self.write_char = None - self._response_queue = asyncio.Queue() - self._notification_active = False - - async def __aenter__(self): - """Establish BLE connection and initialize protocol.""" - try: - device = bluetooth.async_ble_device_from_address( - self.hass, self.mac_address, connectable=True - ) - if not device: - raise BLEConnectionError( - translation_domain=DOMAIN, - translation_key="ble_device_not_found", - translation_placeholders={"mac_address": self.mac_address} - ) - - self.client = await establish_connection( - BleakClientWithServiceCache, - device, - f"BLE-{self.mac_address}", - self._disconnected_callback, - timeout=15.0, - ) - - # Resolve protocol-specific service characteristic - if not self._resolve_characteristic(): - await self.client.disconnect() - raise BLEConnectionError( - translation_domain=DOMAIN, - translation_key="ble_characteristic_not_resolved", - translation_placeholders={ "service_uuid": self.service_uuid} - ) - - # Enable notifications for protocol responses - await self.client.start_notify(self.write_char, self._notification_callback) - self._notification_active = True - - # Let protocol handle its own initialization requirements - await self.protocol.initialize_connection(self) - - return self - - except BleakOutOfConnectionSlotsError as e: - await self._cleanup() - raise BLEConnectionError( - translation_domain=DOMAIN, - translation_key="ble_slots_unavailable", - translation_placeholders={"mac_address": self.mac_address, "error": str(e)} - ) from e - - except (BleakError, asyncio.TimeoutError) as e: - await self._cleanup() - raise BLEConnectionError( - translation_domain=DOMAIN, - translation_key="ble_connection_failed", - translation_placeholders={"mac_address": self.mac_address, "error": str(e)} - ) from e - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Clean up BLE connection.""" - await self._cleanup() - - async def _cleanup(self): - """Clean up connection resources.""" - if self.client and self.client.is_connected: - if self._notification_active: - try: - await self.client.stop_notify(self.write_char) - except Exception: - _LOGGER.debug("Failed to stop notifications during cleanup") - finally: - self._notification_active = False - try: - await self.client.disconnect() - except Exception: - _LOGGER.debug("Failed to disconnect during cleanup") - - def _resolve_characteristic(self) -> bool: - """Resolve BLE characteristic for the protocol-specific service. - - Returns: - bool: True if characteristic was resolved successfully - """ - try: - if not self.client or not self.client.services: - return False - - # Find the protocol-specific service characteristic - char = self.client.services.get_characteristic(self.service_uuid) - if char: - self.write_char = char - _LOGGER.debug( - "Resolved characteristic for service %s on %s", - self.service_uuid, - self.mac_address, - ) - return True - - _LOGGER.error( - "Could not find characteristic for service %s on %s", - self.service_uuid, - self.mac_address, - ) - return False - - except Exception as e: - _LOGGER.error( - "Error resolving characteristic for %s: %s", self.mac_address, e - ) - return False - - def _notification_callback(self, sender, data: bytearray) -> None: - """Handle notification from device. - - Args: - sender: Notification sender - data: Notification data - """ - try: - self._response_queue.put_nowait(bytes(data)) - except asyncio.QueueFull: - _LOGGER.warning( - "Response queue full for %s, dropping notification", self.mac_address - ) - - async def _write_raw(self, data: bytes) -> None: - """Write raw data to device characteristic. - - Args: - data: Raw bytes to write - - Raises: - BLEProtocolError: If write characteristic is not available - """ - if not self.write_char: - raise BLEProtocolError( - translation_domain=DOMAIN, - translation_key="ble_write_char_missing", - ) - - await self.client.write_gatt_char(self.write_char, data, response=False) - - async def write_command_with_response( - self, command: bytes, timeout: float = 10.0 - ) -> bytes: - """Write command and wait for response. - - Args: - command: Command bytes to write - timeout: Response timeout in seconds - - Returns: - bytes: Response data from device - - Raises: - BLETimeoutError: If no response received within timeout - """ - # Clear any pending responses - while not self._response_queue.empty(): - try: - self._response_queue.get_nowait() - except asyncio.QueueEmpty: - break - - await self._write_raw(command) - - try: - response = await asyncio.wait_for(self._response_queue.get(), timeout=timeout) - return response - except asyncio.TimeoutError: - raise BLETimeoutError( - translation_domain=DOMAIN, - translation_key="ble_timeout", - translation_placeholders={"mac_address": self.mac_address, "timeout": timeout}, - ) from None - - async def write_command(self, data: bytes) -> None: - """Write command to device without expecting response. - - Args: - data: Command bytes to write - """ - await self._write_raw(data) - - def _disconnected_callback(self, client: BleakClient) -> None: - """Handle disconnection event. - - Args: - client: Disconnected BleakClient - """ - _LOGGER.debug("Device %s disconnected", self.mac_address) diff --git a/custom_components/opendisplay/ble/exceptions.py b/custom_components/opendisplay/ble/exceptions.py deleted file mode 100644 index 77c34a7..0000000 --- a/custom_components/opendisplay/ble/exceptions.py +++ /dev/null @@ -1,26 +0,0 @@ -"""BLE operation exceptions.""" -from homeassistant.exceptions import HomeAssistantError - - -class BLEError(HomeAssistantError): - """Base BLE operation error.""" - - -class BLEConnectionError(BLEError): - """Connection to device failed.""" - - -class BLEProtocolError(BLEError): - """Protocol communication error.""" - - -class BLETimeoutError(BLEError): - """Operation timed out.""" - - -class UnsupportedProtocolError(BLEError): - """Unknown manufacturer ID or unsupported firmware protocol.""" - - -class ConfigValidationError(BLEError): - """TLV config parsing or validation error.""" diff --git a/custom_components/opendisplay/ble/image_processing.py b/custom_components/opendisplay/ble/image_processing.py deleted file mode 100644 index a9d6b40..0000000 --- a/custom_components/opendisplay/ble/image_processing.py +++ /dev/null @@ -1,276 +0,0 @@ -import numpy as np -from PIL import Image - -from .color_scheme import ColorScheme - -QUANTIZE_CHUNK_PIXELS = 262_144 - - -def perceptual_color_distance(c1_rgb: tuple[int, int, int], c2_rgb: tuple[int, int, int]) -> float: - """ - Calculate weighted perceptual RGB distance with grayscale protection. - - Uses the formula from makeimage.cpp: 3×Δr² + 5.47×Δg² + 1.53×Δb² - This weights green heavily (human eyes are most sensitive to green). - - Grayscale protection prevents gray pixels from matching to colors, - which would cause unwanted color tinting in neutral areas. - - Args: - c1_rgb: Source pixel RGB tuple - c2_rgb: Palette color RGB tuple - - Returns: - Perceptual distance, or infinity if grayscale protection triggers - """ - - r1, g1, b1 = int(c1_rgb[0]), int(c1_rgb[1]), int(c1_rgb[2]) - r2, g2, b2 = int(c2_rgb[0]), int(c2_rgb[1]), int(c2_rgb[2]) - - # Grayscale protection: reject color matches for grayscale source pixels - # A pixel is considered grayscale if R, G, B are all within 20 of each other - is_source_gray = abs(r1 - g1) < 20 and abs(b1 - g1) < 20 - # A palette color is considered chromatic if any channel differs by >20 - is_target_color = abs(r2 - g2) > 20 or abs(b2 - g2) > 20 - - if is_source_gray and is_target_color: - return float('inf') - - # Perceptual weighting from makeimage.cpp - return 3.0 * (r1 - r2) ** 2 + 5.47 * (g1 - g2) ** 2 + 1.53 * (b1 - b2) ** 2 - - -def find_closest_color(pixel_rgb: tuple[int, int, int], palette: list[tuple[int, int, int]]) -> tuple[tuple[int, int, int], int]: - """ - Find the closest palette color using perceptual distance. - - Args: - pixel_rgb: Source pixel RGB tuple - palette: List of palette RGB tuples - - Returns: - Tuple of (closest_color_rgb, palette_index) - """ - - min_dist = float('inf') - closest = palette[0] - closest_idx = 0 - - for idx, color in enumerate(palette): - dist = perceptual_color_distance(pixel_rgb, color) - if dist < min_dist: - min_dist = dist - closest = color - closest_idx = idx - - return closest, closest_idx - - -def _palette_array(color_scheme: ColorScheme) -> np.ndarray: - """Return palette RGB values in display encoding order.""" - return np.array(list(color_scheme.palette.colors.values()), dtype=np.uint8) - - -def _has_only_palette_colors(image: Image.Image, color_scheme: ColorScheme) -> bool: - """Return true when all image pixels are exact colors from the display palette.""" - rgb_image = image if image.mode == 'RGB' else image.convert('RGB') - allowed = set(color_scheme.palette.colors.values()) - colors = rgb_image.getcolors(maxcolors=len(allowed)) - return colors is not None and all(rgb in allowed for _, rgb in colors) - - -def _quantize_pixels_to_palette( - pixels: np.ndarray, - palette: np.ndarray, - *, - chunk_pixels: int = QUANTIZE_CHUNK_PIXELS, -) -> np.ndarray: - """Map RGB pixels to their closest display palette color in bounded chunks.""" - flat = pixels.reshape(-1, 3).astype(np.int16, copy=False) - palette_i16 = palette.astype(np.int16) - target_is_color = ( - (np.abs(palette_i16[:, 0] - palette_i16[:, 1]) > 20) - | (np.abs(palette_i16[:, 2] - palette_i16[:, 1]) > 20) - ) - result = np.empty((flat.shape[0], 3), dtype=np.uint8) - - for start in range(0, flat.shape[0], chunk_pixels): - end = min(start + chunk_pixels, flat.shape[0]) - chunk = flat[start:end] - diff = chunk[:, None, :] - palette_i16[None, :, :] - dist = ( - 3.0 * (diff[:, :, 0].astype(np.float32) ** 2) - + 5.47 * (diff[:, :, 1].astype(np.float32) ** 2) - + 1.53 * (diff[:, :, 2].astype(np.float32) ** 2) - ) - source_is_gray = ( - (np.abs(chunk[:, 0] - chunk[:, 1]) < 20) - & (np.abs(chunk[:, 2] - chunk[:, 1]) < 20) - ) - if np.any(source_is_gray) and np.any(target_is_color): - dist[np.ix_(source_is_gray, target_is_color)] = np.inf - - result[start:end] = palette[np.argmin(dist, axis=1)] - - return result.reshape(pixels.shape) - - -def apply_direct_mapping(image: Image.Image, color_scheme: ColorScheme) -> Image.Image: - """ - Apply direct color mapping without dithering. - - Each pixel is mapped to its perceptually closest palette color. - Fast but can produce harsh banding on gradients. - - Args: - image: PIL Image in RGB mode - color_scheme: ColorScheme enum for palette - - Returns: - Quantized PIL Image - """ - if image.mode != 'RGB': - image = image.convert('RGB') - - if _has_only_palette_colors(image, color_scheme): - return image - - pixels = np.asarray(image) - result = _quantize_pixels_to_palette(pixels, _palette_array(color_scheme)) - return Image.fromarray(result, 'RGB') - -def apply_burkes_dithering(image: Image.Image, color_scheme: ColorScheme) -> Image.Image: - """ - Apply Burkes error diffusion dithering. - - Burkes dithering distributes quantization error to neighboring pixels, - creating smooth gradients. Best for photographs and images with gradients. - - Error diffusion pattern (Burkes): - X 8/32 4/32 - 2/32 4/32 8/32 4/32 2/32 - - Args: - image: PIL Image in RGB mode - color_scheme: ColorScheme enum for palette - - Returns: - Dithered PIL Image quantized to palette colors - """ - - # Convert to RGB if needed - if image.mode != 'RGB': - image = image.convert('RGB') - - # Get palette as list of RGB tuples - palette = list(color_scheme.palette.colors.values()) - - # Convert to float array for error accumulation - pixels = np.array(image, dtype=np.float32) - height, width = pixels.shape[:2] - - # Process each pixel - for y in range(height): - for x in range(width): - old_pixel = tuple(int(c) for c in np.clip(pixels[y, x], 0, 255)) - new_pixel, _ = find_closest_color(old_pixel, palette) - - # Calculate quantization error - error = np.array(old_pixel, dtype=np.float32) - np.array(new_pixel, dtype=np.float32) - - # Set the quantized pixel - pixels[y, x] = new_pixel - - # Distribute error using Burkes pattern - if x + 1 < width: - pixels[y, x + 1] += error * (8 / 32) - if x + 2 < width: - pixels[y, x + 2] += error * (4 / 32) - - if y + 1 < height: - if x - 2 >= 0: - pixels[y + 1, x - 2] += error * (2 / 32) - if x - 1 >= 0: - pixels[y + 1, x - 1] += error * (4 / 32) - pixels[y + 1, x] += error * (8 / 32) - if x + 1 < width: - pixels[y + 1, x + 1] += error * (4 / 32) - if x + 2 < width: - pixels[y + 1, x + 2] += error * (2 / 32) - - # Convert back to uint8 image - result = np.clip(pixels, 0, 255).astype(np.uint8) - return Image.fromarray(result, 'RGB') - - -def apply_ordered_dithering(image: Image.Image, color_scheme: ColorScheme) -> Image.Image: - """ - Apply ordered (Bayer) dithering with adaptive thresholds. - - Ordered dithering uses a fixed threshold pattern, creating regular - halftone-like patterns. Best for text, icons, and sharp edges. - - Uses a 4x4 Bayer matrix for threshold generation. - - Args: - image: PIL Image in RGB mode - color_scheme: ColorScheme enum for palette - - Returns: - Dithered PIL Image quantized to palette colors - """ - # Convert to RGB if needed - if image.mode != 'RGB': - image = image.convert('RGB') - - # 4x4 Bayer matrix (normalized to 0-1 range) - bayer_4x4 = np.array([ - [0, 8, 2, 10], - [12, 4, 14, 6], - [3, 11, 1, 9], - [15, 7, 13, 5] - ], dtype=np.float32) / 16.0 - - pixels = np.array(image, dtype=np.float32) - height, width = pixels.shape[:2] - - # Tile the Bayer matrix across the image - bayer_tiled = np.tile(bayer_4x4, (height // 4 + 1, width // 4 + 1))[:height, :width] - - # Apply threshold adjustment per channel - # Scale factor determines dithering intensity (32 is moderate) - scale = 32.0 - for c in range(3): - pixels[:, :, c] += (bayer_tiled - 0.5) * scale - - pixels = np.clip(pixels, 0, 255).astype(np.int16) - result = _quantize_pixels_to_palette(pixels, _palette_array(color_scheme)) - return Image.fromarray(result, 'RGB') - - -def process_image_for_device(image, color_scheme: int, dither: int = 2) -> Image.Image: - """ - Process image for BLE device display. - - Main entry point for image processing. Applies dithering and color - quantization based on device color scheme and dither mode. - - Args: - image: PIL Image to process - color_scheme: Color scheme int (0-5) matching ColorScheme enum values - dither: Dithering mode: - 0 = None (direct mapping) - 1 = Burkes error diffusion (best for photos) - 2 = Ordered/Bayer (best for text/icons, default) - - Returns: - Processed PIL Image with pixels quantized to palette colors - """ - scheme = ColorScheme.from_int(color_scheme) - - if dither == 1: - return apply_burkes_dithering(image, scheme) - elif dither == 2: - return apply_ordered_dithering(image, scheme) - else: - return apply_direct_mapping(image, scheme) diff --git a/custom_components/opendisplay/ble/image_upload.py b/custom_components/opendisplay/ble/image_upload.py deleted file mode 100644 index 1007bb3..0000000 --- a/custom_components/opendisplay/ble/image_upload.py +++ /dev/null @@ -1,952 +0,0 @@ -"""Shared BLE image upload protocol (compatible with both ATC and OpenDisplay firmware).""" -import asyncio -import struct -import zlib -import logging -from enum import Enum -from time import perf_counter - -import numpy as np -from PIL import Image - -from .exceptions import BLEError -from .image_processing import process_image_for_device -from ..metadata import BLEDeviceMetadata - -_LOGGER = logging.getLogger(__name__) - - -# BLE Protocol Sizes -BLE_BLOCK_SIZE = 4096 -BLE_MAX_PACKET_DATA_SIZE = 230 -DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT = 50 * 1024 -DIRECT_WRITE_COMPRESSION_CHUNK_BYTES = 64 * 1024 - - -class BLEResponse(Enum): - """BLE upload response codes.""" - - BLOCK_REQUEST = "00C6" - BLOCK_PART_ACK = "00C4" - BLOCK_PART_CONTINUE = "00C5" - UPLOAD_COMPLETE = "00C7" - IMAGE_ALREADY_DISPLAYED = "00C8" - # Direct write responses - DIRECT_WRITE_START_ACK = "0070" - DIRECT_WRITE_START_ACK_ALT = "7000" # Alternative format - DIRECT_WRITE_DATA_ACK = "0071" - DIRECT_WRITE_DATA_ACK_ALT = "7100" # Alternative format - DIRECT_WRITE_END_ACK = "0072" - DIRECT_WRITE_END_ACK_ALT = "7200" # Alternative format - - -class BLECommand(Enum): - """BLE upload command codes.""" - - DATA_INFO = "0064" - BLOCK_PART = "0065" - # Direct write commands - DIRECT_WRITE_START = "0070" - DIRECT_WRITE_DATA = "0071" - DIRECT_WRITE_END = "0072" - - -class BLEDataType(Enum): - """BLE image data types.""" - - RAW_BW = 0x20 # Uncompressed monochrome - RAW_COLOR = 0x21 # Uncompressed color (BWR/BWY) - COMPRESSED = 0x30 # Compressed image - - -class RefreshMode(Enum): - """Epaper display refresh modes.""" - FULL = 0 - FAST = 1 - PARTIAL = 2 - PARTIAL2 = 3 - - -def _create_data_info( - checksum: int, - data_ver: int, - data_size: int, - data_type: int, - data_type_argument: int, - next_check_in: int, -) -> bytes: - """Create data info packet for image upload. - - Args: - checksum: Data checksum (usually 255 placeholder) - data_ver: CRC32 of image data - data_size: Image data size in bytes - data_type: Data type enum value (0x20, 0x21, 0x30) - data_type_argument: Additional argument (usually 0) - next_check_in: Next check-in time (usually 0) - - Returns: - bytes: Packed data info structure - """ - return struct.pack( - " bytearray: - """Create a block part packet for image upload. - - Args: - block_id: Block identifier - part_id: Part identifier within block - data: Packet data (max 230 bytes) - - Returns: - bytearray: Block part packet with checksum - - Raises: - ValueError: If data exceeds maximum size - """ - max_data_size = 230 - data_length = len(data) - if data_length > max_data_size: - raise ValueError("Data length exceeds maximum allowed size for a packet.") - - buffer = bytearray(3 + max_data_size) - buffer[1] = block_id & 0xFF - buffer[2] = part_id & 0xFF - buffer[3 : 3 + data_length] = data - buffer[0] = sum(buffer[1 : 3 + data_length]) & 0xFF - return buffer - - -def _convert_image_to_bytes( - image: Image.Image, - color_scheme: int = 0, - compressed: bool = False -) -> tuple[int, bytes]: - """ - Convert a PIL Image to device format. - - Expects image to be pre-quantized to exact palette colors (via dithering). - Uses exact color matching instead of luminance-based detection. - - Supports: - - Monochrome (1-bit) - - Color dual-plane (BWR/BWY/BWRY) - - Optional zlib compression - - Args: - image: PIL Image to convert (should be pre-quantized) - color_scheme: Color scheme int (0=mono, 1=BWR, 2=BWY, 3=BWRY) - compressed: Whether to compress the data - - Returns: - tuple: (data_type, pixel_array) - """ - pixel_array = np.array(image.convert("RGB")) - height, width, _ = pixel_array.shape - - # Get RGB channels - r = pixel_array[:, :, 0] - g = pixel_array[:, :, 1] - b = pixel_array[:, :, 2] - - # Exact color matching (image already quantized by dithering) - black_pixels = (r == 0) & (g == 0) & (b == 0) - # white_pixels = (r == 255) & (g == 255) & (b == 255) - red_pixels = (r == 255) & (g == 0) & (b == 0) - yellow_pixels = (r == 255) & (g == 255) & (b == 0) - - # Determine if multi-color mode - multi_color = color_scheme in (1, 2, 3) # BWR, BWY, or BWRY - - # Dual-plane encoding: - # Plane 1 (BW): 1 = black or yellow, 0 = white or red - # Plane 2 (color): 1 = red or yellow, 0 = black or white - bw_channel_bits = black_pixels | yellow_pixels - - byte_data = np.packbits(bw_channel_bits).tobytes() - bpp_array = bytearray(byte_data) - - if multi_color: - color_pixels = red_pixels | yellow_pixels - byte_data_color = np.packbits(color_pixels).tobytes() - bpp_array += byte_data_color - - if compressed: - buffer = bytearray(6) - buffer[0] = 6 - buffer[1] = width & 0xFF - buffer[2] = (width >> 8) & 0xFF - buffer[3] = height & 0xFF - buffer[4] = (height >> 8) & 0xFF - buffer[5] = 0x02 if multi_color else 0x01 - buffer += bpp_array - the_compressor = zlib.compressobj(wbits=12) - compressed_data = the_compressor.compress(buffer) - compressed_data += the_compressor.flush() - return ( - BLEDataType.COMPRESSED.value, - struct.pack(" str: - """Detect color from RGB values based on color scheme. - - Args: - r: Red component (0-255) - g: Green component (0-255) - b: Blue component (0-255) - color_scheme: Color scheme identifier - - Returns: - Color name: 'black', 'white', 'red', 'yellow', 'green', 'blue' - """ - if r < 128 and g < 128 and b < 128: - return 'black' - if r > 200 and g > 200 and b > 200: - return 'white' - - if color_scheme == 0: - return 'white' if (r + g + b) / 3 > 128 else 'black' - - if color_scheme in (1, 3, 4): - if r > 200 and g < 100 and b < 100: - return 'red' - - if color_scheme in (2, 3, 4): - if r > 200 and g > 200 and b < 100: - return 'yellow' - - if color_scheme == 4: - if r < 100 and g > 200 and b < 100: - return 'green' - if r < 100 and g < 100 and b > 200: - return 'blue' - - return 'white' if (r + g + b) / 3 > 128 else 'black' - - -def _direct_write_color_values(pixel_array: np.ndarray, color_scheme: int) -> np.ndarray: - """Return direct-write firmware color values using the same thresholds as _detect_color.""" - flat = pixel_array.reshape(-1, 3) - r = flat[:, 0] - g = flat[:, 1] - b = flat[:, 2] - average = (r.astype(np.uint16) + g.astype(np.uint16) + b.astype(np.uint16)) / 3.0 - - values = np.where(average > 128, 1, 0).astype(np.uint8) - values[(r < 128) & (g < 128) & (b < 128)] = 0 - values[(r > 200) & (g > 200) & (b > 200)] = 1 - - if color_scheme in (1, 3, 4): - values[(r > 200) & (g < 100) & (b < 100)] = 3 - - if color_scheme in (2, 3, 4): - values[(r > 200) & (g > 200) & (b < 100)] = 2 - - if color_scheme == 4: - values[(r < 100) & (g > 200) & (b < 100)] = 6 - values[(r < 100) & (g < 100) & (b > 200)] = 5 - - return values - - -def _encode_direct_write_1bpp(image: Image.Image) -> bytes: - """Encode image as 1BPP for direct write (monochrome). - - Args: - image: PIL Image to encode - - Returns: - bytes: 1BPP encoded data (white=1, black=0, NOT inverted) - """ - pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8).reshape(-1, 3) - gray = ( - pixel_array[:, 0].astype(np.uint16) - + pixel_array[:, 1].astype(np.uint16) - + pixel_array[:, 2].astype(np.uint16) - ) / 3.0 - return np.packbits(gray > 128).tobytes() - - -def _encode_direct_write_bitplanes(image: Image.Image, color_scheme: int) -> bytes: - """Encode image as bitplanes for direct write (BWR/BWY). - - Args: - image: PIL Image to encode - color_scheme: Color scheme (1=BWR, 2=BWY) - - Returns: - bytes: Plane 1 (B/W, NOT inverted) + Plane 2 (R/Y) - """ - pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) - colors = _direct_write_color_values(pixel_array, color_scheme) - # Plane 1 is B/W: 1=white/red, 0=black/yellow. Plane 2 is color: 1=red/yellow. - plane1 = (colors == 1) | (colors == 3) - plane2 = (colors == 2) | (colors == 3) - return np.packbits(plane1).tobytes() + np.packbits(plane2).tobytes() - - -def _encode_direct_write_2bpp(image: Image.Image, color_scheme: int) -> bytes: - """Encode image as 2BPP for direct write (BWRY or 4 grayscale). - - Args: - image: PIL Image to encode - color_scheme: Color scheme (3=BWRY, 5=4 grayscale) - - Returns: - bytes: 2BPP encoded data (4 pixels per byte) - """ - pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) - - if color_scheme == 5: - flat = pixel_array.reshape(-1, 3) - gray = ( - flat[:, 0].astype(np.uint16) - + flat[:, 1].astype(np.uint16) - + flat[:, 2].astype(np.uint16) - ) / 3.0 - # 4 grayscale: 00=black, 01=dark gray, 10=light gray, 11=white. - values = np.where(gray < 64, 0, np.where(gray < 128, 1, np.where(gray < 192, 2, 3))).astype(np.uint8) - else: - # BWRY: 00=black, 01=white, 10=yellow, 11=red. - colors = _direct_write_color_values(pixel_array, color_scheme) - values = np.zeros_like(colors) - values[colors == 1] = 1 - values[colors == 2] = 2 - values[colors == 3] = 3 - - pad = (-len(values)) % 4 - if pad: - values = np.pad(values, (0, pad)) - groups = values.reshape(-1, 4) - # Pack from MSB: pixel0 at bits 7-6, pixel1 at 5-4, pixel2 at 3-2, pixel3 at 1-0. - return ( - (groups[:, 0] << 6) - | (groups[:, 1] << 4) - | (groups[:, 2] << 2) - | groups[:, 3] - ).astype(np.uint8).tobytes() - - -def _encode_direct_write_4bpp(image: Image.Image) -> bytes: - """Encode image as 4BPP for direct write (6-color). - - Args: - image: PIL Image to encode - - Returns: - bytes: 4BPP encoded data (2 pixels per byte) - """ - pixel_array = np.asarray(image.convert("RGB"), dtype=np.uint8) - # Firmware expects: black=0, white=1, yellow=2, red=3, blue=5, green=6. - values = _direct_write_color_values(pixel_array, 4) - pad = (-len(values)) % 2 - if pad: - values = np.pad(values, (0, pad)) - pairs = values.reshape(-1, 2) - # Pack two pixels per byte: first pixel in the high nibble, second in the low nibble. - return ((pairs[:, 0] << 4) | pairs[:, 1]).astype(np.uint8).tobytes() - - -def _encode_direct_write(image: Image.Image, color_scheme: int) -> bytes: - """Encode image for direct write based on color scheme. - - Args: - image: PIL Image to encode - color_scheme: Color scheme (0=b/w, 1=bwr, 2=bwy, 3=bwry, 4=bwgbry, 5=bw4) - - Returns: - bytes: Encoded image data - """ - if color_scheme == 0: - return _encode_direct_write_1bpp(image) - elif color_scheme in (1, 2): - return _encode_direct_write_bitplanes(image, color_scheme) - elif color_scheme == 3: - return _encode_direct_write_2bpp(image, color_scheme) - elif color_scheme == 4: - return _encode_direct_write_4bpp(image) - elif color_scheme == 5: - return _encode_direct_write_2bpp(image, color_scheme) - else: - # Fallback to 1BPP - return _encode_direct_write_1bpp(image) - - -def _prepare_block_upload( - image: Image.Image, - metadata: BLEDeviceMetadata, - protocol_type: str, - dither: int, -) -> tuple[Image.Image, int, bytes, float]: - """Prepare block-based BLE upload bytes off the event loop.""" - # ATC stores rotated image memory client-side; OpenDisplay handles rotation firmware-side. - if protocol_type == "atc" and metadata.rotatebuffer == 1: - image = image.transpose(Image.Transpose.ROTATE_90) - _LOGGER.debug("Applied 90° ATC memory rotation: %dx%d", image.width, image.height) - - quantize_start = perf_counter() - processed_image = process_image_for_device( - image, - metadata.color_scheme.value, - dither, - ) - quantize_duration = perf_counter() - quantize_start - data_type, pixel_array = _convert_image_to_bytes( - processed_image, - metadata.color_scheme.value, - compressed=True, - ) - return processed_image, data_type, pixel_array, quantize_duration - - -def _prepare_direct_write_upload( - image: Image.Image, - metadata: BLEDeviceMetadata, - allow_compression: bool, - dither: int, -) -> tuple[Image.Image, bytes, list[bytes], int, int, bool, float]: - """Prepare direct-write BLE upload bytes off the event loop.""" - quantize_start = perf_counter() - processed_image = process_image_for_device( - image, - metadata.color_scheme.value, - dither, - ) - quantize_duration = perf_counter() - quantize_start - encoded_data = _encode_direct_write(processed_image, metadata.color_scheme.value) - compressed_data = ( - _compress_direct_write_if_fits(encoded_data, DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT) - if allow_compression - else None - ) - - if compressed_data is not None: - data_to_send = compressed_data - uncompressed_size = len(encoded_data) - compressed = True - else: - data_to_send = encoded_data - uncompressed_size = 0 - compressed = False - - chunks = [ - data_to_send[i:i + BLE_MAX_PACKET_DATA_SIZE] - for i in range(0, len(data_to_send), BLE_MAX_PACKET_DATA_SIZE) - ] - return processed_image, data_to_send, chunks, uncompressed_size, len(encoded_data), compressed, quantize_duration - - -def _compress_direct_write_if_fits(data: bytes, max_size: int) -> bytes | None: - """Compress direct-write data, returning None once the compressed payload is too large.""" - compressor = zlib.compressobj(level=9) - data_view = memoryview(data) - compressed_parts = [] - compressed_size = 0 - - for start in range(0, len(data_view), DIRECT_WRITE_COMPRESSION_CHUNK_BYTES): - part = compressor.compress( - data_view[start:start + DIRECT_WRITE_COMPRESSION_CHUNK_BYTES] - ) - if part: - compressed_parts.append(part) - compressed_size += len(part) - if compressed_size >= max_size: - return None - - part = compressor.flush() - if part: - compressed_parts.append(part) - compressed_size += len(part) - if compressed_size >= max_size: - return None - - return b"".join(compressed_parts) - - -class BLEImageUploader: - """Handles BLE image upload with block-based protocol. - - This class is protocol-agnostic and works with both ATC and OpenDisplay firmware. - """ - - def __init__(self, connection, mac_address: str): - """Initialize image uploader. - - Args: - connection: Active BLEConnection instance - mac_address: Device MAC address - """ - self.connection = connection - self.mac_address = mac_address - self._img_array = b"" - self._img_array_len = 0 - self._packets = [] - self._packet_index = 0 - self._upload_complete = asyncio.Event() - self._upload_error = None - self._upload_task = None - # Direct write state - self._direct_write_chunks = [] - self._direct_write_chunk_index = 0 - self._direct_write_pending_acks = 0 - self._direct_write_compressed = False - self._direct_write_uncompressed_size = 0 - self.refresh_type: int = 0 - - async def _handle_response(self, data: bytes) -> bool: - """Handle upload responses from notification queue. - - Args: - data: Response data from device - - Returns: - bool: True if response was handled successfully - """ - if len(data) < 2: - return False - - response_code = data[:2].hex().upper() - _LOGGER.debug("Upload response for %s: %s", self.mac_address, response_code) - - try: - response_enum = BLEResponse(response_code) - match response_enum: - case BLEResponse.BLOCK_REQUEST: - _LOGGER.debug("Received block request") - block_id = data[11] - if len(data) >= 18: - requested_parts_hex = data[12:18].hex().upper() - _LOGGER.debug( - "Device requested block %d, parts bitmask: %s", - block_id, - requested_parts_hex, - ) - else: - _LOGGER.debug( - "Device requested block %d (partial block request data)", block_id - ) - await self._send_block_data(block_id) - return True - - case BLEResponse.BLOCK_PART_ACK: - _LOGGER.debug("Block part acknowledged") - await self._send_next_block_part() - return True - - case BLEResponse.BLOCK_PART_CONTINUE: - _LOGGER.debug("Block part acknowledged, continuing") - if self._packet_index >= len(self._packets): - _LOGGER.error("Packet index out of range") - return True - self._packet_index += 1 - await self._send_next_block_part() - return True - - case BLEResponse.UPLOAD_COMPLETE: - _LOGGER.debug("Image upload completed successfully") - self._upload_complete.set() - return True - - case BLEResponse.IMAGE_ALREADY_DISPLAYED: - _LOGGER.debug("Image already displayed") - self._upload_complete.set() - return True - - except ValueError: - return False # Unknown response code - - async def upload_image_block_based( - self, - image: Image.Image, - metadata: BLEDeviceMetadata, - protocol_type: str = "atc", - dither: int = 2, - render_duration: float | None = None, - ) -> tuple[bool, Image.Image | None]: - """Upload image using block-based protocol. - - Args: - image: Rendered image - metadata: Device metadata with dimensions and color support - protocol_type: Protocol type ("atc" or "open_display") - dither: 0=none, 1=ordered, 2=floyd-steinberg - render_duration: Time spent rendering the image before upload, in seconds - - Returns: - tuple: (success, processed_image) - processed_image is the dithered PIL Image - """ - try: - _LOGGER.debug( - "Block upload input for %s: %dx%d (protocol=%s, rotatebuffer=%d)", - self.mac_address, - image.width, - image.height, - protocol_type, - metadata.rotatebuffer, - ) - - processed_image, data_type, pixel_array, quantize_duration = await self.connection.hass.async_add_executor_job( - _prepare_block_upload, - image, - metadata, - protocol_type, - dither, - ) - - _LOGGER.debug( - "Upload for %s: DataType=0x%02x, DataLen=%d", - self.mac_address, - data_type, - len(pixel_array), - ) - _LOGGER.info( - "Starting BLE image upload to %s (%d bytes)", self.mac_address, len(pixel_array) - ) - - self._img_array = pixel_array - self._img_array_len = len(self._img_array) - - # Send data info to initiate upload - data_info = _create_data_info( - 255, zlib.crc32(self._img_array) & 0xFFFFFFF, self._img_array_len, data_type, 0, 0 - ) - send_refresh_start = perf_counter() - await self.connection._write_raw(bytes.fromhex(BLECommand.DATA_INFO.value) + data_info) - - # Wait for responses using request-response pattern - while not self._upload_complete.is_set(): - response = await self._wait_for_response() - if response and await self._handle_response(response): - continue - elif response is None: - # Timeout - this is a failure - _LOGGER.error("Upload failed for %s: timeout waiting for response", self.mac_address) - return False, None - - if self._upload_error: - raise BLEError(f"Upload failed: {self._upload_error}") - - # Only reach here if upload_complete was set by a success response - send_refresh_duration = perf_counter() - send_refresh_start - _LOGGER.info( - "BLE block upload completed for %s: render=%.3fs dither_quantize=%.3fs send_refresh=%.3fs bytes=%d data_type=0x%02x", - self.mac_address, - render_duration or 0.0, - quantize_duration, - send_refresh_duration, - len(pixel_array), - data_type, - ) - return True, processed_image - - except Exception as e: - _LOGGER.error("Image upload failed for %s: %s", self.mac_address, e) - return False, None - - async def _wait_for_response(self, timeout: float = 10.0) -> bytes | None: - """Wait for next upload response with timeout. - - Args: - timeout: Timeout in seconds - - Returns: - bytes: Response data or None if timeout - """ - try: - response = await asyncio.wait_for( - self.connection._response_queue.get(), timeout=timeout - ) - - # Basic validation only - if not response or len(response) < 2: - return None - - return response - - except asyncio.TimeoutError: - return None - - async def _send_block_data(self, block_id: int): - """Send block data for specified block ID. - - Args: - block_id: Block identifier to send - """ - _LOGGER.debug("Building block %d for %s", block_id, self.mac_address) - block_start = block_id * BLE_BLOCK_SIZE - block_end = block_start + BLE_BLOCK_SIZE - block_data = self._img_array[block_start:block_end] - - _LOGGER.debug( - "Sending block %d: %d bytes (offset %d-%d)", - block_id, - len(block_data), - block_start, - min(block_end, len(self._img_array)), - ) - - crc_block = sum(block_data) & 0xFFFF - buffer = bytearray(4) - buffer[0] = len(block_data) & 0xFF - buffer[1] = (len(block_data) >> 8) & 0xFF - buffer[2] = crc_block & 0xFF - buffer[3] = (crc_block >> 8) & 0xFF - block_data = buffer + block_data - - # Create packets - packet_count = (len(block_data) + BLE_MAX_PACKET_DATA_SIZE - 1) // BLE_MAX_PACKET_DATA_SIZE - self._packets = [] - for i in range(packet_count): - start = i * BLE_MAX_PACKET_DATA_SIZE - end = start + BLE_MAX_PACKET_DATA_SIZE - slice_data = block_data[start:end] - packet = _create_block_part(block_id, i, slice_data) - self._packets.append(packet) - - _LOGGER.debug("Created %d packets for block %d", len(self._packets), block_id) - self._packet_index = 0 - if self._packets: - await self._send_next_block_part() - - async def _send_next_block_part(self): - """Send next block part packet.""" - if not self._packets or self._packet_index >= len(self._packets): - _LOGGER.debug("No more packets to send") - return - - _LOGGER.debug("Sending packet %d/%d", self._packet_index + 1, len(self._packets)) - await self.connection._write_raw( - bytes.fromhex(BLECommand.BLOCK_PART.value) + self._packets[self._packet_index] - ) - - async def upload_direct_write( - self, - image: Image.Image, - metadata: BLEDeviceMetadata, - allow_compression: bool = False, - dither: int = 2, - refresh_type: int = 0, - render_duration: float | None = None, - ) -> tuple[bool, Image.Image | None]: - """Upload image using direct write protocol (OpenDisplay only). - - Args: - image: Rendered image - metadata: Device metadata with dimensions and color scheme - allow_compression: Whether zip compression may be used if the result fits - dither: 0=none, 1=ordered, 2=floyd-steinberg - refresh_type: Display refresh mode (0=full, 1=fast, 2=partial, 3=partial2) - render_duration: Time spent rendering the image before upload, in seconds - - Returns: - bool: True if upload succeeded, False otherwise - """ - # Reset upload state - self._upload_complete.clear() - self._upload_error = None - - self.refresh_type = refresh_type - - try: - _LOGGER.debug("Direct write: image size %dx%d", image.width, image.height) - - ( - processed_image, - data_to_send, - chunks, - uncompressed_size, - encoded_size, - compressed, - quantize_duration, - ) = await self.connection.hass.async_add_executor_job( - _prepare_direct_write_upload, - image, - metadata, - allow_compression, - dither, - ) - - if compressed: - _LOGGER.debug( - "Direct write compressed: %d bytes -> %d bytes", - encoded_size, - len(data_to_send) - ) - elif allow_compression: - _LOGGER.debug( - "Direct write compression skipped: compressed payload exceeded %d bytes", - DIRECT_WRITE_COMPRESSED_BUFFER_LIMIT, - ) - - _LOGGER.info( - "Starting direct write upload to %s (%d bytes%s, refresh type %d)", - self.mac_address, - len(data_to_send), - " compressed" if compressed else "", - refresh_type - ) - - # Initialize direct write state - self._direct_write_chunks = [] - self._direct_write_chunk_index = 0 - self._direct_write_pending_acks = 0 - self._direct_write_compressed = compressed - self._direct_write_uncompressed_size = uncompressed_size - self._direct_write_chunks = chunks - - _LOGGER.debug("Split into %d chunks", len(self._direct_write_chunks)) - - # Send start command - send_refresh_start = perf_counter() - if compressed: - # Compressed: send 4-byte header + initial data if it fits - header = struct.pack(" bool: - """Handle direct write responses. - - Args: - data: Response data from device - - Returns: - bool: True if response was handled successfully - """ - if len(data) < 2: - return False - - response_code = data[:2].hex().upper() - _LOGGER.debug("Direct write response for %s: %s", self.mac_address, response_code) - - try: - # Handle both formats: "0070" and "7000" - if response_code in ("0070", "7000"): - # Start ACK - _LOGGER.debug("Direct write start acknowledged") - self._direct_write_pending_acks = 0 - await self._send_next_direct_write_chunks() - return True - elif response_code in ("0071", "7100"): - # Data ACK - self._direct_write_pending_acks = max(0, self._direct_write_pending_acks - 1) - await self._send_next_direct_write_chunks() - return True - elif response_code in ("0072", "7200"): - # End ACK - _LOGGER.debug("Direct write end acknowledged") - self._upload_complete.set() - return True - elif response_code == "FFFF": - # Error - _LOGGER.error("Direct write error response (FFFF)") - self._upload_error = "Device returned error (FFFF)" - self._upload_complete.set() - return True - except Exception as e: - _LOGGER.error("Error handling direct write response: %s", e) - return False - - return False # Unknown response code - - async def _send_next_direct_write_chunks(self): - """Send next direct write data chunks with pipelining.""" - DIRECT_WRITE_PIPELINE_SIZE = 1 # Send up to 1 chunk without waiting for ACK - - while (self._direct_write_chunk_index < len(self._direct_write_chunks) and - self._direct_write_pending_acks < DIRECT_WRITE_PIPELINE_SIZE): - - chunk = self._direct_write_chunks[self._direct_write_chunk_index] - _LOGGER.debug( - "Sending direct write chunk %d/%d (%d bytes)", - self._direct_write_chunk_index + 1, - len(self._direct_write_chunks), - len(chunk) - ) - - await self.connection._write_raw( - bytes.fromhex(BLECommand.DIRECT_WRITE_DATA.value) + chunk - ) - - self._direct_write_chunk_index += 1 - self._direct_write_pending_acks += 1 - - # If all chunks sent and no pending ACKs, send end command - if (self._direct_write_chunk_index >= len(self._direct_write_chunks) and - self._direct_write_pending_acks == 0): - _LOGGER.debug("All chunks sent, ending direct write") - await self.connection._write_raw( - bytes.fromhex(BLECommand.DIRECT_WRITE_END.value) + bytes([self.refresh_type]) - ) diff --git a/custom_components/opendisplay/ble/metadata.py b/custom_components/opendisplay/ble/metadata.py deleted file mode 100644 index 1e3a3af..0000000 --- a/custom_components/opendisplay/ble/metadata.py +++ /dev/null @@ -1,217 +0,0 @@ -"""BLE Device Metadata Abstraction. - -Provides a clean interface for accessing device metadata that transparently -handles differences between ATC (flat structure) and OpenDisplay (nested config) formats. -""" -from __future__ import annotations - -from typing import Any - -from .color_scheme import ColorScheme - -class BLEDeviceMetadata: - """Abstraction for BLE device metadata. - - Wraps raw metadata dictionary and provides clean property-based access - to device capabilities, handling both ATC and OpenDisplay metadata formats. - - Args: - raw_metadata: Dictionary containing device metadata - """ - - def __init__(self, raw_metadata: dict[str, Any]) -> None: - """Initialize BLE device metadata wrapper. - - Args: - raw_metadata: Device metadata dictionary from config entry - """ - if "open_display_config" not in raw_metadata and "oepl_config" in raw_metadata: - self._metadata = {**raw_metadata, "open_display_config": raw_metadata["oepl_config"]} - else: - self._metadata = raw_metadata - self._is_open_display = "open_display_config" in self._metadata - - @property - def width(self) -> int: - """Get display width in pixels. - - Returns: - Display width, or 0 if not available - """ - if self._is_open_display: - displays = self._metadata["open_display_config"].get("displays", []) - return displays[0]["pixel_width"] if displays else 0 - return self._metadata.get("width", 0) - - @property - def height(self) -> int: - """Get display height in pixels. - - Returns: - Display height, or 0 if not available - """ - if self._is_open_display: - displays = self._metadata["open_display_config"].get("displays", []) - return displays[0]["pixel_height"] if displays else 0 - return self._metadata.get("height", 0) - - @property - def model_name(self) -> str: - """Get device model name. - - Returns: - Model name string, or "Unknown" if not available - """ - return self._metadata.get("model_name", "Unknown") - - @property - def fw_version(self) -> int | str: - """Get firmware version. - - Returns: - Firmware version number or string, or 0/"" if not available - """ - if self._is_open_display: - # Prefer explicit string/parsed version saved from interrogation - if "fw_version" in self._metadata: - return self._metadata.get("fw_version", "") - major = self._metadata.get("fw_version_major") - minor = self._metadata.get("fw_version_minor") - if major is not None and minor is not None: - return f"{major}.{minor}" - return self._metadata.get("fw_version", 0) - - def formatted_fw_version(self) -> str | None: - """Return firmware version formatted for display.""" - fw = self.fw_version - if fw in (None, ""): - return None - if isinstance(fw, int): - return f"0x{fw:04x}" - return str(fw) - - - @property - def rotatebuffer(self) -> int: - """Get rotation setting. - - For OpenDisplay devices, returns the rotation value from display config. - For ATC devices, returns the rotatebuffer flag. - - Returns: - Rotation value (0, 1, 2, or 3) or rotatebuffer flag (0 or 1) - """ - if self._is_open_display: - displays = self._metadata["open_display_config"].get("displays", []) - return displays[0].get("rotation", 0) if displays else 0 - return self._metadata.get("rotatebuffer", 0) - - @property - def hw_type(self) -> int: - """Get hardware type identifier. - - Returns: - Hardware type code, or 0 if not available - """ - if self._is_open_display: - displays = self._metadata["open_display_config"].get("displays", []) - return displays[0].get("open_display_tagtype", 0) if displays else 0 - return self._metadata.get("hw_type", 0) - - @property - def power_mode(self) -> int: - """Get power mode setting. - - Returns: - Power mode: 1=battery, 2=USB, 3=solar - ATC devices always return 1 (battery) - """ - if self._is_open_display: - power = self._metadata["open_display_config"].get("power") - if power: - return power.get("power_mode", 1) - return 1 # ATC devices always have batteries - - @property - def is_open_display(self) -> bool: - """Check if this is an OpenDisplay device. - - Returns: - True if OpenDisplay device, False if ATC device - """ - return self._is_open_display - - @property - def color_scheme(self) -> ColorScheme: - """ - Get ColorScheme enum for this device. - - ATC: Reads from root level device_metadata["color_scheme"] - - OpenDisplay: Reads from display config device_metadata["open_display_config"]["displays"][0]["color_scheme"] - """ - if self._is_open_display: - displays = self._metadata["open_display_config"].get("displays", []) - raw_scheme = displays[0].get("color_scheme", 0) if displays else 0 - else: - raw_scheme = self._metadata.get("color_scheme", 0) - return ColorScheme.from_int(raw_scheme) - - @property - def accent_color(self) -> str: - """Get accent color name. - - Returns: - Accent color name from color scheme palette - """ - return self.color_scheme.accent_color - - @property - def is_multi_color(self) -> bool: - """Check if device supports multiple colors. - - Returns: - True if color scheme has more than 2 colors, False otherwise - """ - return self.color_scheme.is_multi_color - - @property - def transmission_modes(self) -> int: - """Get supported transmission modes (bitfield). - - Bit flags: - - Bit 0 (0x01): raw transfer (block-based uncompressed) - - Bit 1 (0x02): zip compressed transfer (block-based compressed) - - Bit 3 (0x08): direct_write mode - - Returns: - Transmission modes bitfield, or 0 if not available - ATC devices return 0 (assume block-based only for backward compatibility) - """ - if self._is_open_display: - displays = self._metadata["open_display_config"].get("displays", []) - return displays[0].get("transmission_modes", 0) if displays else 0 - return 0 # ATC devices don't support direct_write - - @property - def supports_zip_compression(self) -> bool: - """Return true if the device advertises zip-compressed transfer support.""" - return (self.transmission_modes & 0x02) != 0 - - def get_best_upload_method(self) -> str: - """Determine the best upload method based on device capabilities. - - Priority order: - 1. direct_write: If direct_write (0x08) is supported - 2. block: Fallback to block-based upload (always supported) - - Returns: - Upload method string: "direct_write" or "block" - """ - modes = self.transmission_modes - has_direct_write = (modes & 0x08) != 0 - - if has_direct_write: - return "direct_write" - else: - return "block" diff --git a/custom_components/opendisplay/ble/operations.py b/custom_components/opendisplay/ble/operations.py deleted file mode 100644 index 191ab08..0000000 --- a/custom_components/opendisplay/ble/operations.py +++ /dev/null @@ -1,148 +0,0 @@ -"""BLE operations with decorator for automatic retry and locking.""" -import asyncio -import logging -from functools import wraps -from typing import Dict - -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from bleak.exc import BleakError - -from .connection import BLEConnection -from .exceptions import BLEConnectionError -from ..const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -# Per-device async locks for preventing concurrent operations -_device_locks: Dict[str, asyncio.Lock] = {} - -# LED control commands (common to all protocols) -CMD_LED_ON = bytes.fromhex("000103") -CMD_LED_OFF = bytes.fromhex("000100") -CMD_LED_OFF_FINAL = bytes.fromhex("0000") - - -def ble_device_operation(func): - """Decorator for BLE operations with automatic connection, retry, and locking. - - Provides: - - Per-device async locking (prevents concurrent operations on same device) - - 3 retry attempts with exponential backoff (0.25s, 0.5s, 0.75s) - - Automatic connection creation with protocol-specific service UUID - - Error handling and logging - - The decorated function receives a BLEConnection as first argument. - Requires 'hass', 'mac_address', 'service_uuid', and 'protocol' in function arguments/kwargs. - """ - - @wraps(func) - async def wrapper(hass: HomeAssistant, mac_address: str, service_uuid: str, protocol, *args, **kwargs): - # Get or create lock for this device - lock = _device_locks.setdefault(mac_address, asyncio.Lock()) - - async with lock: - max_attempts = 3 - for attempt in range(max_attempts): - try: - # Create connection with protocol-specific service UUID - async with BLEConnection(hass, mac_address, service_uuid, protocol) as conn: - # Inject connection as first argument to decorated function - return await func(conn, *args, **kwargs) - - except BLEConnectionError as e: - # Check if it's a connection slots error - don't retry these - if "No available Bluetooth connection slots" in str(e): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="ble_slots_unavailable", - translation_placeholders={"mac_address": mac_address, "error": str(e)}, - ) from e - - # For other connection errors, retry - if attempt == max_attempts - 1: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="ble_operation_failed", - translation_placeholders={ - "operation": func.__name__, - "attempts": max_attempts, - "error": str(e), - }, - ) from e - - backoff_time = 0.25 * (attempt + 1) - _LOGGER.warning( - "BLE operation %s failed on attempt %d: %s. Retrying in %.2f seconds...", - func.__name__, - attempt + 1, - e, - backoff_time, - ) - await asyncio.sleep(backoff_time) - - except BleakError as e: - if attempt == max_attempts - 1: - _LOGGER.error( - "BLE operation %s failed after %d attempts: %s", - func.__name__, - max_attempts, - e, - ) - raise - backoff_time = 0.25 * (attempt + 1) - _LOGGER.warning( - "BLE operation %s failed on attempt %d: %s. Retrying in %.2f seconds...", - func.__name__, - attempt + 1, - e, - backoff_time, - ) - await asyncio.sleep(backoff_time) - - return None - - return wrapper - - -@ble_device_operation -async def turn_led_on(conn: BLEConnection) -> bool: - """Turn on LED for specified device. - - Args: - conn: Active BLE connection - - Returns: - bool: True if command sent successfully - """ - await conn.write_command(CMD_LED_ON) - return True - - -@ble_device_operation -async def turn_led_off(conn: BLEConnection) -> bool: - """Turn off LED for specified device. - - Args: - conn: Active BLE connection - - Returns: - bool: True if command sent successfully - """ - await conn.write_command(CMD_LED_OFF) - await conn.write_command(CMD_LED_OFF_FINAL) # Required finalization command - return True - - -@ble_device_operation -async def ping_device(conn: BLEConnection) -> bool: - """Test device connectivity. - - Args: - conn: Active BLE connection - - Returns: - bool: True if device is reachable - """ - # If connection and initialization succeed, device is reachable - return True diff --git a/custom_components/opendisplay/ble/protocol_atc.py b/custom_components/opendisplay/ble/protocol_atc.py deleted file mode 100644 index 70de92a..0000000 --- a/custom_components/opendisplay/ble/protocol_atc.py +++ /dev/null @@ -1,216 +0,0 @@ -"""ATC firmware protocol implementation.""" -import struct -import logging -from typing import TYPE_CHECKING - -from .protocol_base import BLEProtocol, AdvertisingData, DeviceCapabilities -from .exceptions import BLEProtocolError -from ..const import DOMAIN - -if TYPE_CHECKING: - from .connection import BLEConnection - -_LOGGER = logging.getLogger(__name__) - -# ATC protocol constants -CMD_GET_DISPLAY_INFO = bytes([0x00, 0x05]) -BLE_MIN_RESPONSE_LENGTH = 33 - - -class ATCProtocol(BLEProtocol): - """ATC firmware protocol implementation. - - Supports the original ATC BLE firmware protocol with: - - Manufacturer ID: 0x1337 (4919) - - Service UUID: 00001337-0000-1000-8000-00805f9b34fb - - Interrogation: CMD_GET_DISPLAY_INFO (0x0005) - - Advertising: Version 1 (10 bytes) and Version 2 (11 bytes with temperature) - """ - - @property - def manufacturer_id(self) -> int: - """Bluetooth manufacturer ID for ATC firmware.""" - return 0x1337 # 4919 decimal - - @property - def service_uuid(self) -> str: - """BLE GATT service UUID for ATC firmware.""" - return "00001337-0000-1000-8000-00805f9b34fb" - - @property - def protocol_name(self) -> str: - """Protocol identifier.""" - return "atc" - - def parse_advertising_data(self, data: bytes) -> AdvertisingData: - """Parse ATC manufacturer data for device state updates. - - Supports two advertising formats: - - Version 1: 10 bytes (no temperature) - - Version 2: 11 bytes (with temperature) - - Args: - data: Manufacturer-specific advertising data - - Returns: - AdvertisingData: Parsed advertising information - - Raises: - ValueError: If data format is invalid - """ - if not data: - raise ValueError("Empty advertising data") - - try: - version = data[0] - - if version == 1: - if len(data) < 10: - raise ValueError(f"Version 1 requires 10 bytes, got {len(data)}") - - hw_type = int.from_bytes(data[1:3], "little") - fw_version = int.from_bytes(data[3:5], "little") - battery_mv = int.from_bytes(data[7:9], "little") - battery_pct = self._calculate_battery_percentage(battery_mv) - - return AdvertisingData( - battery_mv=battery_mv, - battery_pct=battery_pct, - temperature=None, # Not available in version 1 - hw_type=hw_type, - fw_version=fw_version, - version=version, - ) - - elif version == 2: - if len(data) < 11: - raise ValueError(f"Version 2 requires 11 bytes, got {len(data)}") - - hw_type = int.from_bytes(data[1:3], "little") - fw_version = int.from_bytes(data[3:5], "little") - battery_mv = int.from_bytes(data[7:9], "little") - battery_pct = self._calculate_battery_percentage(battery_mv) - temperature = struct.unpack(" DeviceCapabilities: - """Query device using CMD_GET_DISPLAY_INFO (0x0005). - - Connects to device and retrieves display specifications including: - - Display dimensions (width, height) - - Color support capabilities - - Buffer rotation requirement - - Args: - connection: Active BLE connection to device - - Returns: - DeviceCapabilities: Minimal device information - - Raises: - BLEProtocolError: If interrogation fails or response is invalid - """ - # Request display information using protocol command 0005 - response = await connection.write_command_with_response(CMD_GET_DISPLAY_INFO) - - _LOGGER.debug( - "ATC device interrogation for %s: received %d bytes", - connection.mac_address, - len(response), - ) - - # Verify response format: 00 05 + payload - if len(response) < BLE_MIN_RESPONSE_LENGTH: - raise BLEProtocolError( - translation_domain=DOMAIN, - translation_key="ble_protocol_invalid_response_length", - translation_placeholders={ - "length": str(len(response)), - "expected_length": str(BLE_MIN_RESPONSE_LENGTH) - } - ) - - # Verify command ID (should be 0x0005) - if response[0] != 0x00 or response[1] != 0x05: - raise BLEProtocolError( - translation_domain=DOMAIN, - translation_key="ble_protocol_invalid_command_id", - translation_placeholders={ - "command_id": f"{response[0]:02x}{response[1]:02x}" - } - ) - - # Skip command ID (first 2 bytes) and parse payload - payload = response[2:] - - if len(payload) < 31: - raise BLEProtocolError( - translation_domain=DOMAIN, - translation_key="ble_protocol_invalid_response_payload", - ) - - # Parse display specifications from 0005 response: - - # Offset 19: Width/Height inversion flag - wh_inverted = payload[19] == 1 - - # Offset 22-23: Height (uint16, little-endian) - height = struct.unpack("= 3: - color_scheme = 3 # BWRY - elif colors >= 2: - color_scheme = 1 # BWR (default for 2-color, refined later) - else: - color_scheme = 0 # MONO - - _LOGGER.debug( - "ATC device %s dimensions: %dx%d, colors=%d, inverted=%s", - connection.mac_address, - width, - height, - colors, - wh_inverted, - ) - - return DeviceCapabilities( - width=width if wh_inverted else height, - height=height if wh_inverted else width, - color_scheme=color_scheme, - rotatebuffer=1, # ATC devices always need 90° rotation - ) - - async def initialize_connection(self, connection: "BLEConnection") -> None: - """ATC protocol requires CMD_INIT command before use.""" - import asyncio - from .connection import CMD_INIT, INIT_DELAY_SECONDS - - _LOGGER.debug( - "Sending CMD_INIT to ATC device %s, waiting %ss", - connection.mac_address, - INIT_DELAY_SECONDS - ) - await connection.write_command(CMD_INIT) - await asyncio.sleep(INIT_DELAY_SECONDS) - diff --git a/custom_components/opendisplay/ble/protocol_base.py b/custom_components/opendisplay/ble/protocol_base.py deleted file mode 100644 index 081f281..0000000 --- a/custom_components/opendisplay/ble/protocol_base.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Base protocol abstraction for BLE firmware types.""" -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .connection import BLEConnection - - -@dataclass -class AdvertisingData: - """Parsed BLE advertising data.""" - - battery_mv: int - battery_pct: int - temperature: float | None - hw_type: int - fw_version: int - version: int # Config/protocol version - - -@dataclass -class DeviceCapabilities: - """Minimal device information needed for Home Assistant setup.""" - - width: int - height: int - color_scheme: int # 0=MONO, 1=BWR, 2=BWY, 3=BWRY, 4=BWGBRY, 5=GRAYSCALE - rotatebuffer: int - - -class BLEProtocol(ABC): - """Abstract base class for BLE firmware protocols. - - Each firmware type (ATC, OpenDisplay) implements this interface to provide - protocol-specific behavior while sharing common infrastructure. - """ - - @staticmethod - def _calculate_battery_percentage(voltage_mv: int) -> int: - """Convert battery voltage (mV) to percentage estimate. - - Args: - voltage_mv: Battery voltage in millivolts - - Returns: - int: Battery percentage (0-100) - """ - if voltage_mv == 0: - return 0 # Unknown battery level - - voltage = voltage_mv / 1000.0 - min_voltage, max_voltage = 2.6, 3.2 # Battery voltage range - percentage = min( - 100, max(0, int((voltage - min_voltage) * 100 / (max_voltage - min_voltage))) - ) - return percentage - - @property - @abstractmethod - def manufacturer_id(self) -> int: - """Bluetooth manufacturer ID for device discovery.""" - - @property - @abstractmethod - def service_uuid(self) -> str: - """BLE GATT service UUID for communication.""" - - @property - @abstractmethod - def protocol_name(self) -> str: - """Protocol identifier: 'atc' or 'open_display'.""" - - @abstractmethod - def parse_advertising_data(self, data: bytes) -> AdvertisingData: - """Parse manufacturer-specific advertising data. - - Args: - data: Raw manufacturer-specific data from BLE advertisement - - Returns: - AdvertisingData: Parsed advertising information - - Raises: - ValueError: If data format is invalid - """ - - @abstractmethod - async def interrogate_device( - self, connection: "BLEConnection" - ) -> DeviceCapabilities: - """Query device capabilities during setup. - - Returns minimal information needed for Home Assistant entity creation. - - For OpenDisplay: Reads full config via 0x0040, extracts display dimensions. - For ATC: Uses legacy 0x0005 command. - - Args: - connection: Active BLE connection to device - - Returns: - DeviceCapabilities: Minimal device information - - Raises: - BLEError: If interrogation fails - ConfigValidationError: If device returns invalid data - """ - - async def initialize_connection(self, connection: "BLEConnection") -> None: - """Perform protocol-specific connection initialization. - - Called after BLE connection is established and notifications are enabled. - Protocols can override this to send initialization commands if needed. - - Args: - connection: Active BLE connection - - Default implementation does nothing - protocols requiring initialization - should override this method. - """ - pass # Default: no initialization needed diff --git a/custom_components/opendisplay/ble/protocol_factory.py b/custom_components/opendisplay/ble/protocol_factory.py deleted file mode 100644 index 03b434c..0000000 --- a/custom_components/opendisplay/ble/protocol_factory.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Protocol factory for detecting and managing BLE firmware protocols.""" -from .protocol_base import BLEProtocol -from .protocol_atc import ATCProtocol -from .protocol_open_display import OpenDisplayProtocol -from .exceptions import UnsupportedProtocolError - - -# Singleton protocol instances -_PROTOCOLS: dict[int, BLEProtocol] = { - 0x1337: ATCProtocol(), # ATC firmware (4919 decimal) - 0x2446: OpenDisplayProtocol(), # OpenDisplay firmware (9286 decimal) -} - - -def get_protocol_by_manufacturer_id(mfg_id: int) -> BLEProtocol: - """Get protocol instance by Bluetooth manufacturer ID. - - Args: - mfg_id: Manufacturer ID from BLE advertisement - - Returns: - BLEProtocol: Protocol instance for the given manufacturer ID - - Raises: - UnsupportedProtocolError: If manufacturer ID is not supported - """ - protocol = _PROTOCOLS.get(mfg_id) - if not protocol: - supported_ids = [f"{mid:#06x} ({mid})" for mid in _PROTOCOLS.keys()] - raise UnsupportedProtocolError( - f"Unknown manufacturer ID: {mfg_id:#06x} ({mfg_id}). " - f"Supported IDs: {', '.join(supported_ids)}" - ) - return protocol - - -def get_protocol_by_name(name: str) -> BLEProtocol: - """Get protocol instance by protocol name. - - Args: - name: Protocol name ('atc' or 'open_display') - - Returns: - BLEProtocol: Protocol instance for the given name - - Raises: - UnsupportedProtocolError: If protocol name is not recognized - """ - for protocol in _PROTOCOLS.values(): - if protocol.protocol_name == name: - return protocol - - supported_names = [p.protocol_name for p in _PROTOCOLS.values()] - raise UnsupportedProtocolError( - f"Unknown protocol: '{name}'. Supported protocols: {', '.join(supported_names)}" - ) - - -def get_supported_manufacturer_ids() -> list[int]: - """Get list of supported manufacturer IDs for discovery. - - Returns: - list[int]: List of manufacturer IDs that can be auto-discovered - """ - return list(_PROTOCOLS.keys()) diff --git a/custom_components/opendisplay/ble/protocol_open_display.py b/custom_components/opendisplay/ble/protocol_open_display.py deleted file mode 100644 index 1556cdc..0000000 --- a/custom_components/opendisplay/ble/protocol_open_display.py +++ /dev/null @@ -1,485 +0,0 @@ -"""OpenDisplay firmware protocol implementation.""" -import logging -from typing import TYPE_CHECKING - -from .protocol_base import BLEProtocol, AdvertisingData, DeviceCapabilities -from .tlv_parser import ( - GlobalConfig, - describe_color_scheme, - extract_display_capabilities, - parse_tlv_config, -) -from .exceptions import ConfigValidationError -from ..const import DOMAIN - -if TYPE_CHECKING: - from .connection import BLEConnection - -_LOGGER = logging.getLogger(__name__) - -# OpenDisplay protocol constants -CMD_READ_CONFIG = bytes([0x00, 0x40]) -CMD_READ_FW_VERSION = bytes([0x00, 0x43]) - - -def _format_config_summary(config: GlobalConfig, mac_address: str) -> str: - """Format OpenDisplay configuration as human-readable debug output. - - Args: - config: Parsed OpenDisplay device configuration - mac_address: Device MAC address for reference - - Returns: - Multi-line formatted configuration summary - """ - lines = [f"\nOpenDisplay Configuration for {mac_address}:"] - - # Device Identity - lines.append(" Device:") - if config.system: - ic_names = {1: "nRF52840", 2: "ESP32-S3", 3: "ESP32-C3", 4: "ESP32-C6"} - ic_type = ic_names.get(config.system.ic_type, f"Unknown ({config.system.ic_type})") - lines.append(f" - IC Type: {ic_type}") - lines.append(f" - Communication Modes: 0x{config.system.communication_modes:02x}") - lines.append(f" - Device Flags: 0x{config.system.device_flags:02x}") - - if config.manufacturer: - lines.append(f" - Manufacturer ID: 0x{config.manufacturer.manufacturer_id:04x}") - lines.append(f" - Board Type: {config.manufacturer.board_type}") - lines.append(f" - Board Revision: {config.manufacturer.board_revision}") - - # Display Configuration (primary display) - if config.displays: - display = config.displays[0] # Primary display - lines.append(" Display (primary):") - - # Calculate diagonal size if physical dimensions available - size_info = f"{display.pixel_width}x{display.pixel_height} pixels" - if display.active_width_mm > 0 and display.active_height_mm > 0: - import math - diagonal_mm = math.sqrt(display.active_width_mm ** 2 + display.active_height_mm ** 2) - diagonal_inches = diagonal_mm / 25.4 - size_info += f" ({display.active_width_mm}x{display.active_height_mm}mm, {diagonal_inches:.1f}\")" - lines.append(f" - Dimensions: {size_info}") - - color_scheme = describe_color_scheme(display.color_scheme) - lines.append(f" - Color Scheme: {color_scheme}") - lines.append(f" - Rotation: {display.rotation}°") - lines.append(f" - Panel IC: {display.panel_ic_type}") - - if len(config.displays) > 1: - lines.append(f" - Additional Displays: {len(config.displays) - 1}") - - # Power Configuration - if config.power: - lines.append(" Power:") - lines.append(f" - Battery Capacity: {config.power.battery_capacity_mah} mAh") - lines.append(f" - Power Mode: {config.power.power_mode}") - - # Convert sleep timeout to human-readable format - sleep_sec = config.power.sleep_timeout_ms / 1000 - if sleep_sec >= 60: - lines.append(f" - Sleep Timeout: {sleep_sec / 60:.1f} minutes") - else: - lines.append(f" - Sleep Timeout: {sleep_sec:.1f} seconds") - - lines.append(f" - TX Power: {config.power.tx_power:+d} dBm") - lines.append(f" - Deep Sleep Current: {config.power.deep_sleep_current_ua} µA") - - # Optional Hardware Summary - hardware_summary = [] - if config.leds: - led_types = ", ".join([f"#{led.instance_number} type {led.led_type}" for led in config.leds]) - hardware_summary.append(f"LEDs: {len(config.leds)} ({led_types})") - - if config.sensors: - sensor_types = ", ".join([f"#{sensor.instance_number} type {sensor.sensor_type}" for sensor in config.sensors]) - hardware_summary.append(f"Sensors: {len(config.sensors)} ({sensor_types})") - - if config.buses: - bus_types = {0: "I2C", 1: "SPI"} - bus_list = ", ".join([f"#{bus.instance_number} {bus_types.get(bus.bus_type, 'Unknown')}" for bus in config.buses]) - hardware_summary.append(f"Buses: {len(config.buses)} ({bus_list})") - - if config.inputs: - hardware_summary.append(f"Digital Inputs: {len(config.inputs)}") - - if hardware_summary: - lines.append(" Optional Hardware:") - for hw in hardware_summary: - lines.append(f" - {hw}") - - return "\n".join(lines) - - -class OpenDisplayProtocol(BLEProtocol): - """OpenDisplay firmware protocol implementation. - - Supports the new OpenDisplay BLE firmware protocol with: - - Manufacturer ID: 0x2446 (9286) - - Service UUID: 00002446-0000-1000-8000-00805f9b34fb - - Interrogation: CMD_READ_CONFIG (0x0040) with TLV parsing - - Advertising: 13-byte format (sensor data currently placeholder) - - Complete TLV configuration system - """ - - def __init__(self): - """Initialize OpenDisplay protocol.""" - self._last_config: GlobalConfig | None = None - self._last_fw_version: dict | None = None - - @property - def manufacturer_id(self) -> int: - """Bluetooth manufacturer ID for OpenDisplay firmware.""" - return 0x2446 # 9286 decimal - - @property - def service_uuid(self) -> str: - """BLE GATT service UUID for OpenDisplay firmware.""" - return "00002446-0000-1000-8000-00805f9b34fb" - - @property - def protocol_name(self) -> str: - """Protocol identifier.""" - return "open_display" - - def parse_advertising_data(self, data: bytes) -> AdvertisingData: - """Parse OpenDisplay manufacturer data for device state updates. - - OpenDisplay firmware has two advertising formats: - - Legacy (11 bytes): Same layout as ATC (battery at 7-8, temp at 9) - - Current/v1 (14 bytes): Firmware 1.0+ (temp at 11, battery at 12-13) - - Args: - data: Manufacturer-specific advertising data - - Returns: - AdvertisingData: Parsed advertising information - - Raises: - ValueError: If data format is invalid - """ - if not data: - raise ValueError("Empty advertising data") - - # Minimum required: version(1) + hw_type(2) + fw_version(2) = 5 bytes - if len(data) < 5: - raise ValueError(f"OpenDisplay advertising requires at least 5 bytes, got {len(data)}") - - # Parse core fields - version = data[0] - hw_type = int.from_bytes(data[1:3], "little") - fw_version = int.from_bytes(data[3:5], "little") - - # Parse optional sensor data if present - battery_mv = 0 - battery_pct = 0 - temperature = None - - if len(data) >= 14: - # Current v1 format (Firmware 1.0+) - # Temperature at index 11: 0.5 C resolution, -40 C offset - temperature = (data[11] / 2.0) - 40.0 - - # Battery at index 12 and 13: 10mV resolution, 9-bit value - battery_mv = ((data[13] & 0x01) << 8 | data[12]) * 10 - if battery_mv > 0: - battery_pct = self._calculate_battery_percentage(battery_mv) - else: - # Legacy format: Battery voltage at bytes 7-8 (same as ATC) - if len(data) >= 9: - battery_mv = int.from_bytes(data[7:9], "little") - - if battery_mv > 0: - battery_pct = self._calculate_battery_percentage(battery_mv) - - # Temperature at byte 9 (signed int8, same as ATC) - if len(data) >= 10: - import struct - temperature = float(struct.unpack(" DeviceCapabilities: - """Query device during setup using CMD_READ_CONFIG (0x0040). - - Reads the complete device TLV configuration but returns only the - minimal display information needed for Home Assistant entity setup. - - This replaces the legacy 0x0005 command used by ATC firmware. - - The OpenDisplay firmware sends config data in chunks: - - Chunk 0: [cmd_echo:2][chunk_num:2][total_len:2][tlv_data:~94] - - Chunk N: [cmd_echo:2][chunk_num:2][tlv_data:~96] - - Args: - connection: Active BLE connection to device - - Returns: - DeviceCapabilities: Minimal device information for HA setup - - Raises: - ConfigValidationError: If config is invalid or missing display data - """ - import asyncio - - _LOGGER.debug("OpenDisplay device interrogation for %s", connection.mac_address) - - # Read first chunk - response = await connection.write_command_with_response(CMD_READ_CONFIG) - - _LOGGER.debug( - "OpenDisplay config response for %s: received %d bytes", - connection.mac_address, - len(response), - ) - - # Debug: log first 20 bytes to understand response format - _LOGGER.debug( - "OpenDisplay config first 20 bytes: %s", - response[:20].hex() if len(response) >= 20 else response.hex() - ) - - # Strip command echo (first 2 bytes are the command 0x0040 echoed back) - if len(response) >= 2 and response[0:2] == CMD_READ_CONFIG: - chunk_data = response[2:] - _LOGGER.debug("Stripped command echo, chunk data is %d bytes", len(chunk_data)) - else: - chunk_data = response - _LOGGER.warning("Expected command echo not found, using full response") - - # Parse chunk header - if len(chunk_data) < 4: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="opendisplay_config_chunk_short", - translation_placeholders={"length": str(len(chunk_data))} - ) - - chunk_num = int.from_bytes(chunk_data[0:2], "little") - _LOGGER.debug("Received chunk number: %d", chunk_num) - - if chunk_num != 0: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="opendisplay_expected_chunk_zero", - translation_placeholders={"chunk_num": str(chunk_num)} - ) - - # Parse total length from chunk 0 - total_length = int.from_bytes(chunk_data[2:4], "little") - _LOGGER.debug("Total config length: %d bytes", total_length) - - # Extract TLV data from chunk 0 (skip 4-byte chunk header) - tlv_data = bytearray(chunk_data[4:]) - _LOGGER.debug("Chunk 0 TLV data: %d bytes", len(tlv_data)) - - # Collect remaining chunks if needed - # Firmware sends all chunks automatically with 50ms delay between them - max_chunks = 10 # Safety limit to prevent infinite loops - current_chunk = 1 - - while len(tlv_data) < total_length and current_chunk < max_chunks: - _LOGGER.debug( - "Waiting for chunk %d (have %d of %d bytes)", - current_chunk, - len(tlv_data), - total_length, - ) - - try: - # Read next chunk from queue (firmware sends them automatically) - next_response = await asyncio.wait_for( - connection._response_queue.get(), timeout=2.0 - ) - except asyncio.TimeoutError: - _LOGGER.warning( - "Timeout waiting for chunk %d (have %d of %d bytes)", - current_chunk, - len(tlv_data), - total_length, - ) - break - - _LOGGER.debug("Received chunk response: %d bytes", len(next_response)) - - # Strip command echo from next chunk - if len(next_response) >= 2 and next_response[0:2] == CMD_READ_CONFIG: - next_chunk_data = next_response[2:] - else: - next_chunk_data = next_response - - # Parse chunk header - if len(next_chunk_data) >= 2: - next_chunk_num = int.from_bytes(next_chunk_data[0:2], "little") - _LOGGER.debug("Received chunk %d", next_chunk_num) - - if next_chunk_num != current_chunk: - _LOGGER.warning( - "Expected chunk %d, got chunk %d", - current_chunk, - next_chunk_num, - ) - - # Subsequent chunks don't have total_length, just chunk_num - tlv_data.extend(next_chunk_data[2:]) - _LOGGER.debug( - "Chunk %d TLV data: %d bytes (total: %d/%d)", - next_chunk_num, - len(next_chunk_data[2:]), - len(tlv_data), - total_length, - ) - - current_chunk += 1 - - _LOGGER.debug("Collected %d bytes of TLV data in %d chunks", len(tlv_data), current_chunk) - _LOGGER.debug("Complete TLV data (hex): %s", tlv_data.hex()) - - # Strip OpenDisplay config header: [length:2][version:1] - # The firmware sends: [length:2][version:1][packets...][crc:2] - if len(tlv_data) < 3: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="opendisplay_config_too_short", - translation_placeholders={"length": str(len(tlv_data))} - ) - - config_length = int.from_bytes(tlv_data[0:2], "little") - config_version = tlv_data[2] - - _LOGGER.debug( - "OpenDisplay config header: length=%d bytes, version=%d", - config_length, - config_version, - ) - - # Extract packet data (skip 3-byte header) - packet_data = tlv_data[3:] - - _LOGGER.debug("Packet data after stripping header: %d bytes", len(packet_data)) - - # Parse complete TLV config (OpenDisplay format: [packet_number:1][packet_id:1][fixed_data]) - try: - full_config = parse_tlv_config(bytes(packet_data)) - except ConfigValidationError as e: - _LOGGER.error("Failed to parse OpenDisplay config for %s: %s", connection.mac_address, e) - raise - - # Store for potential future use (optional - for config management features) - self._last_config = full_config - - # Log complete configuration in human-readable format - _LOGGER.debug(_format_config_summary(full_config, connection.mac_address)) - - _LOGGER.debug( - "OpenDisplay device %s config: %d displays, %d LEDs, %d sensors", - connection.mac_address, - len(full_config.displays), - len(full_config.leds), - len(full_config.sensors), - ) - - # Extract and return only what Home Assistant needs right now - return extract_display_capabilities(full_config) - - async def read_config(self, connection: "BLEConnection") -> GlobalConfig: - """Read complete device configuration (FUTURE - for config management service). - - This is different from interrogate_device(): - - interrogate_device(): Automatic during setup, returns 4 fields - - read_config(): Manual service call, returns everything - - Both send command 0x0040 but return different data structures. - - Args: - connection: Active BLE connection to device - - Returns: - GlobalConfig: Complete device configuration - - Raises: - ConfigValidationError: If config parsing fails - """ - response = await connection.write_command_with_response(CMD_READ_CONFIG) - - # Strip command echo (first 2 bytes are the command 0x0040 echoed back) - if len(response) >= 2 and response[0:2] == CMD_READ_CONFIG: - config_data = response[2:] - else: - config_data = response - - config = parse_tlv_config(config_data) - self._last_config = config - return config - - def get_last_config(self) -> GlobalConfig | None: - """Return last read config (for potential future features). - - Returns: - GlobalConfig: Last config read via interrogate_device() or read_config(), - or None if no config has been read yet - """ - return self._last_config - - async def read_firmware_version(self, connection: "BLEConnection") -> dict: - """Read firmware version using command 0x0043. - - Returns: - dict: Firmware version info with keys: major, minor, sha, version, raw - """ - response = await connection.write_command_with_response(CMD_READ_FW_VERSION) - - # Strip command echo if present - if response.startswith(CMD_READ_FW_VERSION): - payload = response[2:] - else: - payload = response - - if len(payload) < 2: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="opendisplay_fw_response_short", - translation_placeholders={"length": str(len(payload))} - ) - - major = payload[0] - minor = payload[1] - sha = "" - - if len(payload) >= 3: - sha_length = payload[2] - if sha_length > 0: - if len(payload) < 3 + sha_length: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="opendisplay_fw_version_format", - translation_placeholders={ "sha_length": str(sha_length)} - ) - sha_bytes = payload[3:3 + sha_length] - sha = bytes(sha_bytes).decode("ascii", errors="ignore") - - version_str = f"{major}.{minor}" - self._last_fw_version = { - "major": major, - "minor": minor, - "sha": sha, - "version": version_str, - "raw": (major << 8) | minor, - } - - _LOGGER.debug( - "OpenDisplay firmware version for %s: %s (sha=%s)", - connection.mac_address, - version_str, - sha[:8] if sha else "n/a", - ) - - return self._last_fw_version diff --git a/custom_components/opendisplay/ble/tlv_parser.py b/custom_components/opendisplay/ble/tlv_parser.py deleted file mode 100644 index e4a7d55..0000000 --- a/custom_components/opendisplay/ble/tlv_parser.py +++ /dev/null @@ -1,758 +0,0 @@ -"""TLV configuration parser for OpenDisplay BLE firmware. - -Parses the complete device configuration from 0x0040 (Read Config) response. -Based on structs.h from OpenDisplay_BLE firmware. -""" -import struct -import zlib -from dataclasses import asdict, dataclass, field -from typing import Any, ClassVar - -from .exceptions import ConfigValidationError -from .protocol_base import DeviceCapabilities -from .color_scheme import ColorScheme -from ..const import DOMAIN - -# TLV packet type constants -PACKET_TYPE_SYSTEM_CONFIG = 0x01 -PACKET_TYPE_MANUFACTURER_DATA = 0x02 -PACKET_TYPE_POWER_OPTION = 0x04 -PACKET_TYPE_DISPLAY_CONFIG = 0x20 -PACKET_TYPE_LED_CONFIG = 0x21 -PACKET_TYPE_SENSOR_DATA = 0x23 -PACKET_TYPE_DATA_BUS = 0x24 -PACKET_TYPE_BINARY_INPUTS = 0x25 - - -@dataclass -class SystemConfig: - """Packet type 0x01 - System configuration (22 bytes).""" - - SIZE: ClassVar[int] = 22 - - ic_type: int # IC type: 0=nRF52840, 1=ESP32-S3 - communication_modes: int # Supported communication modes (bitfield) - device_flags: int # Misc device flags (bitfield) - pwr_pin: int # Power pin number (0xFF = not present) - reserved: bytes # 17 reserved bytes - - @classmethod - def from_bytes(cls, data: bytes) -> "SystemConfig": - """Parse SystemConfig from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "SystemConfig", "expected": cls.SIZE, "actual": len(data)} - ) - ic_type, comm_modes, dev_flags, pwr_pin = struct.unpack_from(" "ManufacturerData": - """Parse ManufacturerData from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "ManufacturerData", "expected": cls.SIZE, "actual": len(data)} - ) - mfg_id, board_type, board_rev = struct.unpack_from(" "PowerOption": - """Parse PowerOption from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "PowerOption", "expected": cls.SIZE, "actual": len(data)} - ) - - # Battery capacity is 3 bytes (little-endian) - battery_capacity = int.from_bytes(data[1:4], byteorder="little") - - ( - power_mode, - sleep_timeout, - tx_power, - sleep_flags, - bat_sense_pin, - bat_sense_en_pin, - bat_sense_flags, - capacity_est, - voltage_scale, - deep_sleep_ua, - ) = struct.unpack_from(" "DisplayConfig": - """Parse DisplayConfig from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "DisplayConfig", "expected": cls.SIZE, "actual": len(data)} - ) - - ( - instance_num, - display_tech, - panel_ic, - pixel_w, - pixel_h, - active_w_mm, - active_h_mm, - tagtype, - rotation, - reset_pin, - busy_pin, - dc_pin, - cs_pin, - data_pin, - partial_update, - color_scheme, - trans_modes, - clk_pin, - ) = struct.unpack_from(" "LedConfig": - """Parse LedConfig from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "LedConfig", "expected": cls.SIZE, "actual": len(data)} - ) - - instance_num, led_type, led_1, led_2, led_3, led_4, led_flags = struct.unpack_from( - " "SensorData": - """Parse SensorData from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "SensorData", "expected": cls.SIZE, "actual": len(data)} - ) - - instance_num, sensor_type, bus_id = struct.unpack_from(" "DataBus": - """Parse DataBus from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "DataBus", "expected": cls.SIZE, "actual": len(data)} - ) - - ( - instance_num, - bus_type, - pin_1, - pin_2, - pin_3, - pin_4, - pin_5, - pin_6, - pin_7, - bus_speed, - bus_flags, - pullups, - pulldowns, - ) = struct.unpack_from(" "BinaryInputs": - """Parse BinaryInputs from bytes.""" - if len(data) < cls.SIZE: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_section_too_short", - translation_placeholders={"section": "BinaryInputs", "expected": cls.SIZE, "actual": len(data)} - ) - - instance_num, input_type, display_as = struct.unpack_from(" GlobalConfig: - """Parse complete TLV config from 0x0040 response. - - Auto-detects format: - - File format: [magic:4][version:4][crc32:4][data_len:4][TLV packets...] - - BLE format: [TLV packets...] (raw TLV data only) - - Each TLV packet: - [type:1][length:1][data:N] - - Args: - data: Raw config data from device - - Returns: - GlobalConfig: Parsed configuration structure - - Raises: - ConfigValidationError: If data is invalid or CRC check fails - """ - if len(data) < 2: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_data_too_short", - translation_placeholders={ "length": str(len(data))} - ) - - # Auto-detect format by checking for magic number - has_header = False - if len(data) >= 16: - potential_magic = struct.unpack_from(" len(data): - break # Not enough data for packet header - - _packet_number = data[offset] # Packet instance number (not used in parsing) - packet_id = data[offset + 1] - offset += 2 - - # Determine packet size based on packet ID (fixed sizes from structs.h) - packet_size = 0 - if packet_id == PACKET_TYPE_SYSTEM_CONFIG: - packet_size = SystemConfig.SIZE - elif packet_id == PACKET_TYPE_MANUFACTURER_DATA: - packet_size = ManufacturerData.SIZE - elif packet_id == PACKET_TYPE_POWER_OPTION: - packet_size = PowerOption.SIZE - elif packet_id == PACKET_TYPE_DISPLAY_CONFIG: - packet_size = DisplayConfig.SIZE - elif packet_id == PACKET_TYPE_LED_CONFIG: - packet_size = LedConfig.SIZE - elif packet_id == PACKET_TYPE_SENSOR_DATA: - packet_size = SensorData.SIZE - elif packet_id == PACKET_TYPE_DATA_BUS: - packet_size = DataBus.SIZE - elif packet_id == PACKET_TYPE_BINARY_INPUTS: - packet_size = BinaryInputs.SIZE - else: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_unknown_packet", - translation_placeholders={ - "packet_id": f"{packet_id:#04x}", - "offset": offset - 2 - } - ) - - if offset + packet_size > len(data): - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_packet_too_short", - translation_placeholders={ - "packet_id": f"{packet_id:#04x}", - "packet_size": packet_size, - "remaining_bytes": len(data) - offset, - "offset": offset - } - ) - - packet_data = data[offset : offset + packet_size] - offset += packet_size - - # Parse based on packet ID - try: - if packet_id == PACKET_TYPE_SYSTEM_CONFIG: - config.system = SystemConfig.from_bytes(packet_data) - - elif packet_id == PACKET_TYPE_MANUFACTURER_DATA: - config.manufacturer = ManufacturerData.from_bytes(packet_data) - - elif packet_id == PACKET_TYPE_POWER_OPTION: - config.power = PowerOption.from_bytes(packet_data) - - elif packet_id == PACKET_TYPE_DISPLAY_CONFIG: - config.displays.append(DisplayConfig.from_bytes(packet_data)) - - elif packet_id == PACKET_TYPE_LED_CONFIG: - config.leds.append(LedConfig.from_bytes(packet_data)) - - elif packet_id == PACKET_TYPE_SENSOR_DATA: - config.sensors.append(SensorData.from_bytes(packet_data)) - - elif packet_id == PACKET_TYPE_DATA_BUS: - config.buses.append(DataBus.from_bytes(packet_data)) - - elif packet_id == PACKET_TYPE_BINARY_INPUTS: - config.inputs.append(BinaryInputs.from_bytes(packet_data)) - - # Silently ignore unknown packet types for forward compatibility - - except Exception as e: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_packet_parse_failed", - translation_placeholders={ - "packet_id": f"{packet_id:#04x}", - "offset": offset - 2, - "error": str(e) - } - ) from e - - return config - - -def _color_scheme_from_value(value: int) -> ColorScheme | None: - """Return ColorScheme enum for value or None if unknown.""" - for scheme in ColorScheme: - if scheme.value == value: - return scheme - return None - - -def describe_color_scheme(value: int) -> str: - """Convert color scheme int to human-readable description.""" - scheme = _color_scheme_from_value(value) - if scheme is None: - return f"Unknown ({value})" - - descriptions = { - ColorScheme.MONO: "Monochrome", - ColorScheme.BWR: "BWR (black/white/red)", - ColorScheme.BWY: "BWY (black/white/yellow)", - ColorScheme.BWRY: "BWRY (black/white/red/yellow)", - ColorScheme.BWGBRY: "BWGBRY (6-color)", - ColorScheme.GRAYSCALE_4: "Grayscale (4-level)", - } - return descriptions.get(scheme, scheme.name) - - -def extract_display_capabilities(config: GlobalConfig) -> DeviceCapabilities: - """Extract minimal display info from full config for interrogation. - - Used by OpenDisplayProtocol.interrogate_device() to return only what HA needs. - - Args: - config: Complete parsed configuration - - Returns: - DeviceCapabilities: Minimal device information for HA setup - - Raises: - ConfigValidationError: If no display configuration found - """ - if not config.displays: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_no_display_config" - ) - - # Use first display - display = config.displays[0] - - # Swap dimensions when rotation is 90/270 (consistent with ATC wh_inverted behavior) - if display.rotation in (90, 270): - return DeviceCapabilities( - width=display.pixel_height, # Swapped for portrait rotation - height=display.pixel_width, # Swapped for portrait rotation - color_scheme=display.color_scheme, - rotatebuffer=1, - ) - else: - return DeviceCapabilities( - width=display.pixel_width, - height=display.pixel_height, - color_scheme=display.color_scheme, - rotatebuffer=0, - ) - - -def generate_model_name(display: DisplayConfig) -> str: - """Generate human-readable model name from display configuration. - - Creates concise names based on physical dimensions and color capabilities. - Uses diagonal size in inches calculated from millimeter dimensions. - - Examples: - - "2.9\"" (monochrome 2.9" display) - - "7.5\" BWR" (7.5" color display) - - "800x480 BWR" (fallback when physical dimensions unavailable) - - Args: - display: DisplayConfig with physical and pixel dimensions - - Returns: - str: Human-readable model name - - Raises: - ConfigValidationError: If display dimensions are invalid - """ - import math - - # Validate pixel dimensions - if display.pixel_width <= 0 or display.pixel_height <= 0: - raise ConfigValidationError( - translation_domain=DOMAIN, - translation_key="tlv_invalid_dimensions", - translation_placeholders={ - "width": str(display.pixel_width), - "height": str(display.pixel_height) - } - ) - - # Calculate diagonal size from physical dimensions (mm) - if display.active_width_mm > 0 and display.active_height_mm > 0: - diagonal_mm = math.sqrt( - display.active_width_mm ** 2 + display.active_height_mm ** 2 - ) - diagonal_inches = diagonal_mm / 25.4 - size_str = f"{diagonal_inches:.1f}\"" - else: - # Fallback if physical dimensions not available - # Use pixel dimensions as identifier - size_str = f"{display.pixel_width}x{display.pixel_height}" - - # Add color capability suffix - scheme = _color_scheme_from_value(display.color_scheme) - if scheme is None: - color_suffix = f" color={display.color_scheme}" - elif scheme is ColorScheme.MONO: - color_suffix = " BW" - else: - color_suffix = f" {scheme.name}" - - # Build model name: "7.5\" BWR" or "800x480 BWR" - model_name = f"{size_str}{color_suffix}" - - return model_name - - -def encode_tlv_config(config: GlobalConfig) -> bytes: - """Encode GlobalConfig to TLV binary format (for future write support). - - NOT IMPLEMENTED YET - reserved for future config management features. - - Args: - config: Configuration to encode - - Returns: - bytes: Complete TLV config binary data - - Raises: - NotImplementedError: This function is not yet implemented - """ - raise NotImplementedError("Config encoding not yet implemented") - - -def config_to_dict(config: GlobalConfig) -> dict[str, Any]: - """Convert GlobalConfig to JSON-serializable dictionary. - - Converts the complete OpenDisplay configuration structure to a nested dictionary - that can be stored in Home Assistant config entries. Bytes fields are - converted to hex strings for serialization. - - Args: - config: GlobalConfig instance to convert - - Returns: - dict: JSON-serializable nested dictionary representation - """ - def _convert_bytes(obj: Any) -> Any: - """Recursively convert bytes objects to hex strings.""" - if isinstance(obj, bytes): - return obj.hex() - elif isinstance(obj, dict): - return {k: _convert_bytes(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [_convert_bytes(item) for item in obj] - else: - return obj - - # Convert dataclass to dict, then convert all bytes fields - config_dict = asdict(config) - return _convert_bytes(config_dict) diff --git a/tests/test_ble_metadata.py b/tests/test_ble_metadata.py index b45b49e..7d93487 100644 --- a/tests/test_ble_metadata.py +++ b/tests/test_ble_metadata.py @@ -8,16 +8,12 @@ OPENDISPLAY_DIR = os.path.abspath(os.path.join(CURR_DIR, "../custom_components/opendisplay")) sys.path.insert(0, OPENDISPLAY_DIR) -import metadata as new_metadata_mod -NewBLEDeviceMetadata = new_metadata_mod.BLEDeviceMetadata +import metadata as metadata_mod +BLEDeviceMetadata = metadata_mod.BLEDeviceMetadata -sys.path.insert(0, os.path.join(OPENDISPLAY_DIR, "ble")) -import metadata as old_metadata_mod -OldBLEDeviceMetadata = old_metadata_mod.BLEDeviceMetadata - -def test_atc_metadata_compatibility(): - """Verify that both old and new BLEDeviceMetadata produce identical results for ATC (flat) devices.""" +def test_atc_metadata(): + """Verify that BLEDeviceMetadata produces correct results for ATC (flat) devices.""" raw_metadata = { "width": 296, "height": 128, @@ -28,33 +24,32 @@ def test_atc_metadata_compatibility(): "color_scheme": 1, # BWR } - old_metadata = OldBLEDeviceMetadata(raw_metadata) - new_metadata = NewBLEDeviceMetadata(raw_metadata) + metadata = BLEDeviceMetadata(raw_metadata) # Core properties - assert new_metadata.width == old_metadata.width == 296 - assert new_metadata.height == old_metadata.height == 128 - assert new_metadata.model_name == old_metadata.model_name == "ATC_2.9" - assert new_metadata.fw_version == old_metadata.fw_version == 25 - assert new_metadata.formatted_fw_version() == old_metadata.formatted_fw_version() == "0x0019" - assert new_metadata.rotatebuffer == old_metadata.rotatebuffer == 1 - assert new_metadata.hw_type == old_metadata.hw_type == 15 - assert new_metadata.power_mode == old_metadata.power_mode == 1 - assert new_metadata.is_open_display == old_metadata.is_open_display is False + assert metadata.width == 296 + assert metadata.height == 128 + assert metadata.model_name == "ATC_2.9" + assert metadata.fw_version == 25 + assert metadata.formatted_fw_version() == "0x0019" + assert metadata.rotatebuffer == 1 + assert metadata.hw_type == 15 + assert metadata.power_mode == 1 + assert metadata.is_open_display is False # Color scheme properties - assert new_metadata.color_scheme.value == old_metadata.color_scheme.value == 1 - assert new_metadata.accent_color == old_metadata.accent_color == "red" - assert new_metadata.is_multi_color == old_metadata.is_multi_color is True + assert metadata.color_scheme.value == 1 + assert metadata.accent_color == "red" + assert metadata.is_multi_color is True # Transmission / Upload properties - assert new_metadata.transmission_modes == old_metadata.transmission_modes == 0 - assert new_metadata.supports_zip_compression == old_metadata.supports_zip_compression is False - assert new_metadata.get_best_upload_method() == old_metadata.get_best_upload_method() == "block" + assert metadata.transmission_modes == 0 + assert metadata.supports_zip_compression is False + assert metadata.get_best_upload_method() == "block" -def test_opendisplay_metadata_compatibility(): - """Verify that both old and new BLEDeviceMetadata produce identical results for OpenDisplay devices.""" +def test_opendisplay_metadata(): + """Verify that BLEDeviceMetadata produces correct results for OpenDisplay devices.""" raw_metadata = { "fw_version": "2.0.2", "model_name": "OD_7.5_BWR", @@ -121,26 +116,25 @@ def test_opendisplay_metadata_compatibility(): } } - old_metadata = OldBLEDeviceMetadata(raw_metadata) - new_metadata = NewBLEDeviceMetadata(raw_metadata) + metadata = BLEDeviceMetadata(raw_metadata) # Core properties - assert new_metadata.width == old_metadata.width == 800 - assert new_metadata.height == old_metadata.height == 480 - assert new_metadata.model_name == old_metadata.model_name == "OD_7.5_BWR" - assert new_metadata.fw_version == old_metadata.fw_version == "2.0.2" - assert new_metadata.formatted_fw_version() == old_metadata.formatted_fw_version() == "2.0.2" - assert new_metadata.rotatebuffer == old_metadata.rotatebuffer == 90 - assert new_metadata.hw_type == old_metadata.hw_type == 12 - assert new_metadata.power_mode == old_metadata.power_mode == 2 - assert new_metadata.is_open_display == old_metadata.is_open_display is True + assert metadata.width == 800 + assert metadata.height == 480 + assert metadata.model_name == "OD_7.5_BWR" + assert metadata.fw_version == "2.0.2" + assert metadata.formatted_fw_version() == "2.0.2" + assert metadata.rotatebuffer == 90 + assert metadata.hw_type == 12 + assert metadata.power_mode == 2 + assert metadata.is_open_display is True # Color scheme properties - assert new_metadata.color_scheme.value == old_metadata.color_scheme.value == 3 - assert new_metadata.accent_color == old_metadata.accent_color == "red" - assert new_metadata.is_multi_color == old_metadata.is_multi_color is True + assert metadata.color_scheme.value == 3 + assert metadata.accent_color == "red" + assert metadata.is_multi_color is True # Transmission / Upload properties - assert new_metadata.transmission_modes == old_metadata.transmission_modes == 10 - assert new_metadata.supports_zip_compression == old_metadata.supports_zip_compression is True - assert new_metadata.get_best_upload_method() == old_metadata.get_best_upload_method() == "direct_write" + assert metadata.transmission_modes == 10 + assert metadata.supports_zip_compression is True + assert metadata.get_best_upload_method() == "direct_write"