Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion custom_components/opendisplay/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Integration for OpenDisplay BLE e-paper displays."""

from __future__ import annotations

import asyncio
import contextlib
from dataclasses import dataclass
Expand Down Expand Up @@ -27,8 +29,12 @@
if TYPE_CHECKING:
from opendisplay.models import FirmwareVersion

from .const import CONF_ENCRYPTION_KEY, DOMAIN
from .const import (
CONF_ENCRYPTION_KEY,
DOMAIN,
)
from .coordinator import OpenDisplayCoordinator
from .deep_sleep import DeepSleepQueuedUpload
from .services import async_setup_services

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
Expand All @@ -46,6 +52,8 @@ class OpenDisplayRuntimeData:
device_config: GlobalConfig
is_flex: bool
upload_task: asyncio.Task | None = None
deep_sleep_upload: DeepSleepQueuedUpload | None = None
deep_sleep_expiry_handle: asyncio.TimerHandle | None = None


type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData]
Expand Down Expand Up @@ -150,6 +158,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry)
)
entry.async_on_unload(coordinator.async_start())

# Register coordinator listener to flush queued deep-sleep uploads when
# the device wakes up and becomes connectable again.
def _on_coordinator_update() -> None:
"""Try to flush any queued deep-sleep upload when device advertises."""
queued = entry.runtime_data.deep_sleep_upload
if queued is None:
return
if queued.is_expired:
entry.runtime_data.deep_sleep_upload = None
if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None:
handle.cancel()
entry.runtime_data.deep_sleep_expiry_handle = None
return
if async_ble_device_from_address(hass, address, connectable=True) is None:
return
# Device is now connectable – flush the queued upload
entry.runtime_data.deep_sleep_upload = None
if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None:
handle.cancel()
entry.runtime_data.deep_sleep_expiry_handle = None
from .services import _async_connect_and_run # noqa: PLC0415 – avoid circular import at module level
hass.async_create_task(
_async_connect_and_run(hass, entry, queued.action),
name=f"opendisplay_deepsleep_flush_{address}",
)

entry.async_on_unload(coordinator.async_add_listener(_on_coordinator_update))

return True


Expand All @@ -165,6 +201,10 @@ async def async_unload_entry(
hass: HomeAssistant, entry: OpenDisplayConfigEntry
) -> bool:
"""Unload a config entry."""
if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None:
handle.cancel()
entry.runtime_data.deep_sleep_expiry_handle = None

if (task := entry.runtime_data.upload_task) and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
Expand Down
6 changes: 5 additions & 1 deletion custom_components/opendisplay/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import callback

from .const import CONF_ENCRYPTION_KEY, DOMAIN
from .const import (
CONF_ENCRYPTION_KEY,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)

Expand Down
2 changes: 2 additions & 0 deletions custom_components/opendisplay/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
DOMAIN = "opendisplay"
CONF_ENCRYPTION_KEY = "encryption_key"
SIGNAL_IMAGE_UPDATED = f"{DOMAIN}_image_updated"
# Fallback expiry (seconds) used when the device reports deep_sleep_time_seconds = 0
DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS = 14400 # 4 hours
2 changes: 1 addition & 1 deletion custom_components/opendisplay/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def __init__(self, hass: HomeAssistant, address: str) -> None:
_LOGGER,
address,
BluetoothScanningMode.PASSIVE,
connectable=True,
connectable=False,
)
self.data: OpenDisplayUpdate | None = None
self._tracker: AdvertisementTracker = AdvertisementTracker()
Expand Down
25 changes: 25 additions & 0 deletions custom_components/opendisplay/deep_sleep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Deep-sleep upload queue data structures."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Awaitable, Callable

if TYPE_CHECKING:
from opendisplay import OpenDisplayDevice


@dataclass
class DeepSleepQueuedUpload:
"""A pending upload waiting for a sleeping device to wake up."""

action: Callable[["OpenDisplayDevice"], Awaitable[None]]
jpeg_bytes: bytes
queued_at: datetime
expiry: timedelta

@property
def is_expired(self) -> bool:
"""Return True if the upload has passed its expiry window."""
return (datetime.now() - self.queued_at) > self.expiry
53 changes: 51 additions & 2 deletions custom_components/opendisplay/services.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Service registration for the OpenDisplay integration."""

from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable
import contextlib
from datetime import timedelta
from datetime import datetime, timedelta
from enum import IntEnum
import io
import logging
Expand Down Expand Up @@ -293,7 +295,10 @@ async def _async_send_image(
tone: float | str = "auto",
rotate: Rotation = Rotation.ROTATE_0,
) -> None:
"""Upload a PIL image to the device."""
"""Upload a PIL image to the device, queuing if the device is sleeping."""
address = entry.unique_id
assert address is not None

async def _upload(device: OpenDisplayDevice) -> None:
await device.upload_image(
img,
Expand All @@ -303,6 +308,50 @@ async def _upload(device: OpenDisplayDevice) -> None:
fit=fit,
rotate=rotate,
)

deep_sleep_seconds = entry.runtime_data.device_config.power.deep_sleep_time_seconds
if (
async_ble_device_from_address(hass, address, connectable=True) is None
and deep_sleep_seconds > 0
):
# Device is sleeping right now – queue the upload for when it wakes.
# Expire slightly after the configured deep-sleep interval.
expiry_seconds = (
int(deep_sleep_seconds * 1.1)
)
if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None:
handle.cancel()
entry.runtime_data.deep_sleep_expiry_handle = None

from .deep_sleep import DeepSleepQueuedUpload
queued_upload = DeepSleepQueuedUpload(
action=_upload,
jpeg_bytes=b"",
queued_at=datetime.now(),
expiry=timedelta(seconds=expiry_seconds),
)
entry.runtime_data.deep_sleep_upload = queued_upload

def _purge_if_expired() -> None:
"""Drop queued upload if it still exists when the expiry window closes."""
current_queued = entry.runtime_data.deep_sleep_upload
if current_queued is queued_upload:
entry.runtime_data.deep_sleep_upload = None
_LOGGER.info(
"Dropped queued image upload for %s after expiry timeout",
address,
)
entry.runtime_data.deep_sleep_expiry_handle = None

entry.runtime_data.deep_sleep_expiry_handle = hass.loop.call_later(
expiry_seconds, _purge_if_expired
)
_LOGGER.info(
"Device %s is not connectable; image upload queued for next wake-up",
address,
)
return

await _async_connect_and_run(hass, entry, _upload)
jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img)
async_dispatcher_send(hass, f"{SIGNAL_IMAGE_UPDATED}_{entry.unique_id}", jpeg)
Expand Down
Loading
Loading