feat: Complete RTL_433 integration (Phases 1-8)
Implemented full RTL_433 decoder integration for automatic IoT device identification:
## Phase 1-6: Core Implementation
- RTL_433 binary integration (v23.11, 244 protocols)
- Pulse data converter (RAW_Data → am.s16 format)
- Subprocess decoder wrapper with JSON parsing
- RTL433DecoderStrategy for matcher pipeline
- Comprehensive test suite (all tests passing)
## Phase 8: API Integration (this commit)
- GET /api/rtl433/status - Check decoder availability
- GET /api/rtl433/protocols - List 244 supported protocols
- GET /rtl433/protocols/{id} - Get protocol info
- POST /api/v1/captures/upload - Updated with RTL_433 decoding
## Files Added
- src/parser/rtl433_converter.py (~300 lines)
- src/matcher/rtl433_decoder.py (~400 lines)
- src/matcher/strategies.py (RTL433DecoderStrategy)
- docs/RTL433_INTEGRATION_PLAN.md
- docs/RTL433_IMPLEMENTATION_STATUS.md
- docs/RTL433_API_ENDPOINTS.md
- docs/PHASE8_COMPLETE.md
- tests/test_rtl433_integration.py
- tests/test_rtl433_api.sh
## Files Modified
- src/api/main_simple.py - Added RTL_433 decoding to upload
- src/api/routes/hardware.py - Added RTL_433 endpoints
## Performance
- Decode time: <0.05s per file
- 244 protocols supported
- 0.95 confidence for successful decodes
## Testing
- All integration tests passing
- All API endpoint tests passing
- ~3,600+ lines of code & documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
RTL_433 Pulse Data Converter
|
||||
|
||||
Converts Flipper .sub RAW_Data to RTL_433 pulse data format (am.s16)
|
||||
"""
|
||||
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
from .metadata import SignalMetadata
|
||||
|
||||
|
||||
class RTL433Converter:
|
||||
"""
|
||||
Convert Flipper RAW timing data to RTL_433 pulse format
|
||||
|
||||
RTL_433 Pulse Format (am.s16):
|
||||
- 16-bit signed integers (little-endian)
|
||||
- Positive = high pulse duration (μs)
|
||||
- Negative = low pulse duration (μs)
|
||||
- Direct 1:1 mapping from Flipper RAW_Data
|
||||
"""
|
||||
|
||||
MAX_PULSE_VALUE = 32767 # int16_t max
|
||||
MIN_PULSE_VALUE = -32768 # int16_t min
|
||||
|
||||
def __init__(self, temp_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize converter
|
||||
|
||||
Args:
|
||||
temp_dir: Directory for temporary files (default: /tmp/giglez_rtl433)
|
||||
"""
|
||||
if temp_dir is None:
|
||||
self.temp_dir = Path("/tmp/giglez_rtl433")
|
||||
else:
|
||||
self.temp_dir = Path(temp_dir)
|
||||
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def convert_to_pulse_file(self,
|
||||
raw_data: List[int],
|
||||
output_path: str,
|
||||
frequency: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Convert RAW_Data array to RTL_433 pulse data file
|
||||
|
||||
Args:
|
||||
raw_data: Array of timing values from .sub file
|
||||
output_path: Path to output .am.s16 file
|
||||
frequency: Optional frequency for filename (not used in binary format)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Validate input
|
||||
if not raw_data:
|
||||
logger.error("Empty RAW_Data array")
|
||||
return False
|
||||
|
||||
# Validate pulse data
|
||||
is_valid, errors = self.validate_pulse_data(raw_data)
|
||||
if not is_valid:
|
||||
logger.warning(f"Pulse data validation warnings: {errors}")
|
||||
# Continue anyway - some signals may have unusual patterns
|
||||
|
||||
# Clamp values to int16_t range
|
||||
clamped_data = []
|
||||
clamped_count = 0
|
||||
for value in raw_data:
|
||||
if value > self.MAX_PULSE_VALUE:
|
||||
logger.debug(f"Pulse value {value} exceeds max, clamping to {self.MAX_PULSE_VALUE}")
|
||||
clamped_data.append(self.MAX_PULSE_VALUE)
|
||||
clamped_count += 1
|
||||
elif value < self.MIN_PULSE_VALUE:
|
||||
logger.debug(f"Pulse value {value} below min, clamping to {self.MIN_PULSE_VALUE}")
|
||||
clamped_data.append(self.MIN_PULSE_VALUE)
|
||||
clamped_count += 1
|
||||
else:
|
||||
clamped_data.append(value)
|
||||
|
||||
if clamped_count > 0:
|
||||
logger.warning(f"Clamped {clamped_count} pulse values to int16 range")
|
||||
|
||||
# Pack as little-endian int16 array
|
||||
binary_data = struct.pack(f'<{len(clamped_data)}h', *clamped_data)
|
||||
|
||||
# Write to file
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(binary_data)
|
||||
|
||||
logger.info(f"Converted {len(raw_data)} pulses to {output_path} ({len(binary_data)} bytes)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert pulse data: {e}")
|
||||
return False
|
||||
|
||||
def convert_metadata(self, metadata: SignalMetadata) -> Optional[str]:
|
||||
"""
|
||||
Convert SignalMetadata RAW_Data to temp pulse file
|
||||
|
||||
Args:
|
||||
metadata: Parsed .sub file metadata
|
||||
|
||||
Returns:
|
||||
Path to temporary .am.s16 file, or None if failed
|
||||
"""
|
||||
if not metadata.has_raw_data or not metadata.raw_data:
|
||||
logger.debug("No RAW data in metadata")
|
||||
return None
|
||||
|
||||
# Create temp file with frequency in filename
|
||||
freq_mhz = metadata.frequency / 1_000_000
|
||||
temp_file = self.temp_dir / f"pulse_{freq_mhz:.3f}MHz.am.s16"
|
||||
|
||||
if self.convert_to_pulse_file(metadata.raw_data, str(temp_file), metadata.frequency):
|
||||
return str(temp_file)
|
||||
|
||||
return None
|
||||
|
||||
def validate_pulse_data(self, raw_data: List[int]) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate RAW_Data before conversion
|
||||
|
||||
Args:
|
||||
raw_data: Array of timing values
|
||||
|
||||
Returns:
|
||||
(is_valid, error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not raw_data:
|
||||
errors.append("Empty pulse data")
|
||||
return (False, errors)
|
||||
|
||||
# Check for minimum length
|
||||
if len(raw_data) < 10:
|
||||
errors.append(f"Too few pulses: {len(raw_data)} (minimum 10 recommended)")
|
||||
|
||||
# Check for all zeros
|
||||
if all(p == 0 for p in raw_data):
|
||||
errors.append("All pulse values are zero")
|
||||
return (False, errors)
|
||||
|
||||
# Check for reasonable pulse widths (< 100ms)
|
||||
max_pulse = max(abs(p) for p in raw_data)
|
||||
if max_pulse > 100_000:
|
||||
errors.append(f"Suspiciously large pulse: {max_pulse}μs (>100ms)")
|
||||
|
||||
# Check for alternating sign pattern (typical OOK/ASK)
|
||||
positive_count = sum(1 for p in raw_data if p > 0)
|
||||
negative_count = sum(1 for p in raw_data if p < 0)
|
||||
|
||||
if positive_count == 0:
|
||||
errors.append("No positive (high) pulses detected")
|
||||
|
||||
if negative_count == 0:
|
||||
errors.append("No negative (low) pulses detected")
|
||||
|
||||
# Check for very short pulses (< 5μs may be noise)
|
||||
very_short = [p for p in raw_data if 0 < abs(p) < 5]
|
||||
if len(very_short) > len(raw_data) * 0.5:
|
||||
errors.append(f"Too many very short pulses: {len(very_short)} (may be noise)")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
def get_pulse_statistics(self, raw_data: List[int]) -> dict:
|
||||
"""
|
||||
Get statistics about pulse data
|
||||
|
||||
Args:
|
||||
raw_data: Array of timing values
|
||||
|
||||
Returns:
|
||||
Dictionary of statistics
|
||||
"""
|
||||
if not raw_data:
|
||||
return {}
|
||||
|
||||
positive_pulses = [p for p in raw_data if p > 0]
|
||||
negative_pulses = [abs(p) for p in raw_data if p < 0]
|
||||
|
||||
stats = {
|
||||
'total_pulses': len(raw_data),
|
||||
'positive_count': len(positive_pulses),
|
||||
'negative_count': len(negative_pulses),
|
||||
'duration_ms': sum(abs(p) for p in raw_data) / 1000.0,
|
||||
}
|
||||
|
||||
if positive_pulses:
|
||||
stats['avg_high_pulse'] = sum(positive_pulses) / len(positive_pulses)
|
||||
stats['max_high_pulse'] = max(positive_pulses)
|
||||
stats['min_high_pulse'] = min(positive_pulses)
|
||||
|
||||
if negative_pulses:
|
||||
stats['avg_low_pulse'] = sum(negative_pulses) / len(negative_pulses)
|
||||
stats['max_low_pulse'] = max(negative_pulses)
|
||||
stats['min_low_pulse'] = min(negative_pulses)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_converter: Optional[RTL433Converter] = None
|
||||
|
||||
|
||||
def get_converter() -> RTL433Converter:
|
||||
"""Get global converter instance"""
|
||||
global _converter
|
||||
if _converter is None:
|
||||
_converter = RTL433Converter()
|
||||
return _converter
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Test the converter
|
||||
import sys
|
||||
from .sub_parser import parse_sub_file
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 -m src.parser.rtl433_converter <file.sub>")
|
||||
sys.exit(1)
|
||||
|
||||
converter = RTL433Converter()
|
||||
metadata = parse_sub_file(sys.argv[1])
|
||||
|
||||
print(f"File: {sys.argv[1]}")
|
||||
print(f"Frequency: {metadata.frequency / 1_000_000:.3f} MHz")
|
||||
print(f"Pulses: {metadata.pulse_count}")
|
||||
|
||||
if metadata.has_raw_data:
|
||||
stats = converter.get_pulse_statistics(metadata.raw_data)
|
||||
print(f"\nPulse Statistics:")
|
||||
for key, value in stats.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
is_valid, errors = converter.validate_pulse_data(metadata.raw_data)
|
||||
if errors:
|
||||
print(f"\nValidation Issues:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
|
||||
output_path = converter.convert_metadata(metadata)
|
||||
if output_path:
|
||||
print(f"\nCreated: {output_path}")
|
||||
else:
|
||||
print("\nConversion failed")
|
||||
else:
|
||||
print("No RAW data to convert")
|
||||
Reference in New Issue
Block a user