From 6ee1da64c0e67be1bbfbedc9dd7a95bae1f8459a Mon Sep 17 00:00:00 2001 From: GiZano Date: Fri, 14 Aug 2026 22:28:17 +0200 Subject: [PATCH 1/7] feat(backend): geo-zoning, stream ingestion and per-zone endpoints - geohash Redis zone index (geo.py) with PostGIS source of truth - Redis Streams ingestion substrate (ingest.py) + TimescaleDB migration (timescale.py) - GNSS-ready data model: Sensor.last_fix_at, Reading.lat/lon - per-area cooldown fragmentation in worker - DELETE /zones/{id}/readings and GET /zones/{id}/alerts endpoints - zone/geo/timescale integration and unit tests --- backend/docker-compose.yml | 11 +- backend/docker/postgres-timescale.Dockerfile | 8 + backend/scripts/load_test.py | 118 +++++++++ backend/scripts/simulate_zone.py | 144 ++++++++++ backend/src/geo.py | 249 ++++++++++++++++++ backend/src/ingest.py | 116 ++++++++ backend/src/main.py | 179 ++++++++++++- backend/src/models.py | 17 +- backend/src/mqtt_subscriber.py | 7 +- backend/src/schemas.py | 15 ++ backend/src/timescale.py | 147 +++++++++++ backend/src/worker.py | 153 ++++++++--- backend/tests/integration/test_api.py | 2 + .../tests/integration/test_geo_integration.py | 131 +++++++++ .../integration/test_timescale_integration.py | 107 ++++++++ .../tests/integration/test_zone_readings.py | 211 +++++++++++++++ backend/tests/unit/test_geo.py | 113 ++++++++ backend/tests/unit/test_ingest.py | 90 +++++++ backend/tests/unit/test_timescale.py | 81 ++++++ backend/tests/unit/test_worker.py | 64 +++-- 20 files changed, 1894 insertions(+), 69 deletions(-) create mode 100644 backend/docker/postgres-timescale.Dockerfile create mode 100644 backend/scripts/load_test.py create mode 100644 backend/scripts/simulate_zone.py create mode 100644 backend/src/geo.py create mode 100644 backend/src/ingest.py create mode 100644 backend/src/timescale.py create mode 100644 backend/tests/integration/test_geo_integration.py create mode 100644 backend/tests/integration/test_timescale_integration.py create mode 100644 backend/tests/integration/test_zone_readings.py create mode 100644 backend/tests/unit/test_geo.py create mode 100644 backend/tests/unit/test_ingest.py create mode 100644 backend/tests/unit/test_timescale.py diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index f928a12..12aa190 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,9 +1,14 @@ services: - # --- DATABASE --- + # --- DATABASE (TimescaleDB + PostGIS) --- postgres: - image: postgis/postgis:15-3.4-alpine + build: + context: . + dockerfile: docker/postgres-timescale.Dockerfile + image: quakeguard-postgres-timescale:pg15 restart: unless-stopped - command: postgres -c 'max_connections=200' -c 'shared_buffers=128MB' + command: postgres -c 'shared_preload_libraries=timescaledb' -c 'max_connections=200' -c 'shared_buffers=128MB' + ports: + - "${POSTGRES_PORT:-5432}:5432" environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} diff --git a/backend/docker/postgres-timescale.Dockerfile b/backend/docker/postgres-timescale.Dockerfile new file mode 100644 index 0000000..038337f --- /dev/null +++ b/backend/docker/postgres-timescale.Dockerfile @@ -0,0 +1,8 @@ +# TimescaleDB + PostGIS in a single Postgres 15 image. +# The Timescale HA image bundles BOTH the timescaledb extension (hypertable / +# continuous-aggregate time-series substrate) and postgis (zone geometry), so +# geospatial and time-series data share one database, one image, one backup. +FROM timescale/timescaledb-ha:pg15 + +# shared_preload_libraries=timescaledb is already injected by this image's +# entrypoint; docker-compose additionally passes it via `command` to be explicit. diff --git a/backend/scripts/load_test.py b/backend/scripts/load_test.py new file mode 100644 index 0000000..c40f6b2 --- /dev/null +++ b/backend/scripts/load_test.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Load generator for the QuakeGuard ingestion path. + +Simulates ``--sensors`` IoT devices emitting heartbeats at ``--hz`` each and +measures sustained throughput. Two transport modes: + + --mode stream XADD directly to the Redis Streams bus (bypasses the API; this + is the steady-state ingestion bottleneck we must keep O(1)). + --mode http POST to the FastAPI /readings/ endpoint (exercises the full + ingress, but without ECDSA signature validation). + +Usage: + python scripts/load_test.py --sensors 150 --hz 1 --duration 60 + python scripts/load_test.py --sensors 1000 --hz 1 --duration 30 --mode http --api http://localhost:8000 +""" + +import argparse +import json +import time + +import redis + + +def stream_worker(args, stop_event): + client = redis.from_url(args.redis, decode_responses=True) + + def emit(sensor_id): + payload = { + "value": 150, + "sensor_id": sensor_id, + "latitude": 45.0 + (sensor_id % 100) / 1000.0, + "longitude": 9.0 + (sensor_id % 100) / 1000.0, + } + client.xadd( + args.stream, {"payload": json.dumps(payload)}, maxlen=200000, approximate=True + ) + + next_emit = time.monotonic() + interval = 1.0 / args.hz + while not stop_event.is_set(): + now = time.monotonic() + if now < next_emit: + time.sleep(0.001) + continue + next_emit += interval + for sid in range(1, args.sensors + 1): + emit(sid) + + +def http_worker(args, stop_event): + import requests + + def emit(sensor_id): + payload = { + "value": 150, + "sensor_id": sensor_id, + "latitude": 45.0, + "longitude": 9.0, + } + requests.post( + args.api + "/readings/", + json=payload, + headers={"X-API-Key": args.api_key}, + timeout=5, + ) + + emitted = 0 + next_emit = time.monotonic() + interval = 1.0 / args.hz + while not stop_event.is_set(): + now = time.monotonic() + if now < next_emit: + time.sleep(0.001) + continue + next_emit += interval + emit((emitted % args.sensors) + 1) + emitted += 1 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sensors", type=int, default=150, help="number of simulated devices") + parser.add_argument("--hz", type=float, default=1.0, help="heartbeats per second per device") + parser.add_argument("--duration", type=int, default=60, help="test duration in seconds") + parser.add_argument("--mode", choices=["stream", "http"], default="stream") + parser.add_argument("--redis", default="redis://localhost:6379/0") + parser.add_argument("--stream", default="readings:stream") + parser.add_argument("--api", default="http://localhost:8000") + parser.add_argument("--api-key", default="ci-test-key-123") + args = parser.parse_args() + + import threading + + stop_event = threading.Event() + worker = stream_worker if args.mode == "stream" else http_worker + + print( + f"πŸš€ Load test: {args.sensors} sensors @ {args.hz} Hz each, " + f"target {args.sensors * args.hz:.0f} msg/s, {args.duration}s, mode={args.mode}", + flush=True, + ) + + worker_thread = threading.Thread(target=worker, args=(args, stop_event), daemon=True) + worker_thread.start() + + start = time.monotonic() + time.sleep(args.duration) + stop_event.set() + elapsed = time.monotonic() - start + worker_thread.join(timeout=2) + + total_msgs = args.sensors * args.hz * elapsed + print(f"πŸ“Š Emitted ~{total_msgs:.0f} messages in {elapsed:.1f}s", flush=True) + print(f"πŸ“Š Sustained rate: {total_msgs / elapsed:.0f} msg/s", flush=True) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/simulate_zone.py b/backend/scripts/simulate_zone.py new file mode 100644 index 0000000..0b7e5e3 --- /dev/null +++ b/backend/scripts/simulate_zone.py @@ -0,0 +1,144 @@ +""" +QuakeGuard Zone Demo Simulator +------------------------------ +Injects a 20-second stream of (signed) telemetry into the live pipeline for a +given zone, ending with an earthquake ramp that crosses the CRITICAL threshold +(~M4.7+) so the dashboard seismograph and the alert websocket come to life. + +How it works: +1. Ensures the target zone exists (POST /zones/). +2. Registers a throwaway ECDSA (NIST P-256) sensor pinned to that zone. +3. Streams 20 samples (1/sec): random noise, then a seismic ramp spike. +Each reading is signed exactly like the firmware does +(signature over "value:timestamp") and POSTed to the /readings/ ingestion API. + +Run inside the backend container (has requests + cryptography + IOT_API_KEY): + docker cp scripts/simulate_zone.py backend-fastapi-app-1:/tmp/ + docker exec backend-fastapi-app-1 python /tmp/simulate_zone.py +""" + +import os +import time +import math +import random +import json + +import requests +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +API_URL = os.getenv("API_URL", "http://localhost:8000") +IOT_API_KEY = os.getenv("IOT_API_KEY", "") +ZONE_NAME = os.getenv("ZONE_NAME", "TEST ZONE") +SENSOR_LAT = float(os.getenv("SENSOR_LAT", "41.9028")) +SENSOR_LON = float(os.getenv("SENSOR_LON", "12.4964")) +DURATION_SECONDS = int(os.getenv("DURATION_SECONDS", "20")) + + +def normalize(name: str) -> str: + """'ZONE A - TEST' / 'A-TEST' / 'a test' all collapse to the same key.""" + return name.replace("-", "").replace("_", "").replace(" ", "").lower() + +B_OFFSET = 3.0 +K_CALIBRATION = 1.6 +SENSOR_SCALE = 100.0 + + +def estimate_magnitude(value: int) -> float: + """Same M_IoT formula as the backend worker (for the printout only).""" + pga = value / SENSOR_SCALE / K_CALIBRATION + if pga <= 0: + return 0.0 + return max(0.0, min(math.log10(pga) + B_OFFSET, 9.9)) + + +def headers() -> dict: + if not IOT_API_KEY: + raise RuntimeError("IOT_API_KEY not set") + return {"X-API-Key": IOT_API_KEY, "Content-Type": "application/json"} + + +def ensure_zone() -> int: + """Reuse an existing zone whenever possible β€” never duplicate a test zone. + + Matching rules: + 1. Exact city name. + 2. Normalized fuzzy match ('ZONE A - TEST' == 'A-TEST' == 'a test'). + Only when nothing matches do we create a new zone. + """ + existing = requests.get(f"{API_URL}/zones/", params={"limit": 1000}, headers=headers(), timeout=10) + existing.raise_for_status() + for z in existing.json(): + if z["city"] == ZONE_NAME or normalize(z["city"]) == normalize(ZONE_NAME): + print(f"πŸ—ΊοΈ Reusing existing zone '{z['city']}' (id={z['id']})", flush=True) + return z["id"] + + resp = requests.post(f"{API_URL}/zones/", json={"city": ZONE_NAME}, headers=headers(), timeout=10) + resp.raise_for_status() + print(f"πŸ—ΊοΈ Created zone '{ZONE_NAME}' (id={resp.json()['id']})", flush=True) + return resp.json()["id"] + + +def register_sensor(zone_id: int) -> tuple: + sk = ec.generate_private_key(ec.SECP256R1()) + public_key = sk.public_key() + public_key_hex = public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo).hex() + payload = { + "active": True, + "zone_id": zone_id, + "latitude": SENSOR_LAT, + "longitude": SENSOR_LON, + "public_key_hex": public_key_hex, + } + resp = requests.post(f"{API_URL}/sensors/", json=payload, headers=headers(), timeout=10) + resp.raise_for_status() + sensor_id = resp.json()["id"] + print(f"πŸ“‘ Sensor registered (id={sensor_id})", flush=True) + return sensor_id, sk + + +def sign(sk, value: int, ts: int) -> str: + sig = sk.sign(f"{value}:{ts}".encode(), ec.ECDSA(hashes.SHA256())) + return sig.hex() + + +def sample_value(step: int) -> int: + """Random noise for most of the window, then an escalating seismic ramp.""" + if step >= 15: + ramp = [1800, 2600, 3400, 5600, 7600] + return ramp[min(step - 15, len(ramp) - 1)] + if step == 6: + return random.randint(900, 1600) + return random.randint(120, 750) + + +def main() -> None: + zone_id = ensure_zone() + sensor_id, sk = register_sensor(zone_id) + + print(f"🌊 Streaming {DURATION_SECONDS}s of telemetry into zone '{ZONE_NAME}'...", flush=True) + for step in range(DURATION_SECONDS): + value = sample_value(step) + ts = int(time.time()) + payload = { + "value": value, + "sensor_id": sensor_id, + "device_timestamp": ts, + "signature_hex": sign(sk, value, ts), + } + resp = requests.post(f"{API_URL}/readings/", json=payload, headers=headers(), timeout=10) + status = resp.status_code + mag = estimate_magnitude(value) + flag = " 🚨 CRITICAL!" if mag >= 4.5 else (" ⚠️ caution" if mag >= 4.0 else "") + print(f" t+{step:>2}s value={value:>5} Mβ‰ˆ{mag:.2f}{flag} http {status}", flush=True) + if status != 202: + print(f" API: {resp.text}", flush=True) + time.sleep(1.0) + + print("\nβœ… Stream complete. The dashboard should now show the wave; " + "the CRITICAL at the end triggers the siren in the app.", flush=True) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/src/geo.py b/backend/src/geo.py new file mode 100644 index 0000000..006403b --- /dev/null +++ b/backend/src/geo.py @@ -0,0 +1,249 @@ +""" +Geographic zoning helpers (geohash-based Redis fast path) +--------------------------------------------------------- +PostGIS remains the single source of truth for zone geometry. This module +pre-computes, at seed time, the set of geohash cells that intersect each zone +polygon and exposes a fast-path resolver backed by Redis SET lookups. + +On a Redis miss (or an ambiguous multi-zone match) the caller falls back to +the authoritative PostGIS ``ST_Contains`` query, so correctness never depends +on the cache being warm. + +The geohash encoder below implements the standard algorithm (as used by +PostGIS ``ST_GeoHash``) in pure Python, so the Redis keys written at seed time +match the keys computed at runtime with zero new dependencies. +""" + +import math +import os + +import redis +import src.models as models +from sqlalchemy import text +from sqlalchemy.orm import Session + +# --- Configuration ----------------------------------------------------------- +# Geohash precision used for the Redis zone-index lookup sets. +# prec 3 -> cell ~1.40625deg (~156 km at the equator): small cardinality, so +# the zone index set stays cheap to bulk-cover at seed time. +ZONE_INDEX_PRECISION = int(os.getenv("ZONE_INDEX_PRECISION", "3")) + +# Geohash precision used for per-area event cooldown keys. +# prec 4 -> cell ~0.7deg (~39 x 19 km at the equator), aligned with the +# 50-100 km destructive surface-wave reach documented in the roadmap. +COOLDOWN_PRECISION = int(os.getenv("COOLDOWN_PRECISION", "4")) + +ZONE_INDEX_KEY_PREFIX = "zoneindex" +COOLDOWN_KEY_PREFIX = "alert_cooldown" + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +redis_sync = redis.from_url(REDIS_URL, decode_responses=True) + +# --- Pure-Python geohash encoder (matches PostGIS ST_GeoHash) --------------- +_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz" + + +def point_to_geohash(latitude: float, longitude: float, precision: int) -> str: + """Encode a WGS84 coordinate into a geohash string of ``precision`` chars.""" + lat_range = [-90.0, 90.0] + lon_range = [-180.0, 180.0] + bit = 0 + ch = 0 + even = True + out = [] + while len(out) < precision: + if even: + mid = (lon_range[0] + lon_range[1]) / 2.0 + if longitude >= mid: + ch |= 1 << 4 - bit + lon_range[0] = mid + else: + lon_range[1] = mid + else: + mid = (lat_range[0] + lat_range[1]) / 2.0 + if latitude >= mid: + ch |= 1 << 4 - bit + lat_range[0] = mid + else: + lat_range[1] = mid + even = not even + if bit < 4: + bit += 1 + else: + out.append(_BASE32[ch]) + bit = 0 + ch = 0 + return "".join(out) + + +def geohash_bounds(geohash: str) -> tuple[float, float, float, float]: + """Decode a geohash into ``(lon_min, lat_min, lon_max, lat_max)``.""" + lat_range = [-90.0, 90.0] + lon_range = [-180.0, 180.0] + even = True + for char in geohash: + cd = _BASE32.index(char) + for mask in (16, 8, 4, 2, 1): + if even: + mid = (lon_range[0] + lon_range[1]) / 2.0 + if cd & mask: + lon_range[0] = mid + else: + lon_range[1] = mid + else: + mid = (lat_range[0] + lat_range[1]) / 2.0 + if cd & mask: + lat_range[0] = mid + else: + lat_range[1] = mid + even = not even + return lon_range[0], lat_range[0], lon_range[1], lat_range[1] + + +def _cell_indices_to_geohash(lon_index: int, lat_index: int, precision: int) -> str: + """Reconstruct the geohash of a cell from its (lon, lat) grid indices.""" + bits = precision * 5 + lon_bits = (bits + 1) // 2 + lat_bits = bits // 2 + chars = [] + for c in range(precision): + chunk = 0 + for k in range(5): + bit_pos = c * 5 + k + if bit_pos % 2 == 0: + axis_bit = (lon_index >> (lon_bits - 1 - bit_pos // 2)) & 1 + else: + axis_bit = (lat_index >> (lat_bits - 1 - bit_pos // 2)) & 1 + chunk = (chunk << 1) | axis_bit + chars.append(_BASE32[chunk]) + return "".join(chars) + + +def _cells_in_bbox(lat_min: float, lon_min: float, lat_max: float, lon_max: float, precision: int) -> list[str]: + """List the geohash cells (at ``precision``) whose rectangle overlaps the bbox.""" + bits = precision * 5 + lon_bits = (bits + 1) // 2 + lat_bits = bits // 2 + lon_step = 360.0 / (1 << lon_bits) + lat_step = 180.0 / (1 << lat_bits) + + lon_start = max(0, math.floor((lon_min + 180.0) / lon_step)) + lon_end = min((1 << lon_bits) - 1, math.floor((lon_max + 180.0) / lon_step)) + lat_start = max(0, math.floor((lat_min + 90.0) / lat_step)) + lat_end = min((1 << lat_bits) - 1, math.floor((lat_max + 90.0) / lat_step)) + + cells = [] + for lat_i in range(lat_start, lat_end + 1): + for lon_i in range(lon_start, lon_end + 1): + cells.append(_cell_indices_to_geohash(lon_i, lat_i, precision)) + return cells + + +def _zone_bbox( + db: Session, zone_id: int +) -> tuple[float, float, float, float] | None: + """Fetch ``(lon_min, lat_min, lon_max, lat_max)`` for a zone polygon.""" + row = db.execute( + text("SELECT ST_XMin(geom), ST_YMin(geom), ST_XMax(geom), ST_YMax(geom) FROM zones WHERE id = :zid"), + {"zid": zone_id}, + ).first() + if not row or any(v is None for v in row): + return None + return tuple(float(v) for v in row) + + +def zone_covering_geohashes(db: Session, zone: models.Zone, precision: int = ZONE_INDEX_PRECISION) -> list[str]: + """Return the geohash cells (at ``precision``) intersecting a zone polygon.""" + bbox = _zone_bbox(db, zone.id) + if bbox is None: + return [] + lon_min, lat_min, lon_max, lat_max = bbox + candidates = _cells_in_bbox(lat_min, lon_min, lat_max, lon_max, precision) + if not candidates: + return [] + + # Stroke each candidate cell's rectangle into a temp table, then keep only + # the cells actually intersecting the zone polygon. Static SQL + bound + # params throughout (no f-string interpolation), and no bind-param limits. + # IF NOT EXISTS + DELETE keep the temp table reusable across zones within + # the same transaction (ON COMMIT DROP fires only at commit). + db.execute(text("CREATE TEMP TABLE IF NOT EXISTS _zone_cells(cell_key text, geom geometry) ON COMMIT DROP")) + db.execute(text("DELETE FROM _zone_cells")) + db.execute( + text( + "INSERT INTO _zone_cells (cell_key, geom) " + "VALUES (:key, ST_GeomFromText(:wkt, 4326))" + ), + [ + {"key": cell, "wkt": _cell_wkt(cell)} + for cell in candidates + ], + ) + + rows = db.execute( + text( + "SELECT c.cell_key FROM _zone_cells c " + "WHERE ST_Intersects(c.geom, (SELECT geom FROM zones WHERE id = :zid))" + ), + {"zid": zone.id}, + ).fetchall() + return [row[0] for row in rows] + + +def _cell_wkt(cell: str) -> str: + """WKT rectangle for a geohash cell (used only to feed ST_GeomFromText).""" + lon_c, lat_c, lon_c2, lat_c2 = geohash_bounds(cell) + return ( + f"POLYGON(({lon_c} {lat_c},{lon_c2} {lat_c}," + f"{lon_c2} {lat_c2},{lon_c} {lat_c2},{lon_c} {lat_c}))" + ) + + +def clear_zone_index(redis_client=redis_sync) -> None: + """Delete every zone-index key (idempotent rebuild helper).""" + for key in redis_client.scan_iter(match=f"{ZONE_INDEX_KEY_PREFIX}:*"): + redis_client.delete(key) + + +def build_zone_index( + db: Session, + redis_client=redis_sync, + precision: int = ZONE_INDEX_PRECISION, +) -> dict[str, list[int]]: + """ + (Re)build the Redis zone-index from the authoritative PostGIS polygons. + + Maps ``zoneindex:`` -> SET of zone ids whose polygon intersects + that cell. Cells with zero matches are simply absent from Redis. + """ + clear_zone_index(redis_client) + zones = db.query(models.Zone).filter(models.Zone.geom.isnot(None)).all() + + index: dict[str, list[int]] = {} + for zone in zones: + cells = zone_covering_geohashes(db, zone, precision) + for cell in cells: + index.setdefault(cell, []).append(zone.id) + print(f" 🌐 Indexed Zone '{zone.city}': {len(cells)} cells", flush=True) + + pipe = redis_client.pipeline(transaction=False) + for cell, zone_ids in index.items(): + pipe.sadd(f"{ZONE_INDEX_KEY_PREFIX}:{cell}", *zone_ids) + pipe.execute() + print(f"βœ… Zone geo-index rebuilt: {len(index)} cells across {len(zones)} zones", flush=True) + return index + + +def candidate_zone_ids( + latitude: float, + longitude: float, + redis_client=redis_sync, + precision: int = ZONE_INDEX_PRECISION, +) -> set[int]: + """Best-effort Redis fast path: zone ids whose cells contain the coordinate.""" + try: + cell = point_to_geohash(latitude, longitude, precision) + members = redis_client.smembers(f"{ZONE_INDEX_KEY_PREFIX}:{cell}") + return {int(m) for m in members} + except Exception: + return set() diff --git a/backend/src/ingest.py b/backend/src/ingest.py new file mode 100644 index 0000000..1bcbc12 --- /dev/null +++ b/backend/src/ingest.py @@ -0,0 +1,116 @@ +""" +Redis Streams Ingestion Substrate +--------------------------------- +Right-sized backpressure + horizontal scale for the IoT ingestion path. + +The old design enqueued heartbeats into a Redis LIST ('seismic_events') consumed +by a single worker via BRPOP. Lists cannot support consumer goups, so scale was +bounded to one process and a crash mid-read meant a lost message. + +This module replaces that with Redis Streams + consumer groups: + - Any number of producers XADD to the stream (HTTP API, MQTT bridge, ...). + - Any number of worker processes consume with XREADGROUP; Redis balances + deliveries across the group. Adding replicas = `scale worker=N`. + - XAUTOCLAIM recovers stale pending entries from crashed consumers, so + at-least-once delivery holds across worker restarts. + - Unparseable / fatally-failing messages are parked on a DLQ stream and ACKed, + so a poisoned heartbeat can never stall the group. + +Every key can be re-pointed with environment variables so a single backend can +serve multiple logical regions simply by renaming the stream. +""" + +import os + +# --- Stream / group topology ------------------------------------------------- +READINGS_STREAM = os.getenv("READINGS_STREAM", "readings:stream") +READINGS_GROUP = os.getenv("READINGS_GROUP", "quakeguard-ingest") +READINGS_DLQ = os.getenv("READINGS_DLQ", "readings:dlq") +CONSUMER_PREFIX = os.getenv("READINGS_CONSUMER_PREFIX", "worker") +MAXLEN = int(os.getenv("READINGS_STREAM_MAXLEN", "200000")) +MIN_IDLE_MS = int(os.getenv("READINGS_MIN_IDLE_MS", "30000")) +BATCH_SIZE = int(os.getenv("READINGS_BATCH_SIZE", "64")) +BLOCK_MS = int(os.getenv("READINGS_BLOCK_MS", "500")) + +# Field name carrying the serialized payload inside each stream entry. +PAYLOAD_FIELD = "payload" + + +def ensure_group(client) -> None: + """Create the consumer group (and stream) if missing. Best-effort: producers + may legitimately run before any worker, so XADD will create the stream.""" + try: + client.xgroup_create( + READINGS_STREAM, READINGS_GROUP, id="0", mkstream=True + ) + except Exception: + # BUSYGROUP (group exists) is the expected steady state -> ignore. + pass + + +async def ensure_group_async(client) -> None: + """Async counterpart of :func:`ensure_group` for the FastAPI event loop.""" + try: + await client.xgroup_create( + READINGS_STREAM, READINGS_GROUP, id="0", mkstream=True + ) + except Exception: + pass + + +def enqueue_reading(client, payload_json: str) -> str: + """Append one serialized heartbeat to the stream. Returns its message id.""" + return client.xadd( + READINGS_STREAM, + {PAYLOAD_FIELD: payload_json}, + maxlen=MAXLEN, + approximate=True, + ) + + +def read_batch(client, consumer: str, count: int = BATCH_SIZE, + block_ms: int = BLOCK_MS) -> list: + """Block for new messages assigned to this consumer. Returns a list of + ``(message_id, payload_json)`` tuples ([] on timeout).""" + raw = client.xreadgroup( + READINGS_GROUP, consumer, + {READINGS_STREAM: ">"}, + count=count, + block=block_ms, + ) + if not raw: + return [] + # raw == [ [stream_name, [[msg_id, {field: value}], ...]] ] + _, entries = raw[0] + return [(entry_id, values[PAYLOAD_FIELD]) for entry_id, values in entries] + + +def ack(client, message_ids) -> None: + if not message_ids: + return + client.xack(READINGS_STREAM, READINGS_GROUP, *message_ids) + + +def move_to_dlq(client, message_id: str, payload_json: str, reason: str) -> None: + """Park a poisoned message on the DLQ stream and acknowledge the original.""" + client.xadd( + READINGS_DLQ, + {"reason": reason, "original_id": message_id, PAYLOAD_FIELD: payload_json}, + maxlen=MAXLEN, + approximate=True, + ) + ack(client, [message_id]) + + +def recover_pending(client, consumer: str, min_idle_ms: int = MIN_IDLE_MS) -> int: + """Reclaim entries left pending by crashed consumers (at-least-once across + restarts). Returns the number of reclaimed entries.""" + reclaimed = client.xautoclaim( + READINGS_STREAM, + READINGS_GROUP, + consumer, + min_idle_time=min_idle_ms, + start_id="0", + ) + # XAUTOCLAIM => [next_cursor, [ [id, {field: value}], ... ], deleted_ids] + return len(reclaimed[1]) diff --git a/backend/src/main.py b/backend/src/main.py index 59731bb..cb1ceae 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -28,10 +28,19 @@ # --- LOCAL MODULES --- from src.database import get_db, engine, SessionLocal, DATABASE_URL +from src.geo import ( + COOLDOWN_PRECISION, + ZONE_INDEX_PRECISION, + build_zone_index, + candidate_zone_ids, + point_to_geohash, +) +from src import ingest import src.models as models import src.schemas as schemas from src.security import verify_api_key, validate_iot_payload from src.seed import seed_zones +from src.timescale import apply_timescale PING_QUERY = "SELECT 1" @@ -116,17 +125,34 @@ async def redis_alert_listener() -> None: async def lifespan(app: FastAPI): loop = asyncio.get_running_loop() await loop.run_in_executor(None, wait_for_db) + # PostGIS must exist before create_all: the models use `geometry` columns. + # Some DB images (e.g. timescale-ha) do not run the postgis init hook, so the + # app ensures the extension itself β€” idempotent and safe on stock PostGIS. + with engine.begin() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) await loop.run_in_executor(None, lambda: models.Base.metadata.create_all(bind=engine)) with SessionLocal() as db: seed_zones(db) + build_zone_index(db) + # TimescaleDB hypertable + rollups (best-effort; no-op on plain PostGIS). + try: + apply_timescale(db) + except Exception as e: + print(f"⚠️ TimescaleDB migration skipped: {e}", flush=True) + + # Ensure the ingestion consumer group exists so stream reads never error. + try: + await ingest.ensure_group_async(redis_client) + except Exception as e: + print(f"⚠️ Ingest group ensure skipped: {e}", flush=True) listener_task = asyncio.create_task(redis_alert_listener()) yield listener_task.cancel() # Initialize FastAPI -app = FastAPI(title="QuakeGuard Backend", version="2.2.0", lifespan=lifespan) +app = FastAPI(title="QuakeGuard Backend", version="1.2.1", lifespan=lifespan) # ========================================== # MIDDLEWARE @@ -158,13 +184,23 @@ def resolve_zone(db: Session, latitude: float | None, longitude: float | None) - Spatial auto-assignment helper. Finds the smallest containing polygon for given GPS coordinates. Falls back to 'Unknown Region' if no match is found or coordinates are null. + + Fast path: the Redis zone-index (geohash -> zone ids) resolves the zone for + a coordinate without a DB round-trip. On a miss or an ambiguous multi-zone + match we fall back to the authoritative PostGIS ST_Contains query, so the + Redis cache is purely an optimization and can never assign a wrong zone. """ if latitude is None or longitude is None: fallback = db.query(models.Zone).filter(models.Zone.city == "Unknown Region").first() return fallback.id if fallback else 1 + candidates = candidate_zone_ids(latitude, longitude, precision=ZONE_INDEX_PRECISION) + if len(candidates) == 1: + return candidates.pop() + + # Redis miss, empty or ambiguous match -> authoritative PostGIS query point = WKTElement(f"POINT({longitude} {latitude})", srid=4326) - + # Query PostGIS to find the containing polygon, ordered by smallest area first matched_zone = db.query(models.Zone).filter( func.ST_Contains(models.Zone.geom, point) @@ -172,10 +208,10 @@ def resolve_zone(db: Session, latitude: float | None, longitude: float | None) - if matched_zone: return matched_zone.id - + # Fallback to Unknown Region fallback = db.query(models.Zone).filter(models.Zone.city == "Unknown Region").first() - return fallback.id if fallback else 1 # Final failsafe + return fallback.id if fallback else 1 # Final failsafe # ========================================== # WEBSOCKET ENDPOINT @@ -303,6 +339,25 @@ def get_zones(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)): limit = min(limit, 1000) return db.query(models.Zone).offset(skip).limit(limit).all() +@app.get("/zones/locate", response_model=schemas.Zone, tags=["Data Retrieval"], dependencies=[Depends(verify_api_key)]) +def locate_zone(latitude: float, longitude: float, db: Session = Depends(get_db)): + """ + Resolve GPS coordinates to the smallest containing monitored zone. + + Powers the "Detect my zone" flow: the app pings this with a GPS fix and gets + the zone back so alert alarms can be scoped to the operator's own area. + Returns 404 when the coordinate is not covered by any monitored polygon. + """ + matched = ( + db.query(models.Zone) + .filter(func.ST_Contains(models.Zone.geom, WKTElement(f"POINT({longitude} {latitude})", srid=4326))) + .order_by(func.ST_Area(models.Zone.geom).asc()) + .first() + ) + if matched is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Coordinates not inside any monitored zone") + return matched + def _create_sensor(db: Session, active: bool, zone_id: int | None, latitude: float | None, longitude: float | None, public_key_hex: str, mac_address: str | None = None) -> models.Sensor: """Create and persist a Sensor with spatial zone auto-assignment.""" assigned_zone_id = zone_id or resolve_zone(db, latitude, longitude) @@ -340,6 +395,26 @@ def register_device(payload: schemas.DeviceRegisterRequest, db: Session = Depend ).first() if existing: + # GNSS-ready: a relocated node (or a spare re-deployed after a cold + # boot) re-reports its real fix at handshake time. Keep the last known + # coordinates fresh and re-assign the zone if it actually moved. + if payload.latitude is not None and payload.longitude is not None: + moved = ( + existing.latitude != payload.latitude + or existing.longitude != payload.longitude + ) + if moved: + existing.latitude = payload.latitude + existing.longitude = payload.longitude + existing.location = WKTElement( + f"POINT({payload.longitude} {payload.latitude})", srid=4326 + ) + existing.zone_id = resolve_zone(db, payload.latitude, payload.longitude) + db.commit() + print( + f"[PROV] Sensor {existing.id} moved -> new zone_id {existing.zone_id}", + flush=True, + ) return {"sensor_id": existing.id} new_device = _create_sensor(db, True, None, payload.latitude, payload.longitude, payload.public_key_hex, payload.mac_address) @@ -364,13 +439,38 @@ async def create_reading_async( # Enqueue for Worker payload = reading.model_dump() payload['zone_id'] = sensor.zone_id + # GNSS-ready: propagate the sensor's current fix so the worker can persist + # coordinates and fragment the cooldown lock per-area instead of per-zone. + payload['latitude'] = sensor.latitude + payload['longitude'] = sensor.longitude + if sensor.latitude is not None and sensor.longitude is not None: + payload['sensor_geohash'] = point_to_geohash(sensor.latitude, sensor.longitude, COOLDOWN_PRECISION) - # Offload to the Redis queue - await redis_client.lpush("seismic_events", json.dumps(payload)) + # Append to the Redis Streams ingestion bus (O(1)); the worker group drains it. + await ingest.enqueue_reading(redis_client, json.dumps(payload)) return {"status": "accepted"} @app.get("/sensors/{id}/statistics", tags=["Data Retrieval"], dependencies=[Depends(verify_api_key)]) def get_sensor_statistics(id: int, db: Session = Depends(get_db)): + # Fast path: when TimescaleDB provisioned the continuous aggregate, the + # dashboard rollups are served from pre-computed buckets instead of a COUNT + # scan over the hypertable. + has_aggregate = db.execute( + text("SELECT to_regclass('public.readings_minute') IS NOT NULL") + ).scalar() + if has_aggregate: + row = db.execute( + text( + "SELECT COALESCE(SUM(n), 0) AS total, COALESCE(MAX(peak), 0) AS peak " + "FROM readings_minute WHERE sensor_id = :sensor_id" + ), + {"sensor_id": id}, + ).one() + return { + "sensor_id": id, + "total_readings": row.total, + "peak_value": row.peak, + } count = db.query(models.Reading).filter(models.Reading.sensor_id == id).count() return { "sensor_id": id, @@ -388,6 +488,73 @@ def get_readings(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)): limit = min(limit, 1000) return db.query(models.Reading).order_by(models.Reading.recorded_at.desc()).offset(skip).limit(limit).all() +@app.get("/zones/{zone_id}/readings", response_model=List[schemas.Reading], tags=["Data Retrieval"], dependencies=[Depends(verify_api_key)]) +def get_zone_readings(zone_id: int, limit: int = 60, db: Session = Depends(get_db)): + """ + Fetch the most recent readings emitted by sensors belonging to a single + PostGIS zone. Powers the per-zone seismograph on the dashboard: each zone + renders its own sliding window instead of mixing the whole network. + """ + zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first() + if not zone: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found") + limit = max(1, min(limit, 200)) + return ( + db.query(models.Reading) + .join(models.Sensor, models.Reading.sensor_id == models.Sensor.id) + .filter(models.Sensor.zone_id == zone_id) + .order_by(models.Reading.recorded_at.desc()) + .limit(limit) + .all() + ) + +@app.delete("/zones/{zone_id}/readings", tags=["Data Management"], dependencies=[Depends(verify_api_key)]) +def delete_zone_readings(zone_id: int, db: Session = Depends(get_db)): + """ + Clear the telemetry emitted by sensors belonging to a single PostGIS zone. + + Removes every reading whose sensor is assigned to the given zone so the + per-zone seismograph can be reset without touching data from other areas. + Returns the number of deleted readings. + """ + zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first() + if not zone: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found") + + sensor_ids = [ + row[0] + for row in db.query(models.Sensor.id).filter(models.Sensor.zone_id == zone_id).all() + ] + deleted = 0 + if sensor_ids: + deleted = ( + db.query(models.Reading) + .filter(models.Reading.sensor_id.in_(sensor_ids)) + .delete(synchronize_session=False) + ) + db.commit() + return {"deleted": deleted} + +@app.get("/zones/{zone_id}/alerts", response_model=List[schemas.Alert], tags=["Data Retrieval"], dependencies=[Depends(verify_api_key)]) +def get_zone_alerts(zone_id: int, limit: int = 20, db: Session = Depends(get_db)): + """ + Retrieve the confirmed seismic alerts raised for a specific PostGIS zone. + + Orders by most recent first so the dashboard can render an area-scoped + alert history. Returns 404 when the zone does not exist. + """ + zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first() + if not zone: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found") + limit = max(1, min(limit, 100)) + return ( + db.query(models.Alert) + .filter(models.Alert.zone_id == zone_id) + .order_by(models.Alert.created_at.desc()) + .limit(limit) + .all() + ) + @app.get("/reports/{alert_id}", response_model=schemas.EmergencyReport, tags=["Data Retrieval"], dependencies=[Depends(verify_api_key)]) def get_emergency_report(alert_id: int, db: Session = Depends(get_db)): """ diff --git a/backend/src/models.py b/backend/src/models.py index 1871b31..6e5f03e 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -5,7 +5,9 @@ Updated to support Device Provisioning (MAC Address & Firmware Version). """ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text +from sqlalchemy import ( + Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text, +) from sqlalchemy.sql import func from sqlalchemy.orm import relationship from geoalchemy2 import Geometry @@ -46,6 +48,8 @@ class Sensor(Base): latitude = Column(Float, nullable=True) longitude = Column(Float, nullable=True) location = Column(Geometry('POINT', srid=4326), nullable=True) + # GNSS-ready: last reliable fix timestamp (v1.2.1). Null until the first fix. + last_fix_at = Column(DateTime(timezone=True), nullable=True) # --- SECURITY & IDENTITY --- # The public key is the primary cryptographic identity @@ -61,10 +65,17 @@ class Sensor(Base): class Reading(Base): __tablename__ = "readings" - id = Column(Integer, primary_key=True, index=True) - recorded_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + # Composite PK (id, recorded_at): TimescaleDB requires the partitioning + # column to be part of every primary/unique key of the hypertable. + id = Column(Integer, primary_key=True, autoincrement=True, index=True) + recorded_at = Column(DateTime(timezone=True), primary_key=True, server_default=func.now(), nullable=False, index=True) value = Column(Integer, nullable=False) + # GNSS-ready: event coordinates captured at ingestion (v1.2.1) so the worker + # can fragment cooldowns per-area and v2.0 can correlate nodes spatially. + latitude = Column(Float, nullable=True) + longitude = Column(Float, nullable=True) + sensor_id = Column(Integer, ForeignKey("sensors.id"), nullable=False) sensor = relationship("Sensor", back_populates="readings") diff --git a/backend/src/mqtt_subscriber.py b/backend/src/mqtt_subscriber.py index 9e738e5..6bcfbc9 100644 --- a/backend/src/mqtt_subscriber.py +++ b/backend/src/mqtt_subscriber.py @@ -56,8 +56,11 @@ def on_message(client, userdata, msg): if MQTT_USERNAME and MQTT_PASSWORD: client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD) - - client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS) + + # TLS is required on the WAN port (8883) but must be skipped for the local + # plaintext mosquitto (1883), mirroring the firmware's dual-mode transport. + if MQTT_PORT == 8883: + client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS) print(f"πŸ”Œ Connecting to MQTT Broker at {MQTT_BROKER}:{MQTT_PORT}...") client.connect(MQTT_BROKER, MQTT_PORT, 60) diff --git a/backend/src/schemas.py b/backend/src/schemas.py index d8dcf1a..7c37b2d 100644 --- a/backend/src/schemas.py +++ b/backend/src/schemas.py @@ -51,6 +51,7 @@ class Sensor(SensorBase): id: int latitude: Optional[float] = None longitude: Optional[float] = None + last_fix_at: Optional[datetime] = None # We do not return the public key by default to keep responses clean, # but it can be added if needed. model_config = ConfigDict(from_attributes=True) @@ -78,6 +79,8 @@ class Reading(BaseModel): value: int sensor_id: int recorded_at: datetime + latitude: Optional[float] = None + longitude: Optional[float] = None model_config = ConfigDict(from_attributes=True) @@ -92,6 +95,18 @@ class DeviceRegisterRequest(BaseModel): latitude: float = Field(default=None, ge=-90, le=90) longitude: float = Field(default=None, ge=-180, le=180) +# ========================================== +# ALERT SCHEMAS +# ========================================== +class Alert(BaseModel): + id: int + zone_id: int + magnitude: float + message: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + # ========================================== # EMERGENCY REPORT SCHEMAS (AI / Ollama) # ========================================== diff --git a/backend/src/timescale.py b/backend/src/timescale.py new file mode 100644 index 0000000..3670d33 --- /dev/null +++ b/backend/src/timescale.py @@ -0,0 +1,147 @@ +""" +TimescaleDB Provisioning (best-effort, idempotent) +-------------------------------------------------- +Upgrades the ``readings`` table to a TimescaleDB *hypertable* partitioned on +``recorded_at`` so the time-series ingestion path scales with chunking, +compression and continuous aggregates β€” without touching the ORM model. + +The migration is deliberately **best-effort**: on a stock PostGIS container +(no TimescaleDB), every step fails closed with a warning and the application +keeps running on the plain relational table. This lets CI and local dev use the +standard PostGIS image while production uses the TimescaleDB+PostGIS image. + +Run manually: python -m src.timescale +Auto-applied: on FastAPI startup (see src/main.py lifespan) +""" + +import os + +from sqlalchemy import text +from sqlalchemy.orm import Session + +TSDB_RETENTION_DAYS = os.getenv("TSDB_RETENTION_DAYS", "180") + + +def apply_timescale(db: Session) -> dict: + """Apply TimescaleDB DDL where possible. Returns a per-step report dict. + + Steps are isolated: a failure in any step never blocks the others nor the + application startup. + """ + report = { + "timescaledb": False, + "hypertable": False, + "aggregate": False, + "retention": False, + } + + # 1. Extension --------------------------------------------------------- + try: + db.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) + db.commit() + report["timescaledb"] = True + except Exception as e: + db.rollback() + print(f"⚠️ TimescaleDB extension unavailable: {e}", flush=True) + return report + + # 2. Hypertable -------------------------------------------------------- + try: + already_hypertable = db.execute( + text( + "SELECT (EXISTS (SELECT 1 FROM timescaledb_information.hypertables " + "WHERE hypertable_name = 'readings'))" + ) + ).scalar() + if not already_hypertable: + db.execute( + text( + "SELECT create_hypertable('readings', 'recorded_at', " + "if_not_exists => TRUE, migrate_data => TRUE)" + ) + ) + db.commit() + report["hypertable"] = True + print("βœ… readings is a TimescaleDB hypertable (chunked on recorded_at).", flush=True) + except Exception as e: + db.rollback() + print(f"⚠️ Hypertable creation skipped: {e}", flush=True) + + # 3. Continuous aggregate (per-sensor minute rollups) ------------------- + try: + db.execute( + text( + "CREATE MATERIALIZED VIEW IF NOT EXISTS readings_minute " + "WITH (timescaledb.continuous) AS " + "SELECT sensor_id, time_bucket('1 minute', recorded_at) AS bucket, " + "count(*) AS n, max(value) AS peak " + "FROM readings GROUP BY sensor_id, bucket WITH NO DATA" + ) + ) + db.commit() + try: + db.execute( + text( + "SELECT add_continuous_aggregate_policy('readings_minute', " + "start_offset => INTERVAL '3 hours', " + "end_offset => INTERVAL '10 seconds', " + "schedule_interval => INTERVAL '1 minute')" + ) + ) + db.commit() + except Exception: + db.rollback() # policy already present or not yet refreshable + report["aggregate"] = True + print("βœ… Continuous aggregate readings_minute created.", flush=True) + except Exception as e: + db.rollback() + print(f"⚠️ Continuous aggregate skipped: {e}", flush=True) + + # 4. Compression + retention ------------------------------------------- + try: + # Newer TimescaleDB (2.13+/3.x) requires columnstore on the hypertable + # before a columnstore compression policy can be added. + db.execute(text("ALTER TABLE readings SET (timescaledb.columnstore = true)")) + db.commit() + except Exception as e: + db.rollback() + if "already" not in str(e).lower(): + print(f"⚠️ Columnstore enable skipped: {e}", flush=True) + try: + db.execute(text("SELECT add_compression_policy('readings', INTERVAL '1 day')")) + db.commit() + except Exception as e: + db.rollback() + if "already exists" not in str(e).lower(): + print(f"⚠️ Compression policy skipped: {e}", flush=True) + try: + db.execute( + text( + "SELECT add_retention_policy('readings', " + f"INTERVAL '{TSDB_RETENTION_DAYS} days')" + ) + ) + db.commit() + report["retention"] = True + except Exception as e: + db.rollback() + if "already exists" in str(e).lower(): + report["retention"] = True + else: + print(f"⚠️ Retention policy skipped: {e}", flush=True) + if report["retention"]: + print(f"βœ… Retention policy set to {TSDB_RETENTION_DAYS} days.", flush=True) + + return report + + +def run_migration() -> dict: + from src.database import SessionLocal + with SessionLocal() as db: + report = apply_timescale(db) + print(f"πŸ“‹ TimescaleDB migration report: {report}", flush=True) + return report + + +if __name__ == "__main__": + run_migration() diff --git a/backend/src/worker.py b/backend/src/worker.py index 7552ef1..6223c3b 100644 --- a/backend/src/worker.py +++ b/backend/src/worker.py @@ -1,11 +1,25 @@ -import os import json -import time import math +import os +import socket +import time from datetime import datetime, timezone import redis from sqlalchemy.orm import Session from src.database import SessionLocal, engine +from src.geo import COOLDOWN_KEY_PREFIX +from src.ingest import ( + READINGS_STREAM, + READINGS_GROUP, + CONSUMER_PREFIX, + BATCH_SIZE, + BLOCK_MS, + ensure_group, + read_batch, + ack, + move_to_dlq, + recover_pending, +) from src.models import Reading, Alert, EmergencyReport, Zone # Redis Config @@ -42,30 +56,31 @@ def estimate_magnitude(sensor_value: int) -> float: # Clamp to physically meaningful range for MEMS sensors return max(0.0, min(magnitude, 9.9)) -def process_event(event: dict, db: Session): - """Inserts a single sensor measurement into PostGIS and triggers alerts with deduplication.""" - - # 1. Save to Database +def _enrich_event(event: dict, db: Session) -> dict: + """Stage one reading (+ optional alert) into the session. Returns a resolution + dict consumed by ``_finish_alerts`` after the shared commit. No commit here so + a whole stream batch can be flushed in a single transaction.""" new_entry = Reading( value=event.get("value"), - sensor_id=event.get("sensor_id") + sensor_id=event.get("sensor_id"), + latitude=event.get("latitude"), + longitude=event.get("longitude"), ) db.add(new_entry) - # 2. 🚨 ALARM LOGIC: Check if threshold is breached sensor_value = event.get("value", 0) magnitude = estimate_magnitude(sensor_value) zone_id = event.get("zone_id", 0) - alert_published = False alert_entry = None - - # Trigger a CRITICAL alert if physical magnitude is 4.5 or higher + if magnitude >= 4.5: - cooldown_key = f"alert_cooldown:{zone_id}" - - # Atomic check-and-set with 60s TTL (Deduplication) + area_key = event.get("sensor_geohash") or event.get("geohash") + if area_key: + cooldown_key = f"{COOLDOWN_KEY_PREFIX}:{area_key}" + else: + cooldown_key = f"{COOLDOWN_KEY_PREFIX}:zone:{zone_id}" + if redis_sync.set(cooldown_key, "active", nx=True, ex=60): - alert_published = True alert_entry = Alert( zone_id=zone_id, magnitude=magnitude, @@ -73,30 +88,57 @@ def process_event(event: dict, db: Session): ) db.add(alert_entry) else: - print(f"🚫 ALERT SUPPRESSED: Zone {zone_id} is in 60s cooldown.", flush=True) + print(f"🚫 ALERT SUPPRESSED: area '{area_key or zone_id}' is in 60s cooldown.", flush=True) - # 3. Commit atomically: Reading (+ Alert) β€” flush to obtain IDs before enqueue - db.commit() - if alert_published and alert_entry is not None: + return { + "event": event, + "magnitude": magnitude, + "zone_id": zone_id, + "sensor_id": event.get("sensor_id"), + "alert": alert_entry, + } + +def _finish_alerts(db: Session, resolutions: list) -> None: + """After the shared commit: publish any CRITICAL alert to the Redis Pub/Sub + channel and enqueue AI report generation. Runs per-resolution (alerts are rare).""" + for resolution in resolutions: + alert_entry = resolution["alert"] + if alert_entry is None: + continue db.refresh(alert_entry) + zone_id = resolution["zone_id"] + magnitude = resolution["magnitude"] + sensor_id = resolution["sensor_id"] + event = resolution["event"] - # 4. Publish alert to Redis (best-effort after DB commit β€” outbox pattern) - if alert_published and alert_entry is not None: alert_payload = { "type": "CRITICAL", "alert_id": alert_entry.id, "zone_id": zone_id, "magnitude": round(magnitude, 1), - "message": f"High seismic activity detected (Sensor {event.get('sensor_id')})!", + "message": f"High seismic activity detected (Sensor {sensor_id})!", "timestamp": datetime.now(timezone.utc).isoformat() } redis_sync.publish("quake_alerts", json.dumps(alert_payload)) print(f"🚨 ALERT PUBLISHED: Zone {zone_id} - Mag {round(magnitude, 1)}", flush=True) - # 5. πŸ€– AI REPORT: enqueue context for the dedicated worker (non-blocking) if AI_REPORT_ENABLED: enqueue_ai_report(db, event, alert_entry.id, zone_id, magnitude) +def process_event(event: dict, db: Session): + """Single-event processing (compatibility + tests). Batch workloads use + ``process_batch`` so one transaction covers N readings.""" + resolution = _enrich_event(event, db) + db.commit() + _finish_alerts(db, [resolution]) + +def process_batch(events: list, db: Session): + """Process a stream batch in ONE transaction. Cooldown locks are taken + per-event (atomic Redis SET NX), DB writes are batched and committed once.""" + resolutions = [_enrich_event(event, db) for event in events] + db.commit() + _finish_alerts(db, resolutions) + def enqueue_ai_report(db: Session, event: dict, alert_id: int, zone_id: int, magnitude: float) -> None: """Create a PENDING EmergencyReport and enqueue the AI report job. @@ -133,25 +175,60 @@ def enqueue_ai_report(db: Session, event: dict, alert_id: int, zone_id: int, mag print(f"πŸ€– AI Report enqueued (report_id={report.id})", flush=True) def run_worker(): - print("πŸ‘· Worker started. Listening for 'seismic_events'...") + consumer = f"{CONSUMER_PREFIX}-{socket.gethostname()}-{os.getpid()}" + print(f"πŸ‘· Worker started. stream='{READINGS_STREAM}' group='{READINGS_GROUP}' consumer='{consumer}'") db = SessionLocal() - + + # Group must exist before any XREADGROUP; idempotent. + ensure_group(redis_sync) + # Reclaim entries left pending by a crashed/restarted sibling (at-least-once). + try: + recovered = recover_pending(redis_sync, consumer) + if recovered: + print(f"πŸ” Reclaimed {recovered} stale pending entr{'y' if recovered == 1 else 'ies'}.", flush=True) + except Exception as e: + print(f"⚠️ Pending recovery skipped: {e}", flush=True) + while True: try: - # Block until data is available in the queue - result = redis_sync.brpop("seismic_events", timeout=0) - if result: - _, data = result - event = json.loads(data) - + batch = read_batch(redis_sync, consumer, count=BATCH_SIZE, block_ms=BLOCK_MS) + if not batch: + continue + + pending = [] + for message_id, payload in batch: try: - process_event(event, db) - print(f"βœ… Processed sensor {event.get('sensor_id')} -> {event.get('value')} (Mag: {estimate_magnitude(event.get('value', 0))})", flush=True) - except Exception as e: - print(f"❌ DB Error: {e}. Moving to DLQ.", flush=True) - db.rollback() - redis_sync.lpush("seismic_events_dlq", data) - + event = json.loads(payload) + except Exception: + print("❌ Malformed payload -> DLQ.", flush=True) + try: + move_to_dlq(redis_sync, message_id, payload, reason="malformed_json") + except Exception as e: + print(f"❌ DLQ write failed: {e}", flush=True) + continue + pending.append((message_id, event, payload)) + + if not pending: + continue + + try: + process_batch([event for _, event, _ in pending], db) + ack(redis_sync, [message_id for message_id, _, _ in pending]) + for _, event, _ in pending: + print( + f"βœ… Processed sensor {event.get('sensor_id')} -> {event.get('value')} " + f"(Mag: {estimate_magnitude(event.get('value', 0))})", + flush=True, + ) + except Exception as e: + print(f"❌ Batch DB Error: {e}. Moving batch to DLQ.", flush=True) + db.rollback() + for message_id, _, payload in pending: + try: + move_to_dlq(redis_sync, message_id, payload, reason=f"process_error: {e}") + except Exception as dlq_err: + print(f"❌ DLQ write failed: {dlq_err}", flush=True) + except Exception as e: print(f"❌ Redis Connection Error: {e}", flush=True) time.sleep(2) diff --git a/backend/tests/integration/test_api.py b/backend/tests/integration/test_api.py index bffad2e..e9eee1e 100644 --- a/backend/tests/integration/test_api.py +++ b/backend/tests/integration/test_api.py @@ -97,6 +97,8 @@ def test_get_readings(self, client, override_db, auth_headers): assert resp.status_code == 200 def test_get_statistics(self, client, override_db, auth_headers): + # No continuous aggregate available -> the COUNT fallback path runs. + override_db.execute.return_value.scalar.return_value = False override_db.query.return_value.filter.return_value.count.return_value = 42 resp = client.get("/sensors/1/statistics", headers=auth_headers) assert resp.status_code == 200 diff --git a/backend/tests/integration/test_geo_integration.py b/backend/tests/integration/test_geo_integration.py new file mode 100644 index 0000000..a6b923b --- /dev/null +++ b/backend/tests/integration/test_geo_integration.py @@ -0,0 +1,131 @@ +""" +Real-spatial integration tests (run when a live PostGIS + Redis are reachable). + +These tests exercise the actual seeded polygons and the Redis zone index, so +they cross-validate the pure-Python geohash encoder against PostGIS +``ST_GeoHash`` and assert ``resolve_zone`` on known coordinates. + +They skip cleanly when no database/redis is reachable (the shared integration +conftest runs against mocked infra in the base CI job); a dedicated CI job +runs them against the seeded Docker stack. +""" + +import os + +import pytest +from sqlalchemy import text + +from src.database import Base, engine, SessionLocal +from src.geo import ( + COOLDOWN_PRECISION, + ZONE_INDEX_PRECISION, + build_zone_index, + candidate_zone_ids, + clear_zone_index, + point_to_geohash, + redis_sync, +) +from src.main import resolve_zone +from src.seed import seed_zones + +# (latitude, longitude, expected zone name) +KNOWN_POINTS = [ + (45.4642, 9.1900, "Italy - North"), # Milan + (40.4168, -3.7038, "Western Europe"), # Madrid + (35.6762, 139.6503, "East Asia"), # Tokyo + (48.8566, 2.3522, "Western Europe"), # Paris + (4.7110, -74.0721, "South America"), # Bogota + (-23.5505, -46.6333, "South America"), # Sao Paulo + (37.7749, -122.4194, "North America"), # San Francisco + (-1.2833, 36.8167, "Unknown Region"), # Nairobi (no polygon) +] + + +@pytest.fixture(scope="module") +def infra_available(): + try: + with engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + redis_sync.ping() + except Exception: + pytest.skip("PostGIS and/or Redis not reachable; skipping spatial integration tests") + return + + # Create only the `zones` table via raw DDL: the shared integration conftest + # replaces Base.metadata.create_all with a mock (it is designed for + # mocked-DB tests), so we bypass it with an explicit schema. + with engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS zones CASCADE")) + conn.execute( + text( + "CREATE TABLE zones (" + "id SERIAL PRIMARY KEY, " + "city VARCHAR(100) NOT NULL UNIQUE, " + "created_at TIMESTAMPTZ NOT NULL DEFAULT now(), " + "geom geometry(Polygon, 4326))" + ) + ) + try: + with SessionLocal() as db: + seed_zones(db) + build_zone_index(db) + yield True + finally: + with engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS zones CASCADE")) + clear_zone_index() + + +def zone_name_of(zone_id: int) -> str: + with SessionLocal() as db: + row = db.execute(text("SELECT city FROM zones WHERE id = :zid"), {"zid": zone_id}).first() + return row[0] if row else "missing" + + +class TestResolveZoneReal: + @pytest.mark.parametrize("lat,lon,expected", KNOWN_POINTS) + def test_resolves_known_points(self, infra_available, lat, lon, expected): + with SessionLocal() as db: + assert zone_name_of(resolve_zone(db, lat, lon)) == expected + + def test_null_coordinates_use_unknown(self, infra_available): + with SessionLocal() as db: + assert zone_name_of(resolve_zone(db, None, None)) == "Unknown Region" + + +class TestGeohashVsPostGIS: + @pytest.mark.parametrize( + "lat,lon", + [(45.4642, 9.19), (40.4168, -3.7038), (35.6762, 139.6503), (0.0, 0.0), (-33.8688, 151.2093)], + ) + def test_encoder_matches_st_geohash(self, infra_available, lat, lon): + with engine.connect() as conn: + row = conn.execute( + text("SELECT ST_GeoHash(ST_SetSRID(ST_MakePoint(:lon, :lat), 4326), :prec)"), + {"lon": lon, "lat": lat, "prec": ZONE_INDEX_PRECISION}, + ).first() + assert row[0] == point_to_geohash(lat, lon, ZONE_INDEX_PRECISION) + + def test_cooldown_geohash_matches_st_geohash(self, infra_available): + with engine.connect() as conn: + row = conn.execute( + text("SELECT ST_GeoHash(ST_SetSRID(ST_MakePoint(:lon, :lat), 4326), :prec)"), + {"lon": -74.006, "lat": 40.7128, "prec": COOLDOWN_PRECISION}, + ).first() + assert row[0] == point_to_geohash(40.7128, -74.006, COOLDOWN_PRECISION) + + +class TestZoneIndexFastPath: + def test_candidates_resolve_to_expected_zone(self, infra_available): + with SessionLocal() as db: + milan_id = resolve_zone(db, 45.4642, 9.1900) + candidates = candidate_zone_ids(45.4642, 9.1900, precision=ZONE_INDEX_PRECISION) + assert milan_id in candidates + + def test_unknown_point_has_no_candidates(self, infra_available): + assert candidate_zone_ids(-1.2833, 36.8167, precision=ZONE_INDEX_PRECISION) == set() + + def test_index_header_env(self, infra_available): + """Sanity: precision env knobs are processable ints in the default range.""" + assert 1 <= ZONE_INDEX_PRECISION <= 12 + assert 1 <= COOLDOWN_PRECISION <= 12 \ No newline at end of file diff --git a/backend/tests/integration/test_timescale_integration.py b/backend/tests/integration/test_timescale_integration.py new file mode 100644 index 0000000..4a4e374 --- /dev/null +++ b/backend/tests/integration/test_timescale_integration.py @@ -0,0 +1,107 @@ +"""Real TimescaleDB migration tests (run when a live TimescaleDB is reachable). + +Skipped automatically when DATABASE_URL points at a plain Postgres/PostGIS +container (e.g. the geo integration job), because the migration is designed to +fail closed there. The dedicated CI job boots timescale/timescaledb so the +hypertable + continuous aggregate paths are exercised against the real engine. +""" + +import os + +import pytest +from sqlalchemy import text + +from src.database import engine, Base +from src.models import Reading, Sensor, Zone +from src.timescale import apply_timescale + +pytestmark = pytest.mark.integration + + +def _seed_zone_and_sensor(session): + from geoalchemy2.elements import WKTElement + + zone = session.query(Zone).filter(Zone.city == "Unknown Region").first() + if zone is None: + zone = Zone(city="Unknown Region") + session.add(zone) + session.flush() + sensor = Sensor( + active=True, + zone_id=zone.id, + latitude=45.46, + longitude=9.19, + location=WKTElement("POINT(9.19 45.46)", srid=4326), + public_key_hex="a" * 64, + mac_address="00:00:00:00:00:01", + ) + session.add(sensor) + session.commit() + + +@pytest.fixture(scope="module") +def tsdb_session(): + from src.database import SessionLocal + try: + with engine.connect() as c: + c.execute(text("SELECT 1")) + except Exception: + pytest.skip("Database not reachable; skipping TimescaleDB integration tests") + + with engine.begin() as c: + c.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + # The shared integration conftest mocks Base.metadata.create_all (it is + # designed for mocked-DB API tests), so run the real DDL through a clean + # MetaData carrying the ORM tables. + from sqlalchemy import MetaData + + md = MetaData() + for table in Base.metadata.tables.values(): + table.to_metadata(md) + md.create_all(bind=engine) + with SessionLocal() as session: + if session.execute(text("SELECT count(*) FROM sensors")).scalar() == 0: + _seed_zone_and_sensor(session) + yield session + engine.dispose() + + +class TestHypertableMigration: + def test_migration_creates_hypertable(self, tsdb_session): + report = apply_timescale(tsdb_session) + assert report["hypertable"] is True + + def test_readings_is_listed_as_hypertable(self, tsdb_session): + row = tsdb_session.execute( + text( + "SELECT (EXISTS (SELECT 1 FROM timescaledb_information.hypertables " + "WHERE hypertable_name = 'readings'))" + ) + ).scalar() + assert row is True + + def test_continuous_aggregate_view_exists(self, tsdb_session): + assert tsdb_session.execute( + text("SELECT to_regclass('public.readings_minute') IS NOT NULL") + ).scalar() is True + + def test_orm_insert_into_hypertable(self, tsdb_session): + from src.database import SessionLocal + + with SessionLocal() as session: + sensor_id = session.execute(text("SELECT min(id) FROM sensors")).scalar() + if sensor_id is None: + pytest.skip("No sensors seeded; can't insert a reading") + reading = Reading(value=150, sensor_id=sensor_id, latitude=45.46, longitude=9.19) + session.add(reading) + session.commit() + persisted = session.execute( + text("SELECT count(*) FROM readings WHERE sensor_id = :sid"), + {"sid": sensor_id}, + ).scalar() + assert persisted >= 1 + + def test_migration_is_idempotent(self, tsdb_session): + first = apply_timescale(tsdb_session) + second = apply_timescale(tsdb_session) + assert first == second diff --git a/backend/tests/integration/test_zone_readings.py b/backend/tests/integration/test_zone_readings.py new file mode 100644 index 0000000..1cce76a --- /dev/null +++ b/backend/tests/integration/test_zone_readings.py @@ -0,0 +1,211 @@ +"""Integration tests for the per-zone readings endpoint (PostGIS zones). + +These verify the zone-scoped seismograph feed: readings are attributed to a +zone through their sensor's ``zone_id`` and sensors in other zones (or with no +zone) never leak into a zone's window. + +They skip cleanly when no database is reachable. +""" + +import pytest +from sqlalchemy import MetaData, text + +from src.database import Base, engine, SessionLocal +from src.main import delete_zone_readings, get_zone_alerts, get_zone_readings +from src.models import Alert, Reading, Sensor, Zone +from geoalchemy2.elements import WKTElement + + +def _ensure_schema() -> None: + with engine.begin() as c: + c.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + # The shared integration conftest mocks Base.metadata.create_all (it is + # designed for mocked-DB API tests), so run the real DDL through a clean + # MetaData carrying the ORM tables. + md = MetaData() + for table in Base.metadata.tables.values(): + table.to_metadata(md) + md.create_all(bind=engine) + + +@pytest.fixture(scope="module") +def zone_db(): + try: + with engine.connect() as c: + c.execute(text("SELECT 1")) + except Exception: + pytest.skip("Database not reachable; skipping zone readings integration tests") + + _ensure_schema() + with SessionLocal() as session: + yield session + engine.dispose() + + +@pytest.fixture(scope="module") +def seeded_zones_and_sensors(zone_db): + # Idempotent setup: wipe any leftover test data so repeated runs on a + # shared/live DB never accumulate readings across executions. + from src.models import EmergencyReport + + zone_db.query(EmergencyReport).delete() + zone_db.query(Alert).delete() + zone_db.query(Reading).delete() + zone_db.query(Sensor).delete() + zone_db.query(Zone).filter(Zone.city.in_(["Zone A - Test", "Zone B - Test"])).delete() + zone_db.commit() + + zone_a = Zone(city="Zone A - Test") + zone_b = Zone(city="Zone B - Test") + zone_db.add_all([zone_a, zone_b]) + zone_db.flush() + + sensor_in_a = Sensor( + active=True, + zone_id=zone_a.id, + latitude=45.0, + longitude=9.0, + location=WKTElement("POINT(9 45)", srid=4326), + public_key_hex="a" * 64, + mac_address="00:00:00:00:00:AA", + ) + sensor_in_b = Sensor( + active=True, + zone_id=zone_b.id, + latitude=45.1, + longitude=9.1, + location=WKTElement("POINT(9.1 45.1)", srid=4326), + public_key_hex="b" * 64, + mac_address="00:00:00:00:00:BB", + ) + sensor_unassigned = Sensor( + active=True, + zone_id=zone_b.id, + latitude=46.0, + longitude=8.0, + location=WKTElement("POINT(8 46)", srid=4326), + public_key_hex="c" * 64, + mac_address="00:00:00:00:00:CC", + ) + zone_db.add_all([sensor_in_a, sensor_in_b, sensor_unassigned]) + zone_db.flush() + + from datetime import datetime, timedelta, timezone + + base = datetime.now(timezone.utc) + for i, value in enumerate([100, 200, 300]): + zone_db.add( + Reading( + value=value, + sensor_id=sensor_in_a.id, + recorded_at=base - timedelta(seconds=i), + ) + ) + zone_db.add( + Reading( + value=9999, + sensor_id=sensor_in_b.id, + recorded_at=base - timedelta(seconds=1), + ) + ) + zone_db.add( + Reading( + value=7777, + sensor_id=sensor_unassigned.id, + recorded_at=base - timedelta(seconds=2), + ) + ) + zone_db.commit() + + return {"a": zone_a.id, "b": zone_b.id} + + +class TestGetZoneReadings: + def test_returns_only_zone_sensors(self, zone_db, seeded_zones_and_sensors): + readings = get_zone_readings( + zone_id=seeded_zones_and_sensors["a"], limit=10, db=zone_db + ) + assert [r.value for r in readings] == [100, 200, 300] + assert all(r.sensor_id is not None for r in readings) + + def test_other_zone_data_is_excluded(self, zone_db, seeded_zones_and_sensors): + readings = get_zone_readings( + zone_id=seeded_zones_and_sensors["a"], limit=10, db=zone_db + ) + assert 9999 not in [r.value for r in readings] + assert 7777 not in [r.value for r in readings] + + def test_empty_zone_returns_empty_list(self, zone_db, seeded_zones_and_sensors): + empty = Zone(city="Zone Empty - Test") + zone_db.add(empty) + zone_db.commit() + readings = get_zone_readings(zone_id=empty.id, limit=10, db=zone_db) + assert readings == [] + zone_db.delete(empty) + zone_db.commit() + + def test_missing_zone_raises_404(self, zone_db): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as excinfo: + get_zone_readings(zone_id=999999, limit=10, db=zone_db) + assert excinfo.value.status_code == 404 + + +class TestDeleteZoneReadings: + def test_deletes_only_zone_readings(self, zone_db, seeded_zones_and_sensors): + result = delete_zone_readings(zone_id=seeded_zones_and_sensors["a"], db=zone_db) + assert result["deleted"] == 3 + + remaining = get_zone_readings(zone_id=seeded_zones_and_sensors["a"], limit=10, db=zone_db) + assert remaining == [] + + def test_other_zone_data_survives(self, zone_db, seeded_zones_and_sensors): + delete_zone_readings(zone_id=seeded_zones_and_sensors["a"], db=zone_db) + from src.models import Reading + + other = zone_db.query(Reading).filter(Reading.value.in_([9999, 7777])).count() + assert other == 2 + + def test_missing_zone_raises_404(self, zone_db): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as excinfo: + delete_zone_readings(zone_id=999999, db=zone_db) + assert excinfo.value.status_code == 404 + + +class TestGetZoneAlerts: + def test_returns_zone_alerts_desc(self, zone_db, seeded_zones_and_sensors): + from datetime import datetime, timedelta, timezone + + base = datetime.now(timezone.utc) + zone_db.add_all( + [ + Alert(zone_id=seeded_zones_and_sensors["a"], magnitude=4.0, created_at=base - timedelta(seconds=2)), + Alert(zone_id=seeded_zones_and_sensors["a"], magnitude=5.2, created_at=base), + ] + ) + zone_db.commit() + + alerts = get_zone_alerts(zone_id=seeded_zones_and_sensors["a"], limit=10, db=zone_db) + assert [a.magnitude for a in alerts] == [5.2, 4.0] + + def test_other_zone_alerts_excluded(self, zone_db, seeded_zones_and_sensors): + from datetime import datetime, timedelta, timezone + + base = datetime.now(timezone.utc) + zone_db.add( + Alert(zone_id=seeded_zones_and_sensors["b"], magnitude=6.0, created_at=base) + ) + zone_db.commit() + + alerts = get_zone_alerts(zone_id=seeded_zones_and_sensors["a"], limit=10, db=zone_db) + assert all(a.zone_id == seeded_zones_and_sensors["a"] for a in alerts) + + def test_missing_zone_raises_404(self, zone_db): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as excinfo: + get_zone_alerts(zone_id=999999, limit=10, db=zone_db) + assert excinfo.value.status_code == 404 diff --git a/backend/tests/unit/test_geo.py b/backend/tests/unit/test_geo.py new file mode 100644 index 0000000..fe64d4e --- /dev/null +++ b/backend/tests/unit/test_geo.py @@ -0,0 +1,113 @@ +import math +import pytest +from unittest.mock import MagicMock + +from src.geo import ( + _cells_in_bbox, + _cell_indices_to_geohash, + candidate_zone_ids, + geohash_bounds, + point_to_geohash, + zone_covering_geohashes, +) + + +class TestPointToGeohash: + def test_wikipedia_reference(self): + # Aalborg University, the canonical geohash example from Wikipedia. + assert point_to_geohash(57.64911, 10.40744, 11) == "u4pruydqqvj" + assert point_to_geohash(57.64911, 10.40744, 3) == "u4p" + + def test_known_low_precision(self): + # London (BBC) resolves to the "gcp" cells at precision 3. + assert point_to_geohash(51.5218, -0.1817, 3) == "gcp" + + def test_equator_origin(self): + assert point_to_geohash(0.0, 0.0, 3) == "s00" + + def test_consistent_precision_prefix(self): + full = point_to_geohash(45.4642, 9.1900, 6) + assert point_to_geohash(45.4642, 9.1900, 3) == full[:3] + + +class TestGeohashBounds: + @pytest.mark.parametrize( + "lat,lon,prec", + [(45.4642, 9.19, 4), (-33.8688, 151.2093, 5), (0.0, 0.0, 3), (40.7128, -74.006, 6)], + ) + def test_point_inside_own_cell(self, lat, lon, prec): + cell = point_to_geohash(lat, lon, prec) + lon_min, lat_min, lon_max, lat_max = geohash_bounds(cell) + assert lon_min <= lon <= lon_max + assert lat_min <= lat <= lat_max + + def test_cell_indices_reconstruct(self): + for lat, lon in [(45.4642, 9.19), (35.6762, 139.6503), (-1.2833, 36.8167)]: + for precision in (3, 4, 5): + cell = point_to_geohash(lat, lon, precision) + lon_bits = (precision * 5 + 1) // 2 + lat_bits = precision * 5 // 2 + lon_step = 360.0 / (1 << lon_bits) + lat_step = 180.0 / (1 << lat_bits) + lon_min, lat_min, _, _ = geohash_bounds(cell) + lon_idx = math.floor((lon_min + 180.0) / lon_step) + lat_idx = math.floor((lat_min + 90.0) / lat_step) + assert _cell_indices_to_geohash(lon_idx, lat_idx, precision) == cell + + +class TestCellsInBbox: + def test_small_bbox(self): + # ~0.75 deg around Paris -> a handful of prec-3 cells. + cells = _cells_in_bbox(48.5, 1.9, 49.3, 2.7, 3) + assert cells + for cell in cells: + assert len(cell) == 3 + + def test_known_cell_included(self): + cells = _cells_in_bbox(48.0, 1.0, 50.0, 3.0, 3) + assert point_to_geohash(48.8566, 2.3522, 3) in cells + + +class TestCandidateZoneIds: + def test_resolves_from_redis_set(self): + mock_redis = MagicMock() + mock_redis.smembers.return_value = {"7", "9"} + assert candidate_zone_ids(45.46, 9.19, redis_client=mock_redis) == {7, 9} + mock_redis.smembers.assert_called_once() + + def test_redis_failure_is_non_fatal(self): + mock_redis = MagicMock() + mock_redis.smembers.side_effect = Exception("Redis down") + assert candidate_zone_ids(45.46, 9.19, redis_client=mock_redis) == set() + + def test_uses_configured_precision(self): + mock_redis = MagicMock() + mock_redis.smembers.return_value = set() + candidate_zone_ids(45.46, 9.19, redis_client=mock_redis, precision=4) + cell = point_to_geohash(45.46, 9.19, 4) + mock_redis.smembers.assert_called_once_with(f"zoneindex:{cell}") + + +class TestZoneCoveringGeohashes: + def test_queries_db_and_returns_cell_keys(self): + zone = MagicMock() + zone.id = 1 + db = MagicMock() + + bbox_result = MagicMock() + bbox_result.first.return_value = (1.0, 48.0, 3.0, 49.0) + select_result = MagicMock() + select_result.fetchall.return_value = [("spd",), ("spe",)] + # _zone_bbox -> CREATE TEMP TABLE -> DELETE -> INSERT -> SELECT + db.execute.side_effect = [bbox_result, MagicMock(), MagicMock(), MagicMock(), select_result] + + cells = zone_covering_geohashes(db, zone, precision=3) + assert cells == ["spd", "spe"] + assert db.execute.call_count == 5 + + def test_empty_bbox_returns_empty(self): + zone = MagicMock() + zone.id = 99 + db = MagicMock() + db.execute.return_value.first.return_value = None + assert zone_covering_geohashes(db, zone) == [] \ No newline at end of file diff --git a/backend/tests/unit/test_ingest.py b/backend/tests/unit/test_ingest.py new file mode 100644 index 0000000..1f9bf20 --- /dev/null +++ b/backend/tests/unit/test_ingest.py @@ -0,0 +1,90 @@ +from unittest.mock import MagicMock + +from src import ingest + + +def _client(): + return MagicMock() + + +class TestEnqueueReading: + def test_xadd_with_maxlen_and_payload_field(self): + client = _client() + client.xadd.return_value = "42-0" + mid = ingest.enqueue_reading(client, '{"value": 150}') + assert mid == "42-0" + args, kwargs = client.xadd.call_args + assert args[0] == ingest.READINGS_STREAM + assert args[1] == {ingest.PAYLOAD_FIELD: '{"value": 150}'} + assert kwargs["maxlen"] == ingest.MAXLEN + assert kwargs["approximate"] is True + + +class TestReadBatch: + def test_returns_empty_on_timeout(self): + client = _client() + client.xreadgroup.return_value = None + assert ingest.read_batch(client, "worker-a") == [] + + def test_parses_entries_and_forwards_group(self): + client = _client() + client.xreadgroup.return_value = [ + ["readings:stream", [["1-0", {"payload": "{}"}], ["2-0", {"payload": "{}"}]]] + ] + batch = ingest.read_batch(client, "worker-a", count=2, block_ms=100) + assert batch == [("1-0", "{}"), ("2-0", "{}")] + args, kwargs = client.xreadgroup.call_args + assert args[0] == ingest.READINGS_GROUP + assert args[1] == "worker-a" + assert args[2] == {ingest.READINGS_STREAM: ">"} + assert kwargs["count"] == 2 + assert kwargs["block"] == 100 + + +class TestAck: + def test_ack_skips_empty_list(self): + client = _client() + ingest.ack(client, []) + client.xack.assert_not_called() + + def test_ack_forwards_ids(self): + client = _client() + ingest.ack(client, ["1-0", "2-0"]) + client.xack.assert_called_once_with( + ingest.READINGS_STREAM, ingest.READINGS_GROUP, "1-0", "2-0" + ) + + +class TestMoveToDlq: + def test_parks_message_and_acks_original(self): + client = _client() + ingest.move_to_dlq(client, "5-0", '{"x": 1}', reason="malformed_json") + args, _ = client.xadd.call_args + assert args[0] == ingest.READINGS_DLQ + assert args[1]["reason"] == "malformed_json" + assert args[1]["original_id"] == "5-0" + client.xack.assert_called_once_with( + ingest.READINGS_STREAM, ingest.READINGS_GROUP, "5-0" + ) + + +class TestEnsureGroup: + def test_ignores_busygroup(self): + client = _client() + client.xgroup_create.side_effect = Exception("BUSYGROUP Consumer Group name already exists") + ingest.ensure_group(client) # must not raise + client.xgroup_create.assert_called_once() + + def test_creates_with_mkstream(self): + client = _client() + ingest.ensure_group(client) + client.xgroup_create.assert_called_once_with( + ingest.READINGS_STREAM, ingest.READINGS_GROUP, id="0", mkstream=True + ) + + +class TestRecoverPending: + def test_counts_reclaimed_entries(self): + client = _client() + client.xautoclaim.return_value = ("9-9", [["1-0", {}], ["2-0", {}]], []) + assert ingest.recover_pending(client, "worker-a") == 2 diff --git a/backend/tests/unit/test_timescale.py b/backend/tests/unit/test_timescale.py new file mode 100644 index 0000000..e6e9990 --- /dev/null +++ b/backend/tests/unit/test_timescale.py @@ -0,0 +1,81 @@ +from unittest.mock import MagicMock + +from src import timescale + + +class FakeResult: + def __init__(self, scalar): + self._scalar = scalar + + def scalar(self): + return self._scalar + + +def _db_with_execute(fn): + db = MagicMock() + db.execute.side_effect = fn + return db + + +def _ok_execute(statement, *args, **kwargs): + """Success for everything except Timescale policy helpers (already-exists).""" + s = str(statement) + if "add_continuous_aggregate_policy" in s or "add_compression_policy" in s \ + or "add_retention_policy" in s: + raise Exception("policy already exists") + if "timescaledb_information.hypertables" in s: + return FakeResult(False) + return MagicMock() + + +class TestApplyTimescale: + def test_noop_when_extension_unavailable(self): + db = _db_with_execute(lambda *a, **k: (_ for _ in ()).throw(Exception("extension not found"))) + report = timescale.apply_timescale(db) + assert report["timescaledb"] is False + assert report["hypertable"] is False + assert report["aggregate"] is False + + def test_creates_hypertable_and_aggregate_when_missing(self): + db = _db_with_execute(_ok_execute) + report = timescale.apply_timescale(db) + assert report["timescaledb"] is True + assert report["hypertable"] is True + assert report["aggregate"] is True + calls = [str(c.args[0]) for c in db.execute.call_args_list] + assert any("create_hypertable" in c for c in calls) + assert any("readings_minute" in c for c in calls) + assert any("create extension if not exists timescaledb" in c.lower() for c in calls) + + def test_skips_hypertable_when_already_present(self): + def fake_execute(statement, *args, **kwargs): + s = str(statement) + if "timescaledb_information.hypertables" in s: + return FakeResult(True) + return MagicMock() + + db = _db_with_execute(fake_execute) + report = timescale.apply_timescale(db) + assert report["hypertable"] is True + calls = [str(c.args[0]) for c in db.execute.call_args_list] + assert not any("create_hypertable" in c for c in calls) + + def test_continues_after_hypertable_failure(self): + def fake_execute(statement, *args, **kwargs): + s = str(statement) + if "create_hypertable" in s: + raise Exception("TimescaleDB extension not loaded (shared_preload_libraries)") + return _ok_execute(statement, *args, **kwargs) + + db = _db_with_execute(fake_execute) + report = timescale.apply_timescale(db) + assert report["timescaledb"] is True + assert report["hypertable"] is False + assert report["aggregate"] is True + + def test_tolerates_already_existing_policies(self): + db = _db_with_execute(_ok_execute) + report = timescale.apply_timescale(db) + assert report["aggregate"] is True + assert report["retention"] is True + db.rollback.assert_called() diff --git a/backend/tests/unit/test_worker.py b/backend/tests/unit/test_worker.py index 85528ca..b92edab 100644 --- a/backend/tests/unit/test_worker.py +++ b/backend/tests/unit/test_worker.py @@ -72,6 +72,34 @@ def test_process_suppresses_duplicate_alert(self, mock_db_session, mock_redis): assert not mock_redis.publish.called assert not mock_redis.lpush.called + def test_process_fragments_cooldown_by_geohash(self, mock_db_session, mock_redis): + """GNSS-ready: a sensor fix fragments the cooldown key per-area.""" + _mock_zone(mock_db_session) + event = { + "value": 5500, + "sensor_id": 1, + "zone_id": 2, + "latitude": 45.4642, + "longitude": 9.19, + "sensor_geohash": "u0nd", + } + process_event(event, mock_db_session) + called_key = mock_redis.set.call_args[0][0] + assert called_key == "alert_cooldown:u0nd" + assert mock_redis.set.call_args[1]["nx"] is True + + def test_process_falls_back_to_zone_cooldown_without_fix(self, mock_db_session, mock_redis): + """Legacy path: coordinates-less sensors keep the per-zone key.""" + _mock_zone(mock_db_session) + event = { + "value": 5500, + "sensor_id": 1, + "zone_id": 7, + } + process_event(event, mock_db_session) + called_key = mock_redis.set.call_args[0][0] + assert called_key == "alert_cooldown:zone:7" + def test_process_propagates_db_error(self, mock_db_session, mock_redis): mock_db_session.commit.side_effect = Exception("DB Error") event = { @@ -116,37 +144,39 @@ def fake_cooldown_set(*args, **kwargs): class TestWorkerLoopResilience: - def test_loop_moves_db_error_to_dlq_and_continues(self, mock_redis): - """Ingestion resilience: an event that raises is pushed to the DLQ, loop keeps running.""" + def test_loop_moves_batch_error_to_dlq_and_continues(self, mock_redis): + """Ingestion resilience: a failed batch and a malformed entry go to the DLQ + stream (and are ACKed), the loop keeps running and later entries still drain.""" + mock_redis.xautoclaim.return_value = ("0-0", [], []) db = MagicMock() batch = [ - (b"seismic_events", json.dumps({"value": 150, "sensor_id": 1})), - (b"seismic_events", "{ not-valid-json"), # malformed -> consumer must survive - (b"seismic_events", json.dumps({"value": 160, "sensor_id": 1})), - None, # brpop timeout -> loop iterates again + [("readings:stream", [ + [b"1-0", {"payload": '{"value": 150, "sensor_id": 1}'}], + [b"2-0", {"payload": "{ not-valid-json"}], + [b"3-0", {"payload": '{"value": 160, "sensor_id": 1}'}], + ])], + [], # xreadgroup timeout -> loop iterates again KeyboardInterrupt, ] - mock_redis.brpop.side_effect = batch + mock_redis.xreadgroup.side_effect = batch processed = [] - def fake_process_event(event, session): - processed.append(event) - if event.get("sensor_id") == 1 and event.get("value") == 150: + def fake_process_batch(events, session): + processed.extend(events) + if any(e.get("sensor_id") == 1 and e.get("value") == 150 for e in events): raise RuntimeError("simulated DB failure") with patch("src.worker.SessionLocal", return_value=db): - with patch("src.worker.process_event", side_effect=fake_process_event): + with patch("src.worker.process_batch", side_effect=fake_process_batch): with patch("src.worker.time.sleep"): with pytest.raises(KeyboardInterrupt): run_worker() assert db.rollback.called - assert mock_redis.lpush.called - dlq_queue, dlq_payload = mock_redis.lpush.call_args[0] - assert dlq_queue == "seismic_events_dlq" - assert json.loads(dlq_payload)["value"] == 150 - assert mock_redis.brpop.call_count >= 4 - # Second + third valid/clean items still reached process_event + dlq_reasons = [c.args[1].get("reason") for c in mock_redis.xadd.call_args_list] + assert "malformed_json" in dlq_reasons + assert any(r.startswith("process_error") for r in dlq_reasons) + assert mock_redis.xack.called assert processed[-1]["value"] == 160 From 0da15959f5bf67fde6ab26bf8965a709508e868f Mon Sep 17 00:00:00 2001 From: GiZano Date: Fri, 14 Aug 2026 22:28:23 +0200 Subject: [PATCH 2/7] feat(frontend): per-zone seismograph, theme and audio alert - live per-zone seismograph feed (useZoneReadings) with horizontal zone strip - sliding window chart anchored to wall clock + linear MAG scale - GPS zone detection (expo-location -> /zones/locate) - MIC/RESEARCH theme toggle + civil-defense siren on critical alerts - Settings Explore section + v1.2.1 footer --- mobile/__tests__/quakeStore.test.ts | 8 +- mobile/__tests__/usePreferencesStore.test.ts | 12 + mobile/__tests__/useThemeStore.test.ts | 30 + mobile/api/hooks/useDashboard.ts | 47 +- mobile/app.json | 23 +- mobile/app/(tabs)/_layout.tsx | 27 +- mobile/app/(tabs)/index.tsx | 473 ++- mobile/app/(tabs)/map.tsx | 67 +- mobile/app/(tabs)/settings.tsx | 341 +- mobile/app/+html.tsx | 8 +- mobile/app/_layout.tsx | 2 + mobile/assets/sounds/alarm.wav | Bin 0 -> 352844 bytes mobile/audio/alarm.ts | 47 + mobile/components/AlertHistoryList.tsx | 54 +- mobile/components/ErrorBanner.tsx | 24 +- mobile/components/LoadingSkeleton.tsx | 18 +- mobile/context/WebSocketContext.tsx | 42 +- mobile/package-lock.json | 3908 ++++++++---------- mobile/package.json | 10 +- mobile/store/usePreferencesStore.ts | 9 + mobile/store/useThemeStore.ts | 15 + mobile/theme/index.ts | 108 + mobile/theme/mapStyle.ts | 118 + mobile/theme/useTheme.ts | 14 + mobile/theme/victory.ts | 83 + mobile/utils/magnitude.ts | 28 + 26 files changed, 3001 insertions(+), 2515 deletions(-) create mode 100644 mobile/__tests__/useThemeStore.test.ts create mode 100644 mobile/assets/sounds/alarm.wav create mode 100644 mobile/audio/alarm.ts create mode 100644 mobile/store/useThemeStore.ts create mode 100644 mobile/theme/index.ts create mode 100644 mobile/theme/mapStyle.ts create mode 100644 mobile/theme/useTheme.ts create mode 100644 mobile/theme/victory.ts create mode 100644 mobile/utils/magnitude.ts diff --git a/mobile/__tests__/quakeStore.test.ts b/mobile/__tests__/quakeStore.test.ts index c7eb091..fb5dac5 100644 --- a/mobile/__tests__/quakeStore.test.ts +++ b/mobile/__tests__/quakeStore.test.ts @@ -35,7 +35,7 @@ describe("useQuakeStore", () => { }); it("fetchSensors handles network error gracefully", async () => { - global.fetch = jest.fn().mockRejectedValue(new Error("Network error")); + globalThis.fetch = jest.fn().mockRejectedValue(new Error("Network error")); await useQuakeStore.getState().fetchSensors(); expect(useQuakeStore.getState().sensors).toEqual([]); }); @@ -44,7 +44,7 @@ describe("useQuakeStore", () => { const mockSensors = [ { id: 1, lat: 41.9, lon: 12.5, status: "Active" }, ]; - global.fetch = jest.fn().mockResolvedValue({ + globalThis.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockSensors), }); @@ -53,7 +53,7 @@ describe("useQuakeStore", () => { }); it("startMonitoring sets up polling interval", () => { - const spy = jest.spyOn(global, "setInterval"); + const spy = jest.spyOn(globalThis, "setInterval"); useQuakeStore.getState().startMonitoring(); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith(expect.any(Function), 2000); @@ -67,7 +67,7 @@ describe("useQuakeStore", () => { }); it("does not start multiple intervals", () => { - const spy = jest.spyOn(global, "setInterval"); + const spy = jest.spyOn(globalThis, "setInterval"); useQuakeStore.getState().startMonitoring(); useQuakeStore.getState().startMonitoring(); expect(spy).toHaveBeenCalledTimes(1); diff --git a/mobile/__tests__/usePreferencesStore.test.ts b/mobile/__tests__/usePreferencesStore.test.ts index 68c84f3..8cac4fa 100644 --- a/mobile/__tests__/usePreferencesStore.test.ts +++ b/mobile/__tests__/usePreferencesStore.test.ts @@ -29,4 +29,16 @@ describe("usePreferencesStore", () => { usePreferencesStore.getState().toggleNotifications(); expect(usePreferencesStore.getState().notificationsEnabled).toBe(true); }); + + it("starts with no home zone (ring for every alert)", () => { + expect(usePreferencesStore.getState().homeZoneId).toBeNull(); + }); + + it("sets and clears the home zone", () => { + usePreferencesStore.getState().setHomeZoneId(3); + expect(usePreferencesStore.getState().homeZoneId).toBe(3); + + usePreferencesStore.getState().setHomeZoneId(null); + expect(usePreferencesStore.getState().homeZoneId).toBeNull(); + }); }); diff --git a/mobile/__tests__/useThemeStore.test.ts b/mobile/__tests__/useThemeStore.test.ts new file mode 100644 index 0000000..bf47fd0 --- /dev/null +++ b/mobile/__tests__/useThemeStore.test.ts @@ -0,0 +1,30 @@ +import { useThemeStore } from "../store/useThemeStore"; +import { THEMES } from "../theme"; + +beforeEach(() => { + useThemeStore.setState({ themeMode: "dark" }); +}); + +describe("useThemeStore", () => { + it("starts in MIC (dark) mode", () => { + expect(useThemeStore.getState().themeMode).toBe("dark"); + }); + + it("toggles between dark and light", () => { + useThemeStore.getState().toggleTheme(); + expect(useThemeStore.getState().themeMode).toBe("light"); + + useThemeStore.getState().toggleTheme(); + expect(useThemeStore.getState().themeMode).toBe("dark"); + }); + + it("sets mode explicitly", () => { + useThemeStore.getState().setThemeMode("light"); + expect(useThemeStore.getState().themeMode).toBe("light"); + }); + + it("both palettes exist and differ", () => { + expect(THEMES.dark.bg).not.toBe(THEMES.light.bg); + expect(THEMES.dark.text).not.toBe(THEMES.light.text); + }); +}); \ No newline at end of file diff --git a/mobile/api/hooks/useDashboard.ts b/mobile/api/hooks/useDashboard.ts index 7ad2179..cba78bd 100644 --- a/mobile/api/hooks/useDashboard.ts +++ b/mobile/api/hooks/useDashboard.ts @@ -11,11 +11,50 @@ export const useSensors = () => { const { data } = await apiClient.get('/sensors/'); return data; }, - refetchInterval: 10000, + refetchInterval: 10000, enabled: !isOfflineMode, }); }; +/** PostGIS zones for the per-zone dashboard selector. */ +export const useZones = () => { + const isOfflineMode = usePreferencesStore((state) => state.isOfflineMode); + + return useQuery({ + queryKey: ['zones'], + queryFn: async () => { + const { data } = await apiClient.get('/zones/'); + return data; + }, + refetchInterval: 15000, + enabled: !isOfflineMode, + }); +}; + +/** + * Live per-zone seismograph feed: the latest N readings emitted by sensors + * belonging to one PostGIS zone. Polls every second so the sliding window + * scrolls smoothly. + */ +export const useZoneReadings = (zoneId: number | undefined, limit = 60) => { + const isOfflineMode = usePreferencesStore((state) => state.isOfflineMode); + + return useQuery({ + queryKey: ['zoneReadings', zoneId, limit], + queryFn: async () => { + const { data } = await apiClient.get(`/zones/${zoneId}/readings`, { + params: { limit }, + }); + // Newest-first from the API; the dashboard maintains its own sliding + // window in chronological order. + return data; + }, + refetchInterval: 1000, + enabled: !isOfflineMode && zoneId != null, + placeholderData: (prev) => prev, + }); +}; + export const useRecentReadings = () => { const isOfflineMode = usePreferencesStore((state) => state.isOfflineMode); @@ -23,9 +62,9 @@ export const useRecentReadings = () => { queryKey: ['recentReadings'], queryFn: async () => { const { data } = await apiClient.get('/readings/?limit=50'); - return data.reverse(); + return data.reverse(); }, - refetchInterval: 2000, + refetchInterval: 2000, enabled: !isOfflineMode, }); -}; \ No newline at end of file +}; diff --git a/mobile/app.json b/mobile/app.json index a386e5f..5cbe1bc 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -6,12 +6,12 @@ "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "frontendmobileapp", - "userInterfaceStyle": "automatic", + "userInterfaceStyle": "dark", "newArchEnabled": true, "splash": { "image": "./assets/images/splash-icon.png", "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#09090b" }, "ios": { "supportsTablet": true @@ -35,7 +35,24 @@ "plugins": [ "expo-router", "expo-notifications", - "expo-font" + "expo-font", + "expo-audio", + [ + "expo-location", + { + "locationWhenInUsePermission": "QuakeGuard uses your location to detect which monitored region you are in." + } + ], + [ + "expo-splash-screen", + { + "backgroundColor": "#09090b", + "image": "./assets/images/splash-icon.png", + "imageWidth": 200 + } + ], + "expo-web-browser", + "expo-asset" ], "experiments": { "typedRoutes": true diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 46d92c3..6cda05e 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,30 +1,33 @@ import { Tabs } from "expo-router"; import { Map, ShieldCheck, Settings } from "lucide-react-native"; import React from "react"; -// πŸ’‘ IMPORT THE INSETS HOOK -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useAppTheme } from "../../theme/useTheme"; +import { MONO } from "../../theme"; export default function TabLayout() { - // πŸ’‘ GET SYSTEM DIMENSIONS - const insets = useSafeAreaInsets(); + const insets = useSafeAreaInsets(); + const { colors } = useAppTheme(); return ( diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index f7007d6..40aec27 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,6 +1,6 @@ import { ShieldAlert, ShieldCheck, Wifi, WifiOff, Activity } from "lucide-react-native"; -import React, { useEffect, useRef, useState } from "react"; -import { StyleSheet, Text, View, ScrollView } from "react-native"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { StyleSheet, Text, View, ScrollView, Pressable } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Animated, { Easing, @@ -10,20 +10,32 @@ import Animated, { withSequence, withTiming, } from "react-native-reanimated"; -import { VictoryChart, VictoryLine, VictoryTheme } from "victory-native"; +import { VictoryChart, VictoryLine, VictoryAxis, VictoryLabel } from "victory-native"; import { useWebSocket } from "../../context/WebSocketContext"; -import { useSensors, useRecentReadings } from "../../api/hooks/useDashboard"; +import { useSensors, useZones, useZoneReadings } from "../../api/hooks/useDashboard"; import { LoadingSkeleton } from "../../components/LoadingSkeleton"; import { ErrorBanner } from "../../components/ErrorBanner"; import { AlertHistoryList } from "../../components/AlertHistoryList"; +import { useAppTheme } from "../../theme/useTheme"; +import { createQuakeGuardTheme } from "../../theme/victory"; +import { MONO } from "../../theme"; +import { estimateMagnitude, thresholdOf } from "../../utils/magnitude"; -function TopBar({ isConnected }: Readonly<{ isConnected: boolean }>) { +const WINDOW_MAX = 60; // samples kept in the sliding window +const WINDOW_SECONDS = 30; // time domain of the seismograph +const RIGHT_PAD = 8; // breathing room right of x=0 so live samples stay in-field + +type ThemeColors = ReturnType["colors"]; + +function TopBar({ isConnected, colors }: Readonly<{ isConnected: boolean; colors: ThemeColors }>) { + const styles = createStyles(colors); return ( - Network Status + NETWORK STATUS - {isConnected ? : } - + {isConnected ? : } + + {isConnected ? "LIVE" : "OFFLINE"} @@ -31,43 +43,46 @@ function TopBar({ isConnected }: Readonly<{ isConnected: boolean }>) { ); } -function HeroSection({ isAlertActive, animatedStyle, textColor }: Readonly<{ +function HeroSection({ isAlertActive, animatedStyle, colors }: Readonly<{ isAlertActive: boolean; animatedStyle: any; - textColor: string; + colors: ThemeColors; }>) { + const styles = createStyles(colors); return ( {isAlertActive ? ( - + ) : ( - + )} - - {isAlertActive ? "⚠️ SEISMIC ALERT ⚠️" : "SYSTEM SECURE"} + + {isAlertActive ? "⚠ SEISMIC ALERT ⚠" : "SYSTEM SECURE"} ); } -function AlertBanner({ lastAlert }: Readonly<{ lastAlert: any }>) { +function AlertBanner({ lastAlert, colors }: Readonly<{ lastAlert: any; colors: ThemeColors }>) { + const styles = createStyles(colors); return ( - Mag: {lastAlert.magnitude.toFixed(1)} + MAG {lastAlert.magnitude.toFixed(1)} {`"${lastAlert.message}"`} ); } -function AiReportCard({ lastReport }: Readonly<{ lastReport: any }>) { +function AiReportCard({ lastReport, colors }: Readonly<{ lastReport: any; colors: ThemeColors }>) { + const styles = createStyles(colors); if (!lastReport) return null; if (lastReport.status === "FAILED") { return ( - πŸ€– AI Report Unavailable + AI REPORT // UNAVAILABLE The AI report could not be generated. Verify with local authorities. ); @@ -75,12 +90,12 @@ function AiReportCard({ lastReport }: Readonly<{ lastReport: any }>) { return ( - πŸ€– AI Emergency Report + AI EMERGENCY REPORT {lastReport.summary} {lastReport.recommendations && lastReport.recommendations.length > 0 && ( - {[...new Set(lastReport.recommendations)].map((r: string) => ( - {`β€’ ${r}`} + {[...new Set(lastReport.recommendations)].map((r) => ( + {`> ${r}`} ))} )} @@ -88,79 +103,226 @@ function AiReportCard({ lastReport }: Readonly<{ lastReport: any }>) { ); } -function NetworkChart({ readings, isAlertActive }: Readonly<{ readings: any[]; isAlertActive: boolean }>) { - if (!readings || readings.length === 0) { - return Awaiting telemetry...; - } - +/** Horizontal strip of PostGIS zones β€” one seismograph per zone. */ +function ZoneSelector({ zones, selectedId, onSelect, colors }: Readonly<{ + zones: any[]; + selectedId: number | undefined; + onSelect: (id: number) => void; + colors: ThemeColors; +}>) { + const styles = createStyles(colors); return ( - - - + {zones.map((zone) => { + const active = zone.id === selectedId; + return ( + onSelect(zone.id)} + style={[styles.zoneChip, active && styles.zoneChipActive]} + > + + {zone.city.toUpperCase()} + + + ); + })} + ); } -function DashboardContent({ errorSensors, errorReadings, loadingSensors, loadingReadings, activeSensors, totalSensors, readings, isAlertActive }: Readonly<{ - errorSensors: boolean; - errorReadings: boolean; - loadingSensors: boolean; - loadingReadings: boolean; - activeSensors: number; - totalSensors: number; - readings: any[]; +/** Per-zone telemetry strip: nodes, current magnitude, signal state. */ +function ZoneSummaryStrip({ activeNodes, totalNodes, latestMagnitude, isAlertActive, colors }: Readonly<{ + activeNodes: number; + totalNodes: number; + latestMagnitude: number | null; isAlertActive: boolean; + colors: ThemeColors; }>) { - if (errorSensors || errorReadings) return ; - if (loadingSensors || loadingReadings) return ; + const styles = createStyles(colors); + const mag = latestMagnitude ?? 0; + const magColor = mag >= 4.5 ? colors.alert : mag >= 4.0 ? colors.caution : colors.live; return ( - <> - - - Active Nodes - {activeSensors} / {totalSensors} - - - Signal Status - - - Stable - + + + NODES + {activeNodes} / {totalNodes} + + + MAG + {mag.toFixed(2)} + + + SIGNAL + + + + {isAlertActive ? "ALERT" : "STABLE"} + + + ); +} - - Live Network Seismograph - - +type WindowPoint = { x: number; y: number; t: number }; // x = seconds relative to newest +type WindowEntry = { t: number; y: number }; // t = epoch ms (merge key) + +function NetworkChart({ points, isAlertActive, colors }: Readonly<{ + points: WindowPoint[]; + isAlertActive: boolean; + colors: ThemeColors; +}>) { + const styles = createStyles(colors); + const theme = useMemo(() => createQuakeGuardTheme(colors), [colors]); + const last = points[points.length - 1]; + const lineColor = last + ? thresholdOf(last.y) === "alert" + ? colors.alert + : thresholdOf(last.y) === "caution" + ? colors.caution + : colors.live + : colors.live; + + if (points.length === 0) { + return ( + AWAITING TELEMETRY // ZONE ... + ); + } + + // Right breathing room so the newest live sample (xβ‰ˆ0) never touches or + // spills past the plot edge β€” that read as "the graph starts out of field". + const magTicks = [3.5, 4.0, 4.5]; // MIN / MED / ALTO thresholds on a linear MAG axis + + // Plot in magnitude units (linear scale) instead of raw sensor values + // (log-compressed near the noise floor β€” that made the 3.0/3.5 ticks crowd). + const data = points.map(({ x, y, t }) => ({ x, y: estimateMagnitude(y), t })); - - + const CHART_WIDTH = 450; // victory-native default + const PAD_LEFT = 52; + const PAD_RIGHT = 12; + const PAD_BOTTOM = 46; + const chartHeight = isAlertActive ? 150 : 200; + + // TIME under the X axis, centered on the middle of the DATA domain (-15s, + // i.e. between the 20s and 10s ticks) rather than the physical chart center, + // which sits right-of-center because of the RIGHT_PAD breathing room. + const spanX = WINDOW_SECONDS + RIGHT_PAD; + const timeX = PAD_LEFT + ((WINDOW_SECONDS - 15) / spanX) * (CHART_WIDTH - PAD_LEFT - PAD_RIGHT); + const timeY = chartHeight - PAD_BOTTOM + 34; + + return ( + + + `${t.toFixed(1)}`} + label="MAG" + style={{ + axisLabel: { padding: 38, fill: colors.tick, fontFamily: MONO, fontSize: 10 }, + }} + /> + `${Math.abs(Math.round(t))}s`} + /> + + ); } export default function MonitorScreen() { const { isConnected, lastAlert, lastReport } = useWebSocket(); + const { colors } = useAppTheme(); const [isAlertActive, setIsAlertActive] = useState(false); + const [selectedZoneId, setSelectedZoneId] = useState(undefined); + const [window, setWindow] = useState([]); const pulse = useSharedValue(1); const alertTimerRef = useRef | null>(null); + const styles = createStyles(colors); const { data: sensors, isLoading: loadingSensors, isError: errorSensors } = useSensors(); - const { data: readings, isLoading: loadingReadings, isError: errorReadings } = useRecentReadings(); + const { data: zones, isLoading: loadingZones, isError: errorZones } = useZones(); + const { data: readings, isLoading: loadingReadings, isError: errorReadings } = useZoneReadings(selectedZoneId, WINDOW_MAX); const totalSensors = sensors?.length || 0; const activeSensors = sensors?.filter((s: any) => s.active).length || 0; + const selectedZone = zones?.find((z: any) => z.id === selectedZoneId); + + // Default to the first zone once the PostGIS list is available. + useEffect(() => { + if (selectedZoneId === undefined && zones && zones.length > 0) { + setSelectedZoneId(zones[0].id); + } + }, [zones, selectedZoneId]); + + // Reset the sliding window when switching zone. + useEffect(() => { + setWindow([]); + }, [selectedZoneId]); + + // Merge incoming readings into the sliding window (push newest, drop oldest). + // The time axis is anchored to the wall clock (Date.now), NOT to the newest + // sample: stale readings drift to the left and leave the window, while live + // ones enter at the right edge. Anchoring to the newest sample instead pins + // a finished run at the right edge forever ("lines going out of the field"). + useEffect(() => { + if (!readings || readings.length === 0) return; + setWindow((prev) => { + const now = Date.now(); + const merged = new Map(); + for (const p of prev) merged.set(p.t, { t: p.t, y: p.y }); + for (const r of readings) { + const t = Date.parse(r.recorded_at); + if (Number.isNaN(t)) continue; + merged.set(t, { t, y: r.value }); + } + const list = [...merged.values()].filter(({ t }) => (now - t) / 1000 <= WINDOW_SECONDS); + if (list.length === 0) return prev; + return list + .sort((a, b) => a.t - b.t) + .slice(-WINDOW_MAX) + .map(({ t, y }) => ({ + // Clamp to the domain so nothing can ever spill past the plot edges. + x: Math.max(-WINDOW_SECONDS, Math.min(RIGHT_PAD, (t - now) / 1000)), + y, + t, + })); + }); + }, [readings, selectedZoneId]); + + const latestMagnitude = useMemo(() => { + if (!readings || readings.length === 0) return null; + return estimateMagnitude(readings[0].value); + }, [readings]); useEffect(() => { if (lastAlert) { @@ -195,78 +357,137 @@ export default function MonitorScreen() { opacity: isAlertActive ? pulse.value : 1, })); - const backgroundColor = isAlertActive ? "#fef2f2" : "#f9fafb"; - const textColor = isAlertActive ? "#991b1b" : "#1f2937"; + const loading = loadingSensors || loadingZones || loadingReadings; + const errored = errorSensors || errorZones || errorReadings; return ( - + - + - + - {isAlertActive && lastAlert && } + {isAlertActive && lastAlert && } - + - + {errored ? ( + + ) : loading ? ( + + ) : ( + <> + + + + + SEISMOGRAPH // {selectedZone?.city?.toUpperCase() ?? "ZONE"} + + Z-ACCEL // RAW + + + + + + + + + )} + + ); } -const styles = StyleSheet.create({ - safeArea: { flex: 1 }, - scrollContent: { flexGrow: 1, padding: 20 }, - topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }, - headerTitle: { fontSize: 24, fontWeight: "bold", color: "#111827" }, - connectionBadge: { flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "#ffffff", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 }, - connectionText: { fontSize: 12, fontWeight: "800" }, - heroSection: { alignItems: 'center', marginVertical: 10 }, - iconContainer: { marginBottom: 10 }, - statusText: { fontSize: 26, fontWeight: "900", textAlign: "center" }, - dashboardCard: { - backgroundColor: "white", - borderRadius: 20, - padding: 20, - elevation: 3, - marginTop: 10 - }, - summaryRow: { - flexDirection: 'row', - justifyContent: 'space-between', - borderBottomWidth: 1, - borderBottomColor: "#f3f4f6", - paddingBottom: 15, - marginBottom: 10, - flexShrink: 0 - }, - summaryItem: { alignItems: 'flex-start' }, - summaryLabel: { fontSize: 12, color: "#6b7280", fontWeight: "600", textTransform: "uppercase" }, - summaryValue: { fontSize: 20, fontWeight: "bold", color: "#1f2937" }, - chartContainer: { marginVertical: 10 }, - chartTitle: { fontSize: 16, fontWeight: "700", color: "#374151" }, - alertDetails: { marginBottom: 10, padding: 10, backgroundColor: "#fee2e2", borderRadius: 12, alignItems: 'center' }, - alertValue: { fontSize: 18, fontWeight: "800", color: "#b91c1c" }, - alertMessage: { fontSize: 14, fontStyle: "italic", color: "#991b1b" }, - reportCard: { marginBottom: 10, padding: 12, backgroundColor: "#f5f3ff", borderRadius: 12 }, - reportCardFailed: { marginBottom: 10, padding: 12, backgroundColor: "#fef3c7", borderRadius: 12 }, - reportCardTitle: { fontSize: 14, fontWeight: "800", color: "#7c3aed", marginBottom: 6 }, - reportCardBody: { fontSize: 13, color: "#4c1d95", lineHeight: 18 }, - reportRecommendations: { marginTop: 8 }, - reportRecommendationItem: { fontSize: 12, color: "#6d28d9", lineHeight: 16 } -}); +const createStyles = (c: ThemeColors) => + StyleSheet.create({ + safeArea: { flex: 1 }, + scrollContent: { flexGrow: 1, padding: 20 }, + topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }, + headerTitle: { fontSize: 18, fontWeight: "700", color: c.text, letterSpacing: 1.5, fontFamily: MONO }, + connectionBadge: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: c.surface, + borderColor: c.border, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + }, + statusDot: { width: 8, height: 8, borderRadius: 4 }, + connectionText: { fontSize: 12, fontWeight: "800", fontFamily: MONO }, + heroSection: { alignItems: 'center', marginVertical: 10 }, + iconContainer: { marginBottom: 10 }, + statusText: { fontSize: 24, fontWeight: "900", textAlign: "center", letterSpacing: 2, fontFamily: MONO }, + dashboardCard: { + backgroundColor: c.surface, + borderColor: c.border, + borderWidth: 1, + borderRadius: 16, + padding: 20, + marginTop: 10, + }, + zoneStrip: { gap: 8, paddingVertical: 4, marginBottom: 16 }, + zoneChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: c.surfaceAlt, + borderColor: c.border, + borderWidth: 1, + }, + zoneChipActive: { + borderColor: c.live, + backgroundColor: c.bg, + }, + zoneChipText: { fontSize: 11, fontWeight: "700", color: c.textSecondary, letterSpacing: 1, fontFamily: MONO }, + zoneChipTextActive: { color: c.live }, + chartHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10, gap: 8 }, + chartTitle: { fontSize: 13, fontWeight: "700", color: c.text, letterSpacing: 1.2, fontFamily: MONO, flexShrink: 1 }, + chartSubtitle: { fontSize: 10, color: c.textMuted, letterSpacing: 1, fontFamily: MONO, flexShrink: 0 }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + borderTopWidth: 1, + borderTopColor: c.border, + borderBottomWidth: 1, + borderBottomColor: c.border, + paddingVertical: 10, + marginBottom: 10, + }, + summaryItem: { alignItems: 'flex-start', flex: 1 }, + summaryLabel: { fontSize: 10, color: c.textMuted, fontWeight: "700", letterSpacing: 1.2, fontFamily: MONO }, + summaryValue: { fontSize: 16, fontWeight: "700", color: c.text, fontFamily: MONO, marginTop: 2 }, + summaryValueSmall: { fontSize: 14, fontWeight: "700", fontFamily: MONO }, + signalRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 }, + chartContainer: { marginVertical: 4 }, + chartEmpty: { textAlign: 'center', color: c.textMuted, fontFamily: MONO, fontSize: 12, paddingVertical: 60, letterSpacing: 1 }, + alertDetails: { marginBottom: 10, padding: 12, backgroundColor: c.bg, borderColor: c.alert, borderWidth: 1, borderRadius: 12, alignItems: 'center' }, + alertValue: { fontSize: 18, fontWeight: "800", color: c.alert, fontFamily: MONO }, + alertMessage: { fontSize: 14, fontStyle: "italic", color: c.textSecondary }, + reportCard: { marginBottom: 10, padding: 12, backgroundColor: c.surfaceAlt, borderColor: c.border, borderWidth: 1, borderRadius: 12 }, + reportCardFailed: { marginBottom: 10, padding: 12, backgroundColor: c.surfaceAlt, borderColor: c.caution, borderWidth: 1, borderRadius: 12 }, + reportCardTitle: { fontSize: 12, fontWeight: "800", color: c.info, marginBottom: 6, letterSpacing: 1.2, fontFamily: MONO }, + reportCardBody: { fontSize: 13, color: c.textSecondary, lineHeight: 18 }, + reportRecommendations: { marginTop: 8 }, + reportRecommendationItem: { fontSize: 12, color: c.text, lineHeight: 16, fontFamily: MONO }, + }); \ No newline at end of file diff --git a/mobile/app/(tabs)/map.tsx b/mobile/app/(tabs)/map.tsx index a9e51b2..feb05a8 100644 --- a/mobile/app/(tabs)/map.tsx +++ b/mobile/app/(tabs)/map.tsx @@ -1,41 +1,48 @@ import { Radio } from "lucide-react-native"; import React from "react"; -import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; +import { ActivityIndicator, Platform, StyleSheet, Text, View } from "react-native"; import MapView, { Callout, Marker, PROVIDER_DEFAULT } from "react-native-maps"; import { useSensors } from "../../api/hooks/useDashboard"; import { useSensorStatistics } from "../../api/hooks/useSensors"; import { LoadingSkeleton } from "../../components/LoadingSkeleton"; import { ErrorBanner } from "../../components/ErrorBanner"; +import { useAppTheme } from "../../theme/useTheme"; +import { MONO } from "../../theme"; +import { darkMapStyle, lightMapStyle } from "../../theme/mapStyle"; -const CalloutStats = ({ stats }: { stats: any }) => { +type ThemeColors = ReturnType["colors"]; + +const CalloutStats = ({ stats, colors }: { stats: any; colors: ThemeColors }) => { + const styles = createStyles(colors); return ( - Total Readings: + TOTAL READINGS: {stats?.total_readings || 0} ); }; -const SensorCalloutDetails = ({ sensor }: { sensor: any }) => { +const SensorCalloutDetails = ({ sensor, colors }: { sensor: any; colors: ThemeColors }) => { const { data: stats, isLoading, isError } = useSensorStatistics(sensor.id); + const styles = createStyles(colors); let content: React.JSX.Element; if (isLoading) { - content = ; + content = ; } else if (isError) { - content = Data unavailable; + content = DATA UNAVAILABLE; } else { - content = ; + content = ; } return ( - Sensor ID: {sensor.id} - + SENSOR {sensor.id} + - - - {sensor.active ? "Active" : "Offline"} + + + {sensor.active ? "ACTIVE" : "OFFLINE"} @@ -48,6 +55,8 @@ const SensorCalloutDetails = ({ sensor }: { sensor: any }) => { export default function MapScreen() { const { data: sensors, isLoading, isError } = useSensors(); + const { colors, isDark } = useAppTheme(); + const styles = createStyles(colors); if (isLoading) { return ; @@ -55,9 +64,9 @@ export default function MapScreen() { if (isError) { return ( - ); } @@ -67,6 +76,7 @@ export default function MapScreen() { {/* Inject the lazy-loading details component */} - + ))} @@ -91,15 +101,16 @@ export default function MapScreen() { ); } -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#fff" }, - map: { width: "100%", height: "100%" }, - calloutContainer: { padding: 10, width: 160, backgroundColor: "white", borderRadius: 8 }, - calloutTitle: { fontWeight: "800", fontSize: 14, marginBottom: 6, color: "#111827" }, - statusRow: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 }, - statusText: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5 }, - statsDivider: { height: 1, backgroundColor: "#e5e7eb", marginVertical: 6 }, - statsRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 4 }, - statsLabel: { fontSize: 12, color: "#4b5563", fontWeight: "500" }, - statsValue: { fontSize: 14, fontWeight: "700", color: "#4f46e5" }, -}); \ No newline at end of file +const createStyles = (c: ThemeColors) => + StyleSheet.create({ + container: { flex: 1, backgroundColor: c.bg }, + map: { width: "100%", height: "100%" }, + calloutContainer: { padding: 10, width: 180, backgroundColor: c.surfaceAlt, borderRadius: 8, borderColor: c.borderStrong, borderWidth: 1 }, + calloutTitle: { fontWeight: "800", fontSize: 14, marginBottom: 6, color: c.text, fontFamily: MONO, letterSpacing: 1 }, + statusRow: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 }, + statusText: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, fontFamily: MONO }, + statsDivider: { height: 1, backgroundColor: c.border, marginVertical: 6 }, + statsRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 4 }, + statsLabel: { fontSize: 11, color: c.textSecondary, fontWeight: "500", fontFamily: MONO }, + statsValue: { fontSize: 14, fontWeight: "700", color: c.live, fontFamily: MONO }, + }); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index 0e40eae..c8ca621 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -1,103 +1,360 @@ -import React from "react"; -// πŸ’‘ IMPORT Alert and TouchableOpacity -import { View, Text, StyleSheet, Switch, Alert, TouchableOpacity } from "react-native"; +import React, { useState } from "react"; +import { View, Text, StyleSheet, Switch, Alert, TouchableOpacity, ActivityIndicator, ScrollView, Linking } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -// πŸ’‘ ADD Trash2 icon -import { Settings as SettingsIcon, Bell, WifiOff, Trash2 } from "lucide-react-native"; +import { + Settings as SettingsIcon, + Bell, + WifiOff, + Trash2, + Microscope, + RadioTower, + MapPin, + Crosshair, + Globe, + Github, + ExternalLink, +} from "lucide-react-native"; +import * as Location from "expo-location"; import { usePreferencesStore } from "../../store/usePreferencesStore"; -// πŸ’‘ IMPORT the store import { useAlertStore } from "../../store/useAlertStore"; +import { useThemeStore } from "../../store/useThemeStore"; +import { useAppTheme } from "../../theme/useTheme"; +import { useZones } from "../../api/hooks/useDashboard"; +import { apiClient } from "../../api/client"; +import { MONO } from "../../theme"; + +interface Zone { + id: number; + city: string; +} export default function SettingsScreen() { - const { isOfflineMode, notificationsEnabled, setOfflineMode, toggleNotifications } = usePreferencesStore(); - // πŸ’‘ Consume the orphaned action and the alerts array (to disable the button if empty) + const { + isOfflineMode, + notificationsEnabled, + homeZoneId, + setOfflineMode, + toggleNotifications, + setHomeZoneId, + } = usePreferencesStore(); const { clearAlerts, alerts } = useAlertStore(); + const { colors, mode } = useAppTheme(); + const { toggleTheme } = useThemeStore(); + const { data: zones } = useZones(); + const [detecting, setDetecting] = useState(false); + const styles = createStyles(colors); const handleClearHistory = () => { if (alerts.length === 0) return; - + Alert.alert( "Clear History", "Are you sure you want to delete all recent alerts?", [ { text: "Cancel", style: "cancel" }, - { - text: "Clear", - style: "destructive", + { + text: "Clear", + style: "destructive", onPress: () => { clearAlerts(); console.log("[Settings] Alert history cleared."); - } - } + }, + }, ] ); }; + const myZoneCity = + homeZoneId == null + ? "ALL REGIONS (ring for every alert)" + : (zones as Zone[] | undefined)?.find((z) => z.id === homeZoneId)?.city; + + const handleDetectZone = async () => { + if (isOfflineMode) { + Alert.alert("Offline", "Connect to the network to detect your zone."); + return; + } + + setDetecting(true); + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== Location.PermissionStatus.GRANTED) { + Alert.alert("Permission denied", "Location access is required to detect your zone. Pick it manually below."); + return; + } + + const position = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }); + + const { data } = await apiClient.get("/zones/locate", { + params: { latitude: position.coords.latitude, longitude: position.coords.longitude }, + }); + + setHomeZoneId(data.id); + Alert.alert("Zone updated", `Your area resolved to "${data.city}". Alerts will now ring only for this region.`); + } catch (err: any) { + if (err?.response?.status === 404) { + Alert.alert( + "Outside monitored area", + "Your position is not covered by a monitored polygon. Pick a zone manually below." + ); + } else { + Alert.alert("Detection failed", "Could not resolve your zone. Pick one manually below."); + } + } finally { + setDetecting(false); + } + }; + + const selectZone = (zoneId: number | null) => { + setHomeZoneId(zoneId); + const city = + zoneId == null + ? "ALL REGIONS" + : (zones as Zone[] | undefined)?.find((z) => z.id === zoneId)?.city ?? `Zone #${zoneId}`; + console.log(`[Settings] Home zone set: ${city}`); + }; + return ( - + - - Settings + + SYSTEM CONFIG + + + {/* Appearance */} + APPEARANCE + + + + {mode === "dark" ? : } + + + {mode === "dark" ? "MIC MODE (Military)" : "RESEARCH MODE"} + + + {mode === "dark" ? "Dark command console" : "Light scientific study"} + + + + + + {/* Alerts */} + GLOBAL - + Enable Notifications - {/* πŸ’‘ Removed styles.lastRow from here */} - + Force Offline Mode - {/* πŸ’‘ NEW ROW: Clear History */} - - - + + Clear Alert History + + + {/* My Zone */} + ZONE + + + + + + MY ZONE + ONLY RING FOR YOUR REGION + + + + + + + + Detect my zone via GPS + + + {detecting ? ( + + ) : ( + DETECT + )} + + + + + + + + Ring only for + + {myZoneCity} + + + + + + + selectZone(null)} + > + ALL + + {(zones as Zone[] | undefined)?.map((zone) => { + const active = homeZoneId === zone.id; + return ( + selectZone(zone.id)} + > + {zone.city} + + ); + })} + + + + {/* Explore */} + EXPLORE + + Linking.openURL("https://github.com/Gizano/QuakeGuard")} + > + + + + GitHub repo + Gizano/QuakeGuard + + + + + Linking.openURL("https://giovanni-zanotti.is-a.dev/Projects/quakeguard.html")} + > + + + + QuakeGuard website + Discover more about QuakeGuard! + + + + - + + QuakeGuard v1.2.1 + ); } -// ... [styles remain exactly the same] -const styles = StyleSheet.create({ - safeArea: { flex: 1, backgroundColor: "#f9fafb" }, - container: { flex: 1, padding: 20 }, - header: { flexDirection: "row", alignItems: "center", marginBottom: 30, marginTop: 10, gap: 10 }, - headerTitle: { fontSize: 28, fontWeight: "bold", color: "#1f2937" }, - card: { backgroundColor: "white", borderRadius: 16, padding: 16, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 8, elevation: 2 }, - settingRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingVertical: 16, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }, - lastRow: { borderBottomWidth: 0 }, - settingLabelContainer: { flexDirection: "row", alignItems: "center", gap: 12 }, - settingLabel: { fontSize: 16, fontWeight: "500", color: "#374151" }, -}); \ No newline at end of file +const createStyles = (c: ReturnType["colors"]) => + StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: c.bg }, + container: { padding: 20, paddingBottom: 100 }, + header: { flexDirection: "row", alignItems: "center", marginBottom: 30, marginTop: 10, gap: 10 }, + headerTitle: { fontSize: 22, fontWeight: "bold", color: c.text, letterSpacing: 1.5, fontFamily: MONO }, + card: { + backgroundColor: c.surface, + borderColor: c.border, + borderWidth: 1, + borderRadius: 16, + padding: 16, + }, + sectionTitle: { + fontSize: 11, + color: c.textMuted, + fontWeight: "700", + letterSpacing: 1.5, + fontFamily: MONO, + marginBottom: 8, + marginTop: 4, + }, + sectionTitleSpaced: { marginTop: 24 }, + settingRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + lastRow: { borderBottomWidth: 0 }, + settingLabelContainer: { flexDirection: "row", alignItems: "center", gap: 12, flex: 1 }, + settingLabel: { fontSize: 15, fontWeight: "500", color: c.text }, + settingHint: { fontSize: 11, color: c.textMuted, fontFamily: MONO, marginTop: 2, letterSpacing: 0.5 }, + detectButton: { + backgroundColor: c.live, + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 8, + minWidth: 74, + alignItems: "center", + }, + detectButtonText: { color: c.bg, fontSize: 12, fontWeight: "700", fontFamily: MONO, letterSpacing: 1 }, + chipWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8, paddingTop: 12 }, + chip: { + borderWidth: 1, + borderColor: c.borderStrong, + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 6, + }, + chipActive: { backgroundColor: c.live, borderColor: c.live }, + chipText: { color: c.textSecondary, fontSize: 12, fontFamily: MONO }, + chipTextActive: { color: c.bg, fontSize: 12, fontFamily: MONO, fontWeight: "700" }, + versionFooter: { + marginTop: 24, + textAlign: "center", + fontSize: 11, + color: c.textMuted, + fontFamily: MONO, + letterSpacing: 1, + }, + }); \ No newline at end of file diff --git a/mobile/app/+html.tsx b/mobile/app/+html.tsx index e94a5ed..358e21f 100644 --- a/mobile/app/+html.tsx +++ b/mobile/app/+html.tsx @@ -29,10 +29,8 @@ export default function Root({ children }: Readonly<{ children: React.ReactNode const responsiveBackground = ` body { - background-color: #fff; + background-color: #09090b; } -@media (prefers-color-scheme: dark) { - body { - background-color: #000; - } +html { + background-color: #09090b; }`; diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 54b5a58..40606c0 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -5,6 +5,7 @@ import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; import { WebSocketProvider } from "../context/WebSocketContext"; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { FONTS } from "../theme"; // 1. Import TanStack Query essentials import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -18,6 +19,7 @@ const queryClient = new QueryClient(); export default function RootLayout() { const [loaded, error] = useFonts({ ...FontAwesome.font, + [FONTS.mono]: require("../assets/fonts/SpaceMono-Regular.ttf"), }); useEffect(() => { diff --git a/mobile/assets/sounds/alarm.wav b/mobile/assets/sounds/alarm.wav new file mode 100644 index 0000000000000000000000000000000000000000..93dac04d4142d4b581594c4c17979a5b304b0666 GIT binary patch literal 352844 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