M8-T07: extract reusable P1 parser
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"""Pure, privacy-preserving parser for DSMR and WarmteLink P1 telegrams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
|
||||
_OBIS_LINE = re.compile(r"^(?P<code>\d+-\d+:\d+\.\d+\.\d+)(?P<values>(?:\([^)]*\))*)$")
|
||||
_NUMBER_WITH_UNIT = re.compile(r"^(?P<number>[+-]?\d+(?:\.\d+)?)(?:\*(?P<unit>.+))?$")
|
||||
_CHANNEL_OBIS = re.compile(r"^0-(?P<channel>[1-9]\d*):(24|96)\.")
|
||||
_EQUIPMENT_ID_CODES = re.compile(r"^0-(?:0|[1-9]\d*):96\.1\.[01]$")
|
||||
|
||||
|
||||
class IntegrityStatus(StrEnum):
|
||||
"""Whether a frame has a verifiable standard DSMR checksum."""
|
||||
|
||||
VALID = "valid"
|
||||
INVALID = "invalid"
|
||||
UNVERIFIABLE = "unverifiable"
|
||||
|
||||
|
||||
class P1ParseError(ValueError):
|
||||
"""A parse error whose message never includes telegram contents."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObisField:
|
||||
"""One sanitized OBIS line, including values not understood by the parser."""
|
||||
|
||||
code: str
|
||||
raw_values: tuple[str, ...]
|
||||
value: Decimal | None = None
|
||||
unit: str | None = None
|
||||
comparison_token: str | None = dataclass_field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P1Channel:
|
||||
"""Fields associated with one M-Bus channel, without its raw identifier."""
|
||||
|
||||
number: int
|
||||
device_type: str | None
|
||||
equipment_fingerprint: str | None = dataclass_field(repr=False)
|
||||
readings: tuple[ObisField, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P1Telegram:
|
||||
"""A parsed telegram with only sanitized, persistence-safe data."""
|
||||
|
||||
frame_length: int
|
||||
integrity: IntegrityStatus
|
||||
integrity_reason: str
|
||||
timestamp: str | None
|
||||
equipment_fingerprint: str | None = dataclass_field(repr=False)
|
||||
fields: tuple[ObisField, ...]
|
||||
channels: tuple[P1Channel, ...]
|
||||
|
||||
|
||||
class TelegramFramer:
|
||||
"""Incrementally extract newline-terminated variable-length telegrams."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
def feed(self, chunk: bytes) -> list[bytes]:
|
||||
"""Append *chunk* and return every complete frame now available."""
|
||||
|
||||
self._buffer.extend(chunk)
|
||||
frames: list[bytes] = []
|
||||
while (bang := self._buffer.find(b"!")) >= 0:
|
||||
newline = self._buffer.find(b"\n", bang)
|
||||
if newline < 0:
|
||||
break
|
||||
standard_start = self._buffer.find(b"/")
|
||||
start = standard_start if 0 <= standard_start < bang else 0
|
||||
frames.append(bytes(self._buffer[start : newline + 1]))
|
||||
del self._buffer[: newline + 1]
|
||||
return frames
|
||||
|
||||
|
||||
def dsmr_crc16(data: bytes) -> int:
|
||||
"""Return the DSMR CRC-16 over *data* (normally from ``/`` through ``!``)."""
|
||||
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return crc & 0xFFFF
|
||||
|
||||
|
||||
def parse_telegram(frame: bytes) -> P1Telegram:
|
||||
"""Parse one complete frame without retaining raw telegram bytes or IDs."""
|
||||
|
||||
bang = frame.find(b"!")
|
||||
if bang < 0:
|
||||
raise P1ParseError("telegram has no footer marker")
|
||||
body = frame[:bang]
|
||||
footer = frame[bang + 1 :].rstrip(b"\r\n")
|
||||
integrity, reason = _integrity(frame, bang, footer)
|
||||
fields, identifiers = _parse_obis_fields(body)
|
||||
timestamp = _field_value(fields, "0-0:1.0.0")
|
||||
return P1Telegram(
|
||||
frame_length=len(frame),
|
||||
integrity=integrity,
|
||||
integrity_reason=reason,
|
||||
timestamp=timestamp,
|
||||
equipment_fingerprint=identifiers.get("0-0:96.1.1"),
|
||||
fields=tuple(fields),
|
||||
channels=_parse_channels(fields, identifiers),
|
||||
)
|
||||
|
||||
|
||||
def _integrity(frame: bytes, bang: int, footer: bytes) -> tuple[IntegrityStatus, str]:
|
||||
if not frame.startswith(b"/"):
|
||||
return IntegrityStatus.UNVERIFIABLE, "missing standard DSMR '/' header"
|
||||
if len(footer) != 4 or not all(chr(byte) in "0123456789abcdefABCDEF" for byte in footer):
|
||||
return IntegrityStatus.UNVERIFIABLE, "footer is not a four-digit hexadecimal CRC"
|
||||
expected = int(footer, 16)
|
||||
actual = dsmr_crc16(frame[: bang + 1])
|
||||
if actual == expected:
|
||||
return IntegrityStatus.VALID, "CRC16 verified from '/' through '!'"
|
||||
return IntegrityStatus.INVALID, f"CRC16 mismatch: expected {expected:04X}, calculated {actual:04X}"
|
||||
|
||||
|
||||
def _parse_obis_fields(body: bytes) -> tuple[list[ObisField], dict[str, str]]:
|
||||
fields: list[ObisField] = []
|
||||
identifiers: dict[str, str] = {}
|
||||
for line in body.decode("ascii", errors="replace").splitlines()[1:]:
|
||||
match = _OBIS_LINE.fullmatch(line)
|
||||
if not match:
|
||||
continue
|
||||
code = match.group("code")
|
||||
raw_values = tuple(re.findall(r"\(([^)]*)\)", match.group("values")))
|
||||
if _EQUIPMENT_ID_CODES.fullmatch(code):
|
||||
comparison_token = _fingerprint(raw_values[-1]) if raw_values else None
|
||||
if comparison_token is not None:
|
||||
identifiers[code] = comparison_token
|
||||
fields.append(ObisField(code, ("<redacted>",), comparison_token=comparison_token))
|
||||
continue
|
||||
value, unit = _numeric_value(raw_values)
|
||||
fields.append(ObisField(code, raw_values, value, unit))
|
||||
return fields, identifiers
|
||||
|
||||
|
||||
def _fingerprint(identifier: str) -> str:
|
||||
"""Hash an identifier locally; callers never receive its original value."""
|
||||
|
||||
return hashlib.sha256(identifier.encode("ascii", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
def _numeric_value(raw_values: tuple[str, ...]) -> tuple[Decimal | None, str | None]:
|
||||
if not raw_values:
|
||||
return None, None
|
||||
match = _NUMBER_WITH_UNIT.fullmatch(raw_values[-1])
|
||||
if not match:
|
||||
return None, None
|
||||
try:
|
||||
return Decimal(match.group("number")), match.group("unit")
|
||||
except InvalidOperation:
|
||||
return None, None
|
||||
|
||||
|
||||
def _field_value(fields: list[ObisField], code: str) -> str | None:
|
||||
field = next((item for item in fields if item.code == code), None)
|
||||
return field.raw_values[-1] if field and field.raw_values else None
|
||||
|
||||
|
||||
def _parse_channels(fields: list[ObisField], identifiers: dict[str, str]) -> tuple[P1Channel, ...]:
|
||||
by_channel: dict[int, list[ObisField]] = {}
|
||||
for field in fields:
|
||||
match = _CHANNEL_OBIS.match(field.code)
|
||||
if match:
|
||||
by_channel.setdefault(int(match.group("channel")), []).append(field)
|
||||
return tuple(
|
||||
P1Channel(
|
||||
number=number,
|
||||
device_type=_field_value(channel_fields, f"0-{number}:24.1.0"),
|
||||
equipment_fingerprint=identifiers.get(f"0-{number}:96.1.0"),
|
||||
readings=tuple(
|
||||
field
|
||||
for field in channel_fields
|
||||
if field.code == f"0-{number}:24.2.1" and field.value is not None
|
||||
),
|
||||
)
|
||||
for number, channel_fields in sorted(by_channel.items())
|
||||
)
|
||||
@@ -541,7 +541,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
||||
|
||||
### M8-T07 — 提取可复用 P1 parser(零行为变化)
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Status**: `done`
|
||||
- **Depends**: M8-T02
|
||||
- **Context**: Pre-M8 parser 已有真机 fixture 证据;先无损提取,避免 worker 与 probe 维护两份协议逻辑。
|
||||
|
||||
|
||||
+11
-185
@@ -3,195 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import StrEnum
|
||||
import errno
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import BinaryIO, Callable, TextIO
|
||||
|
||||
import serial
|
||||
from app.integrations.p1 import IntegrityStatus, ObisField, P1Telegram, TelegramFramer, parse_telegram
|
||||
|
||||
|
||||
_OBIS_LINE = re.compile(r"^(?P<code>\d+-\d+:\d+\.\d+\.\d+)(?P<values>(?:\([^)]*\))*)$")
|
||||
_NUMBER_WITH_UNIT = re.compile(r"^(?P<number>[+-]?\d+(?:\.\d+)?)(?:\*(?P<unit>.+))?$")
|
||||
_CHANNEL_OBIS = re.compile(r"^0-(?P<channel>[1-9]\d*):(24|96)\.")
|
||||
|
||||
|
||||
class IntegrityStatus(StrEnum):
|
||||
"""Whether a frame has a verifiable standard DSMR checksum."""
|
||||
|
||||
VALID = "valid"
|
||||
INVALID = "invalid"
|
||||
UNVERIFIABLE = "unverifiable"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObisField:
|
||||
"""One OBIS line, including values not understood by this proof of concept."""
|
||||
|
||||
code: str
|
||||
raw_values: tuple[str, ...]
|
||||
value: Decimal | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P1Channel:
|
||||
"""Fields associated with one M-Bus channel, discovered from its OBIS code."""
|
||||
|
||||
number: int
|
||||
device_type: str | None
|
||||
equipment_id: str | None
|
||||
readings: tuple[ObisField, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class P1Telegram:
|
||||
"""A parsed telegram while retaining its raw framing and all OBIS fields."""
|
||||
|
||||
raw: bytes
|
||||
header: bytes
|
||||
footer: bytes
|
||||
integrity: IntegrityStatus
|
||||
integrity_reason: str
|
||||
timestamp: str | None
|
||||
fields: tuple[ObisField, ...]
|
||||
channels: tuple[P1Channel, ...]
|
||||
|
||||
|
||||
def dsmr_crc16(data: bytes) -> int:
|
||||
"""Return the DSMR CRC-16 over *data* (normally from ``/`` through ``!``)."""
|
||||
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return crc & 0xFFFF
|
||||
|
||||
|
||||
class TelegramFramer:
|
||||
"""Incrementally extract newline-terminated telegrams from byte chunks.
|
||||
|
||||
A standard telegram starts with ``/``. WarmteLink's observed telegrams do
|
||||
not, so a non-standard frame is retained from the current buffer start until
|
||||
its ``!`` footer line instead of fabricating a standard header.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
|
||||
def feed(self, chunk: bytes) -> list[bytes]:
|
||||
"""Append *chunk* and return every complete frame now available."""
|
||||
|
||||
self._buffer.extend(chunk)
|
||||
frames: list[bytes] = []
|
||||
|
||||
while (bang := self._buffer.find(b"!")) >= 0:
|
||||
newline = self._buffer.find(b"\n", bang)
|
||||
if newline < 0:
|
||||
break
|
||||
|
||||
standard_start = self._buffer.find(b"/")
|
||||
start = standard_start if 0 <= standard_start < bang else 0
|
||||
frames.append(bytes(self._buffer[start : newline + 1]))
|
||||
del self._buffer[: newline + 1]
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def parse_telegram(frame: bytes) -> P1Telegram:
|
||||
"""Parse a complete frame without guessing missing DSMR framing bytes."""
|
||||
|
||||
bang = frame.find(b"!")
|
||||
if bang < 0:
|
||||
raise ValueError("telegram has no footer marker '!'")
|
||||
|
||||
body = frame[:bang]
|
||||
footer = frame[bang + 1 :].rstrip(b"\r\n")
|
||||
header = body.splitlines()[0] if body else b""
|
||||
integrity, reason = _integrity(frame, bang, footer)
|
||||
fields = _parse_obis_fields(body)
|
||||
timestamp = _field_value(fields, "0-0:1.0.0")
|
||||
channels = _parse_channels(fields)
|
||||
|
||||
return P1Telegram(
|
||||
raw=frame,
|
||||
header=header,
|
||||
footer=footer,
|
||||
integrity=integrity,
|
||||
integrity_reason=reason,
|
||||
timestamp=timestamp,
|
||||
fields=tuple(fields),
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
|
||||
def _integrity(frame: bytes, bang: int, footer: bytes) -> tuple[IntegrityStatus, str]:
|
||||
if not frame.startswith(b"/"):
|
||||
return IntegrityStatus.UNVERIFIABLE, "missing standard DSMR '/' header"
|
||||
if len(footer) != 4 or not all(chr(byte) in "0123456789abcdefABCDEF" for byte in footer):
|
||||
return IntegrityStatus.UNVERIFIABLE, "footer is not a four-digit hexadecimal CRC"
|
||||
|
||||
expected = int(footer, 16)
|
||||
actual = dsmr_crc16(frame[: bang + 1])
|
||||
if actual == expected:
|
||||
return IntegrityStatus.VALID, "CRC16 verified from '/' through '!'"
|
||||
return IntegrityStatus.INVALID, f"CRC16 mismatch: expected {expected:04X}, calculated {actual:04X}"
|
||||
|
||||
|
||||
def _parse_obis_fields(body: bytes) -> list[ObisField]:
|
||||
fields: list[ObisField] = []
|
||||
for line in body.decode("ascii", errors="replace").splitlines()[1:]:
|
||||
match = _OBIS_LINE.fullmatch(line)
|
||||
if not match:
|
||||
continue
|
||||
raw_values = tuple(re.findall(r"\(([^)]*)\)", match.group("values")))
|
||||
value, unit = _numeric_value(raw_values)
|
||||
fields.append(ObisField(match.group("code"), raw_values, value, unit))
|
||||
return fields
|
||||
|
||||
|
||||
def _numeric_value(raw_values: tuple[str, ...]) -> tuple[Decimal | None, str | None]:
|
||||
if not raw_values:
|
||||
return None, None
|
||||
match = _NUMBER_WITH_UNIT.fullmatch(raw_values[-1])
|
||||
if not match:
|
||||
return None, None
|
||||
try:
|
||||
return Decimal(match.group("number")), match.group("unit")
|
||||
except InvalidOperation:
|
||||
return None, None
|
||||
|
||||
|
||||
def _field_value(fields: list[ObisField], code: str) -> str | None:
|
||||
field = next((item for item in fields if item.code == code), None)
|
||||
return field.raw_values[-1] if field and field.raw_values else None
|
||||
|
||||
|
||||
def _parse_channels(fields: list[ObisField]) -> tuple[P1Channel, ...]:
|
||||
by_channel: dict[int, list[ObisField]] = {}
|
||||
for field in fields:
|
||||
match = _CHANNEL_OBIS.match(field.code)
|
||||
if match:
|
||||
by_channel.setdefault(int(match.group("channel")), []).append(field)
|
||||
|
||||
return tuple(
|
||||
P1Channel(
|
||||
number=number,
|
||||
device_type=_field_value(channel_fields, f"0-{number}:24.1.0"),
|
||||
equipment_id=_field_value(channel_fields, f"0-{number}:96.1.0"),
|
||||
readings=tuple(
|
||||
field
|
||||
for field in channel_fields
|
||||
if field.code == f"0-{number}:24.2.1" and field.value is not None
|
||||
),
|
||||
)
|
||||
for number, channel_fields in sorted(by_channel.items())
|
||||
)
|
||||
__all__ = ["IntegrityStatus", "TelegramFramer", "build_parser", "parse_telegram", "run_probe"]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -279,6 +99,12 @@ def _format_field(field: ObisField) -> str:
|
||||
return f" {field.code}: {raw_values}{value}".rstrip()
|
||||
|
||||
|
||||
def _comparison_values(field: ObisField) -> tuple[str, ...]:
|
||||
"""Return a local change-detection token without exposing identifiers."""
|
||||
|
||||
return (field.comparison_token,) if field.comparison_token is not None else field.raw_values
|
||||
|
||||
|
||||
def _print_telegram(
|
||||
telegram: P1Telegram,
|
||||
frame_number: int,
|
||||
@@ -289,14 +115,14 @@ def _print_telegram(
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
cadence_text = "first frame" if cadence is None else f"cadence={cadence:.1f}s"
|
||||
print(
|
||||
f"frame {frame_number}: {telegram.integrity.value}; bytes={len(telegram.raw)}; {cadence_text}",
|
||||
f"frame {frame_number}: {telegram.integrity.value}; bytes={telegram.frame_length}; {cadence_text}",
|
||||
file=output,
|
||||
)
|
||||
print(f" integrity: {telegram.integrity_reason}", file=output)
|
||||
current_fields = {field.code: field.raw_values for field in telegram.fields}
|
||||
current_fields = {field.code: _comparison_values(field) for field in telegram.fields}
|
||||
fields = telegram.fields
|
||||
if show_changes and previous_fields:
|
||||
fields = tuple(field for field in fields if previous_fields.get(field.code) != field.raw_values)
|
||||
fields = tuple(field for field in fields if previous_fields.get(field.code) != _comparison_values(field))
|
||||
print(f" changed fields: {len(fields)}", file=output)
|
||||
for field in fields:
|
||||
print(_format_field(field), file=output)
|
||||
|
||||
@@ -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