Merge feat/decoded-key-exact-db-match: exact signature-DB match for decoded KEY .sub files
This commit is contained in:
@@ -146,10 +146,41 @@ class PatternDecoder:
|
||||
Identify a decoded (KEY) .sub file from its Protocol name.
|
||||
|
||||
KEY files have no RAW pulses but carry a decoded `Protocol:` name,
|
||||
frequency and key. We route the protocol name to a device category and
|
||||
emit a match surfacing the real Flipper protocol + routed category, so
|
||||
decoded uploads are no longer silently unidentifiable.
|
||||
frequency and key. Two cases:
|
||||
|
||||
1. The named protocol is in our signature database. This is the
|
||||
strongest possible identification — the capture device already
|
||||
demodulated and *named* the protocol, and we recognise it — so we
|
||||
surface the REAL catalog signature (true manufacturer + category)
|
||||
with high, frequency-aware confidence. This is an exact "database
|
||||
match", not a timing guess, and it keeps the surfaced category
|
||||
consistent with the catalog (e.g. Princeton -> Garage Door Opener)
|
||||
instead of the router's coarser guess.
|
||||
|
||||
2. The name is unknown to us. Fall back to category routing so the
|
||||
upload is still identified by family rather than silently dropped.
|
||||
"""
|
||||
known = self._lookup_known_protocol(metadata.protocol, metadata.frequency)
|
||||
if known is not None:
|
||||
freq_ok = bool(metadata.frequency) and known.matches_frequency(metadata.frequency)
|
||||
# Exact, already-decoded match against a known protocol. Confidence
|
||||
# is high but never a literal 1.0 (the key payload itself is not
|
||||
# cryptographically verified); a consistent frequency lifts it.
|
||||
confidence = 0.95 if freq_ok else 0.90
|
||||
details = {
|
||||
"predicted_category": known.category,
|
||||
"source": "decoded_key_exact",
|
||||
"bit_length": metadata.bit_length,
|
||||
"frequency_match": freq_ok,
|
||||
"manufacturer": known.manufacturer,
|
||||
}
|
||||
return [DeviceMatch(
|
||||
protocol=known,
|
||||
confidence=confidence,
|
||||
match_method="decoded_key_exact",
|
||||
details=details,
|
||||
)]
|
||||
|
||||
pred = self.category_router.route_by_protocol(
|
||||
metadata.protocol, metadata.frequency
|
||||
)
|
||||
@@ -172,6 +203,30 @@ class PatternDecoder:
|
||||
details=details,
|
||||
)]
|
||||
|
||||
def _lookup_known_protocol(
|
||||
self, name: Optional[str], frequency: Optional[int]
|
||||
) -> Optional[ProtocolSignature]:
|
||||
"""Exact (case-insensitive) protocol-name lookup in the signature DB.
|
||||
|
||||
When several catalog entries share a name, prefer one whose frequency
|
||||
band matches the capture; otherwise return the first. Returns None if
|
||||
the name is unknown.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
target = name.strip().lower()
|
||||
candidates = [
|
||||
p for p in self.protocol_db.get_all()
|
||||
if p.name.strip().lower() == target
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
if frequency:
|
||||
for cand in candidates:
|
||||
if cand.matches_frequency(frequency):
|
||||
return cand
|
||||
return candidates[0]
|
||||
|
||||
def _identify_pulse_widths(self, pulses: List[int]) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Identify SHORT/LONG pulse and gap durations using robust timing analyzer
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Offline unit tests for decoded (KEY) .sub identification against the signature DB.
|
||||
|
||||
A Flipper KEY file carries no RAW pulses but *names* a decoded protocol (e.g.
|
||||
``Protocol: Princeton``). When that name is in our signature database this is the
|
||||
strongest possible identification -- the capture device already demodulated and
|
||||
named the protocol and we recognise it -- so it must:
|
||||
|
||||
- resolve to the REAL catalog signature (true manufacturer + category), not the
|
||||
category router's coarser guess (Princeton is a Garage Door Opener in the
|
||||
catalog, which the router previously mislabelled "Remote Control"), and
|
||||
- score with high, frequency-aware confidence (exact database match), not the
|
||||
~0.45 fuzzy-guess confidence it used to get.
|
||||
|
||||
Unknown protocol names must still fall back to category routing so an upload is
|
||||
identified by family rather than silently dropped ("accept any capture ->
|
||||
identify the device").
|
||||
|
||||
Offline / author-time: bare ``pytest`` runs this with no docker, no server, no
|
||||
network. KEY .sub content is embedded and written to ``tmp_path``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.parser.sub_parser import SubFileParser
|
||||
from src.matcher.engine import SignatureMatcher
|
||||
from src.matcher.pattern_decoder import PatternDecoder
|
||||
from src.matcher.protocol_database import get_protocol_database
|
||||
|
||||
|
||||
def _key_sub(protocol: str, frequency: int, bit: int = 24, key: str = "00 00 00 00 00 95 D5 D4") -> str:
|
||||
return "\n".join([
|
||||
"Filetype: Flipper SubGhz Key File",
|
||||
"Version: 1",
|
||||
f"Frequency: {frequency}",
|
||||
"Preset: FuriHalSubGhzPresetOok270Async",
|
||||
f"Protocol: {protocol}",
|
||||
f"Bit: {bit}",
|
||||
f"Key: {key}",
|
||||
"TE: 400",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def _write(tmp_path, content, name="k.sub"):
|
||||
p = tmp_path / name
|
||||
p.write_text(content)
|
||||
return str(p)
|
||||
|
||||
|
||||
def _identify(tmp_path, content):
|
||||
meta = SubFileParser().parse(_write(tmp_path, content))
|
||||
return meta, SignatureMatcher().match(meta, max_results=5)
|
||||
|
||||
|
||||
# A known catalog protocol and a known one that carries a manufacturer, both
|
||||
# taken from the DB itself so the tests stay correct if the catalog changes.
|
||||
_DB = get_protocol_database()
|
||||
_KNOWN = next(p for p in _DB.get_all())
|
||||
_KNOWN_WITH_MFR = next((p for p in _DB.get_all() if p.manufacturer), None)
|
||||
_UNKNOWN_NAME = "ZZ_NOT_A_REAL_PROTOCOL_9999"
|
||||
|
||||
|
||||
class TestKnownProtocolExactMatch:
|
||||
def test_named_protocol_resolves_to_catalog_signature(self, tmp_path):
|
||||
meta, matches = _identify(tmp_path, _key_sub(_KNOWN.name, _KNOWN.frequency))
|
||||
assert meta.file_format == "KEY"
|
||||
assert meta.is_decoded is True
|
||||
assert len(matches) >= 1
|
||||
top = matches[0]
|
||||
assert top.device_name == _KNOWN.name
|
||||
assert top.match_method == "decoded_key_exact"
|
||||
# Category comes from the catalog, not the router's guess.
|
||||
assert top.match_details.get("device_category") == _KNOWN.category
|
||||
|
||||
def test_exact_match_scores_high_when_frequency_consistent(self, tmp_path):
|
||||
_, matches = _identify(tmp_path, _key_sub(_KNOWN.name, _KNOWN.frequency))
|
||||
assert matches[0].confidence == pytest.approx(0.95)
|
||||
assert matches[0].match_details.get("frequency_match") is True
|
||||
|
||||
def test_exact_match_still_high_but_lower_on_frequency_mismatch(self, tmp_path):
|
||||
# Same known name, but a frequency far outside its band.
|
||||
off_band = _KNOWN.frequency + 50_000_000
|
||||
_, matches = _identify(tmp_path, _key_sub(_KNOWN.name, off_band))
|
||||
top = matches[0]
|
||||
assert top.match_method == "decoded_key_exact"
|
||||
assert top.confidence == pytest.approx(0.90)
|
||||
assert top.match_details.get("frequency_match") is False
|
||||
|
||||
@pytest.mark.skipif(_KNOWN_WITH_MFR is None, reason="no catalog protocol carries a manufacturer")
|
||||
def test_real_manufacturer_is_surfaced(self, tmp_path):
|
||||
_, matches = _identify(
|
||||
tmp_path, _key_sub(_KNOWN_WITH_MFR.name, _KNOWN_WITH_MFR.frequency, bit=66)
|
||||
)
|
||||
assert matches[0].manufacturer == _KNOWN_WITH_MFR.manufacturer
|
||||
|
||||
|
||||
class TestUnknownProtocolFallback:
|
||||
def test_unknown_name_falls_back_to_router(self, tmp_path):
|
||||
_, matches = _identify(tmp_path, _key_sub(_UNKNOWN_NAME, 433920000))
|
||||
assert len(matches) >= 1, "unknown decoded protocol must still be identified by family"
|
||||
top = matches[0]
|
||||
assert top.match_method == "decoded_key"
|
||||
# A routed guess must not masquerade as an exact database match.
|
||||
assert top.confidence < 0.90
|
||||
|
||||
def test_unknown_name_still_names_the_protocol(self, tmp_path):
|
||||
_, matches = _identify(tmp_path, _key_sub(_UNKNOWN_NAME, 433920000))
|
||||
assert matches[0].device_name == _UNKNOWN_NAME
|
||||
|
||||
|
||||
class TestLookupHelper:
|
||||
def test_known_name_case_insensitive(self):
|
||||
dec = PatternDecoder()
|
||||
sig = dec._lookup_known_protocol(_KNOWN.name.upper(), _KNOWN.frequency)
|
||||
assert sig is not None
|
||||
assert sig.name == _KNOWN.name
|
||||
|
||||
def test_unknown_name_returns_none(self):
|
||||
assert PatternDecoder()._lookup_known_protocol(_UNKNOWN_NAME, 433920000) is None
|
||||
|
||||
def test_empty_name_returns_none(self):
|
||||
assert PatternDecoder()._lookup_known_protocol(None, 433920000) is None
|
||||
|
||||
def test_prefers_frequency_matching_candidate(self):
|
||||
"""If duplicate-named entries exist, the frequency-consistent one wins."""
|
||||
dec = PatternDecoder()
|
||||
sig = dec._lookup_known_protocol(_KNOWN.name, _KNOWN.frequency)
|
||||
assert sig is not None
|
||||
assert sig.matches_frequency(_KNOWN.frequency)
|
||||
Reference in New Issue
Block a user