From 3fb5c77d09facd42c6ae8e5e223fd366683270e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 04:37:58 +0000 Subject: [PATCH 1/5] Plan deep sleep queue migration --- tests/drawcustom/test_images/rename_me.jpg | Bin 0 -> 10558 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/drawcustom/test_images/rename_me.jpg diff --git a/tests/drawcustom/test_images/rename_me.jpg b/tests/drawcustom/test_images/rename_me.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9edf0d1109fe15b081bd93302d981d2577809bbe GIT binary patch literal 10558 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 literal 0 HcmV?d00001 From 8dcbefdfe3ba935820e555b48727fa12d608f874 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 04:39:05 +0000 Subject: [PATCH 2/5] Add deep sleep upload queue and wake flush flow --- custom_components/opendisplay/const.py | 1 + custom_components/opendisplay/coordinator.py | 70 +++++++++++++++++-- custom_components/opendisplay/services.py | 48 +++++++++++-- custom_components/opendisplay/upload.py | 61 ++++++++++++++-- tests/test_deep_sleep_queue.py | 73 ++++++++++++++++++++ 5 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 tests/test_deep_sleep_queue.py diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index 4f58b5d..fe4bc8b 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -1,6 +1,7 @@ DOMAIN = "opendisplay" SIGNAL_TAG_UPDATE = f"{DOMAIN}_tag_update" SIGNAL_TAG_IMAGE_UPDATE = f"{DOMAIN}_tag_image_update" +SIGNAL_TAG_CHECKIN = f"{DOMAIN}_tag_checkin" SIGNAL_AP_UPDATE = f"{DOMAIN}_ap_update" OPENDISPLAY_CONFIG_URL = "https://opendisplay.org/firmware/config/" ATC_CONFIG_URL = "https://atc1441.github.io/ATC_BLE_OEPL_Image_Upload.html" diff --git a/custom_components/opendisplay/coordinator.py b/custom_components/opendisplay/coordinator.py index a83408d..6bc6037 100644 --- a/custom_components/opendisplay/coordinator.py +++ b/custom_components/opendisplay/coordinator.py @@ -19,7 +19,13 @@ from homeassistant.helpers import entity_registry as er import logging -from .const import DOMAIN, SIGNAL_AP_UPDATE, SIGNAL_TAG_UPDATE, SIGNAL_TAG_IMAGE_UPDATE +from .const import ( + DOMAIN, + SIGNAL_AP_UPDATE, + SIGNAL_TAG_CHECKIN, + SIGNAL_TAG_IMAGE_UPDATE, + SIGNAL_TAG_UPDATE, +) from .tag_types import get_tag_types_manager, get_hw_string _LOGGER: Final = logging.getLogger(__name__) @@ -516,10 +522,13 @@ async def _handle_tag_message(self, tag_data: dict) -> None: Args: tag_data: Dictionary containing tag properties from the AP """ - tag_mac = tag_data.get("mac") + tag_mac = self._normalize_tag_mac(tag_data.get("mac")) if not tag_mac: return + tag_data = dict(tag_data) + tag_data["mac"] = tag_mac + # Process tag data is_new_tag = await self._process_tag_data(tag_mac, tag_data) # Save to storage if this was a new tag @@ -698,6 +707,8 @@ async def _process_tag_data(self, tag_mac: str, tag_data: dict, is_initial_load: # Fire state update event async_dispatcher_send(self.hass, f"{SIGNAL_TAG_UPDATE}_{tag_mac}") + if not is_initial_load: + async_dispatcher_send(self.hass, SIGNAL_TAG_CHECKIN, tag_mac) # Handle wakeup event if needed and not initial load wakeup_reason = tag_data.get("wakeupReason") @@ -850,7 +861,10 @@ async def _fetch_all_tags_from_ap(self) -> dict: # Add tags to set for tag in data.get("tags", []): if "mac" in tag: - result[tag["mac"]] = tag + normalized_mac = self._normalize_tag_mac(tag["mac"]) + tag_copy = dict(tag) + tag_copy["mac"] = normalized_mac + result[normalized_mac] = tag_copy # Check for pagination if "continu" in data and data["continu"] > 0: @@ -1448,10 +1462,11 @@ def is_tag_online(self, tag_mac: str) -> bool: Returns: True if the tag is online, False if timed out or not found. """ - if tag_mac not in self.tags: + normalized_mac = self._normalize_tag_mac(tag_mac) + if normalized_mac not in self.tags: return False - tag_data = self.get_tag_data(tag_mac) + tag_data = self.get_tag_data(normalized_mac) last_seen = tag_data.get("last_seen", 0) if last_seen == 0: @@ -1467,3 +1482,48 @@ def is_tag_online(self, tag_mac: str) -> bool: current_time = datetime.now(timezone.utc).timestamp() return (current_time - last_seen) < timeout_threshold + + @staticmethod + def _normalize_tag_mac(tag_mac: str | None) -> str | None: + """Normalize tag MAC address to uppercase.""" + if tag_mac is None: + return None + return tag_mac.upper() + + def is_tag_in_deep_sleep(self, tag_mac: str) -> bool: + """Return whether the tag is configured for deep sleep.""" + normalized_mac = self._normalize_tag_mac(tag_mac) + if normalized_mac is None: + return False + + tag_data = self.get_tag_data(normalized_mac) + modecfgjson = tag_data.get("modecfgjson") + if not isinstance(modecfgjson, dict): + return False + + if bool(modecfgjson.get("deepsleep")): + return True + + maxsleep = modecfgjson.get("maxsleep") + if isinstance(maxsleep, (int, float)) and maxsleep >= 15: + return True + + return False + + def is_tag_currently_sleeping(self, tag_mac: str) -> bool: + """Return whether the tag is currently sleeping.""" + normalized_mac = self._normalize_tag_mac(tag_mac) + if normalized_mac is None: + return False + + tag_data = self.get_tag_data(normalized_mac) + next_checkin = tag_data.get("next_checkin") + if not isinstance(next_checkin, (int, float)) or next_checkin <= 0: + return False + + current_time = datetime.now(timezone.utc).timestamp() + return current_time < next_checkin + + def should_queue_image_upload(self, tag_mac: str) -> bool: + """Return whether image upload should be queued for this tag.""" + return self.is_tag_in_deep_sleep(tag_mac) and self.is_tag_currently_sleeping(tag_mac) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index db5b444..bd6e64f 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -5,13 +5,13 @@ from time import perf_counter from typing import Final, Any, Callable -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError, HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send from .coordinator import Hub from .ble import BLEConnectionError, BLETimeoutError, BLEProtocolError, BLEDeviceMetadata -from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE +from .const import DOMAIN, SIGNAL_TAG_CHECKIN, SIGNAL_TAG_IMAGE_UPDATE from .imagegen import ImageGen from .tag_types import get_tag_types_manager from .upload import ( @@ -37,7 +37,27 @@ async def async_setup_services(hass: HomeAssistant) -> None: """ # Create upload queues - ble_upload_queue, hub_upload_queue = create_upload_queues() + ble_upload_queue, hub_upload_queue, deep_sleep_upload_queue = create_upload_queues() + + @callback + def _handle_tag_checkin(tag_mac: str) -> None: + """Flush queued deep-sleep uploads when a tag checks in.""" + hub = get_hub_from_hass(hass) + + async def _flush() -> None: + queued_upload = await deep_sleep_upload_queue.pop_upload(tag_mac) + if queued_upload is None: + return + await hub_upload_queue.add_to_queue( + queued_upload.upload_func, + *queued_upload.args, + **queued_upload.kwargs, + ) + _LOGGER.info("Flushed queued deep-sleep upload for %s", tag_mac) + + hass.async_create_task(_flush()) + + async_dispatcher_connect(hass, SIGNAL_TAG_CHECKIN, _handle_tag_checkin) async def get_device_ids_from_label_id(label_id: str) -> list[str]: """Get device_ids for OpenDisplay devices with a specific label.""" @@ -348,14 +368,30 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: # 0→1 (full), 1→3 (fast), 2→2 (fast no-reds), 3→0 (no-repeats) ap_lut_mapping = {0: 1, 1: 3, 2: 2, 3: 0} ap_lut = ap_lut_mapping.get(refresh_type, 1) # Default to 1 (full) if invalid - await hub_upload_queue.add_to_queue( - upload_to_hub, hub, entity_id, image, dither, + upload_args = ( + hub, + entity_id, + image, + dither, service.data.get("ttl", 60), service.data.get("preload_type", 0), service.data.get("preload_lut", 0), ap_lut, render_duration, ) + tag_mac = get_mac_from_entity_id(entity_id) + if hub.should_queue_image_upload(tag_mac): + await deep_sleep_upload_queue.queue_upload( + tag_mac, + upload_to_hub, + *upload_args, + ) + _LOGGER.info( + "Tag %s is sleeping in deep sleep mode, image queued until next check-in", + tag_mac, + ) + else: + await hub_upload_queue.add_to_queue(upload_to_hub, *upload_args) except ServiceValidationError: raise # User input errors - propagate unchanged diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 28a39cb..3023a50 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -1,11 +1,12 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging -from datetime import datetime +from datetime import datetime, timedelta from io import BytesIO from time import perf_counter -from typing import Final +from typing import Final, Callable, Awaitable, Any import async_timeout import requests @@ -31,6 +32,16 @@ INITIAL_BACKOFF = 2 # seconds +@dataclass +class QueuedDeepSleepUpload: + """Stores a pending deep-sleep upload for a single tag.""" + + upload_func: Callable[..., Awaitable[Any]] + args: tuple + kwargs: dict + queued_at: datetime + + def image_to_jpeg_bytes(image: Image.Image, quality: int | str = 95) -> bytes: """Encode a PIL image as JPEG bytes for AP upload or HA image preview.""" buffer = BytesIO() @@ -220,6 +231,45 @@ async def _execute_upload(self, upload_func, args, kwargs, entity_id): _LOGGER.debug("Upload task for %s finished. %s", entity_id, self) +class DeepSleepUploadQueue: + """Store one pending AP upload per sleeping tag with expiration.""" + + def __init__(self, expiry: timedelta | None = None) -> None: + self._expiry = expiry or timedelta(minutes=30) + self._pending_by_tag: dict[str, QueuedDeepSleepUpload] = {} + self._lock = asyncio.Lock() + + async def queue_upload(self, tag_mac: str, upload_func, *args, **kwargs) -> None: + """Queue or replace a pending upload for a tag.""" + normalized_mac = tag_mac.upper() + async with self._lock: + self._cleanup_expired_locked() + self._pending_by_tag[normalized_mac] = QueuedDeepSleepUpload( + upload_func=upload_func, + args=args, + kwargs=kwargs, + queued_at=datetime.now(), + ) + + async def pop_upload(self, tag_mac: str) -> QueuedDeepSleepUpload | None: + """Return and remove pending upload for tag if it is not expired.""" + normalized_mac = tag_mac.upper() + async with self._lock: + self._cleanup_expired_locked() + return self._pending_by_tag.pop(normalized_mac, None) + + def _cleanup_expired_locked(self) -> None: + """Remove expired queued entries (lock must already be held).""" + cutoff = datetime.now() - self._expiry + expired = [ + mac + for mac, queued_upload in self._pending_by_tag.items() + if queued_upload.queued_at < cutoff + ] + for mac in expired: + self._pending_by_tag.pop(mac, None) + + async def upload_to_hub(hub, entity_id: str, img: Image.Image, dither: int, ttl: int, preload_type: int = 0, preload_lut: int = 0, lut: int = 1, render_duration: float | None = None) -> None: @@ -552,8 +602,9 @@ async def upload_to_ble_direct( ) from err -def create_upload_queues() -> tuple[UploadQueueHandler, UploadQueueHandler]: - """Create BLE and Hub upload queues with appropriate settings.""" +def create_upload_queues() -> tuple[UploadQueueHandler, UploadQueueHandler, DeepSleepUploadQueue]: + """Create BLE, Hub, and deep-sleep upload queues.""" ble_queue = UploadQueueHandler(max_concurrent=1, cooldown=0.1) hub_queue = UploadQueueHandler(max_concurrent=1, cooldown=1.0) - return ble_queue, hub_queue + deep_sleep_queue = DeepSleepUploadQueue() + return ble_queue, hub_queue, deep_sleep_queue diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py new file mode 100644 index 0000000..a0e5a0c --- /dev/null +++ b/tests/test_deep_sleep_queue.py @@ -0,0 +1,73 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from custom_components.opendisplay.coordinator import Hub +from custom_components.opendisplay.upload import DeepSleepUploadQueue + + +@pytest.mark.asyncio +async def test_deep_sleep_queue_replaces_existing_image() -> None: + """Queue keeps only the latest image for a sleeping tag.""" + queue = DeepSleepUploadQueue() + + async def upload_a(): + return None + + async def upload_b(): + return None + + await queue.queue_upload("aa:bb", upload_a, "first") + await queue.queue_upload("AA:BB", upload_b, "second") + + queued = await queue.pop_upload("aa:bb") + assert queued is not None + assert queued.upload_func is upload_b + assert queued.args == ("second",) + + +@pytest.mark.asyncio +async def test_deep_sleep_queue_expires_after_30_minutes() -> None: + """Queued image is dropped after expiration.""" + queue = DeepSleepUploadQueue() + + async def upload(): + return None + + await queue.queue_upload("aa:bb", upload, "payload") + queue._pending_by_tag["AA:BB"].queued_at = datetime.now() - timedelta(minutes=31) + + queued = await queue.pop_upload("aa:bb") + assert queued is None + + +def test_hub_should_queue_image_upload_for_sleeping_deep_sleep_tag() -> None: + """Deep-sleeping tag should use pending upload queue.""" + now = datetime.now(timezone.utc).timestamp() + hub = Hub.__new__(Hub) + hub._data = { + "AA:BB": { + "modecfgjson": {"deepsleep": 1, "maxsleep": 60}, + "next_checkin": now + 60, + } + } + + assert hub.is_tag_in_deep_sleep("aa:bb") + assert hub.is_tag_currently_sleeping("aa:bb") + assert hub.should_queue_image_upload("aa:bb") + + +def test_hub_should_not_queue_when_tag_not_sleeping() -> None: + """Awake tag should upload immediately.""" + now = datetime.now(timezone.utc).timestamp() + hub = Hub.__new__(Hub) + hub._data = { + "AA:BB": { + "modecfgjson": {"deepsleep": 1, "maxsleep": 60}, + "next_checkin": now - 5, + } + } + + assert hub.is_tag_in_deep_sleep("aa:bb") + assert not hub.is_tag_currently_sleeping("aa:bb") + assert not hub.should_queue_image_upload("aa:bb") From 222ccbabb1e5922769b2974208ba667cf21afd31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 04:57:16 +0000 Subject: [PATCH 3/5] feat: make deep-sleep queue expiry configurable and default to 4h --- custom_components/opendisplay/__init__.py | 1 - custom_components/opendisplay/config_flow.py | 43 ++++++++++++-- custom_components/opendisplay/const.py | 4 ++ .../opendisplay/device_trigger.py | 1 - custom_components/opendisplay/diagnostics.py | 1 - .../opendisplay/imagegen/core.py | 3 - .../opendisplay/imagegen/shapes.py | 4 +- .../opendisplay/imagegen/text.py | 4 +- custom_components/opendisplay/select.py | 2 +- custom_components/opendisplay/sensor.py | 1 - custom_components/opendisplay/services.py | 26 ++++++++- custom_components/opendisplay/strings.json | 6 +- custom_components/opendisplay/switch.py | 2 +- custom_components/opendisplay/tag_types.py | 3 +- custom_components/opendisplay/text.py | 2 +- .../opendisplay/translations/de.json | 6 +- .../opendisplay/translations/en.json | 6 +- .../opendisplay/translations/pl.json | 6 +- .../opendisplay/translations/pt.json | 6 +- custom_components/opendisplay/upload.py | 36 +++++++++--- custom_components/opendisplay/util.py | 2 - tests/drawcustom/test_images/rename_me.jpg | Bin 10558 -> 1663 bytes tests/test_deep_sleep_queue.py | 55 +++++++++++++++++- 23 files changed, 175 insertions(+), 45 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index db94cbd..6fc63cf 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -16,7 +16,6 @@ from .coordinator import Hub from .runtime_data import OpenDisplayConfigEntry, OpenDisplayBLERuntimeData from .services import async_setup_services -from .tag_types import get_tag_types_manager from .util import is_ble_entry _LOGGER: Final = logging.getLogger(__name__) diff --git a/custom_components/opendisplay/config_flow.py b/custom_components/opendisplay/config_flow.py index 9c73192..f72c938 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -1,23 +1,28 @@ """Config flow for OpenDisplay integration.""" from __future__ import annotations -from typing import Any, Final, Mapping +from typing import Any, Final import asyncio import aiohttp import voluptuous as vol from habluetooth.models import BluetoothServiceInfoBleak from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry, OptionsFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_HOST from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelectorType -from .const import DOMAIN +from .const import ( + DOMAIN, + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, +) from .ble import ( get_protocol_by_manufacturer_id, BLEConnection, @@ -569,6 +574,7 @@ def __init__(self) -> None: self._button_debounce = 0.5 self._nfc_debounce = 1.0 self._custom_font_dirs = "" + self._deep_sleep_queue_expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS async def async_step_init(self, user_input=None): """Manage OpenDisplay options. @@ -589,6 +595,10 @@ async def async_step_init(self, user_input=None): self._button_debounce = self.config_entry.options.get("button_debounce", 0.5) self._nfc_debounce = self.config_entry.options.get("nfc_debounce", 1.0) self._custom_font_dirs = self.config_entry.options.get("custom_font_dirs", "") + self._deep_sleep_queue_expiry_hours = self.config_entry.options.get( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + ) # Check if this is a BLE device entry_data = self.config_entry.runtime_data @@ -600,6 +610,18 @@ async def async_step_init(self, user_input=None): if user_input is not None: # Update blacklisted tags + deep_sleep_queue_expiry_hours = user_input.get( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + ) + try: + deep_sleep_queue_expiry_hours = int(deep_sleep_queue_expiry_hours) + except (TypeError, ValueError): + deep_sleep_queue_expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + deep_sleep_queue_expiry_hours = max( + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + min(deep_sleep_queue_expiry_hours, MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS), + ) return self.async_create_entry( title="", data={ @@ -607,6 +629,7 @@ async def async_step_init(self, user_input=None): "button_debounce": user_input.get("button_debounce", 0.5), "nfc_debounce": user_input.get("nfc_debounce", 1.0), "custom_font_dirs": user_input.get("custom_font_dirs", ""), + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS: deep_sleep_queue_expiry_hours, } ) @@ -672,5 +695,17 @@ async def async_step_init(self, user_input=None): autocomplete="path" ) ), + vol.Optional( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + default=self._deep_sleep_queue_expiry_hours, + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + max=MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + step=1, + unit_of_measurement="h", + mode=selector.NumberSelectorMode.BOX, + ) + ), }), ) diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index fe4bc8b..f42b495 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -3,6 +3,10 @@ SIGNAL_TAG_IMAGE_UPDATE = f"{DOMAIN}_tag_image_update" SIGNAL_TAG_CHECKIN = f"{DOMAIN}_tag_checkin" SIGNAL_AP_UPDATE = f"{DOMAIN}_ap_update" +CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = "deep_sleep_queue_expiry_hours" +DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 4 +MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 1 +MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 24 OPENDISPLAY_CONFIG_URL = "https://opendisplay.org/firmware/config/" ATC_CONFIG_URL = "https://atc1441.github.io/ATC_BLE_OEPL_Image_Upload.html" diff --git a/custom_components/opendisplay/device_trigger.py b/custom_components/opendisplay/device_trigger.py index 793dcca..77fb16c 100644 --- a/custom_components/opendisplay/device_trigger.py +++ b/custom_components/opendisplay/device_trigger.py @@ -12,7 +12,6 @@ CONF_PLATFORM, CONF_TYPE, ) -from homeassistant.helpers import device_registry as dr from .const import DOMAIN _LOGGER: Final = logging.getLogger(__name__) diff --git a/custom_components/opendisplay/diagnostics.py b/custom_components/opendisplay/diagnostics.py index fdcce66..9c5174e 100644 --- a/custom_components/opendisplay/diagnostics.py +++ b/custom_components/opendisplay/diagnostics.py @@ -7,7 +7,6 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import Hub from .runtime_data import OpenDisplayConfigEntry, OpenDisplayBLERuntimeData diff --git a/custom_components/opendisplay/imagegen/core.py b/custom_components/opendisplay/imagegen/core.py index 42b90af..1180636 100644 --- a/custom_components/opendisplay/imagegen/core.py +++ b/custom_components/opendisplay/imagegen/core.py @@ -19,7 +19,6 @@ from .registry import get_all_handlers # Import handler modules to trigger decorator registration -from . import text, shapes, icons, media, visualizations, debug _LOGGER = logging.getLogger(__name__) @@ -222,7 +221,6 @@ async def get_ble_tag_info(self, hass: HomeAssistant, entity_id: str) -> tuple[i if runtime_data is not None and isinstance(runtime_data, OpenDisplayBLERuntimeData): if runtime_data.mac_address.upper() == tag_mac: device_metadata = runtime_data.device_metadata - protocol_type = runtime_data.protocol_type break if not device_metadata: @@ -236,7 +234,6 @@ async def get_ble_tag_info(self, hass: HomeAssistant, entity_id: str) -> tuple[i metadata = BLEDeviceMetadata(device_metadata) # Extract device capabilities - hw_type = metadata.hw_type width = metadata.width height = metadata.height diff --git a/custom_components/opendisplay/imagegen/shapes.py b/custom_components/opendisplay/imagegen/shapes.py index 2ab61cb..8cdf5a4 100644 --- a/custom_components/opendisplay/imagegen/shapes.py +++ b/custom_components/opendisplay/imagegen/shapes.py @@ -172,7 +172,7 @@ async def draw_polygon(ctx: DrawingContext, element: dict) -> None: # Get polygon properties fill = ctx.colors.resolve(element.get("fill")) outline = ctx.colors.resolve(element.get("outline", "black")) - width = element.get("width", 1) + element.get("width", 1) # Draw the polygon draw.polygon(vertices, fill=fill, outline=outline) @@ -346,7 +346,7 @@ def draw_dashed_line(draw: ImageDraw.ImageDraw, if dash_end >= line_length: # A partial dash exists that ends exactly or beyond the line_end dash_end = line_length - segment_len = dash_end - current_pos + dash_end - current_pos segment_start_x = x1 + step_x * current_pos segment_start_y = y1 + step_y * current_pos diff --git a/custom_components/opendisplay/imagegen/text.py b/custom_components/opendisplay/imagegen/text.py index 027dd3e..ed691ee 100644 --- a/custom_components/opendisplay/imagegen/text.py +++ b/custom_components/opendisplay/imagegen/text.py @@ -226,7 +226,7 @@ async def draw_multiline(ctx: DrawingContext, element: dict) -> None: for segment in segments: color = ctx.colors.resolve(segment.color) - bbox = draw.textbbox( + draw.textbbox( (segment.start_x, current_y), segment.text, font=font, @@ -242,7 +242,7 @@ async def draw_multiline(ctx: DrawingContext, element: dict) -> None: stroke_fill=stroke_fill ) else: - bbox = draw.textbbox( + draw.textbbox( (x, current_y), str(line), font=font, diff --git a/custom_components/opendisplay/select.py b/custom_components/opendisplay/select.py index 6d843b2..9cd112d 100644 --- a/custom_components/opendisplay/select.py +++ b/custom_components/opendisplay/select.py @@ -3,7 +3,7 @@ PARALLEL_UPDATES = 1 from homeassistant.components.select import SelectEntity -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index af87d4e..0ef1bda 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -31,7 +31,6 @@ from .runtime_data import OpenDisplayConfigEntry from .const import DOMAIN from .util import is_ble_entry -from .tag_types import get_hw_string, get_hw_dimensions _LOGGER: Final = logging.getLogger(__name__) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index bd6e64f..eb467bb 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -4,6 +4,7 @@ from functools import wraps from time import perf_counter from typing import Final, Any, Callable +from datetime import timedelta from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError, HomeAssistantError @@ -11,7 +12,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect, async_dispatcher_send from .coordinator import Hub from .ble import BLEConnectionError, BLETimeoutError, BLEProtocolError, BLEDeviceMetadata -from .const import DOMAIN, SIGNAL_TAG_CHECKIN, SIGNAL_TAG_IMAGE_UPDATE +from .const import ( + DOMAIN, + SIGNAL_TAG_CHECKIN, + SIGNAL_TAG_IMAGE_UPDATE, + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, +) from .imagegen import ImageGen from .tag_types import get_tag_types_manager from .upload import ( @@ -42,7 +51,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: @callback def _handle_tag_checkin(tag_mac: str) -> None: """Flush queued deep-sleep uploads when a tag checks in.""" - hub = get_hub_from_hass(hass) + get_hub_from_hass(hass) async def _flush() -> None: queued_upload = await deep_sleep_upload_queue.pop_upload(tag_mac) @@ -381,10 +390,23 @@ async def drawcustom_service(service: ServiceCall, entity_id: str) -> None: ) tag_mac = get_mac_from_entity_id(entity_id) if hub.should_queue_image_upload(tag_mac): + expiry_hours_raw = hub.entry.options.get( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + ) + try: + expiry_hours = int(expiry_hours_raw) + except (TypeError, ValueError): + expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + expiry_hours = max( + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + min(expiry_hours, MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS), + ) await deep_sleep_upload_queue.queue_upload( tag_mac, upload_to_hub, *upload_args, + expiry=timedelta(hours=expiry_hours), ) _LOGGER.info( "Tag %s is sleeping in deep sleep mode, image queued until next check-in", diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 33e987b..6633c30 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -49,13 +49,15 @@ "blacklisted_tags": "Blacklisted Tags", "button_debounce": "Button Debounce Time (seconds)", "nfc_debounce": "NFC Debounce Time (seconds)", - "custom_font_dirs": "Custom Font Directories" + "custom_font_dirs": "Custom Font Directories", + "deep_sleep_queue_expiry_hours": "Deep Sleep Queue Expiry (hours)" }, "data_description": { "blacklisted_tags": "Tags to hide; blacklisted tags will not update or trigger services.", "button_debounce": "Delay before another button press is accepted (seconds).", "nfc_debounce": "Delay before another NFC tap is accepted (seconds).", - "custom_font_dirs": "Comma-separated directories containing additional fonts for generated images." + "custom_font_dirs": "Comma-separated directories containing additional fonts for generated images.", + "deep_sleep_queue_expiry_hours": "How long queued uploads for sleeping tags are kept before being dropped." } } }, diff --git a/custom_components/opendisplay/switch.py b/custom_components/opendisplay/switch.py index 52abc53..ce94f4e 100644 --- a/custom_components/opendisplay/switch.py +++ b/custom_components/opendisplay/switch.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass, SwitchEntityDescription -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/custom_components/opendisplay/tag_types.py b/custom_components/opendisplay/tag_types.py index 25f4776..32d4b69 100644 --- a/custom_components/opendisplay/tag_types.py +++ b/custom_components/opendisplay/tag_types.py @@ -10,9 +10,8 @@ from typing import Any, Dict, Optional, Tuple from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import storage -from .const import DOMAIN, FALLBACK_TAG_DEFINITIONS +from .const import FALLBACK_TAG_DEFINITIONS _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/opendisplay/text.py b/custom_components/opendisplay/text.py index d09cd30..d6ee0d3 100644 --- a/custom_components/opendisplay/text.py +++ b/custom_components/opendisplay/text.py @@ -6,7 +6,7 @@ import requests from homeassistant.components.text import TextEntity, TextMode, TextEntityDescription -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory diff --git a/custom_components/opendisplay/translations/de.json b/custom_components/opendisplay/translations/de.json index 97895a3..71a64c6 100644 --- a/custom_components/opendisplay/translations/de.json +++ b/custom_components/opendisplay/translations/de.json @@ -49,13 +49,15 @@ "blacklisted_tags": "Ignorierte Tags", "button_debounce": "Tasten-Entstörzeit (Sekunden)", "nfc_debounce": "NFC-Entstörzeit (Sekunden)", - "custom_font_dirs": "Benutzerdefinierte Schriftarten-Verzeichnisse" + "custom_font_dirs": "Benutzerdefinierte Schriftarten-Verzeichnisse", + "deep_sleep_queue_expiry_hours": "Ablaufzeit der Deep-Sleep-Warteschlange (Stunden)" }, "data_description": { "blacklisted_tags":"Tags ausblenden; geblacklistete Tags werden nicht aktualisiert und lösen keine Dienste aus.", "button_debounce": "Wartezeit, bevor ein weiterer Tastendruck akzeptiert wird (Sekunden).", "nfc_debounce": "Wartezeit, bevor ein weiterer NFC-Tap akzeptiert wird (Sekunden).", - "custom_font_dirs": "Kommagetrennte Verzeichnisse mit zusätzlichen Schriftarten für erzeugte Bilder." + "custom_font_dirs": "Kommagetrennte Verzeichnisse mit zusätzlichen Schriftarten für erzeugte Bilder.", + "deep_sleep_queue_expiry_hours": "Wie lange Warteschlangen-Uploads für schlafende Tags aufbewahrt werden, bevor sie verworfen werden." } } }, diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index b172e82..3ca7515 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -49,13 +49,15 @@ "blacklisted_tags": "Blacklisted Tags", "button_debounce": "Button Debounce Time (seconds)", "nfc_debounce": "NFC Debounce Time (seconds)", - "custom_font_dirs": "Custom Font Directories" + "custom_font_dirs": "Custom Font Directories", + "deep_sleep_queue_expiry_hours": "Deep Sleep Queue Expiry (hours)" }, "data_description": { "blacklisted_tags": "Tags to hide; blacklisted tags will not update or trigger services.", "button_debounce": "Delay before another button press is accepted (seconds).", "nfc_debounce": "Delay before another NFC tap is accepted (seconds).", - "custom_font_dirs": "Comma-separated directories containing additional fonts for generated images." + "custom_font_dirs": "Comma-separated directories containing additional fonts for generated images.", + "deep_sleep_queue_expiry_hours": "How long queued uploads for sleeping tags are kept before being dropped." } } }, diff --git a/custom_components/opendisplay/translations/pl.json b/custom_components/opendisplay/translations/pl.json index 2ad5ff1..941aadb 100644 --- a/custom_components/opendisplay/translations/pl.json +++ b/custom_components/opendisplay/translations/pl.json @@ -49,13 +49,15 @@ "blacklisted_tags": "Czarna lista tagów", "button_debounce": "Opóźnienie przycisku (sekundy)", "nfc_debounce": "Opóźnienie NFC (sekundy)", - "custom_font_dirs": "Niestandardowe katalogi czcionek" + "custom_font_dirs": "Niestandardowe katalogi czcionek", + "deep_sleep_queue_expiry_hours": "Czas wygaśnięcia kolejki deep sleep (godziny)" }, "data_description": { "blacklisted_tags": "Tagi do ukrycia; tagi na czarnej liście nie będą aktualizowane ani wywoływały usług.", "button_debounce": "Opóźnienie przed przyjęciem kolejnego naciśnięcia przycisku (sekundy).", "nfc_debounce": "Opóźnienie przed przyjęciem kolejnego odczytu NFC (sekundy).", - "custom_font_dirs": "Katalogi z dodatkowymi czcionkami do generowanych obrazów (oddzielone przecinkami)." + "custom_font_dirs": "Katalogi z dodatkowymi czcionkami do generowanych obrazów (oddzielone przecinkami).", + "deep_sleep_queue_expiry_hours": "Jak długo przesłane zadania dla śpiących tagów są przechowywane przed usunięciem." } } }, diff --git a/custom_components/opendisplay/translations/pt.json b/custom_components/opendisplay/translations/pt.json index 1986557..1ba1441 100644 --- a/custom_components/opendisplay/translations/pt.json +++ b/custom_components/opendisplay/translations/pt.json @@ -49,13 +49,15 @@ "blacklisted_tags": "Etiquetas na Lista Negra", "button_debounce": "Tempo de Debounce do Botão (segundos)", "nfc_debounce": "Tempo de Debounce NFC (segundos)", - "custom_font_dirs": "Diretórios de Fontes Personalizadas" + "custom_font_dirs": "Diretórios de Fontes Personalizadas", + "deep_sleep_queue_expiry_hours": "Expiração da Fila de Deep Sleep (horas)" }, "data_description": { "blacklisted_tags": "Tags para ocultar; tags na lista negra não serão atualizadas nem disparam serviços.", "button_debounce": "Atraso antes de aceitar outro acionamento do botão (segundos).", "nfc_debounce": "Atraso antes de aceitar outro toque NFC (segundos).", - "custom_font_dirs": "Diretórios separados por vírgula com fontes adicionais para imagens geradas." + "custom_font_dirs": "Diretórios separados por vírgula com fontes adicionais para imagens geradas.", + "deep_sleep_queue_expiry_hours": "Por quanto tempo uploads em fila para etiquetas em deep sleep são mantidos antes de serem descartados." } } }, diff --git a/custom_components/opendisplay/upload.py b/custom_components/opendisplay/upload.py index 3023a50..04903a6 100644 --- a/custom_components/opendisplay/upload.py +++ b/custom_components/opendisplay/upload.py @@ -17,7 +17,11 @@ from homeassistant.exceptions import ServiceValidationError, HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from .runtime_data import OpenDisplayBLERuntimeData -from .const import DOMAIN, SIGNAL_TAG_IMAGE_UPDATE +from .const import ( + DOMAIN, + SIGNAL_TAG_IMAGE_UPDATE, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, +) from .ble import BLEConnection, BLEImageUploader, BLEDeviceMetadata, get_protocol_by_name, BLEConnectionError, \ BLETimeoutError, BLEProtocolError @@ -40,6 +44,7 @@ class QueuedDeepSleepUpload: args: tuple kwargs: dict queued_at: datetime + expiry: timedelta def image_to_jpeg_bytes(image: Image.Image, quality: int | str = 95) -> bytes: @@ -235,13 +240,23 @@ class DeepSleepUploadQueue: """Store one pending AP upload per sleeping tag with expiration.""" def __init__(self, expiry: timedelta | None = None) -> None: - self._expiry = expiry or timedelta(minutes=30) + self._default_expiry = expiry or timedelta( + hours=DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + ) self._pending_by_tag: dict[str, QueuedDeepSleepUpload] = {} self._lock = asyncio.Lock() - async def queue_upload(self, tag_mac: str, upload_func, *args, **kwargs) -> None: + async def queue_upload( + self, + tag_mac: str, + upload_func, + *args, + expiry: timedelta | None = None, + **kwargs, + ) -> None: """Queue or replace a pending upload for a tag.""" normalized_mac = tag_mac.upper() + effective_expiry = expiry or self._default_expiry async with self._lock: self._cleanup_expired_locked() self._pending_by_tag[normalized_mac] = QueuedDeepSleepUpload( @@ -249,6 +264,7 @@ async def queue_upload(self, tag_mac: str, upload_func, *args, **kwargs) -> None args=args, kwargs=kwargs, queued_at=datetime.now(), + expiry=effective_expiry, ) async def pop_upload(self, tag_mac: str) -> QueuedDeepSleepUpload | None: @@ -260,11 +276,11 @@ async def pop_upload(self, tag_mac: str) -> QueuedDeepSleepUpload | None: def _cleanup_expired_locked(self) -> None: """Remove expired queued entries (lock must already be held).""" - cutoff = datetime.now() - self._expiry + now = datetime.now() expired = [ mac for mac, queued_upload in self._pending_by_tag.items() - if queued_upload.queued_at < cutoff + if (now - queued_upload.queued_at) > queued_upload.expiry ] for mac in expired: self._pending_by_tag.pop(mac, None) @@ -482,7 +498,7 @@ async def upload_to_ble_block( except ServiceValidationError: raise # Config/validation errors - propagate unchanged - except (BLEConnectionError, BLETimeoutError, BLEProtocolError) as err: + except (BLEConnectionError, BLETimeoutError, BLEProtocolError): # BLE-specific errors already inherit from HomeAssistantError raise # Propagate with specific type except Exception as err: @@ -592,7 +608,7 @@ async def upload_to_ble_direct( except ServiceValidationError: raise # Config/validation errors - propagate unchanged - except (BLEConnectionError, BLETimeoutError, BLEProtocolError) as err: + except (BLEConnectionError, BLETimeoutError, BLEProtocolError): raise # BLE operational errors - propagate unchanged except Exception as err: raise HomeAssistantError( @@ -602,9 +618,11 @@ async def upload_to_ble_direct( ) from err -def create_upload_queues() -> tuple[UploadQueueHandler, UploadQueueHandler, DeepSleepUploadQueue]: +def create_upload_queues( + deep_sleep_expiry: timedelta | None = None, +) -> tuple[UploadQueueHandler, UploadQueueHandler, DeepSleepUploadQueue]: """Create BLE, Hub, and deep-sleep upload queues.""" ble_queue = UploadQueueHandler(max_concurrent=1, cooldown=0.1) hub_queue = UploadQueueHandler(max_concurrent=1, cooldown=1.0) - deep_sleep_queue = DeepSleepUploadQueue() + deep_sleep_queue = DeepSleepUploadQueue(expiry=deep_sleep_expiry) return ble_queue, hub_queue, deep_sleep_queue diff --git a/custom_components/opendisplay/util.py b/custom_components/opendisplay/util.py index aa9393d..5b09fa6 100644 --- a/custom_components/opendisplay/util.py +++ b/custom_components/opendisplay/util.py @@ -1,11 +1,9 @@ from __future__ import annotations from .const import DOMAIN -import requests import logging from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_send from .runtime_data import OpenDisplayBLERuntimeData _LOGGER = logging.getLogger(__name__) diff --git a/tests/drawcustom/test_images/rename_me.jpg b/tests/drawcustom/test_images/rename_me.jpg index 9edf0d1109fe15b081bd93302d981d2577809bbe..af068613c9d8a07a34e23ebb0cfa39bdcb7e7a0d 100644 GIT binary patch delta 27 ZcmdlN^q*%#9rNS~Oz)7e2{ZHmn*g_647~sV delta 9336 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 diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index a0e5a0c..b6cb619 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -1,7 +1,12 @@ from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest +from homeassistant.helpers.dispatcher import async_dispatcher_send +from custom_components.opendisplay import services as services_module +from custom_components.opendisplay.const import SIGNAL_TAG_CHECKIN from custom_components.opendisplay.coordinator import Hub from custom_components.opendisplay.upload import DeepSleepUploadQueue @@ -27,7 +32,7 @@ async def upload_b(): @pytest.mark.asyncio -async def test_deep_sleep_queue_expires_after_30_minutes() -> None: +async def test_deep_sleep_queue_default_expires_after_4_hours() -> None: """Queued image is dropped after expiration.""" queue = DeepSleepUploadQueue() @@ -35,12 +40,58 @@ async def upload(): return None await queue.queue_upload("aa:bb", upload, "payload") - queue._pending_by_tag["AA:BB"].queued_at = datetime.now() - timedelta(minutes=31) + queue._pending_by_tag["AA:BB"].queued_at = ( + datetime.now() - timedelta(hours=4, minutes=1) + ) queued = await queue.pop_upload("aa:bb") assert queued is None +@pytest.mark.asyncio +async def test_deep_sleep_queue_uses_configured_expiry() -> None: + """Queued image expiration follows configured queue timeout.""" + queue = DeepSleepUploadQueue(expiry=timedelta(hours=1)) + + async def upload(): + return None + + await queue.queue_upload("aa:bb", upload, "payload") + queue._pending_by_tag["AA:BB"].queued_at = datetime.now() - timedelta(minutes=61) + + queued = await queue.pop_upload("aa:bb") + assert queued is None + + +@pytest.mark.asyncio +async def test_deep_sleep_queue_flushes_when_tag_checks_in(hass) -> None: + """Queued upload is flushed when a sleeping tag wakes and checks in.""" + deep_sleep_upload_queue = DeepSleepUploadQueue() + hub_upload_queue = SimpleNamespace(add_to_queue=AsyncMock()) + + async def upload(): + return None + + await deep_sleep_upload_queue.queue_upload("aa:bb", upload, "payload") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + services_module, + "create_upload_queues", + lambda: (SimpleNamespace(), hub_upload_queue, deep_sleep_upload_queue), + ) + mp.setattr( + services_module, + "get_hub_from_hass", + lambda _hass: SimpleNamespace(), + ) + await services_module.async_setup_services(hass) + async_dispatcher_send(hass, SIGNAL_TAG_CHECKIN, "AA:BB") + await hass.async_block_till_done() + + hub_upload_queue.add_to_queue.assert_awaited_once_with(upload, "payload") + + def test_hub_should_queue_image_upload_for_sleeping_deep_sleep_tag() -> None: """Deep-sleeping tag should use pending upload queue.""" now = datetime.now(timezone.utc).timestamp() From b3c527d06c871ed15766dc5e27b2223a1baf15af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 05:06:24 +0000 Subject: [PATCH 4/5] fix: use connectable=False in BLE entity available check for deep sleep support --- custom_components/opendisplay/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/opendisplay/entity.py b/custom_components/opendisplay/entity.py index 828b1ea..4ce8b71 100644 --- a/custom_components/opendisplay/entity.py +++ b/custom_components/opendisplay/entity.py @@ -216,4 +216,4 @@ def device_info(self) -> DeviceInfo: @property def available(self) -> bool: """Return if the entity is available.""" - return bluetooth.async_address_present(self.hass, self._mac_address) + return bluetooth.async_address_present(self.hass, self._mac_address, connectable=False) From 74e3d5c2219348ec27f7e359cd2e9e171c062912 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 05:22:10 +0000 Subject: [PATCH 5/5] test: add BLE entity available property tests for deep sleep non-connectable fix --- tests/test_ble_entity_available.py | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/test_ble_entity_available.py diff --git a/tests/test_ble_entity_available.py b/tests/test_ble_entity_available.py new file mode 100644 index 0000000..fab9067 --- /dev/null +++ b/tests/test_ble_entity_available.py @@ -0,0 +1,92 @@ +"""Tests for OpenDisplayBLEEntity.available property. + +Verifies that the entity uses connectable=False when checking +BLE advertisement presence, so that non-connectable advertisements +(e.g. from a device waking from deep sleep) correctly mark the +entity as available. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from custom_components.opendisplay.entity import OpenDisplayBLEEntity + + +def _make_entity(mac: str = "AA:BB:CC:DD:EE:FF") -> OpenDisplayBLEEntity: + """Create a minimal OpenDisplayBLEEntity with mocked dependencies.""" + entry = SimpleNamespace( + runtime_data=SimpleNamespace( + device_metadata={ + "model_name": "Test", + "fw_version": "1.0", + "width": 296, + "height": 128, + } + ) + ) + entity = OpenDisplayBLEEntity.__new__(OpenDisplayBLEEntity) + entity._mac_address = mac + entity._name = "Test Device" + entity._entry = entry + entity.hass = MagicMock() + return entity + + +def test_available_true_when_non_connectable_advertisement_present() -> None: + """Entity is available when device is seen via a non-connectable advertisement.""" + entity = _make_entity() + + with patch( + "custom_components.opendisplay.entity.bluetooth.async_address_present", + return_value=True, + ) as mock_present: + result = entity.available + + assert result is True + mock_present.assert_called_once_with( + entity.hass, "AA:BB:CC:DD:EE:FF", connectable=False + ) + + +def test_available_false_when_device_not_seen() -> None: + """Entity is unavailable when no advertisement (connectable or not) is present.""" + entity = _make_entity() + + with patch( + "custom_components.opendisplay.entity.bluetooth.async_address_present", + return_value=False, + ) as mock_present: + result = entity.available + + assert result is False + mock_present.assert_called_once_with( + entity.hass, "AA:BB:CC:DD:EE:FF", connectable=False + ) + + +def test_available_does_not_require_connectable_advertisement() -> None: + """Passing connectable=True would miss non-connectable deep-sleep wakeups. + + This test documents the fix: we must NOT call async_address_present with + connectable=True (or its default, which is True), because devices waking + from deep sleep broadcast non-connectable advertisements. + """ + entity = _make_entity() + + # Simulate a device that is present only as non-connectable + def _mock_present(hass, address, connectable=True): + # Only visible when scanning includes non-connectable devices + if connectable is False: + return True + return False + + with patch( + "custom_components.opendisplay.entity.bluetooth.async_address_present", + side_effect=_mock_present, + ): + assert entity.available is True, ( + "Entity should be available when device broadcasts a non-connectable " + "advertisement (e.g. after waking from deep sleep)" + )