258 lines
11 KiB
Python
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()
|