From f6abcdf3482bd29aee9114f6472127c6858d47da Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Sat, 14 Mar 2026 16:27:59 -0400 Subject: [PATCH 1/2] Add page counter support (impressions, pages, media sheets) Add a Counters dataclass that exposes printer-impressions-completed, printer-pages-completed, and printer-media-sheets-completed IPP attributes. These counters are requested by default and available via printer.counters on the Printer model. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pyipp/__init__.py | 2 ++ src/pyipp/const.py | 3 +++ src/pyipp/models.py | 22 +++++++++++++++++ src/pyipp/tags.py | 3 +++ tests/test_models.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/src/pyipp/__init__.py b/src/pyipp/__init__.py index 12c2b05be..40a1cb3ee 100644 --- a/src/pyipp/__init__.py +++ b/src/pyipp/__init__.py @@ -9,6 +9,7 @@ ) from .ipp import IPP from .models import ( + Counters, Info, Marker, Printer, @@ -17,6 +18,7 @@ ) __all__ = [ + "Counters", "Info", "Marker", "Printer", diff --git a/src/pyipp/const.py b/src/pyipp/const.py index 608ec8aa6..edf80628f 100644 --- a/src/pyipp/const.py +++ b/src/pyipp/const.py @@ -42,6 +42,9 @@ "marker-low-levels", "marker-names", "marker-types", + "printer-impressions-completed", + "printer-pages-completed", + "printer-media-sheets-completed", ] DEFAULT_PORT = 631 diff --git a/src/pyipp/models.py b/src/pyipp/models.py index 0ba0a9590..747c1dc38 100644 --- a/src/pyipp/models.py +++ b/src/pyipp/models.py @@ -115,6 +115,24 @@ class Uri: security: str | None +@dataclass +class Counters: + """Object holding page counter information from IPP.""" + + impressions_completed: int + pages_completed: int + media_sheets_completed: int + + @staticmethod + def from_dict(data: dict[str, Any]) -> Counters: + """Return Counters object from IPP response.""" + return Counters( + impressions_completed=data.get("printer-impressions-completed", -1), + pages_completed=data.get("printer-pages-completed", -1), + media_sheets_completed=data.get("printer-media-sheets-completed", -1), + ) + + @dataclass class State: """Object holding the IPP printer state.""" @@ -143,6 +161,7 @@ class Printer: """Object holding the IPP printer information.""" info: Info + counters: Counters markers: list[Marker] state: State uris: list[Uri] @@ -152,6 +171,7 @@ def as_dict(self) -> dict[str, Any]: """Return dictionary version of this printer.""" return { "info": asdict(self.info), + "counters": asdict(self.counters), "state": asdict(self.state), "markers": [asdict(marker) for marker in self.markers], "uris": [asdict(uri) for uri in self.uris], @@ -163,6 +183,7 @@ def update_from_dict(self, data: dict[str, Any]) -> Printer: last_uptime = self.info.uptime self.info = Info.from_dict(data) + self.counters = Counters.from_dict(data) self.markers = Printer.merge_marker_data(data) self.state = State.from_dict(data) self.uris = Printer.merge_uri_data(data) @@ -180,6 +201,7 @@ def from_dict(data: dict[str, Any]) -> Printer: return Printer( info=info, + counters=Counters.from_dict(data), markers=Printer.merge_marker_data(data), state=State.from_dict(data), uris=Printer.merge_uri_data(data), diff --git a/src/pyipp/tags.py b/src/pyipp/tags.py index af343020a..6e289ddf2 100644 --- a/src/pyipp/tags.py +++ b/src/pyipp/tags.py @@ -62,4 +62,7 @@ "media": IppTag.NAME, "center-of-pixel": IppTag.BOOLEAN, "sides": IppTag.KEYWORD, + "printer-impressions-completed": IppTag.INTEGER, + "printer-pages-completed": IppTag.INTEGER, + "printer-media-sheets-completed": IppTag.INTEGER, } diff --git a/tests/test_models.py b/tests/test_models.py index 52a8d1a02..11d8a52d7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -343,6 +343,61 @@ async def test_printer_with_single_supported_uri_invalid_uri() -> None: assert len(printer.uris) == 0 +@pytest.mark.asyncio +async def test_counters() -> None: + """Test Counters model.""" + data: dict[str, Any] = { + "printer-impressions-completed": 1234, + "printer-pages-completed": 5678, + "printer-media-sheets-completed": 9012, + } + + counters = models.Counters.from_dict(data) + + assert counters + assert counters.impressions_completed == 1234 + assert counters.pages_completed == 5678 + assert counters.media_sheets_completed == 9012 + + +@pytest.mark.asyncio +async def test_counters_defaults() -> None: + """Test Counters model with missing data.""" + counters = models.Counters.from_dict({}) + + assert counters + assert counters.impressions_completed == -1 + assert counters.pages_completed == -1 + assert counters.media_sheets_completed == -1 + + +@pytest.mark.asyncio +async def test_printer_counters() -> None: + """Test Printer model includes counters.""" + data = IPPE10_PRINTER_ATTRS.copy() + data["printer-impressions-completed"] = 500 + data["printer-pages-completed"] = 400 + data["printer-media-sheets-completed"] = 300 + + printer = models.Printer.from_dict(data) + assert printer + assert printer.counters.impressions_completed == 500 + assert printer.counters.pages_completed == 400 + assert printer.counters.media_sheets_completed == 300 + + +@pytest.mark.asyncio +async def test_printer_counters_in_as_dict() -> None: + """Test Printer as_dict includes counters.""" + data = IPPE10_PRINTER_ATTRS.copy() + data["printer-impressions-completed"] = 100 + + printer = models.Printer.from_dict(data) + printer_dict = printer.as_dict() + assert "counters" in printer_dict + assert printer_dict["counters"]["impressions_completed"] == 100 + + @pytest.mark.asyncio async def test_printer_with_single_supported_uri_with_security() -> None: """Test Printer model with multiple markers.""" From d57918ce938bb7b00c5fdecb97ba272775067e4f Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Sat, 14 Mar 2026 16:30:57 -0400 Subject: [PATCH 2/2] Support printer-impressions-completed-col for HP printers HP printers report impressions via a collection attribute (printer-impressions-completed-col) with monochrome/full-color breakdown instead of the scalar printer-impressions-completed. When the scalar is absent, sum the collection values as a fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pyipp/const.py | 1 + src/pyipp/models.py | 12 +++++++++++- tests/test_models.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/pyipp/const.py b/src/pyipp/const.py index edf80628f..fa0d41a2a 100644 --- a/src/pyipp/const.py +++ b/src/pyipp/const.py @@ -43,6 +43,7 @@ "marker-names", "marker-types", "printer-impressions-completed", + "printer-impressions-completed-col", "printer-pages-completed", "printer-media-sheets-completed", ] diff --git a/src/pyipp/models.py b/src/pyipp/models.py index 747c1dc38..0f3180076 100644 --- a/src/pyipp/models.py +++ b/src/pyipp/models.py @@ -120,14 +120,24 @@ class Counters: """Object holding page counter information from IPP.""" impressions_completed: int + impressions_completed_col: dict[str, int] pages_completed: int media_sheets_completed: int @staticmethod def from_dict(data: dict[str, Any]) -> Counters: """Return Counters object from IPP response.""" + col = data.get("printer-impressions-completed-col", {}) + if not isinstance(col, dict): + col = {} + + impressions = data.get("printer-impressions-completed", -1) + if impressions == -1 and col: + impressions = sum(col.values()) + return Counters( - impressions_completed=data.get("printer-impressions-completed", -1), + impressions_completed=impressions, + impressions_completed_col=col, pages_completed=data.get("printer-pages-completed", -1), media_sheets_completed=data.get("printer-media-sheets-completed", -1), ) diff --git a/tests/test_models.py b/tests/test_models.py index 11d8a52d7..7a8c10053 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -356,6 +356,7 @@ async def test_counters() -> None: assert counters assert counters.impressions_completed == 1234 + assert counters.impressions_completed_col == {} assert counters.pages_completed == 5678 assert counters.media_sheets_completed == 9012 @@ -367,10 +368,45 @@ async def test_counters_defaults() -> None: assert counters assert counters.impressions_completed == -1 + assert counters.impressions_completed_col == {} assert counters.pages_completed == -1 assert counters.media_sheets_completed == -1 +@pytest.mark.asyncio +async def test_counters_col() -> None: + """Test Counters model with collection-based impressions.""" + data: dict[str, Any] = { + "printer-impressions-completed-col": { + "monochrome": 0, + "full-color": 10, + }, + } + + counters = models.Counters.from_dict(data) + + assert counters + assert counters.impressions_completed == 10 + assert counters.impressions_completed_col == {"monochrome": 0, "full-color": 10} + + +@pytest.mark.asyncio +async def test_counters_col_does_not_override_scalar() -> None: + """Test that scalar impressions-completed takes precedence over col.""" + data: dict[str, Any] = { + "printer-impressions-completed": 500, + "printer-impressions-completed-col": { + "monochrome": 100, + "full-color": 200, + }, + } + + counters = models.Counters.from_dict(data) + + assert counters.impressions_completed == 500 + assert counters.impressions_completed_col == {"monochrome": 100, "full-color": 200} + + @pytest.mark.asyncio async def test_printer_counters() -> None: """Test Printer model includes counters."""