Add a binary sensor platform to the Brother integration (#183084)

This commit is contained in:
Maciej Bieniek
2026-09-26 15:00:35 +02:00
committed by GitHub
parent d60a9257c9
commit f45151cfd0
9 changed files with 1723 additions and 15 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
@@ -0,0 +1,163 @@
"""Support for the Brother binary sensors."""
from typing import TYPE_CHECKING, override
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import CONF_TYPE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import PRINTER_TYPE_INK, PRINTER_TYPE_LASER
from .coordinator import BrotherConfigEntry
from .entity import BrotherPrinterEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
key="door_open",
translation_key="door_open",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
BinarySensorEntityDescription(
key="input_tray_empty",
translation_key="input_tray_empty",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="input_tray_missing",
translation_key="input_tray_missing",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="jammed",
translation_key="jammed",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
BinarySensorEntityDescription(
key="low_paper",
translation_key="low_paper",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="no_paper",
translation_key="no_paper",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
BinarySensorEntityDescription(
key="output_full",
translation_key="output_full",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="output_near_full",
translation_key="output_near_full",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="output_tray_missing",
translation_key="output_tray_missing",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="overdue_prevent_maint",
translation_key="overdue_prevent_maint",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="service_requested",
translation_key="service_requested",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
SUPPLY_BINARY_SENSOR_TYPES: dict[str, tuple[BinarySensorEntityDescription, ...]] = {
PRINTER_TYPE_LASER: (
BinarySensorEntityDescription(
key="low_toner",
translation_key="low_toner",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
BinarySensorEntityDescription(
key="no_toner",
translation_key="no_toner",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
PRINTER_TYPE_INK: (
BinarySensorEntityDescription(
key="low_toner",
translation_key="low_ink",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
BinarySensorEntityDescription(
key="no_toner",
translation_key="no_ink",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: BrotherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Brother entities from a config_entry."""
coordinator = entry.runtime_data
# printer_errors is None only if the printer does not support the OID that returns
# errors, in that case, we do not create binary sensors
if coordinator.data.printer_errors is None:
return
descriptions = (
BINARY_SENSOR_TYPES + SUPPLY_BINARY_SENSOR_TYPES[entry.data[CONF_TYPE]]
)
async_add_entities(
BrotherPrinterBinarySensor(coordinator, description)
for description in descriptions
)
class BrotherPrinterBinarySensor(BrotherPrinterEntity, BinarySensorEntity):
"""Define a Brother Printer binary sensor."""
@property
@override
def is_on(self) -> bool:
"""Return true if the printer reports this error."""
if TYPE_CHECKING:
assert self.coordinator.data.printer_errors is not None
return self.entity_description.key in self.coordinator.data.printer_errors
@@ -1,6 +1,7 @@
"""Define the Brother entity."""
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -15,9 +16,13 @@ class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
def __init__(
self,
coordinator: BrotherDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
self.entity_description = description
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.brother.host}/",
identifiers={(DOMAIN, coordinator.brother.serial)},
+1 -12
View File
@@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
from .coordinator import BrotherConfigEntry
from .entity import BrotherPrinterEntity
# Coordinator is used to centralize the data updates
@@ -354,17 +354,6 @@ class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
entity_description: BrotherSensorEntityDescription
def __init__(
self,
coordinator: BrotherDataUpdateCoordinator,
description: BrotherSensorEntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
self.entity_description = description
@property
@override
def native_value(self) -> StateType | datetime:
@@ -83,6 +83,53 @@
}
},
"entity": {
"binary_sensor": {
"door_open": {
"name": "Door open"
},
"input_tray_empty": {
"name": "Input tray empty"
},
"input_tray_missing": {
"name": "Input tray missing"
},
"jammed": {
"name": "Paper jam"
},
"low_ink": {
"name": "Low ink"
},
"low_paper": {
"name": "Low paper"
},
"low_toner": {
"name": "Low toner"
},
"no_ink": {
"name": "No ink"
},
"no_paper": {
"name": "No paper"
},
"no_toner": {
"name": "No toner"
},
"output_full": {
"name": "Output tray full"
},
"output_near_full": {
"name": "Output tray near full"
},
"output_tray_missing": {
"name": "Output tray missing"
},
"overdue_prevent_maint": {
"name": "Preventive maintenance overdue"
},
"service_requested": {
"name": "Service requested"
}
},
"sensor": {
"belt_unit_remaining_life": {
"name": "Belt unit remaining lifetime"
+26 -1
View File
@@ -68,8 +68,8 @@ BROTHER_DATA = BrotherSensors(
pf_kit_1_remaining_pages=48741,
pf_kit_mp_remaining_life=None,
pf_kit_mp_remaining_pages=None,
printer_errors=None,
printer_status="idle",
printer_errors=["door_open", "low_toner"],
status="waiting",
uptime=datetime(2024, 3, 3, 15, 4, 24, tzinfo=UTC),
yellow_counter=None,
@@ -117,6 +117,15 @@ def mock_brother_client(mock_brother: AsyncMock) -> AsyncMock:
return client
@pytest.fixture
def mock_ink_brother_client(mock_brother_client: AsyncMock) -> AsyncMock:
"""Mock Brother client for an inkjet printer."""
mock_brother_client.serial = "9876543210"
mock_brother_client.model = "DCP-J562DW"
return mock_brother_client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
@@ -131,3 +140,19 @@ def mock_config_entry() -> MockConfigEntry:
},
minor_version=2,
)
@pytest.fixture
def mock_ink_config_entry() -> MockConfigEntry:
"""Mock a config entry for an inkjet printer."""
return MockConfigEntry(
domain=DOMAIN,
title="DCP-J562DW 9876543210",
unique_id="9876543210",
data={
CONF_HOST: "localhost",
CONF_TYPE: "ink",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
minor_version=2,
)
File diff suppressed because it is too large Load Diff
@@ -53,7 +53,10 @@
'pf_kit_1_remaining_pages': 48741,
'pf_kit_mp_remaining_life': None,
'pf_kit_mp_remaining_pages': None,
'printer_errors': None,
'printer_errors': list([
'door_open',
'low_toner',
]),
'printer_status': 'idle',
'status': 'waiting',
'uptime': '2024-03-03T15:04:24+00:00',
@@ -0,0 +1,149 @@
"""Test binary sensor of Brother integration."""
from dataclasses import replace
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.brother.const import UPDATE_INTERVAL
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import init_integration
from .conftest import BROTHER_DATA
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_brother_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test states of the binary sensors."""
with patch("homeassistant.components.brother.PLATFORMS", [Platform.BINARY_SENSOR]):
await init_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors_ink(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_ink_brother_client: AsyncMock,
mock_ink_config_entry: MockConfigEntry,
) -> None:
"""Test states of the binary sensors for an inkjet printer."""
with patch("homeassistant.components.brother.PLATFORMS", [Platform.BINARY_SENSOR]):
await init_integration(hass, mock_ink_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_ink_config_entry.entry_id
)
@pytest.mark.parametrize(
"entity_id",
[
"binary_sensor.hl_l2340dw_input_tray_empty",
"binary_sensor.hl_l2340dw_input_tray_missing",
"binary_sensor.hl_l2340dw_low_paper",
"binary_sensor.hl_l2340dw_output_tray_full",
"binary_sensor.hl_l2340dw_output_tray_missing",
"binary_sensor.hl_l2340dw_output_tray_near_full",
"binary_sensor.hl_l2340dw_preventive_maintenance_overdue",
"binary_sensor.hl_l2340dw_service_requested",
],
)
async def test_binary_sensors_disabled_by_default(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_brother_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
) -> None:
"""Test the less critical errors are disabled by default."""
await init_integration(hass, mock_config_entry)
assert (entry := entity_registry.async_get(entity_id))
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
assert hass.states.get(entity_id) is None
async def test_no_binary_sensors_when_printer_errors_unavailable(
hass: HomeAssistant,
mock_brother_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test no binary sensors are created when the printer does not report errors."""
mock_brother_client.async_update.return_value = replace(
BROTHER_DATA, printer_errors=None
)
await init_integration(hass, mock_config_entry)
assert hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN) == []
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("entity_id", "expected_state"),
[
("binary_sensor.hl_l2340dw_door_open", STATE_ON),
("binary_sensor.hl_l2340dw_low_toner", STATE_ON),
("binary_sensor.hl_l2340dw_low_paper", STATE_OFF),
("binary_sensor.hl_l2340dw_no_toner", STATE_OFF),
],
)
async def test_binary_sensor_state_reflects_printer_errors(
hass: HomeAssistant,
mock_brother_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
expected_state: str,
) -> None:
"""Test the binary sensor state matches the active printer errors."""
await init_integration(hass, mock_config_entry)
assert (state := hass.states.get(entity_id))
assert state.state == expected_state
async def test_availability(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_brother_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Ensure that we mark the entities unavailable correctly when device is offline."""
entity_id = "binary_sensor.hl_l2340dw_door_open"
await init_integration(hass, mock_config_entry)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_ON
mock_brother_client.async_update.side_effect = ConnectionError
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_UNAVAILABLE
mock_brother_client.async_update.side_effect = None
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_ON