From 59e386fbf2943478827445a5f080b3a04cd4a545 Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Wed, 16 Sep 2026 10:15:39 -0400 Subject: [PATCH] Add page count sensors to IPP integration (#165570) Co-authored-by: Claude Opus 4.6 --- homeassistant/components/ipp/icons.json | 15 + homeassistant/components/ipp/sensor.py | 60 ++++ homeassistant/components/ipp/strings.json | 20 ++ tests/components/ipp/fixtures/printer.json | 9 +- .../ipp/snapshots/test_diagnostics.ambr | 11 +- .../components/ipp/snapshots/test_sensor.ambr | 270 ++++++++++++++++++ tests/components/ipp/test_sensor.py | 123 +++++++- 7 files changed, 497 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/ipp/icons.json b/homeassistant/components/ipp/icons.json index 1469ba015183..555220f57eba 100644 --- a/homeassistant/components/ipp/icons.json +++ b/homeassistant/components/ipp/icons.json @@ -1,9 +1,24 @@ { "entity": { "sensor": { + "impressions_completed": { + "default": "mdi:file-document-outline" + }, + "impressions_completed_full_color": { + "default": "mdi:file-document-outline" + }, + "impressions_completed_monochrome": { + "default": "mdi:file-document-outline" + }, "marker": { "default": "mdi:water" }, + "media_sheets_completed": { + "default": "mdi:file-document-outline" + }, + "pages_completed": { + "default": "mdi:file-document-outline" + }, "printer": { "default": "mdi:printer" }, diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index e3cc30dc5e04..c437790c1b45 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -39,6 +39,7 @@ class IPPSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Printer], StateType | datetime] attributes_fn: Callable[[Printer], dict[Any, StateType]] = lambda _: {} + exists_fn: Callable[[Printer], bool] = lambda _: True def _get_marker_attributes_fn( @@ -81,6 +82,59 @@ PRINTER_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( ), ) +PAGE_COUNT_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( + IPPSensorEntityDescription( + key="pages_completed", + translation_key="pages_completed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: "pages_completed" in printer.counters.supported, + value_fn=lambda printer: printer.counters.pages_completed, + ), + IPPSensorEntityDescription( + key="impressions_completed", + translation_key="impressions_completed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: "impressions_completed" in printer.counters.supported, + value_fn=lambda printer: printer.counters.impressions_completed, + ), + IPPSensorEntityDescription( + key="media_sheets_completed", + translation_key="media_sheets_completed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: ( + "media_sheets_completed" in printer.counters.supported + ), + value_fn=lambda printer: printer.counters.media_sheets_completed, + ), + IPPSensorEntityDescription( + key="impressions_completed_monochrome", + translation_key="impressions_completed_monochrome", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: ( + "monochrome" in printer.counters.impressions_completed_col + ), + value_fn=lambda printer: printer.counters.impressions_completed_col.get( + "monochrome" + ), + ), + IPPSensorEntityDescription( + key="impressions_completed_full_color", + translation_key="impressions_completed_full_color", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda printer: ( + "full-color" in printer.counters.impressions_completed_col + ), + value_fn=lambda printer: printer.counters.impressions_completed_col.get( + "full-color" + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -123,6 +177,12 @@ async def async_setup_entry( ) ) + sensors.extend( + IPPSensor(coordinator, description) + for description in PAGE_COUNT_SENSORS + if description.exists_fn(coordinator.data) + ) + async_add_entities(sensors, True) diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index 2bb9f08a701c..f94fd835d2fd 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -34,6 +34,26 @@ }, "entity": { "sensor": { + "impressions_completed": { + "name": "Impressions completed", + "unit_of_measurement": "impressions" + }, + "impressions_completed_full_color": { + "name": "Color impressions completed", + "unit_of_measurement": "[%key:component::ipp::entity::sensor::impressions_completed::unit_of_measurement%]" + }, + "impressions_completed_monochrome": { + "name": "Monochrome impressions completed", + "unit_of_measurement": "[%key:component::ipp::entity::sensor::impressions_completed::unit_of_measurement%]" + }, + "media_sheets_completed": { + "name": "Media sheets completed", + "unit_of_measurement": "sheets" + }, + "pages_completed": { + "name": "Pages completed", + "unit_of_measurement": "pages" + }, "printer": { "state": { "idle": "[%key:common::state::idle%]", diff --git a/tests/components/ipp/fixtures/printer.json b/tests/components/ipp/fixtures/printer.json index 6c3f9cd05459..2e2f511a2f1d 100644 --- a/tests/components/ipp/fixtures/printer.json +++ b/tests/components/ipp/fixtures/printer.json @@ -32,5 +32,12 @@ "marker-colors": ["#000000", "#000000", "#00FFFF", "#FFFF00", "#FF00FF"], "marker-levels": [58, 98, 91, 95, 73], "marker-low-levels": [10, 10, 10, 10, 10], - "marker-high-levels": [100, 100, 100, 100, 100] + "marker-high-levels": [100, 100, 100, 100, 100], + "printer-impressions-completed": 2468, + "printer-impressions-completed-col": { + "monochrome": 1500, + "full-color": 968 + }, + "printer-media-sheets-completed": 1234, + "printer-pages-completed": 1234 } diff --git a/tests/components/ipp/snapshots/test_diagnostics.ambr b/tests/components/ipp/snapshots/test_diagnostics.ambr index 29966d46fabd..ef778411c88a 100644 --- a/tests/components/ipp/snapshots/test_diagnostics.ambr +++ b/tests/components/ipp/snapshots/test_diagnostics.ambr @@ -4,12 +4,17 @@ 'data': dict({ 'booted_at': '2019-11-11T09:10:02+00:00', 'counters': dict({ - 'impressions_completed': None, + 'impressions_completed': 2468, 'impressions_completed_col': dict({ + 'full-color': 968, + 'monochrome': 1500, }), - 'media_sheets_completed': None, - 'pages_completed': None, + 'media_sheets_completed': 1234, + 'pages_completed': 1234, 'supported': list([ + 'impressions_completed', + 'pages_completed', + 'media_sheets_completed', ]), }), 'info': dict({ diff --git a/tests/components/ipp/snapshots/test_sensor.ambr b/tests/components/ipp/snapshots/test_sensor.ambr index 717164343b8d..454d9fb0f040 100644 --- a/tests/components/ipp/snapshots/test_sensor.ambr +++ b/tests/components/ipp/snapshots/test_sensor.ambr @@ -125,6 +125,60 @@ 'state': '58', }) # --- +# name: test_sensors[sensor.test_ha_1000_series_color_impressions_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_color_impressions_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Color impressions completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Color impressions completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'impressions_completed_full_color', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_impressions_completed_full_color', + 'unit_of_measurement': 'impressions', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_color_impressions_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Color impressions completed', + : , + : 'impressions', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_color_impressions_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '968', + }) +# --- # name: test_sensors[sensor.test_ha_1000_series_cyan_ink-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -182,6 +236,60 @@ 'state': '91', }) # --- +# name: test_sensors[sensor.test_ha_1000_series_impressions_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_impressions_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Impressions completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Impressions completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'impressions_completed', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_impressions_completed', + 'unit_of_measurement': 'impressions', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_impressions_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Impressions completed', + : , + : 'impressions', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_impressions_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2468', + }) +# --- # name: test_sensors[sensor.test_ha_1000_series_magenta_ink-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -239,6 +347,168 @@ 'state': '73', }) # --- +# name: test_sensors[sensor.test_ha_1000_series_media_sheets_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_media_sheets_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Media sheets completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Media sheets completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'media_sheets_completed', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_media_sheets_completed', + 'unit_of_measurement': 'sheets', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_media_sheets_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Media sheets completed', + : , + : 'sheets', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_media_sheets_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_monochrome_impressions_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_monochrome_impressions_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Monochrome impressions completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monochrome impressions completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'impressions_completed_monochrome', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_impressions_completed_monochrome', + 'unit_of_measurement': 'impressions', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_monochrome_impressions_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Monochrome impressions completed', + : , + : 'impressions', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_monochrome_impressions_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_pages_completed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_ha_1000_series_pages_completed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pages completed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pages completed', + 'platform': 'ipp', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pages_completed', + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251_pages_completed', + 'unit_of_measurement': 'pages', + }) +# --- +# name: test_sensors[sensor.test_ha_1000_series_pages_completed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test HA-1000 Series Pages completed', + : , + : 'pages', + }), + 'context': , + 'entity_id': 'sensor.test_ha_1000_series_pages_completed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234', + }) +# --- # name: test_sensors[sensor.test_ha_1000_series_photo_black_ink-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index bdbb9a88d355..4e8e90da1440 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -1,14 +1,19 @@ """Tests for the IPP sensor platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock +from freezegun.api import FrozenDateTimeFactory +from pyipp import Counters, Printer import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ipp.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.freeze_time("2019-11-11 09:10:32+00:00") @@ -29,13 +34,18 @@ async def test_disabled_by_default_sensors( init_integration: MockConfigEntry, ) -> None: """Test the disabled by default IPP sensors.""" - state = hass.states.get("sensor.test_ha_1000_series_uptime") + entity_id = entity_registry.async_get_entity_id( + "sensor", "ipp", f"{init_integration.unique_id}_uptime" + ) + assert entity_id is not None + + state = hass.states.get(entity_id) assert state is None - entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_missing_entry_unique_id( @@ -54,3 +64,102 @@ async def test_missing_entry_unique_id( entity = entity_registry.async_get("sensor.test_ha_1000_series") assert entity assert entity.unique_id == f"{mock_config_entry.entry_id}_printer" + + +async def test_no_page_count_sensors_when_unsupported( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_printer: Printer, + mock_ipp: MagicMock, +) -> None: + """Test that page count sensors are not created when printer doesn't support them.""" + mock_printer.counters = Counters( + impressions_completed=None, + impressions_completed_col={}, + pages_completed=None, + media_sheets_completed=None, + supported=(), + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + unique_id = mock_config_entry.unique_id + for key in ( + "pages_completed", + "impressions_completed", + "media_sheets_completed", + "impressions_completed_monochrome", + "impressions_completed_full_color", + ): + assert not entity_registry.async_get_entity_id( + "sensor", "ipp", f"{unique_id}_{key}" + ) + + +async def test_page_count_sensors_with_partial_counters( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_printer: Printer, + mock_ipp: MagicMock, +) -> None: + """Test sensors follow which counters the printer supports. + + A supported counter with an unknown value gets a sensor in the unknown + state; a counter the printer does not report gets no sensor. + """ + mock_printer.counters = Counters( + impressions_completed=None, + impressions_completed_col={"monochrome": 1500, "full-color": None}, + pages_completed=None, + media_sheets_completed=None, + supported=("impressions_completed", "pages_completed"), + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not entity_registry.async_get_entity_id( + "sensor", "ipp", f"{mock_config_entry.unique_id}_media_sheets_completed" + ) + + state = hass.states.get( + "sensor.test_ha_1000_series_monochrome_impressions_completed" + ) + assert state + assert state.state == "1500" + + for entity_id in ( + "sensor.test_ha_1000_series_pages_completed", + "sensor.test_ha_1000_series_impressions_completed", + "sensor.test_ha_1000_series_color_impressions_completed", + ): + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_page_count_unknown_when_counter_missing( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + init_integration: MockConfigEntry, + mock_printer: Printer, +) -> None: + """Test a page count sensor becomes unknown when the printer stops reporting it.""" + assert hass.states.get("sensor.test_ha_1000_series_pages_completed").state == "1234" + + mock_printer.counters.pages_completed = None + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.test_ha_1000_series_pages_completed").state + == STATE_UNKNOWN + )