M8-T07: extract reusable P1 parser
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.integrations.p1 import IntegrityStatus, P1ParseError, TelegramFramer, parse_telegram
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _fixture(name: str) -> bytes:
|
||||
return (FIXTURES / name).read_bytes()
|
||||
|
||||
|
||||
def test_parser_preserves_fixture_quality_and_uses_only_fingerprints_for_identifiers():
|
||||
frame = _fixture("warmtelink_p1_7n1.txt")
|
||||
telegram = parse_telegram(frame)
|
||||
|
||||
assert telegram.integrity is IntegrityStatus.UNVERIFIABLE
|
||||
assert telegram.frame_length == len(frame)
|
||||
assert [channel.number for channel in telegram.channels] == [1, 2]
|
||||
assert telegram.channels[0].readings[0].value == Decimal("5.900")
|
||||
assert telegram.channels[1].readings[0].unit == "GJ"
|
||||
assert all(field.raw_values == ("<redacted>",) for field in telegram.fields if "96.1" in field.code)
|
||||
assert repr(telegram).find("<redacted>") >= 0
|
||||
assert "KAM" not in repr(telegram)
|
||||
|
||||
|
||||
def test_parser_hashes_identifiers_without_returning_them():
|
||||
telegram_identifier = "secret-telegram-equipment-42"
|
||||
channel_identifier = "secret-channel-equipment-42"
|
||||
telegram = parse_telegram(
|
||||
f")HEADER\n0-0:96.1.1({telegram_identifier})\n"
|
||||
f"0-1:96.1.0({channel_identifier})\n!nope\n".encode()
|
||||
)
|
||||
|
||||
telegram_fingerprint = hashlib.sha256(telegram_identifier.encode()).hexdigest()
|
||||
channel_fingerprint = hashlib.sha256(channel_identifier.encode()).hexdigest()
|
||||
assert telegram.equipment_fingerprint == telegram_fingerprint
|
||||
assert telegram.channels[0].equipment_fingerprint == channel_fingerprint
|
||||
rendered = repr(telegram)
|
||||
assert telegram_identifier not in rendered
|
||||
assert channel_identifier not in rendered
|
||||
assert telegram_fingerprint not in rendered
|
||||
assert channel_fingerprint not in rendered
|
||||
|
||||
|
||||
def test_framer_handles_truncation_concatenation_and_variable_lengths_without_channel_mixup():
|
||||
first = _fixture("warmtelink_p1_7n1.txt")
|
||||
second = first.replace(b"(5.900*m3)", b"(12345.678*m3)")
|
||||
framer = TelegramFramer()
|
||||
|
||||
assert framer.feed(first[:-4]) == []
|
||||
frames = framer.feed(first[-4:] + second)
|
||||
|
||||
assert [parse_telegram(frame).channels[0].readings[0].value for frame in frames] == [
|
||||
Decimal("5.900"),
|
||||
Decimal("12345.678"),
|
||||
]
|
||||
|
||||
|
||||
def test_parser_tolerates_bad_text_without_exposing_it_and_rejects_missing_footer():
|
||||
telegram = parse_telegram(b")HEADER\n0-1:24.2.1(1.000*m3)\n\xff\n!not-hex\n")
|
||||
assert telegram.integrity is IntegrityStatus.UNVERIFIABLE
|
||||
assert telegram.channels[0].readings[0].value == Decimal("1.000")
|
||||
|
||||
with pytest.raises(P1ParseError, match="footer marker") as exc_info:
|
||||
parse_telegram(b"secret raw telegram")
|
||||
assert "secret" not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_parser_error_does_not_expose_equipment_identifiers():
|
||||
first_identifier = "private-equipment-id-one"
|
||||
second_identifier = "private-equipment-id-two"
|
||||
|
||||
with pytest.raises(P1ParseError) as exc_info:
|
||||
parse_telegram(
|
||||
f")HEADER\n0-0:96.1.1({first_identifier})\n"
|
||||
f"0-1:96.1.0({second_identifier})\n".encode()
|
||||
)
|
||||
|
||||
assert first_identifier not in str(exc_info.value)
|
||||
assert second_identifier not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_parser_keeps_standard_invalid_crc_invalid():
|
||||
frame = _fixture("dsmr_p1_valid.txt")
|
||||
assert parse_telegram(frame[:-5] + b"0000\n").integrity is IntegrityStatus.INVALID
|
||||
+58
-2
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,7 +30,7 @@ def test_standard_dsmr_fixture_has_a_valid_frame_and_crc():
|
||||
|
||||
telegram = parse_telegram(frame)
|
||||
assert telegram.integrity is IntegrityStatus.VALID
|
||||
assert telegram.header == b"/ISk5\\2MT382-1000"
|
||||
assert telegram.frame_length == len(frame)
|
||||
assert telegram.timestamp == "240822120000S"
|
||||
|
||||
|
||||
@@ -73,7 +74,7 @@ def test_missing_header_and_non_hex_footer_are_unverifiable():
|
||||
telegram = parse_telegram(b")HEADER\n0-0:1.0.0(240822120000S)\n!zzzz\n")
|
||||
|
||||
assert telegram.integrity is IntegrityStatus.UNVERIFIABLE
|
||||
assert telegram.footer == b"zzzz"
|
||||
assert telegram.frame_length == len(b")HEADER\n0-0:1.0.0(240822120000S)\n!zzzz\n")
|
||||
|
||||
|
||||
def test_crc_mismatch_is_invalid_when_standard_framing_is_present():
|
||||
@@ -189,6 +190,61 @@ def test_show_changes_filters_unchanged_fields_and_reports_cadence():
|
||||
assert fake.closed
|
||||
|
||||
|
||||
def test_probe_stdout_redacts_equipment_identifiers():
|
||||
identifier = "private-equipment-id"
|
||||
frame = (
|
||||
f")HEADER\n0-0:96.1.1({identifier})\n0-1:96.1.0({identifier})\n"
|
||||
"0-1:24.2.1(1.000*m3)\n!nope\n"
|
||||
).encode()
|
||||
fake = FakeSerial([frame])
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "1"])
|
||||
output = io.StringIO()
|
||||
|
||||
assert run_probe(args, serial_factory=lambda **_kwargs: fake, clock=_clock([0, 0.1, 2]), output=output) == 0
|
||||
assert identifier not in output.getvalue()
|
||||
assert "<redacted>" in output.getvalue()
|
||||
|
||||
|
||||
def test_show_changes_detects_redacted_identifier_changes_without_leaking_them():
|
||||
first_telegram_identifier = "private-telegram-id-one"
|
||||
first_channel_identifier = "private-channel-id-one"
|
||||
second_telegram_identifier = "private-telegram-id-two"
|
||||
second_channel_identifier = "private-channel-id-two"
|
||||
|
||||
def frame(telegram_identifier: str, channel_identifier: str) -> bytes:
|
||||
return (
|
||||
f")HEADER\n0-0:96.1.1({telegram_identifier})\n"
|
||||
f"0-1:96.1.0({channel_identifier})\n"
|
||||
"0-1:24.2.1(1.000*m3)\n!nope\n"
|
||||
).encode()
|
||||
|
||||
first_frame = frame(first_telegram_identifier, first_channel_identifier)
|
||||
second_frame = frame(second_telegram_identifier, second_channel_identifier)
|
||||
fake = FakeSerial([first_frame + second_frame])
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "2", "--show-changes"])
|
||||
output = io.StringIO()
|
||||
|
||||
assert run_probe(
|
||||
args,
|
||||
serial_factory=lambda **_kwargs: fake,
|
||||
clock=_clock([0, 0.1, 0.2, 1.2, 3]),
|
||||
output=output,
|
||||
) == 0
|
||||
rendered = output.getvalue()
|
||||
assert "changed fields: 2" in rendered
|
||||
assert "<redacted>" in rendered
|
||||
identifiers = (
|
||||
first_telegram_identifier,
|
||||
first_channel_identifier,
|
||||
second_telegram_identifier,
|
||||
second_channel_identifier,
|
||||
)
|
||||
assert all(identifier not in rendered for identifier in identifiers)
|
||||
assert all(hashlib.sha256(identifier.encode()).hexdigest() not in rendered for identifier in identifiers)
|
||||
assert all(identifier not in repr(parse_telegram(first_frame)) for identifier in identifiers[:2])
|
||||
assert all(identifier not in repr(parse_telegram(second_frame)) for identifier in identifiers[2:])
|
||||
|
||||
|
||||
def test_probe_diagnoses_permission_errors_without_suggesting_root():
|
||||
args = build_parser().parse_args(["--device", "/dev/fake", "--duration", "1"])
|
||||
errors = io.StringIO()
|
||||
|
||||
Reference in New Issue
Block a user