565 lines
18 KiB
Python
565 lines
18 KiB
Python
"""
|
|
GigLez SQLAlchemy ORM Models
|
|
|
|
Database models matching the PostgreSQL + PostGIS schema
|
|
Based on Wigle wardriving patterns adapted for IoT RF device mapping
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Optional, List
|
|
from sqlalchemy import (
|
|
Column, String, Integer, Float, DateTime, Text, Boolean,
|
|
DECIMAL, ARRAY, ForeignKey, CheckConstraint, UniqueConstraint,
|
|
Index, BYTEA
|
|
)
|
|
from sqlalchemy.orm import declarative_base, relationship
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from geoalchemy2 import Geometry
|
|
from geoalchemy2.functions import ST_SetSRID, ST_MakePoint
|
|
|
|
|
|
# =============================================================================
|
|
# BASE
|
|
# =============================================================================
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
# =============================================================================
|
|
# CORE MODELS
|
|
# =============================================================================
|
|
|
|
class User(Base):
|
|
"""User accounts for community features"""
|
|
|
|
__tablename__ = 'users'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Authentication
|
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
|
password_hash = Column(String(255), nullable=False)
|
|
|
|
# Profile
|
|
display_name = Column(String(100))
|
|
avatar_url = Column(Text)
|
|
bio = Column(Text)
|
|
|
|
# Statistics
|
|
total_captures = Column(Integer, default=0)
|
|
total_identifications = Column(Integer, default=0)
|
|
reputation_score = Column(Integer, default=0)
|
|
|
|
# Settings
|
|
api_key = Column(String(64), unique=True, index=True)
|
|
email_verified = Column(Boolean, default=False)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
last_login = Column(DateTime)
|
|
|
|
# Relationships
|
|
sessions = relationship("Session", back_populates="user")
|
|
captures = relationship("Capture", back_populates="user")
|
|
identifications = relationship("Identification", back_populates="user")
|
|
votes = relationship("Vote", back_populates="user")
|
|
|
|
def __repr__(self):
|
|
return f"<User(id={self.id}, username='{self.username}')>"
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary"""
|
|
return {
|
|
'id': self.id,
|
|
'username': self.username,
|
|
'display_name': self.display_name,
|
|
'total_captures': self.total_captures,
|
|
'reputation_score': self.reputation_score,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
|
}
|
|
|
|
|
|
class Session(Base):
|
|
"""Wardriving/capture sessions (Wigle pattern)"""
|
|
|
|
__tablename__ = 'sessions'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Keys
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), index=True)
|
|
|
|
# Session Metadata
|
|
session_uuid = Column(String(64), unique=True, nullable=False, index=True)
|
|
name = Column(String(200))
|
|
description = Column(Text)
|
|
started_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
|
|
ended_at = Column(DateTime)
|
|
|
|
# Privacy Settings
|
|
is_public = Column(Boolean, default=True, index=True)
|
|
anonymize_gps = Column(Boolean, default=False)
|
|
gps_precision_meters = Column(Integer, default=10)
|
|
|
|
# Session Statistics (auto-updated by triggers)
|
|
total_captures = Column(Integer, default=0)
|
|
unique_devices = Column(Integer, default=0)
|
|
distance_km = Column(DECIMAL(10, 2))
|
|
|
|
# Bounding Box (auto-updated by triggers)
|
|
min_latitude = Column(DECIMAL(10, 8))
|
|
max_latitude = Column(DECIMAL(10, 8))
|
|
min_longitude = Column(DECIMAL(11, 8))
|
|
max_longitude = Column(DECIMAL(11, 8))
|
|
|
|
# Relationships
|
|
user = relationship("User", back_populates="sessions")
|
|
captures = relationship("Capture", back_populates="session")
|
|
|
|
def __repr__(self):
|
|
return f"<Session(id={self.id}, uuid='{self.session_uuid}', name='{self.name}')>"
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary"""
|
|
return {
|
|
'id': self.id,
|
|
'session_uuid': self.session_uuid,
|
|
'name': self.name,
|
|
'started_at': self.started_at.isoformat() if self.started_at else None,
|
|
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
|
|
'total_captures': self.total_captures,
|
|
'unique_devices': self.unique_devices,
|
|
'is_public': self.is_public
|
|
}
|
|
|
|
|
|
class Device(Base):
|
|
"""Known IoT device types from signature databases"""
|
|
|
|
__tablename__ = 'devices'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Device Identification
|
|
manufacturer = Column(String(200), index=True)
|
|
model = Column(String(200))
|
|
device_type = Column(String(100), index=True)
|
|
description = Column(Text)
|
|
|
|
# RF Characteristics
|
|
typical_frequency = Column(Integer, index=True)
|
|
frequency_range_low = Column(Integer)
|
|
frequency_range_high = Column(Integer)
|
|
modulation_types = Column(ARRAY(Text))
|
|
|
|
# Protocol Information
|
|
protocol = Column(String(100), index=True)
|
|
bit_length = Column(Integer)
|
|
encoding = Column(String(50))
|
|
|
|
# Metadata
|
|
fcc_id = Column(String(50))
|
|
manufacturer_code = Column(String(50))
|
|
|
|
# Community Data
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
verified = Column(Boolean, default=False, index=True)
|
|
verification_count = Column(Integer, default=0)
|
|
|
|
# Source
|
|
source = Column(String(50), index=True) # 'flipper', 'rtl433', 'urh', 'community'
|
|
source_url = Column(Text)
|
|
|
|
# Full-text search (auto-updated by trigger)
|
|
search_vector = Column('search_vector', nullable=True)
|
|
|
|
# Relationships
|
|
signatures = relationship("Signature", back_populates="device")
|
|
captures = relationship("Capture", back_populates="device")
|
|
capture_matches = relationship("CaptureMatch", back_populates="device")
|
|
|
|
def __repr__(self):
|
|
return f"<Device(id={self.id}, manufacturer='{self.manufacturer}', model='{self.model}')>"
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary"""
|
|
return {
|
|
'id': self.id,
|
|
'manufacturer': self.manufacturer,
|
|
'model': self.model,
|
|
'device_type': self.device_type,
|
|
'description': self.description,
|
|
'typical_frequency': self.typical_frequency,
|
|
'protocol': self.protocol,
|
|
'verified': self.verified,
|
|
'source': self.source
|
|
}
|
|
|
|
|
|
class Capture(Base):
|
|
"""RF signal captures with GPS coordinates"""
|
|
|
|
__tablename__ = 'captures'
|
|
|
|
# Primary Key: SHA256 hash of file (Wigle deduplication pattern)
|
|
file_hash = Column(String(64), primary_key=True)
|
|
|
|
# Foreign Keys
|
|
session_id = Column(Integer, ForeignKey('sessions.id', ondelete='CASCADE'), index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), index=True)
|
|
device_id = Column(Integer, ForeignKey('devices.id', ondelete='SET NULL'), index=True)
|
|
|
|
# GPS Data
|
|
latitude = Column(DECIMAL(10, 8), nullable=False)
|
|
longitude = Column(DECIMAL(11, 8), nullable=False)
|
|
altitude = Column(DECIMAL(8, 2))
|
|
gps_accuracy = Column(DECIMAL(6, 2))
|
|
geom = Column(Geometry('POINT', srid=4326)) # Auto-populated by trigger
|
|
|
|
# Timestamps
|
|
captured_at = Column(DateTime, nullable=False, index=True)
|
|
uploaded_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
# RF Signal Data
|
|
frequency = Column(Integer, nullable=False, index=True)
|
|
rssi = Column(Integer)
|
|
modulation = Column(String(50))
|
|
preset = Column(String(100))
|
|
|
|
# Protocol Information (if decoded)
|
|
protocol = Column(String(100), index=True)
|
|
bit_length = Column(Integer)
|
|
key_data = Column(BYTEA)
|
|
timing_element = Column(Integer)
|
|
|
|
# Raw Signal Data
|
|
raw_data = Column(Text)
|
|
raw_format = Column(String(20)) # 'RAW', 'BinRAW', 'KEY'
|
|
|
|
# File Storage
|
|
file_path = Column(String(500))
|
|
file_size = Column(Integer)
|
|
|
|
# Automatic Matching Results (best match)
|
|
match_confidence = Column(DECIMAL(5, 4))
|
|
match_method = Column(String(50))
|
|
|
|
# Constraints
|
|
__table_args__ = (
|
|
CheckConstraint('latitude >= -90 AND latitude <= 90', name='valid_latitude'),
|
|
CheckConstraint('longitude >= -180 AND longitude <= 180', name='valid_longitude'),
|
|
CheckConstraint('match_confidence >= 0 AND match_confidence <= 1', name='valid_confidence'),
|
|
CheckConstraint('frequency >= 300000000 AND frequency <= 928000000', name='valid_frequency'),
|
|
Index('idx_captures_geom', 'geom', postgresql_using='gist'),
|
|
)
|
|
|
|
# Relationships
|
|
session = relationship("Session", back_populates="captures")
|
|
user = relationship("User", back_populates="captures")
|
|
device = relationship("Device", back_populates="captures")
|
|
capture_matches = relationship("CaptureMatch", back_populates="capture")
|
|
identifications = relationship("Identification", back_populates="capture")
|
|
|
|
def __repr__(self):
|
|
return f"<Capture(hash='{self.file_hash[:8]}...', freq={self.frequency}, protocol='{self.protocol}')>"
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary"""
|
|
return {
|
|
'file_hash': self.file_hash,
|
|
'latitude': float(self.latitude) if self.latitude else None,
|
|
'longitude': float(self.longitude) if self.longitude else None,
|
|
'altitude': float(self.altitude) if self.altitude else None,
|
|
'gps_accuracy': float(self.gps_accuracy) if self.gps_accuracy else None,
|
|
'captured_at': self.captured_at.isoformat() if self.captured_at else None,
|
|
'frequency': self.frequency,
|
|
'rssi': self.rssi,
|
|
'modulation': self.modulation,
|
|
'protocol': self.protocol,
|
|
'bit_length': self.bit_length,
|
|
'device_id': self.device_id,
|
|
'match_confidence': float(self.match_confidence) if self.match_confidence else None,
|
|
'match_method': self.match_method
|
|
}
|
|
|
|
|
|
class Signature(Base):
|
|
"""Protocol signatures for device matching"""
|
|
|
|
__tablename__ = 'signatures'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Keys
|
|
device_id = Column(Integer, ForeignKey('devices.id', ondelete='CASCADE'), index=True)
|
|
|
|
# Signature Pattern
|
|
protocol = Column(String(100), nullable=False, index=True)
|
|
frequency = Column(Integer, index=True)
|
|
modulation = Column(String(50))
|
|
|
|
# Matching Criteria
|
|
bit_pattern = Column(BYTEA)
|
|
bit_mask = Column(BYTEA)
|
|
timing_min = Column(Integer)
|
|
timing_max = Column(Integer)
|
|
|
|
# Raw Pattern
|
|
pattern_regex = Column(Text)
|
|
|
|
# Confidence Weighting
|
|
weight = Column(DECIMAL(4, 3), default=1.0)
|
|
|
|
# Metadata
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
source = Column(String(50), index=True)
|
|
source_file = Column(String(500))
|
|
|
|
# Constraints
|
|
__table_args__ = (
|
|
UniqueConstraint('device_id', 'protocol', 'bit_pattern', name='uq_device_protocol_pattern'),
|
|
)
|
|
|
|
# Relationships
|
|
device = relationship("Device", back_populates="signatures")
|
|
flipper_signature = relationship("FlipperSignature", back_populates="signature", uselist=False)
|
|
rtl433_protocol = relationship("RTL433Protocol", back_populates="signature", uselist=False)
|
|
|
|
def __repr__(self):
|
|
return f"<Signature(id={self.id}, protocol='{self.protocol}', device_id={self.device_id})>"
|
|
|
|
|
|
class CaptureMatch(Base):
|
|
"""Many-to-many mapping of captures to devices with confidence scores"""
|
|
|
|
__tablename__ = 'capture_matches'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Keys
|
|
capture_id = Column(String(64), ForeignKey('captures.file_hash', ondelete='CASCADE'), index=True)
|
|
device_id = Column(Integer, ForeignKey('devices.id', ondelete='CASCADE'), index=True)
|
|
signature_id = Column(Integer, ForeignKey('signatures.id', ondelete='SET NULL'), index=True)
|
|
|
|
# Match Details
|
|
confidence = Column(DECIMAL(5, 4), nullable=False, index=True)
|
|
match_method = Column(String(50), nullable=False)
|
|
match_details = Column(JSONB)
|
|
|
|
# Timestamp
|
|
matched_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
# Constraints
|
|
__table_args__ = (
|
|
CheckConstraint('confidence >= 0 AND confidence <= 1', name='valid_match_confidence'),
|
|
)
|
|
|
|
# Relationships
|
|
capture = relationship("Capture", back_populates="capture_matches")
|
|
device = relationship("Device", back_populates="capture_matches")
|
|
|
|
def __repr__(self):
|
|
return f"<CaptureMatch(capture='{self.capture_id[:8]}...', device={self.device_id}, confidence={self.confidence})>"
|
|
|
|
|
|
class Identification(Base):
|
|
"""User-submitted device identifications"""
|
|
|
|
__tablename__ = 'identifications'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Keys
|
|
capture_id = Column(String(64), ForeignKey('captures.file_hash', ondelete='CASCADE'), index=True)
|
|
device_id = Column(Integer, ForeignKey('devices.id'), index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), index=True)
|
|
|
|
# Identification Details
|
|
confidence = Column(String(20)) # 'certain', 'likely', 'guess'
|
|
notes = Column(Text)
|
|
|
|
# Visual Evidence
|
|
photo_urls = Column(ARRAY(Text))
|
|
|
|
# Community Validation
|
|
upvotes = Column(Integer, default=0)
|
|
downvotes = Column(Integer, default=0)
|
|
verified = Column(Boolean, default=False, index=True)
|
|
verified_by = Column(Integer, ForeignKey('users.id'))
|
|
verified_at = Column(DateTime)
|
|
|
|
# Timestamp
|
|
submitted_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
# Constraints
|
|
__table_args__ = (
|
|
UniqueConstraint('capture_id', 'user_id', 'device_id', name='uq_capture_user_device'),
|
|
)
|
|
|
|
# Relationships
|
|
capture = relationship("Capture", back_populates="identifications")
|
|
user = relationship("User", back_populates="identifications", foreign_keys=[user_id])
|
|
votes = relationship("Vote", back_populates="identification")
|
|
|
|
def __repr__(self):
|
|
return f"<Identification(id={self.id}, capture='{self.capture_id[:8]}...', device={self.device_id})>"
|
|
|
|
|
|
class Vote(Base):
|
|
"""Votes on device identifications"""
|
|
|
|
__tablename__ = 'votes'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Keys
|
|
identification_id = Column(Integer, ForeignKey('identifications.id', ondelete='CASCADE'), index=True)
|
|
user_id = Column(Integer, ForeignKey('users.id'), index=True)
|
|
|
|
# Vote Data
|
|
vote_type = Column(Integer, nullable=False) # 1 = upvote, -1 = downvote
|
|
voted_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
# Constraints
|
|
__table_args__ = (
|
|
CheckConstraint('vote_type IN (1, -1)', name='valid_vote'),
|
|
UniqueConstraint('identification_id', 'user_id', name='uq_identification_user'),
|
|
)
|
|
|
|
# Relationships
|
|
identification = relationship("Identification", back_populates="votes")
|
|
user = relationship("User", back_populates="votes")
|
|
|
|
def __repr__(self):
|
|
return f"<Vote(id={self.id}, identification={self.identification_id}, type={self.vote_type})>"
|
|
|
|
|
|
class UploadMarker(Base):
|
|
"""Track last uploaded capture per user/session (Wigle incremental sync pattern)"""
|
|
|
|
__tablename__ = 'upload_markers'
|
|
|
|
# Composite Primary Key
|
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
|
|
session_id = Column(Integer, ForeignKey('sessions.id', ondelete='CASCADE'), primary_key=True)
|
|
|
|
# Marker Data
|
|
last_capture_hash = Column(String(64))
|
|
last_upload_time = Column(DateTime, nullable=False, default=datetime.utcnow)
|
|
|
|
def __repr__(self):
|
|
return f"<UploadMarker(user={self.user_id}, session={self.session_id})>"
|
|
|
|
|
|
# =============================================================================
|
|
# SIGNATURE DATABASE MODELS
|
|
# =============================================================================
|
|
|
|
class FlipperSignature(Base):
|
|
"""Flipper Zero specific signature data"""
|
|
|
|
__tablename__ = 'flipper_signatures'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Key
|
|
signature_id = Column(Integer, ForeignKey('signatures.id', ondelete='CASCADE'), index=True)
|
|
|
|
# Flipper-Specific Fields
|
|
filetype = Column(String(50))
|
|
version = Column(Integer)
|
|
preset = Column(String(100), index=True)
|
|
|
|
# Custom Preset Data
|
|
custom_preset_module = Column(String(50))
|
|
custom_preset_data = Column(BYTEA)
|
|
|
|
# Protocol Data
|
|
protocol = Column(String(100), index=True)
|
|
bit = Column(Integer)
|
|
key = Column(BYTEA)
|
|
te = Column(Integer)
|
|
|
|
# RAW Data
|
|
raw_data = Column(Text)
|
|
bin_raw_bit = Column(Integer)
|
|
bin_raw_te = Column(Integer)
|
|
bin_raw_data = Column(BYTEA)
|
|
|
|
# Source
|
|
source_file = Column(String(500))
|
|
imported_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
# Relationships
|
|
signature = relationship("Signature", back_populates="flipper_signature")
|
|
|
|
def __repr__(self):
|
|
return f"<FlipperSignature(id={self.id}, protocol='{self.protocol}')>"
|
|
|
|
|
|
class RTL433Protocol(Base):
|
|
"""RTL_433 specific protocol data"""
|
|
|
|
__tablename__ = 'rtl433_protocols'
|
|
|
|
# Primary Key
|
|
id = Column(Integer, primary_key=True)
|
|
|
|
# Foreign Key
|
|
signature_id = Column(Integer, ForeignKey('signatures.id', ondelete='CASCADE'), index=True)
|
|
|
|
# Protocol Identification
|
|
protocol_number = Column(Integer, index=True)
|
|
protocol_name = Column(String(200))
|
|
model = Column(String(200), index=True)
|
|
|
|
# RF Characteristics
|
|
frequency = Column(Integer)
|
|
modulation = Column(String(50), index=True)
|
|
|
|
# Timing Information
|
|
short_width = Column(Integer)
|
|
long_width = Column(Integer)
|
|
reset_limit = Column(Integer)
|
|
gap_limit = Column(Integer)
|
|
|
|
# Decoding
|
|
decoder_type = Column(String(50))
|
|
bit_count = Column(Integer)
|
|
|
|
# JSON Fields Mapping
|
|
json_fields = Column(JSONB)
|
|
|
|
# Source
|
|
source_file = Column(String(500))
|
|
imported_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
# Relationships
|
|
signature = relationship("Signature", back_populates="rtl433_protocol")
|
|
|
|
def __repr__(self):
|
|
return f"<RTL433Protocol(id={self.id}, name='{self.protocol_name}')>"
|
|
|
|
|
|
# =============================================================================
|
|
# HELPER FUNCTIONS
|
|
# =============================================================================
|
|
|
|
def create_all_tables(engine):
|
|
"""Create all tables in the database"""
|
|
Base.metadata.create_all(engine)
|
|
|
|
|
|
def drop_all_tables(engine):
|
|
"""Drop all tables from the database (use with caution!)"""
|
|
Base.metadata.drop_all(engine)
|