-- GigLez Database Schema -- PostgreSQL 16 + PostGIS 3.4 -- Created: 2026-01-12 -- Based on: docs/database_schema.md + Wigle analysis -- ============================================================================= -- EXTENSIONS -- ============================================================================= CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis_topology; CREATE EXTENSION IF NOT EXISTS cube; -- For earthdistance CREATE EXTENSION IF NOT EXISTS earthdistance; -- ============================================================================= -- CORE TABLES -- ============================================================================= -- ----------------------------------------------------------------------------- -- USERS TABLE -- Optional user accounts for community features -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, -- Authentication username VARCHAR(50) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, -- Profile display_name VARCHAR(100), avatar_url TEXT, bio TEXT, -- Statistics total_captures INTEGER DEFAULT 0, total_identifications INTEGER DEFAULT 0, reputation_score INTEGER DEFAULT 0, -- Settings api_key VARCHAR(64) UNIQUE, email_verified BOOLEAN DEFAULT false, -- Timestamps created_at TIMESTAMP DEFAULT NOW(), last_login TIMESTAMP ); CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL; -- ----------------------------------------------------------------------------- -- SESSIONS TABLE -- Wardriving/capture sessions to group related captures -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS sessions ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Session Metadata session_uuid VARCHAR(64) UNIQUE NOT NULL, -- UUID for external reference name VARCHAR(200), description TEXT, started_at TIMESTAMP NOT NULL DEFAULT NOW(), ended_at TIMESTAMP, -- Privacy Settings is_public BOOLEAN DEFAULT true, anonymize_gps BOOLEAN DEFAULT false, gps_precision_meters INTEGER DEFAULT 10, -- Session Statistics (computed) total_captures INTEGER DEFAULT 0, unique_devices INTEGER DEFAULT 0, distance_km DECIMAL(10, 2), -- Bounding Box (for quick filtering) min_latitude DECIMAL(10, 8), max_latitude DECIMAL(10, 8), min_longitude DECIMAL(11, 8), max_longitude DECIMAL(11, 8) ); CREATE INDEX idx_sessions_user ON sessions(user_id); CREATE INDEX idx_sessions_uuid ON sessions(session_uuid); CREATE INDEX idx_sessions_started ON sessions(started_at DESC); CREATE INDEX idx_sessions_public ON sessions(is_public) WHERE is_public = true; -- ----------------------------------------------------------------------------- -- DEVICES TABLE -- Known IoT device types with signature information -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS devices ( id SERIAL PRIMARY KEY, -- Device Identification manufacturer VARCHAR(200), model VARCHAR(200), device_type VARCHAR(100), -- 'garage_door', 'weather_station', 'key_fob', etc. description TEXT, -- RF Characteristics typical_frequency INTEGER, -- Most common frequency in Hz frequency_range_low INTEGER, frequency_range_high INTEGER, modulation_types TEXT[], -- Array: ['OOK', '2FSK', 'GFSK'] -- Protocol Information protocol VARCHAR(100), bit_length INTEGER, encoding VARCHAR(50), -- 'PWM', 'PPM', 'Manchester', etc. -- Metadata fcc_id VARCHAR(50), manufacturer_code VARCHAR(50), -- Community Data created_at TIMESTAMP DEFAULT NOW(), verified BOOLEAN DEFAULT false, verification_count INTEGER DEFAULT 0, -- Source source VARCHAR(50), -- 'flipper', 'rtl433', 'urh', 'community' source_url TEXT, -- Full-text search search_vector tsvector ); CREATE INDEX idx_devices_manufacturer ON devices(manufacturer); CREATE INDEX idx_devices_type ON devices(device_type); CREATE INDEX idx_devices_frequency ON devices(typical_frequency); CREATE INDEX idx_devices_protocol ON devices(protocol); CREATE INDEX idx_devices_source ON devices(source); CREATE INDEX idx_devices_verified ON devices(verified) WHERE verified = true; CREATE INDEX idx_devices_search ON devices USING GIN(search_vector); -- Trigger to update search vector CREATE OR REPLACE FUNCTION devices_search_vector_update() RETURNS trigger AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('english', COALESCE(NEW.manufacturer, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.model, '')), 'A') || setweight(to_tsvector('english', COALESCE(NEW.device_type, '')), 'B') || setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'C'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tsvector_update BEFORE INSERT OR UPDATE ON devices FOR EACH ROW EXECUTE FUNCTION devices_search_vector_update(); -- ----------------------------------------------------------------------------- -- CAPTURES TABLE -- Primary table storing RF signal captures with GPS coordinates -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS captures ( -- Primary Key: SHA256 hash of file contents (Wigle pattern adapted) file_hash VARCHAR(64) PRIMARY KEY, -- Foreign Keys session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- GPS Data latitude DECIMAL(10, 8) NOT NULL, longitude DECIMAL(11, 8) NOT NULL, altitude DECIMAL(8, 2), gps_accuracy DECIMAL(6, 2), geom geometry(Point, 4326), -- PostGIS geometry for spatial queries -- Timestamp captured_at TIMESTAMP NOT NULL, uploaded_at TIMESTAMP DEFAULT NOW(), -- RF Signal Data frequency INTEGER NOT NULL, -- in Hz rssi INTEGER, -- Received Signal Strength Indicator modulation VARCHAR(50), -- OOK, 2FSK, GFSK, etc. preset VARCHAR(100), -- Protocol Information (if decoded) protocol VARCHAR(100), bit_length INTEGER, key_data BYTEA, timing_element INTEGER, -- TE value in microseconds -- Raw Signal Data raw_data TEXT, -- RAW_Data timings or BinRAW data raw_format VARCHAR(20), -- 'RAW', 'BinRAW', 'KEY' -- File Storage file_path VARCHAR(500), -- Path to .sub file file_size INTEGER, -- File size in bytes -- Automatic Matching Results (best match) device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL, match_confidence DECIMAL(5, 4), -- 0.0 to 1.0 match_method VARCHAR(50), -- 'exact', 'partial', 'pattern', 'timing' -- Constraints CONSTRAINT valid_latitude CHECK (latitude >= -90 AND latitude <= 90), CONSTRAINT valid_longitude CHECK (longitude >= -180 AND longitude <= 180), CONSTRAINT valid_confidence CHECK (match_confidence >= 0 AND match_confidence <= 1), CONSTRAINT valid_frequency CHECK (frequency >= 300000000 AND frequency <= 928000000) -- 300-928 MHz ); -- Geospatial Indexes (critical for performance) CREATE INDEX idx_captures_geom ON captures USING GIST(geom); CREATE INDEX idx_captures_location ON captures USING GIST(ll_to_earth(latitude, longitude)); -- Query Indexes CREATE INDEX idx_captures_session ON captures(session_id); CREATE INDEX idx_captures_user ON captures(user_id); CREATE INDEX idx_captures_device ON captures(device_id); CREATE INDEX idx_captures_frequency ON captures(frequency); CREATE INDEX idx_captures_protocol ON captures(protocol); CREATE INDEX idx_captures_timestamp ON captures(captured_at DESC); CREATE INDEX idx_captures_uploaded ON captures(uploaded_at DESC); -- Trigger to auto-populate geom from lat/lon CREATE OR REPLACE FUNCTION captures_geom_update() RETURNS trigger AS $$ BEGIN NEW.geom := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER captures_geom_trigger BEFORE INSERT OR UPDATE ON captures FOR EACH ROW EXECUTE FUNCTION captures_geom_update(); -- ----------------------------------------------------------------------------- -- SIGNATURES TABLE -- Protocol signatures for device matching -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS signatures ( id SERIAL PRIMARY KEY, device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE, -- Signature Pattern protocol VARCHAR(100) NOT NULL, frequency INTEGER, modulation VARCHAR(50), -- Matching Criteria bit_pattern BYTEA, bit_mask BYTEA, -- Which bits to match (1=match, 0=ignore) timing_min INTEGER, -- TE range in microseconds timing_max INTEGER, -- Raw Pattern (for regex-like matching) pattern_regex TEXT, -- Confidence Weighting weight DECIMAL(4, 3) DEFAULT 1.0, -- Higher = more reliable signature -- Metadata created_at TIMESTAMP DEFAULT NOW(), source VARCHAR(50), -- 'flipper', 'rtl433', 'urh', 'community' source_file VARCHAR(500), -- Original .sub or config file UNIQUE(device_id, protocol, bit_pattern) ); CREATE INDEX idx_signatures_device ON signatures(device_id); CREATE INDEX idx_signatures_protocol ON signatures(protocol); CREATE INDEX idx_signatures_frequency ON signatures(frequency); CREATE INDEX idx_signatures_source ON signatures(source); -- ----------------------------------------------------------------------------- -- CAPTURE_MATCHES TABLE -- All device identification results (many-to-many with captures) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS capture_matches ( id SERIAL PRIMARY KEY, capture_id VARCHAR(64) REFERENCES captures(file_hash) ON DELETE CASCADE, device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE, signature_id INTEGER REFERENCES signatures(id) ON DELETE SET NULL, -- Match Details confidence DECIMAL(5, 4) NOT NULL, -- 0.0 to 1.0 match_method VARCHAR(50) NOT NULL, -- 'exact', 'partial', 'pattern', 'timing' match_details JSONB, -- Additional matching information -- Timestamps matched_at TIMESTAMP DEFAULT NOW(), CONSTRAINT valid_match_confidence CHECK (confidence >= 0 AND confidence <= 1) ); CREATE INDEX idx_capture_matches_capture ON capture_matches(capture_id); CREATE INDEX idx_capture_matches_device ON capture_matches(device_id); CREATE INDEX idx_capture_matches_signature ON capture_matches(signature_id); CREATE INDEX idx_capture_matches_confidence ON capture_matches(confidence DESC); -- ----------------------------------------------------------------------------- -- IDENTIFICATIONS TABLE -- User-submitted device identifications (community contributions) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS identifications ( id SERIAL PRIMARY KEY, capture_id VARCHAR(64) REFERENCES captures(file_hash) ON DELETE CASCADE, device_id INTEGER REFERENCES devices(id), user_id INTEGER REFERENCES users(id), -- Identification Details confidence VARCHAR(20), -- 'certain', 'likely', 'guess' notes TEXT, -- Visual Evidence photo_urls TEXT[], -- Array of image URLs -- Community Validation upvotes INTEGER DEFAULT 0, downvotes INTEGER DEFAULT 0, verified BOOLEAN DEFAULT false, verified_by INTEGER REFERENCES users(id), verified_at TIMESTAMP, -- Timestamps submitted_at TIMESTAMP DEFAULT NOW(), UNIQUE(capture_id, user_id, device_id) ); CREATE INDEX idx_identifications_capture ON identifications(capture_id); CREATE INDEX idx_identifications_device ON identifications(device_id); CREATE INDEX idx_identifications_user ON identifications(user_id); CREATE INDEX idx_identifications_verified ON identifications(verified) WHERE verified = true; -- ----------------------------------------------------------------------------- -- VOTES TABLE -- Voting on device identifications -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS votes ( id SERIAL PRIMARY KEY, identification_id INTEGER REFERENCES identifications(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id), vote_type INTEGER NOT NULL, -- 1 = upvote, -1 = downvote voted_at TIMESTAMP DEFAULT NOW(), CONSTRAINT valid_vote CHECK (vote_type IN (1, -1)), UNIQUE(identification_id, user_id) ); CREATE INDEX idx_votes_identification ON votes(identification_id); CREATE INDEX idx_votes_user ON votes(user_id); -- ----------------------------------------------------------------------------- -- UPLOAD_MARKERS TABLE -- Track last uploaded capture per user/session for incremental sync -- Wigle pattern for efficient resumable uploads -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS upload_markers ( user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE, last_capture_hash VARCHAR(64), last_upload_time TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, session_id) ); CREATE INDEX idx_upload_markers_user ON upload_markers(user_id); CREATE INDEX idx_upload_markers_session ON upload_markers(session_id); -- ============================================================================= -- FLIPPER ZERO SPECIFIC TABLES -- ============================================================================= -- ----------------------------------------------------------------------------- -- FLIPPER_SIGNATURES TABLE -- Imported Flipper Zero .sub file signatures -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS flipper_signatures ( id SERIAL PRIMARY KEY, signature_id INTEGER REFERENCES signatures(id) ON DELETE CASCADE, -- Flipper-Specific Fields filetype VARCHAR(50), -- 'Flipper SubGhz Key File' or 'RAW File' version INTEGER, preset VARCHAR(100), -- Custom Preset Data custom_preset_module VARCHAR(50), custom_preset_data BYTEA, -- Protocol Data protocol VARCHAR(100), bit INTEGER, key BYTEA, te INTEGER, -- Timing element -- RAW Data raw_data TEXT, bin_raw_bit INTEGER, bin_raw_te INTEGER, bin_raw_data BYTEA, -- Source source_file VARCHAR(500), imported_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_flipper_protocol ON flipper_signatures(protocol); CREATE INDEX idx_flipper_preset ON flipper_signatures(preset); CREATE INDEX idx_flipper_signature ON flipper_signatures(signature_id); -- ============================================================================= -- RTL_433 SPECIFIC TABLES -- ============================================================================= -- ----------------------------------------------------------------------------- -- RTL433_PROTOCOLS TABLE -- Imported RTL_433 protocol definitions -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS rtl433_protocols ( id SERIAL PRIMARY KEY, signature_id INTEGER REFERENCES signatures(id) ON DELETE CASCADE, -- Protocol Identification protocol_number INTEGER, protocol_name VARCHAR(200), model VARCHAR(200), -- RF Characteristics frequency INTEGER, modulation VARCHAR(50), -- 'OOK_PWM', 'FSK_PCM', etc. -- Timing Information (in microseconds) short_width INTEGER, long_width INTEGER, reset_limit INTEGER, gap_limit INTEGER, -- Decoding decoder_type VARCHAR(50), bit_count INTEGER, -- JSON Fields Mapping json_fields JSONB, -- Expected output fields -- Source source_file VARCHAR(500), imported_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_rtl433_protocol_num ON rtl433_protocols(protocol_number); CREATE INDEX idx_rtl433_model ON rtl433_protocols(model); CREATE INDEX idx_rtl433_modulation ON rtl433_protocols(modulation); CREATE INDEX idx_rtl433_signature ON rtl433_protocols(signature_id); -- ============================================================================= -- MATERIALIZED VIEWS -- ============================================================================= -- ----------------------------------------------------------------------------- -- DEVICE_STATISTICS VIEW -- Pre-computed statistics for device types -- ----------------------------------------------------------------------------- CREATE MATERIALIZED VIEW IF NOT EXISTS device_statistics AS SELECT d.id as device_id, d.manufacturer, d.model, d.device_type, COUNT(c.file_hash) as total_captures, COUNT(DISTINCT c.session_id) as total_sessions, COUNT(DISTINCT c.user_id) as total_users, MIN(c.captured_at) as first_seen, MAX(c.captured_at) as last_seen, AVG(c.rssi) as avg_rssi, AVG(c.match_confidence) as avg_confidence FROM devices d LEFT JOIN captures c ON c.device_id = d.id GROUP BY d.id, d.manufacturer, d.model, d.device_type; CREATE UNIQUE INDEX idx_device_stats_device ON device_statistics(device_id); CREATE INDEX idx_device_stats_captures ON device_statistics(total_captures DESC); -- ----------------------------------------------------------------------------- -- GEOGRAPHIC_HEATMAP VIEW -- Aggregated capture density for mapping -- ----------------------------------------------------------------------------- CREATE MATERIALIZED VIEW IF NOT EXISTS geographic_heatmap AS SELECT ROUND(latitude::numeric, 3) as lat_bucket, ROUND(longitude::numeric, 3) as lon_bucket, COUNT(*) as capture_count, COUNT(DISTINCT device_id) as unique_devices, COUNT(DISTINCT session_id) as unique_sessions, array_agg(DISTINCT protocol) FILTER (WHERE protocol IS NOT NULL) as protocols_seen, AVG(rssi) as avg_rssi FROM captures GROUP BY lat_bucket, lon_bucket; CREATE UNIQUE INDEX idx_heatmap_location ON geographic_heatmap(lat_bucket, lon_bucket); CREATE INDEX idx_heatmap_count ON geographic_heatmap(capture_count DESC); -- ============================================================================= -- FUNCTIONS -- ============================================================================= -- ----------------------------------------------------------------------------- -- CALCULATE_DISTANCE FUNCTION -- Calculate distance between two GPS coordinates (Haversine formula) -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION calculate_distance( lat1 DECIMAL, lon1 DECIMAL, lat2 DECIMAL, lon2 DECIMAL ) RETURNS DECIMAL AS $$ DECLARE R DECIMAL := 6371.0; -- Earth radius in km dLat DECIMAL; dLon DECIMAL; a DECIMAL; c DECIMAL; BEGIN dLat := radians(lat2 - lat1); dLon := radians(lon2 - lon1); a := sin(dLat/2) * sin(dLat/2) + cos(radians(lat1)) * cos(radians(lat2)) * sin(dLon/2) * sin(dLon/2); c := 2 * atan2(sqrt(a), sqrt(1-a)); RETURN R * c; END; $$ LANGUAGE plpgsql IMMUTABLE; -- ============================================================================= -- TRIGGERS -- ============================================================================= -- ----------------------------------------------------------------------------- -- UPDATE_SESSION_STATISTICS TRIGGER -- Automatically update session statistics when captures are added -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION update_session_stats() RETURNS TRIGGER AS $$ BEGIN UPDATE sessions SET total_captures = ( SELECT COUNT(*) FROM captures WHERE session_id = NEW.session_id ), unique_devices = ( SELECT COUNT(DISTINCT device_id) FROM captures WHERE session_id = NEW.session_id AND device_id IS NOT NULL ), min_latitude = LEAST( COALESCE(min_latitude, NEW.latitude), NEW.latitude ), max_latitude = GREATEST( COALESCE(max_latitude, NEW.latitude), NEW.latitude ), min_longitude = LEAST( COALESCE(min_longitude, NEW.longitude), NEW.longitude ), max_longitude = GREATEST( COALESCE(max_longitude, NEW.longitude), NEW.longitude ) WHERE id = NEW.session_id; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_update_session_stats AFTER INSERT ON captures FOR EACH ROW WHEN (NEW.session_id IS NOT NULL) EXECUTE FUNCTION update_session_stats(); -- ----------------------------------------------------------------------------- -- UPDATE_USER_STATISTICS TRIGGER -- Update user capture count -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION update_user_stats() RETURNS TRIGGER AS $$ BEGIN IF NEW.user_id IS NOT NULL THEN UPDATE users SET total_captures = ( SELECT COUNT(*) FROM captures WHERE user_id = NEW.user_id ) WHERE id = NEW.user_id; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_update_user_stats AFTER INSERT ON captures FOR EACH ROW EXECUTE FUNCTION update_user_stats(); -- ----------------------------------------------------------------------------- -- UPDATE_IDENTIFICATION_VOTES TRIGGER -- Update upvotes/downvotes count on identifications -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION update_identification_votes() RETURNS TRIGGER AS $$ DECLARE up_count INTEGER; down_count INTEGER; BEGIN SELECT COUNT(*) FILTER (WHERE vote_type = 1), COUNT(*) FILTER (WHERE vote_type = -1) INTO up_count, down_count FROM votes WHERE identification_id = COALESCE(NEW.identification_id, OLD.identification_id); UPDATE identifications SET upvotes = up_count, downvotes = down_count, verified = (up_count - down_count >= 5) -- Auto-verify at +5 net votes WHERE id = COALESCE(NEW.identification_id, OLD.identification_id); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_update_identification_votes AFTER INSERT OR UPDATE OR DELETE ON votes FOR EACH ROW EXECUTE FUNCTION update_identification_votes(); -- ============================================================================= -- INITIAL DATA / SEED -- ============================================================================= -- Insert a default "anonymous" user for uploads without accounts INSERT INTO users (id, username, email, password_hash, display_name) VALUES (0, 'anonymous', 'anonymous@giglez.local', '', 'Anonymous User') ON CONFLICT (id) DO NOTHING; -- Reset sequence to avoid conflicts SELECT setval('users_id_seq', GREATEST(1, (SELECT MAX(id) + 1 FROM users))); -- ============================================================================= -- PERMISSIONS (Optional - uncomment if using specific user) -- ============================================================================= -- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO giglez_user; -- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO giglez_user; -- GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO giglez_user; -- ============================================================================= -- COMPLETION -- ============================================================================= -- Refresh materialized views REFRESH MATERIALIZED VIEW device_statistics; REFRESH MATERIALIZED VIEW geographic_heatmap; -- Analyze tables for query optimization ANALYZE; SELECT 'GigLez database schema created successfully!' as status;