feat: identify decoded KEY .sub files (real-world coverage 58%→93%)
KEY .sub files (~35% of real captures) returned zero identification because every path bailed on `not has_raw_data`, despite the file already carrying a decoded Protocol name. Route those by protocol name instead: - category_router: add route_by_protocol() — device-specific brands (CAME/Nice/KeeLoq/Security+/Honeywell) map to one confident category; generic shared encoders (Princeton/EV1527/Holtek/Intertechno) map to a broad allowed family set, since the same silicon spans remote/doorbell/fan/gate. - pattern_decoder.decode: emit a decoded_key DeviceMatch (details carry predicted_category) for KEY files instead of []. - device_identifier.identify: only bail when there is neither RAW data nor a protocol name; gate statistical scoring on has_raw_data. Adds scripts/benchmark_realworld.py — validates the real pipeline against the real UberGuidoZ corpus (folder = ground-truth device type), reporting top-1, routed (truth in allowed set), and coverage. Measured on n=344 (seed 42): coverage 58%→93%, routed 59.3%→65.2%; generalizes on seed 7 (93%/63.6%). RAW path byte-identical (no regression); synthetic phase-0 gate still passes (top-3 67%). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,36 @@ class DeviceCategory:
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
# ── Decoded-protocol-name routing (KEY .sub files) ─────────────────────────
|
||||
# A KEY file already carries a Flipper `Protocol:` name. For device-specific
|
||||
# brands that name alone pins the category. Checked in order; first hit wins.
|
||||
# (keywords, category, confidence)
|
||||
_PROTOCOL_BRAND_RULES = [
|
||||
(("honeywell", "2gig", "magellan", "scher", "khan", "mastercode"),
|
||||
DeviceCategory.SECURITY_SENSOR, 0.80),
|
||||
(("came", "nice", "faac", "hormann", "somfy", "keeloq", "gate", "megacode",
|
||||
"linear", "chamberlain", "liftmaster", "marantec", "doorhan", "an-motors",
|
||||
"an_motors", "aprimatic", "beninca", "bft", "security+", "secplus", "genie",
|
||||
"clemsa", "ansonic", "sommer", "novoferm", "dooya", "alutech", "elmes",
|
||||
"nero", "hcs101", "starline", "star_line"),
|
||||
DeviceCategory.GARAGE_DOOR, 0.80),
|
||||
(("nexus", "lacrosse", "acurite", "oregon", "ambient", "infactory", "auriol",
|
||||
"bresser", "thermo", "gt-wt", "gt_wt", "wt450", "tx8300", "tx_8300",
|
||||
"wendox", "kedsum"),
|
||||
DeviceCategory.WEATHER_SENSOR, 0.75),
|
||||
(("tpms", "schrader", "pmv"),
|
||||
DeviceCategory.TPMS, 0.80),
|
||||
]
|
||||
|
||||
# Shared line-encoder silicon reused across remotes/doorbells/fans/gates. The
|
||||
# name does NOT determine device type, so we route to the whole family rather
|
||||
# than dishonestly narrowing to one category.
|
||||
_GENERIC_ENCODERS = (
|
||||
"princeton", "ev1527", "pt2262", "pt2264", "holtek", "ht12", "ht6",
|
||||
"smc5326", "intertechno", "rcswitch", "rc-switch", "x10", "power_smart",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CategoryPrediction:
|
||||
"""Result of category routing"""
|
||||
@@ -136,6 +166,72 @@ class CategoryRouter:
|
||||
use_full_db=True,
|
||||
)
|
||||
|
||||
def route_by_protocol(
|
||||
self, protocol: Optional[str], frequency: int
|
||||
) -> CategoryPrediction:
|
||||
"""
|
||||
Route a *decoded* KEY-file to a category from its Protocol name.
|
||||
|
||||
KEY .sub files have no RAW pulses to time, but they DO carry a Flipper
|
||||
`Protocol:` name. Device-specific brands (CAME, Nice, KeeLoq, Security+,
|
||||
Honeywell, ...) pin one category. Generic shared encoders (Princeton,
|
||||
EV1527, Holtek, Intertechno, ...) are the same silicon in remotes,
|
||||
doorbells, fans and gates — so we route to the whole family (a broad
|
||||
allowed set) rather than guessing a single type.
|
||||
|
||||
Args:
|
||||
protocol: Decoded Flipper protocol name (e.g. "CAME", "Princeton")
|
||||
frequency: Carrier frequency in Hz
|
||||
|
||||
Returns:
|
||||
CategoryPrediction
|
||||
"""
|
||||
name = (protocol or "").strip().lower()
|
||||
freq_mhz = (frequency or 0) / 1_000_000
|
||||
|
||||
if not name or name in ("raw", "binraw", "unknown"):
|
||||
return CategoryPrediction(
|
||||
primary_category=DeviceCategory.UNKNOWN,
|
||||
confidence=0.0,
|
||||
allowed_categories=[],
|
||||
reasoning="no decoded protocol name",
|
||||
use_full_db=True,
|
||||
)
|
||||
|
||||
# Device-specific brand → single confident category
|
||||
for keywords, category, conf in _PROTOCOL_BRAND_RULES:
|
||||
if any(k in name for k in keywords):
|
||||
return CategoryPrediction(
|
||||
primary_category=category,
|
||||
confidence=conf,
|
||||
allowed_categories=[category],
|
||||
reasoning=f"decoded protocol '{protocol}' → {category}",
|
||||
)
|
||||
|
||||
# Generic shared encoder → broad family (honest 'routed' signal)
|
||||
if any(k in name for k in _GENERIC_ENCODERS):
|
||||
allowed = [DeviceCategory.REMOTE_CONTROL, DeviceCategory.DOORBELL,
|
||||
DeviceCategory.FAN_CONTROLLER, DeviceCategory.GARAGE_DOOR]
|
||||
# 315/390 bands skew toward garage/gate; 433 is the free-for-all.
|
||||
primary = (DeviceCategory.GARAGE_DOOR if 300 <= freq_mhz <= 392
|
||||
else DeviceCategory.REMOTE_CONTROL)
|
||||
return CategoryPrediction(
|
||||
primary_category=primary,
|
||||
confidence=0.45,
|
||||
allowed_categories=allowed,
|
||||
reasoning=(f"generic encoder '{protocol}' @ {freq_mhz:.1f} MHz "
|
||||
"→ shared-silicon family (remote/doorbell/fan/gate)"),
|
||||
)
|
||||
|
||||
# Unrecognised name — don't guess, search everything.
|
||||
return CategoryPrediction(
|
||||
primary_category=DeviceCategory.UNKNOWN,
|
||||
confidence=0.0,
|
||||
allowed_categories=[],
|
||||
reasoning=f"unrecognised protocol '{protocol}'",
|
||||
use_full_db=True,
|
||||
)
|
||||
|
||||
# ── Band-specific helpers ──────────────────────────────────────────────
|
||||
|
||||
def _route_300mhz_band(
|
||||
|
||||
@@ -117,16 +117,17 @@ class DeviceIdentifier:
|
||||
t0 = time.time()
|
||||
result = IdentificationResult(signal_metadata=signal_data)
|
||||
|
||||
if not signal_data.has_raw_data:
|
||||
# No raw data - cannot identify
|
||||
if not signal_data.has_raw_data and not signal_data.protocol:
|
||||
# No raw data AND no decoded protocol name - cannot identify
|
||||
result.method = "none"
|
||||
return result
|
||||
|
||||
# Step 1: Pattern-based decoding (heuristic)
|
||||
# Step 1: Pattern-based decoding (heuristic). For KEY files this routes
|
||||
# by the decoded protocol name; for RAW files it times the pulses.
|
||||
heuristic_matches = self.pattern_decoder.decode(signal_data)
|
||||
|
||||
# Step 2: Statistical classification (if enabled and sufficient data)
|
||||
if use_statistical and heuristic_matches:
|
||||
# Step 2: Statistical classification (needs RAW pulses; skip on KEY)
|
||||
if use_statistical and heuristic_matches and signal_data.has_raw_data:
|
||||
statistical_matches = self._apply_statistical_scoring(
|
||||
signal_data,
|
||||
heuristic_matches
|
||||
|
||||
@@ -103,6 +103,10 @@ class PatternDecoder:
|
||||
List of DeviceMatch sorted by confidence (highest first)
|
||||
"""
|
||||
if not metadata.has_raw_data:
|
||||
# KEY files carry no pulses to time, but a decoded Protocol name is
|
||||
# itself identifying — route it instead of returning nothing.
|
||||
if metadata.protocol:
|
||||
return self._decode_from_key(metadata)
|
||||
return []
|
||||
|
||||
pulses = metadata.raw_data
|
||||
@@ -125,6 +129,37 @@ class PatternDecoder:
|
||||
# Deduplicate and rank by confidence
|
||||
return self._rank_matches(matches)
|
||||
|
||||
def _decode_from_key(self, metadata: SignalMetadata) -> List[DeviceMatch]:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
pred = self.category_router.route_by_protocol(
|
||||
metadata.protocol, metadata.frequency
|
||||
)
|
||||
signature = ProtocolSignature(
|
||||
name=metadata.protocol,
|
||||
category=pred.primary_category,
|
||||
frequency=metadata.frequency or 433920000,
|
||||
)
|
||||
details = {
|
||||
"predicted_category": pred.primary_category,
|
||||
"allowed_categories": pred.allowed_categories,
|
||||
"routing_reason": pred.reasoning,
|
||||
"source": "decoded_key",
|
||||
"bit_length": metadata.bit_length,
|
||||
}
|
||||
return [DeviceMatch(
|
||||
protocol=signature,
|
||||
confidence=pred.confidence,
|
||||
match_method="decoded_key",
|
||||
details=details,
|
||||
)]
|
||||
|
||||
def _identify_pulse_widths(self, pulses: List[int]) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Identify SHORT/LONG pulse and gap durations using robust timing analyzer
|
||||
|
||||
Reference in New Issue
Block a user