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 4f58b5d..f42b495 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -1,7 +1,12 @@ 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" +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/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/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/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) 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 db5b444..eb467bb 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -4,14 +4,23 @@ 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 +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, + 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 ( @@ -37,7 +46,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.""" + 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 +377,43 @@ 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): + 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", + 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/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 28a39cb..04903a6 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 @@ -16,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 @@ -31,6 +36,17 @@ 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 + expiry: timedelta + + 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 +236,56 @@ 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._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, + 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( + upload_func=upload_func, + args=args, + kwargs=kwargs, + queued_at=datetime.now(), + expiry=effective_expiry, + ) + + 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).""" + now = datetime.now() + expired = [ + mac + for mac, queued_upload in self._pending_by_tag.items() + if (now - queued_upload.queued_at) > queued_upload.expiry + ] + 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: @@ -432,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: @@ -542,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( @@ -552,8 +618,11 @@ 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( + 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) - return ble_queue, hub_queue + 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 new file mode 100644 index 0000000..af06861 Binary files /dev/null and b/tests/drawcustom/test_images/rename_me.jpg differ 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)" + ) diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py new file mode 100644 index 0000000..b6cb619 --- /dev/null +++ b/tests/test_deep_sleep_queue.py @@ -0,0 +1,124 @@ +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 + + +@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_default_expires_after_4_hours() -> 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(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() + 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")