Skip to content
Open
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
5 changes: 5 additions & 0 deletions custom_components/opendisplay/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
"firmware": {
"default": "mdi:chip"
}
},
"sensor": {
"resolution": {
"default": "mdi:aspect-ratio"
}
}
},
"services": {
Expand Down
87 changes: 77 additions & 10 deletions custom_components/opendisplay/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import time

from opendisplay import voltage_to_percent
from opendisplay.models.enums import CapacityEstimator, PowerMode
from opendisplay.models.config import DisplayConfig
from opendisplay.models.enums import CapacityEstimator, PowerMode, Rotation

from homeassistant.components.bluetooth import async_last_service_info
from homeassistant.components.sensor import (
Expand All @@ -26,7 +27,7 @@
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import OpenDisplayConfigEntry
from .coordinator import OpenDisplayUpdate
from .coordinator import OpenDisplayCoordinator, OpenDisplayUpdate
from .entity import OpenDisplayEntity

PARALLEL_UPDATES = 0
Expand All @@ -36,7 +37,9 @@
class OpenDisplaySensorEntityDescription(SensorEntityDescription):
"""Describes an OpenDisplay sensor entity."""

value_fn: Callable[[OpenDisplayUpdate], float | int | str | datetime | None]
value_fn: (
Callable[[OpenDisplayUpdate], float | int | str | datetime | None] | None
) = None


_TEMPERATURE_DESCRIPTION = OpenDisplaySensorEntityDescription(
Expand Down Expand Up @@ -79,9 +82,13 @@ class OpenDisplaySensorEntityDescription(SensorEntityDescription):
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
# native_value is overridden by OpenDisplayLastSeenSensor, so this value_fn
# is dead code; value_fn is a required field, hence the no-op.
value_fn=lambda _upd: None,
)

_RESOLUTION_DESCRIPTION = OpenDisplaySensorEntityDescription(
key="resolution",
translation_key="resolution",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
)


Expand Down Expand Up @@ -115,14 +122,21 @@ async def async_setup_entry(
),
]

async_add_entities(
entities: list[OpenDisplaySensorEntity] = [
(
OpenDisplayLastSeenSensor(coordinator, description)
if description.key == "last_seen"
else OpenDisplaySensorEntity(coordinator, description)
)
for description in descriptions
)
]

if entry.runtime_data.device_config.displays:
entities.append(
OpenDisplayResolutionSensor(coordinator, _RESOLUTION_DESCRIPTION, entry)
)

async_add_entities(entities)


class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity):
Expand All @@ -133,9 +147,10 @@ class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity):
@property
def native_value(self) -> float | int | str | datetime | None:
"""Return the sensor value."""
if self.coordinator.data is None:
value_fn = self.entity_description.value_fn
if value_fn is None or self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data)
return value_fn(self.coordinator.data)


class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity):
Expand All @@ -155,3 +170,55 @@ def native_value(self) -> datetime | None:
# wall time with the same offset the advertisement monitor uses.
wall = info.time + (time.time() - time.monotonic())
return datetime.fromtimestamp(wall, tz=timezone.utc)


class OpenDisplayResolutionSensor(OpenDisplaySensorEntity):
"""A panel's native resolution, with its physical size and colour scheme."""

def __init__(
self,
coordinator: OpenDisplayCoordinator,
description: OpenDisplaySensorEntityDescription,
entry: OpenDisplayConfigEntry,
) -> None:
"""Initialize against the config entry whose display is reported."""
super().__init__(coordinator, description)
self._entry = entry

@property
def _display(self) -> DisplayConfig | None:
"""Return the display config, re-read since a resync replaces it."""
displays = self._entry.runtime_data.device_config.displays
return displays[0] if displays else None

@property
def available(self) -> bool:
"""Return True while the display is in the config, awake or not."""
return self._display is not None

@property
def native_value(self) -> str | None:
"""Return the native resolution as ``WIDTHxHEIGHT``."""
display = self._display
if display is None:
return None
return f"{display.pixel_width}x{display.pixel_height}"

@property
def extra_state_attributes(self) -> dict[str, int | str | None] | None:
"""Return the panel's pixel and physical dimensions, scheme and rotation."""
display = self._display
if display is None:
return None
color_scheme = display.color_scheme_enum
rotation = display.rotation_enum
return {
"pixel_width": display.pixel_width,
"pixel_height": display.pixel_height,
"active_width_mm": display.active_width_mm,
"active_height_mm": display.active_height_mm,
"color_scheme": (
color_scheme if isinstance(color_scheme, int) else color_scheme.name
),
"rotation": int(rotation) if isinstance(rotation, Rotation) else None,
}
3 changes: 3 additions & 0 deletions custom_components/opendisplay/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
},
"last_seen": {
"name": "Last seen"
},
"resolution": {
"name": "Resolution"
}
},
"update": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/opendisplay/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
},
"last_seen": {
"name": "Last seen"
},
"resolution": {
"name": "Resolution"
}
},
"update": {
Expand Down
72 changes: 72 additions & 0 deletions tests/test_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Unit tests for the resolution sensor.

Like test_last_seen, these avoid the full Home Assistant test harness. Real
``DisplayConfig`` instances are used rather than stubs, so rotation and colour
scheme resolve as they do in production.
"""

from types import SimpleNamespace
from unittest.mock import MagicMock

from opendisplay.models.config import DisplayConfig

from custom_components.opendisplay.sensor import (
_RESOLUTION_DESCRIPTION,
OpenDisplayResolutionSensor,
)


def _display(rotation=2, color_scheme=4, **overrides):
"""Return a real DisplayConfig, matching a 7.3" six-colour panel."""
fields = {
"instance_number": 0,
"display_technology": 1,
"panel_ic_type": 35,
"pixel_width": 800,
"pixel_height": 480,
"active_width_mm": 160,
"active_height_mm": 96,
"tag_type": 0,
"rotation": rotation,
"reset_pin": 12,
"busy_pin": 13,
"dc_pin": 8,
"cs_pin": 9,
"data_pin": 11,
"partial_update_support": 0,
"color_scheme": color_scheme,
"transmission_modes": 9,
"clk_pin": 10,
"reserved_pins": b"\x00" * 7,
"full_update_mC": 400,
"reserved": b"\x00" * 13,
}
fields.update(overrides)
return DisplayConfig(**fields)


def _make_sensor(*displays):
"""Return a resolution sensor reading from a mutable runtime_data."""
entry = SimpleNamespace(
runtime_data=SimpleNamespace(
device_config=SimpleNamespace(displays=list(displays))
)
)
coordinator = MagicMock()
coordinator.address = "AA:BB:CC:DD:EE:FF"
return OpenDisplayResolutionSensor(coordinator, _RESOLUTION_DESCRIPTION, entry)


def test_unrecognised_values_do_not_masquerade_as_valid_ones():
attrs = _make_sensor(_display(rotation=99, color_scheme=99)).extra_state_attributes
assert attrs["rotation"] is None
assert attrs["color_scheme"] == 99


def test_config_is_re_read_so_a_wake_time_resync_is_picked_up():
sensor = _make_sensor(_display())
assert sensor.native_value == "800x480"
sensor._entry.runtime_data.device_config = SimpleNamespace(
displays=[_display(pixel_width=960, pixel_height=640)]
)
assert sensor.native_value == "960x640"
Loading