Files
KAT/scripts/analyze_sub_vag.py
T
2026-02-17 22:37:39 -05:00

258 lines
11 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
"""
Analyze a Flipper .sub file against the VAG decoder logic (vag.rs) to determine
why the signal does not decode as VAG (or even as Unknown).
Usage:
python scripts/analyze_sub_vag.py path/to/file.sub
python scripts/analyze_sub_vag.py IMPORTS/Test_55_unlock_and_55_lock_suran.sub
Reads the .sub file (Frequency + RAW_Data: positive=HIGH, negative=LOW, µs),
then:
1. Checks if frequency is in VAG's supported range (433.92 / 434.42 MHz, 2% tolerance).
2. Simulates the VAG decoder state machine (Reset -> Preamble1/2 -> ...) and reports
the first pulse index where the decoder fails and why.
3. Scans for the first occurrence of a VAG-like preamble (HIGH 300±79 or 500±79 µs)
so you can see if the frame appears later in the stream.
4. Tries both normal and inverted polarity (KAT tries inverted if normal yields no decode).
"""
import re
import sys
from pathlib import Path
# --- Timing constants from vag.rs (Type 1/2 vs Type 3/4) ---
TE_SHORT_12 = 300 # Type 1/2 short
TE_LONG_12 = 600 # Type 1/2 long
TE_SHORT = 500 # Type 3/4 short
TE_LONG = 1000 # Type 3/4 long
REF_RESET_DELTA = 79
REF_PREAMBLE_SYNC = 80
REF_GAP1_DELTA = 79
# VAG supported frequencies (Hz) from vag.rs
VAG_FREQS = [433_920_000, 434_420_000]
# KAT frequency tolerance: diff < (f / 50) => 2%
def frequency_supported(file_hz: int) -> tuple[bool, str]:
for f in VAG_FREQS:
diff = abs(f - file_hz)
if diff < (f / 50):
return True, f"Frequency {file_hz} Hz is within 2% of VAG supported {f} Hz"
return False, f"Frequency {file_hz} Hz is NOT in VAG supported list {VAG_FREQS} (2% tolerance)"
def parse_sub(path: Path) -> tuple[int, list[tuple[bool, int]]]:
"""Parse .sub file; return (frequency_hz, list of (level, duration_us))."""
text = path.read_text()
frequency_hz = 433_920_000
raw_data: list[int] = []
for line in text.splitlines():
line = line.strip()
if line.startswith("Frequency:"):
frequency_hz = int(line.split(":", 1)[1].strip())
elif line.startswith("RAW_Data:"):
rest = line.split(":", 1)[1].strip()
for word in rest.split():
raw_data.append(int(word))
# positive => HIGH (True), negative => LOW (False); duration in µs
pairs: list[tuple[bool, int]] = []
for v in raw_data:
duration_us = abs(v)
level = v >= 0
pairs.append((level, duration_us))
return frequency_hz, pairs
def analyze_reset_and_first_steps(pairs: list[tuple[bool, int]], invert: bool) -> list[str]:
"""
Simulate VAG decoder from Reset: what does it expect, and at which index do we fail?
Returns list of report lines.
"""
lines: list[str] = []
count = 0
for i, (level, duration) in enumerate(pairs):
if invert:
level = not level
if count >= 20:
break
if i == 0:
lines.append("")
lines.append("--- Step-by-step from start (first 20 pulses) ---")
lines.append("Reset expects: first pulse must be HIGH, duration 300±79 µs (Type1/2) or 500±79 µs (Type3/4).")
# Reset: only look at HIGH pulses
if not level:
lines.append(f" [{i}] LOW {duration:5} µs -> Reset ignores LOW (stays in Reset)")
count += 1
continue
# HIGH pulse
count += 1
if duration < TE_SHORT_12:
diff = TE_SHORT_12 - duration
if diff <= REF_RESET_DELTA:
lines.append(f" [{i}] HIGH {duration:5} µs -> 300-duration={diff}<=79 -> would enter Preamble1 (Type1/2)")
else:
lines.append(f" [{i}] HIGH {duration:5} µs -> 300-duration={diff}>79 -> REJECT (stays Reset)")
elif duration - TE_SHORT_12 <= REF_RESET_DELTA:
lines.append(f" [{i}] HIGH {duration:5} µs -> duration-300<={duration - TE_SHORT_12}<=79 -> would enter Preamble1 (Type1/2)")
else:
if TE_SHORT - REF_RESET_DELTA <= duration <= TE_SHORT + REF_RESET_DELTA:
lines.append(f" [{i}] HIGH {duration:5} µs -> 500±79 -> would enter Preamble2 (Type3/4)")
else:
lines.append(f" [{i}] HIGH {duration:5} µs -> not 300±79 nor 500±79 -> REJECT (stays Reset)")
return lines
def find_first_vag_like_pulse(
pairs: list[tuple[bool, int]], invert: bool, tolerance_300: int = 79, tolerance_500: int = 79
) -> list[str]:
"""Find first HIGH pulse that looks like VAG preamble (300±tolerance or 500±tolerance)."""
lines: list[str] = []
for i, (level, duration) in enumerate(pairs):
if invert:
level = not level
if not level:
continue
ok_300 = (TE_SHORT_12 - tolerance_300 <= duration <= TE_SHORT_12 + tolerance_300)
ok_500 = (TE_SHORT - tolerance_500 <= duration <= TE_SHORT + tolerance_500)
if ok_300 or ok_500:
kind = f"300±{tolerance_300} (Type1/2)" if ok_300 else f"500±{tolerance_500} (Type3/4)"
lines.append(f"First VAG-like HIGH pulse at index {i}: {duration} µs ({kind})")
return lines
lines.append(f"No HIGH pulse matches 300±{tolerance_300} or 500±{tolerance_500} µs.")
return lines
def scan_vag_preamble_with_tolerances(pairs: list[tuple[bool, int]], invert: bool) -> list[str]:
"""
Scan entire file for HIGH pulses that could be VAG preamble (300 or 500 µs)
with multiple tolerances. Reports counts and first few indices for each.
"""
lines: list[str] = []
# Tolerances to try: current (79), then relaxed
tolerances = [79, 100, 120, 150, 200]
for tol in tolerances:
indices_300: list[int] = []
indices_500: list[int] = []
for i, (level, duration) in enumerate(pairs):
if invert:
level = not level
if not level:
continue
if TE_SHORT_12 - tol <= duration <= TE_SHORT_12 + tol:
indices_300.append(i)
if TE_SHORT - tol <= duration <= TE_SHORT + tol:
indices_500.append(i)
lines.append(f" Tolerance ±{tol} µs: {len(indices_300)} pulses near 300 µs, {len(indices_500)} near 500 µs")
if indices_300:
first_few = indices_300[:5]
lines.append(f" First 300±{tol} at indices: {first_few}")
if indices_500:
first_few = indices_500[:5]
lines.append(f" First 500±{tol} at indices: {first_few}")
return lines
def histogram_high_pulses(pairs: list[tuple[bool, int]], invert: bool, buckets: list[tuple[int, int]]) -> list[str]:
"""Count HIGH pulse durations in buckets (center, half_width) -> (min, max) µs."""
lines: list[str] = []
counts: list[tuple[str, int]] = []
for center, half in buckets:
lo, hi = center - half, center + half
n = sum(1 for level, d in pairs if (level if not invert else not level) and lo <= d <= hi)
counts.append((f"{center}±{half}", n))
for label, n in counts:
lines.append(f" {label} µs: {n} HIGH pulses")
return lines
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python analyze_sub_vag.py <path/to/file.sub>")
sys.exit(1)
path = Path(sys.argv[1])
if not path.exists():
print(f"File not found: {path}")
sys.exit(1)
print("=" * 60)
print("VAG decoder analysis for:", path)
print("=" * 60)
freq_hz, pairs = parse_sub(path)
print(f"\nParsed: frequency = {freq_hz} Hz ({freq_hz/1e6:.2f} MHz), {len(pairs)} level/duration pairs.")
ok, msg = frequency_supported(freq_hz)
print(f"\nFrequency check: {msg}")
if not ok:
print(" -> VAG decoder is never tried for this file (frequency filter in KAT).")
# First few pulses
print("\nFirst 10 pulses (level, duration_us):")
for i, (level, dur) in enumerate(pairs[:10]):
lvl = "HIGH " if level else "LOW "
print(f" [{i}] {lvl} {dur} µs")
# Why decoder fails from start
report = analyze_reset_and_first_steps(pairs, invert=False)
for line in report:
print(line)
# Find first VAG-like pulse in stream
print("\n--- Search for VAG preamble in full stream ---")
for line in find_first_vag_like_pulse(pairs, invert=False):
print(line)
print("\nWith INVERTED polarity (KAT tries this if normal fails):")
for line in find_first_vag_like_pulse(pairs, invert=True):
print(line)
# Scan entire file with multiple tolerances (is there any VAG preamble at all?)
print("\n--- VAG preamble scan: counts at different tolerances ---")
print("Normal polarity:")
for line in scan_vag_preamble_with_tolerances(pairs, invert=False):
print(line)
print("Inverted polarity:")
for line in scan_vag_preamble_with_tolerances(pairs, invert=True):
print(line)
# Histogram of HIGH pulse durations (VAG uses 300, 500, 600, 1000 µs)
print("\n--- Histogram of HIGH pulse durations (µs) ---")
buckets = [(300, 79), (500, 79), (600, 79), (1000, 79), (300, 150), (500, 150)]
print("Normal polarity:")
for line in histogram_high_pulses(pairs, invert=False, buckets=buckets):
print(line)
print("Inverted polarity:")
for line in histogram_high_pulses(pairs, invert=True, buckets=buckets):
print(line)
# Summary
print("\n--- Summary (why no decode / no Unknown) ---")
if not ok:
print("1. Frequency is outside VAG supported range -> VAG decoder is skipped.")
else:
first_high = next((i for i, (l, d) in enumerate(pairs) if l), None)
if first_high is None:
print("1. No HIGH pulse in file (invalid or empty?).")
else:
_, first_high_dur = pairs[first_high]
if first_high_dur < TE_SHORT_12 - REF_RESET_DELTA or (
first_high_dur > TE_SHORT_12 + REF_RESET_DELTA
and not (TE_SHORT - REF_RESET_DELTA <= first_high_dur <= TE_SHORT + REF_RESET_DELTA)
):
print(f"1. First HIGH pulse is at index {first_high} with duration {first_high_dur} µs.")
print(" VAG Reset requires first HIGH to be 300±79 µs (Type1/2) or 500±79 µs (Type3/4).")
print(" So the decoder never leaves Reset and never produces a decode (or Unknown).")
print("2. KAT only creates a capture (including Unknown) when a decoder consumes the stream")
print(" and returns a result. VAG never returns because it stays in Reset.")
print("3. If the VAG frame appears later in the file, the decoder would need to be run from")
print(" that position (sliding window). Currently KAT feeds the stream from the start only.")
if __name__ == "__main__":
main()