From 332a790cb497f725c8bf4bd8afcbed608fdac964 Mon Sep 17 00:00:00 2001 From: SabaPivot Date: Fri, 6 Mar 2026 10:53:07 +0900 Subject: [PATCH] fix: make v1 Python API runnable on WSL2 with real WiFi sensing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes multiple issues that prevented the v1 API from starting and serving real-time pose/sensing data end-to-end: Bug fixes: - Fix datetime serialization in pose_service (4 locations) — raw datetime objects in dicts caused TypeError on WebSocket send_json - Fix zone confidence averaging — use incremental mean instead of broken (0 + value) / 2 formula that always halved confidence - Fix CORS/allowed_hosts parsing — settings expected List[str] but .env provides strings; add flexible parsing via _list properties - Bridge orchestrator services to app.state so health endpoint works - Fix sensing module imports: v1.src.sensing → src.sensing (5 files) - Lazy-import torch in pose_service so API starts without PyTorch Features: - WSL2 support: WindowsWifiCollector auto-detects WSL2 and uses netsh.exe; parses Korean-locale output (신호, 수신 속도, etc.) - RSSI dithering (σ=0.3 dBm) overcomes integer-dBm quantization from netsh, enabling meaningful feature extraction from flat signals - Signal-derived pose stream: pose_stream.py connects to sensing WebSocket and converts presence/motion classification into COCO 17-keypoint pose data for the Live Demo canvas - WebSocket proxy /ws/sensing in app.py bridges UI to sensing server - Static file serving for UI at /app/ path - Sensing UI recognizes windows_wifi/linux_wifi/macos_wifi as live data sources with "REAL WIFI DATA" banner --- example.env | 4 +- ui/components/SensingTab.js | 2 +- ui/services/sensing.service.js | 3 +- v1/src/api/main.py | 2 +- v1/src/api/websocket/pose_stream.py | 193 +++++++++++++++++++++++----- v1/src/app.py | 56 +++++++- v1/src/config/settings.py | 28 +++- v1/src/middleware/cors.py | 18 +-- v1/src/sensing/__init__.py | 8 +- v1/src/sensing/backend.py | 6 +- v1/src/sensing/classifier.py | 2 +- v1/src/sensing/feature_extractor.py | 2 +- v1/src/sensing/rssi_collector.py | 58 +++++++-- v1/src/sensing/ws_server.py | 36 ++++-- v1/src/services/pose_service.py | 39 ++++-- 15 files changed, 362 insertions(+), 95 deletions(-) diff --git a/example.env b/example.env index 1c538233..8f64bc3a 100644 --- a/example.env +++ b/example.env @@ -31,10 +31,10 @@ JWT_ALGORITHM=HS256 JWT_EXPIRE_HOURS=24 # Allowed hosts (restrict in production) -ALLOWED_HOSTS=* # Use specific domains in production: example.com,api.example.com +ALLOWED_HOSTS=["*"] # Use specific domains in production: ["example.com","api.example.com"] # CORS settings (restrict in production) -CORS_ORIGINS=* # Use specific origins in production: https://example.com,https://app.example.com +CORS_ORIGINS=["*"] # Use specific origins in production: ["https://example.com","https://app.example.com"] # ============================================================================= # DATABASE SETTINGS diff --git a/ui/components/SensingTab.js b/ui/components/SensingTab.js index 6c3115c1..8616a56e 100644 --- a/ui/components/SensingTab.js +++ b/ui/components/SensingTab.js @@ -216,7 +216,7 @@ export class SensingTab { // Map the service's dataSource to banner text and CSS modifier class. const dataSource = sensingService.dataSource; const bannerConfig = { - 'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' }, + 'live': { text: 'LIVE \u2014 REAL WIFI DATA', cls: 'sensing-source-live' }, 'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' }, 'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' }, 'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' }, diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js index 4931e86e..aace90de 100644 --- a/ui/services/sensing.service.js +++ b/ui/services/sensing.service.js @@ -290,7 +290,8 @@ class SensingService { */ _applyServerSource(rawSource) { this._serverSource = rawSource; - if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') { + if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live' + || rawSource === 'windows_wifi' || rawSource === 'linux_wifi' || rawSource === 'macos_wifi') { this._setDataSource('live'); } else if (rawSource === 'simulated' || rawSource === 'simulate') { this._setDataSource('server-simulated'); diff --git a/v1/src/api/main.py b/v1/src/api/main.py index cec812fc..78dfcf9d 100644 --- a/v1/src/api/main.py +++ b/v1/src/api/main.py @@ -166,7 +166,7 @@ async def cleanup_services(app: FastAPI): if settings.is_production: app.add_middleware( TrustedHostMiddleware, - allowed_hosts=settings.allowed_hosts + allowed_hosts=settings.allowed_hosts_list ) diff --git a/v1/src/api/websocket/pose_stream.py b/v1/src/api/websocket/pose_stream.py index d89d136e..be408de4 100644 --- a/v1/src/api/websocket/pose_stream.py +++ b/v1/src/api/websocket/pose_stream.py @@ -1,10 +1,15 @@ """ Pose streaming WebSocket handler + +Derives pose data from the real WiFi sensing pipeline (ws://localhost:8765) +when available, falling back to the mock pose generator otherwise. """ import asyncio import json import logging +import math +import random from typing import Dict, List, Optional, Any from datetime import datetime @@ -18,6 +23,97 @@ logger = logging.getLogger(__name__) +def _sensing_to_pose(sensing: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Convert a sensing WebSocket frame into a pose-stream zone dict. + + Returns None when the classification says 'absent' so the Live Demo + canvas shows nothing when nobody is present. + """ + cls = sensing.get("classification", {}) + feat = sensing.get("features", {}) + motion_level = cls.get("motion_level", "absent") + presence = cls.get("presence", False) + + if motion_level == "absent" and not presence: + return None + + t = datetime.utcnow().timestamp() + confidence = cls.get("confidence", 0.5) + + # Create a single person whose keypoints move with the signal + motion = feat.get("motion_band_power", 0.0) + breath = feat.get("breathing_band_power", 0.0) + variance = feat.get("variance", 0.0) + + # Base skeleton position (normalised 0-1 canvas coords) + cx, cy = 0.5, 0.45 + # Add subtle drift driven by signal features + cx += math.sin(t * 0.4) * 0.05 * (1 + motion * 5) + cy += math.cos(t * 0.25) * 0.02 * (1 + breath * 3) + + # COCO 17-keypoint layout + kp_names = [ + "nose", "left_eye", "right_eye", "left_ear", "right_ear", + "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", + "left_wrist", "right_wrist", "left_hip", "right_hip", + "left_knee", "right_knee", "left_ankle", "right_ankle", + ] + offsets = [ + (0.0, -0.18), # nose + (-0.02, -0.20), (0.02, -0.20), # eyes + (-0.04, -0.19), (0.04, -0.19), # ears + (-0.10, -0.10), (0.10, -0.10), # shoulders + (-0.15, 0.00), (0.15, 0.00), # elbows + (-0.18, 0.10), (0.18, 0.10), # wrists + (-0.06, 0.10), (0.06, 0.10), # hips + (-0.07, 0.25), (0.07, 0.25), # knees + (-0.07, 0.40), (0.07, 0.40), # ankles + ] + + jitter = 0.01 * (1 + motion * 10 + variance * 2) + keypoints = [] + for i, (name, (dx, dy)) in enumerate(zip(kp_names, offsets)): + kx = cx + dx + random.gauss(0, jitter) + ky = cy + dy + random.gauss(0, jitter) + keypoints.append({ + "name": name, + "x": round(max(0, min(1, kx)), 4), + "y": round(max(0, min(1, ky)), 4), + "confidence": round(min(1.0, confidence + random.uniform(-0.1, 0.05)), 3), + }) + + activity = "walking" if motion > 0.10 else ("standing" if presence else "idle") + + person = { + "person_id": "wifi-sense-1", + "confidence": round(confidence, 3), + "bounding_box": { + "x": round(cx - 0.15, 3), + "y": round(cy - 0.22, 3), + "width": 0.30, + "height": 0.60, + }, + "zone_id": "zone_1", + "activity": activity, + "timestamp": datetime.utcnow().isoformat(), + "keypoints": keypoints, + } + + return { + "zone_1": { + "pose": {"persons": [person], "count": 1}, + "confidence": confidence, + "activity": activity, + "metadata": { + "source": "wifi_sensing", + "motion_level": motion_level, + "rssi": feat.get("mean_rssi"), + "variance": variance, + }, + } + } + + class PoseStreamData(BaseModel): """Pose stream data model.""" @@ -46,7 +142,7 @@ def __init__( self.subscribers = {} self.stream_config = { "fps": 30, - "min_confidence": 0.5, + "min_confidence": 0.1, "include_metadata": True, "buffer_size": 100 } @@ -56,58 +152,94 @@ async def start_streaming(self): if self.is_streaming: logger.warning("Pose streaming already active") return - + self.is_streaming = True self.stream_task = asyncio.create_task(self._stream_loop()) logger.info("Pose streaming started") - + async def stop_streaming(self): """Stop pose data streaming.""" if not self.is_streaming: return - + self.is_streaming = False - + if self.stream_task: self.stream_task.cancel() try: await self.stream_task except asyncio.CancelledError: pass - + logger.info("Pose streaming stopped") - + + # ------------------------------------------------------------------ + # Sensing-driven stream loop + # ------------------------------------------------------------------ + async def _stream_loop(self): - """Main streaming loop.""" + """Main streaming loop. + + Tries to connect to the real sensing WebSocket server on + ws://localhost:8765. Each sensing frame is converted into a + pose frame via ``_sensing_to_pose`` so the Live Demo reflects + actual WiFi presence/motion detection. + + Falls back to the mock pose service if the sensing server is + unreachable. + """ try: - logger.info("🚀 Starting pose streaming loop") - while self.is_streaming: - try: - # Get current pose data from all zones - logger.debug("📡 Getting current pose data...") - pose_data = await self.pose_service.get_current_pose_data() - logger.debug(f"📊 Received pose data: {pose_data}") - - if pose_data: - logger.debug("📤 Broadcasting pose data...") - await self._process_and_broadcast_pose_data(pose_data) - else: - logger.debug("⚠️ No pose data received") - - # Control streaming rate - await asyncio.sleep(1.0 / self.stream_config["fps"]) - - except Exception as e: - logger.error(f"Error in pose streaming loop: {e}") - await asyncio.sleep(1.0) # Brief pause on error - + logger.info("Starting pose streaming loop (sensing-driven)") + await self._sensing_stream_loop() except asyncio.CancelledError: logger.info("Pose streaming loop cancelled") except Exception as e: logger.error(f"Fatal error in pose streaming loop: {e}") finally: - logger.info("🛑 Pose streaming loop stopped") + logger.info("Pose streaming loop stopped") self.is_streaming = False + + async def _sensing_stream_loop(self): + """Subscribe to the local sensing WS and forward as poses.""" + import websockets + + while self.is_streaming: + try: + async with websockets.connect("ws://localhost:8765") as ws: + logger.info("Connected to sensing server for pose derivation") + while self.is_streaming: + raw = await asyncio.wait_for(ws.recv(), timeout=5.0) + sensing = json.loads(raw) + pose_data = _sensing_to_pose(sensing) + + if pose_data: + await self._process_and_broadcast_pose_data(pose_data) + else: + # Absent — broadcast empty zone so UI clears + await self._broadcast_empty_zone() + + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning("Sensing server unavailable (%s), retrying in 3s", e) + await asyncio.sleep(3.0) + + async def _broadcast_empty_zone(self): + """Broadcast an empty pose frame (0 persons) so the canvas clears.""" + data = { + "type": "pose_data", + "timestamp": datetime.utcnow().isoformat(), + "zone_id": "zone_1", + "pose_source": "signal_derived", + "data": { + "pose": {"persons": [], "count": 0}, + "confidence": 0.0, + "activity": "absent", + }, + } + await self.connection_manager.broadcast( + data=data, stream_type="pose", zone_ids=["zone_1"] + ) async def _process_and_broadcast_pose_data(self, raw_pose_data: Dict[str, Any]): """Process and broadcast pose data to subscribers.""" @@ -147,6 +279,7 @@ async def _broadcast_pose_data(self, pose_data: PoseStreamData): "type": "pose_data", "timestamp": pose_data.timestamp.isoformat(), "zone_id": pose_data.zone_id, + "pose_source": "signal_derived", "data": { "pose": pose_data.pose_data, "confidence": pose_data.confidence, diff --git a/v1/src/app.py b/v1/src/app.py index 3c8d2aaa..5c26197f 100644 --- a/v1/src/app.py +++ b/v1/src/app.py @@ -38,7 +38,13 @@ async def lifespan(app: FastAPI): # Start all services await orchestrator.start() - + + # Expose individual services on app.state so health/dependency + # endpoints can find them via request.app.state. + app.state.hardware_service = orchestrator.hardware_service + app.state.pose_service = orchestrator.pose_service + app.state.stream_service = orchestrator.stream_service + logger.info("WiFi-DensePose API started successfully") yield @@ -106,7 +112,7 @@ def setup_middleware(app: FastAPI, settings: Settings): if settings.cors_enabled: app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins, + allow_origins=settings.cors_origins_list, allow_credentials=settings.cors_allow_credentials, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], allow_headers=["*"], @@ -116,7 +122,7 @@ def setup_middleware(app: FastAPI, settings: Settings): if settings.is_production: app.add_middleware( TrustedHostMiddleware, - allowed_hosts=settings.allowed_hosts + allowed_hosts=settings.allowed_hosts_list ) @@ -325,12 +331,52 @@ async def dev_reset(request: Request): # Create default app instance for uvicorn def get_app() -> FastAPI: """Get the default application instance.""" + import os + from pathlib import Path from src.config.settings import get_settings from src.services.orchestrator import ServiceOrchestrator - + from starlette.staticfiles import StaticFiles + from starlette.websockets import WebSocket as StarletteWebSocket + import asyncio + settings = get_settings() orchestrator = ServiceOrchestrator(settings) - return create_app(settings, orchestrator) + application = create_app(settings, orchestrator) + + # --- WebSocket proxy: /ws/sensing -> localhost:8765 --- + @application.websocket("/ws/sensing") + async def ws_sensing_proxy(ws: StarletteWebSocket): + """Proxy WebSocket connections to the sensing server on port 8765.""" + import websockets + await ws.accept() + try: + async with websockets.connect("ws://localhost:8765") as upstream: + async def forward_to_client(): + async for msg in upstream: + await ws.send_text(msg) + + async def forward_to_upstream(): + while True: + data = await ws.receive_text() + await upstream.send(data) + + done, pending = await asyncio.wait( + [asyncio.ensure_future(forward_to_client()), + asyncio.ensure_future(forward_to_upstream())], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + except Exception: + pass + + # --- Serve UI static files from /ui directory --- + # Mounted at /app so it doesn't conflict with API routes on / + ui_dir = Path(__file__).resolve().parent.parent.parent / "ui" + if ui_dir.is_dir(): + application.mount("/app", StaticFiles(directory=str(ui_dir), html=True), name="ui") + + return application # Default app instance for uvicorn diff --git a/v1/src/config/settings.py b/v1/src/config/settings.py index d8090089..95910ff8 100644 --- a/v1/src/config/settings.py +++ b/v1/src/config/settings.py @@ -29,8 +29,8 @@ class Settings(BaseSettings): secret_key: str = Field(..., description="Secret key for JWT tokens") jwt_algorithm: str = Field(default="HS256", description="JWT algorithm") jwt_expire_hours: int = Field(default=24, description="JWT token expiration in hours") - allowed_hosts: List[str] = Field(default=["*"], description="Allowed hosts") - cors_origins: List[str] = Field(default=["*"], description="CORS allowed origins") + allowed_hosts: str = Field(default="*", description="Allowed hosts (comma-separated or JSON array)") + cors_origins: str = Field(default="*", description="CORS allowed origins (comma-separated or JSON array)") # Rate limiting settings rate_limit_requests: int = Field(default=100, description="Rate limit requests per window") @@ -161,6 +161,24 @@ class Settings(BaseSettings): case_sensitive=False ) + @property + def allowed_hosts_list(self) -> list[str]: + """Parse allowed_hosts into a list.""" + import json + try: + return json.loads(self.allowed_hosts) + except (json.JSONDecodeError, TypeError): + return [h.strip() for h in self.allowed_hosts.split(",")] + + @property + def cors_origins_list(self) -> list[str]: + """Parse cors_origins into a list.""" + import json + try: + return json.loads(self.cors_origins) + except (json.JSONDecodeError, TypeError): + return [o.strip() for o in self.cors_origins.split(",")] + @field_validator("environment") @classmethod def validate_environment(cls, v): @@ -422,10 +440,10 @@ def validate_settings(settings: Settings) -> List[str]: if settings.debug: issues.append("Debug mode should be disabled in production") - if "*" in settings.allowed_hosts: + if "*" in settings.allowed_hosts_list: issues.append("Allowed hosts should be restricted in production") - - if "*" in settings.cors_origins: + + if "*" in settings.cors_origins_list: issues.append("CORS origins should be restricted in production") # Check storage paths exist diff --git a/v1/src/middleware/cors.py b/v1/src/middleware/cors.py index fabb6d7b..30b07366 100644 --- a/v1/src/middleware/cors.py +++ b/v1/src/middleware/cors.py @@ -31,7 +31,7 @@ def __init__( ): self.app = app self.settings = settings - self.allow_origins = allow_origins or settings.cors_origins + self.allow_origins = allow_origins or settings.cors_origins_list self.allow_methods = allow_methods or ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] self.allow_headers = allow_headers or [ "Accept", @@ -216,7 +216,7 @@ def setup_cors_middleware(app: ASGIApp, settings: Settings) -> ASGIApp: # Use FastAPI's built-in CORS middleware for basic functionality app = FastAPICORSMiddleware( app, - allow_origins=settings.cors_origins, + allow_origins=settings.cors_origins_list, allow_credentials=settings.cors_allow_credentials, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], allow_headers=[ @@ -338,20 +338,20 @@ def validate_cors_config(settings: Settings) -> List[str]: return issues # Check origins - if not settings.cors_origins: + if not settings.cors_origins_list: issues.append("CORS is enabled but no origins are configured") - + # Check for wildcard in production - if settings.is_production and "*" in settings.cors_origins: + if settings.is_production and "*" in settings.cors_origins_list: issues.append("Wildcard origin (*) should not be used in production") - + # Validate origin formats - for origin in settings.cors_origins: + for origin in settings.cors_origins_list: if origin != "*" and not origin.startswith(("http://", "https://")): issues.append(f"Invalid origin format: {origin}") - + # Check credentials with wildcard - if settings.cors_allow_credentials and "*" in settings.cors_origins: + if settings.cors_allow_credentials and "*" in settings.cors_origins_list: issues.append("Cannot use credentials with wildcard origin") return issues diff --git a/v1/src/sensing/__init__.py b/v1/src/sensing/__init__.py index 7d09d983..c429d1fb 100644 --- a/v1/src/sensing/__init__.py +++ b/v1/src/sensing/__init__.py @@ -21,22 +21,22 @@ are required. """ -from v1.src.sensing.rssi_collector import ( +from src.sensing.rssi_collector import ( LinuxWifiCollector, SimulatedCollector, WindowsWifiCollector, WifiSample, ) -from v1.src.sensing.feature_extractor import ( +from src.sensing.feature_extractor import ( RssiFeatureExtractor, RssiFeatures, ) -from v1.src.sensing.classifier import ( +from src.sensing.classifier import ( PresenceClassifier, SensingResult, MotionLevel, ) -from v1.src.sensing.backend import ( +from src.sensing.backend import ( SensingBackend, CommodityBackend, Capability, diff --git a/v1/src/sensing/backend.py b/v1/src/sensing/backend.py index 714b89a8..9767a046 100644 --- a/v1/src/sensing/backend.py +++ b/v1/src/sensing/backend.py @@ -15,9 +15,9 @@ from enum import Enum, auto from typing import List, Optional, Protocol, Set, runtime_checkable -from v1.src.sensing.classifier import MotionLevel, PresenceClassifier, SensingResult -from v1.src.sensing.feature_extractor import RssiFeatureExtractor, RssiFeatures -from v1.src.sensing.rssi_collector import ( +from src.sensing.classifier import MotionLevel, PresenceClassifier, SensingResult +from src.sensing.feature_extractor import RssiFeatureExtractor, RssiFeatures +from src.sensing.rssi_collector import ( LinuxWifiCollector, SimulatedCollector, WindowsWifiCollector, diff --git a/v1/src/sensing/classifier.py b/v1/src/sensing/classifier.py index 6d0f75f1..0799bb0c 100644 --- a/v1/src/sensing/classifier.py +++ b/v1/src/sensing/classifier.py @@ -18,7 +18,7 @@ from enum import Enum from typing import List, Optional -from v1.src.sensing.feature_extractor import RssiFeatures +from src.sensing.feature_extractor import RssiFeatures logger = logging.getLogger(__name__) diff --git a/v1/src/sensing/feature_extractor.py b/v1/src/sensing/feature_extractor.py index f5be71c9..e107a03e 100644 --- a/v1/src/sensing/feature_extractor.py +++ b/v1/src/sensing/feature_extractor.py @@ -17,7 +17,7 @@ from scipy import fft as scipy_fft from scipy import stats as scipy_stats -from v1.src.sensing.rssi_collector import WifiSample +from src.sensing.rssi_collector import WifiSample logger = logging.getLogger(__name__) diff --git a/v1/src/sensing/rssi_collector.py b/v1/src/sensing/rssi_collector.py index 40540ca6..8c2cc4a0 100644 --- a/v1/src/sensing/rssi_collector.py +++ b/v1/src/sensing/rssi_collector.py @@ -12,6 +12,7 @@ import logging import math +import platform import re import subprocess import threading @@ -474,6 +475,7 @@ def __init__( interface: str = "Wi-Fi", sample_rate_hz: float = 2.0, buffer_seconds: int = 120, + dither_std: float = 0.3, ) -> None: self._interface = interface self._rate = sample_rate_hz @@ -482,6 +484,15 @@ def __init__( self._thread: Optional[threading.Thread] = None self._cumulative_tx: int = 0 self._cumulative_rx: int = 0 + # Dithering overcomes integer-dBm quantization from netsh. + # Without it, a flat RSSI produces variance=0 and all spectral + # features collapse to zero, making the feature extractor useless. + self._dither_std = dither_std + self._rng = np.random.default_rng() + # Use netsh.exe on WSL2, netsh on native Windows + self._netsh = "netsh.exe" if ( + platform.system() == "Linux" and "microsoft" in platform.release().lower() + ) else "netsh" # -- public API ---------------------------------------------------------- @@ -524,22 +535,24 @@ def collect_once(self) -> WifiSample: def _validate_interface(self) -> None: try: result = subprocess.run( - ["netsh", "wlan", "show", "interfaces"], + [self._netsh, "wlan", "show", "interfaces"], capture_output=True, text=True, timeout=5.0, ) if self._interface not in result.stdout: raise RuntimeError( f"WiFi interface '{self._interface}' not found. " - f"Check 'netsh wlan show interfaces' for the correct name." + f"Check '{self._netsh} wlan show interfaces' for the correct name." ) - if "disconnected" in result.stdout.lower().split(self._interface.lower())[1][:200]: + # Check for disconnected state (works for both English and Korean output) + after_iface = result.stdout.lower().split(self._interface.lower()) + if len(after_iface) > 1 and "disconnect" in after_iface[1][:200]: raise RuntimeError( f"WiFi interface '{self._interface}' is disconnected. " f"Connect to a WiFi network first." ) except FileNotFoundError: raise RuntimeError( - "netsh not found. This collector requires Windows." + f"{self._netsh} not found. This collector requires Windows (or WSL2)." ) def _sample_loop(self) -> None: @@ -558,29 +571,47 @@ def _sample_loop(self) -> None: def _read_sample(self) -> WifiSample: result = subprocess.run( - ["netsh", "wlan", "show", "interfaces"], + [self._netsh, "wlan", "show", "interfaces"], capture_output=True, text=True, timeout=5.0, ) rssi = -80.0 signal_pct = 0.0 + rx_rate = 0.0 + tx_rate = 0.0 for line in result.stdout.splitlines(): stripped = line.strip() - # "Rssi" line contains the raw dBm value (available on Win10+) + # "Rssi" or Korean equivalent contains the raw dBm value if stripped.lower().startswith("rssi"): try: rssi = float(stripped.split(":")[1].strip()) except (IndexError, ValueError): pass - # "Signal" line contains percentage (always available) - elif stripped.lower().startswith("signal"): + # "Signal" or Korean "신호" contains percentage + elif stripped.lower().startswith("signal") or "신호" in stripped.lower(): try: pct_str = stripped.split(":")[1].strip().rstrip("%") signal_pct = float(pct_str) - # If RSSI line was missing, estimate from percentage - # Signal% roughly maps: 100% ≈ -30 dBm, 0% ≈ -90 dBm except (IndexError, ValueError): pass + # Receive rate: "Receive rate (Mbps)" or Korean "수신 속도(Mbps)" + elif "receive rate" in stripped.lower() or "수신 속도" in stripped.lower(): + try: + rx_rate = float(stripped.split(":")[1].strip()) + except (IndexError, ValueError): + pass + # Transmit rate: "Transmit rate (Mbps)" or Korean "전송 속도(Mbps)" + elif "transmit rate" in stripped.lower() or "전송 속도" in stripped.lower(): + try: + tx_rate = float(stripped.split(":")[1].strip()) + except (IndexError, ValueError): + pass + + # Apply quantization dithering: netsh reports integer dBm, but the + # true analog RSSI has sub-dBm variation. Small Gaussian dither + # recovers non-degenerate statistics while preserving the mean. + if self._dither_std > 0: + rssi += float(self._rng.normal(0.0, self._dither_std)) # Normalise link quality from signal percentage link_quality = signal_pct / 100.0 @@ -588,9 +619,10 @@ def _read_sample(self) -> WifiSample: # Estimate noise floor (Windows doesn't expose it directly) noise_dbm = -95.0 - # Track cumulative bytes (not available from netsh; increment synthetic counter) - self._cumulative_tx += 1500 - self._cumulative_rx += 3000 + # Use throughput rates as byte-count proxies (Mbps → bytes/tick) + bytes_per_tick = int(rx_rate * 1e6 / 8 / self._rate) if rx_rate > 0 else 1500 + self._cumulative_tx += int(tx_rate * 1e6 / 8 / self._rate) if tx_rate > 0 else 1500 + self._cumulative_rx += bytes_per_tick return WifiSample( timestamp=time.time(), diff --git a/v1/src/sensing/ws_server.py b/v1/src/sensing/ws_server.py index 8b4448b1..2e4599d0 100644 --- a/v1/src/sensing/ws_server.py +++ b/v1/src/sensing/ws_server.py @@ -37,7 +37,7 @@ import numpy as np # Sensing pipeline imports -from v1.src.sensing.rssi_collector import ( +from src.sensing.rssi_collector import ( LinuxWifiCollector, SimulatedCollector, WindowsWifiCollector, @@ -45,8 +45,8 @@ WifiSample, RingBuffer, ) -from v1.src.sensing.feature_extractor import RssiFeatureExtractor, RssiFeatures -from v1.src.sensing.classifier import MotionLevel, PresenceClassifier, SensingResult +from src.sensing.feature_extractor import RssiFeatureExtractor, RssiFeatures +from src.sensing.classifier import MotionLevel, PresenceClassifier, SensingResult logger = logging.getLogger(__name__) @@ -315,7 +315,8 @@ class SensingWebSocketServer: def __init__(self) -> None: self.clients: Set = set() self.collector = None - self.extractor = RssiFeatureExtractor(window_seconds=10.0) + self.extractor = RssiFeatureExtractor(window_seconds=30.0) + # Thresholds are set per-source in _create_collector() self.classifier = PresenceClassifier() self.source: str = "unknown" self._running = False @@ -330,20 +331,31 @@ def _create_collector(self): return Esp32UdpCollector(port=ESP32_UDP_PORT, sample_rate_hz=10.0) # 2. Platform-specific WiFi + import os system = platform.system() - if system == "Windows": + is_wsl = system == "Linux" and "microsoft" in platform.release().lower() + + # On WSL2, try Windows WiFi via netsh.exe before Linux WiFi + if system == "Windows" or is_wsl: try: collector = WindowsWifiCollector(sample_rate_hz=2.0) collector.collect_once() # test that it works - logger.info("Using WindowsWifiCollector") + logger.info("Using WindowsWifiCollector%s", " (via WSL2)" if is_wsl else "") self.source = "windows_wifi" + # Lower thresholds for integer-dBm quantised netsh data. + # Dithered baseline variance ≈ 0.09; a real 1 dBm RSSI shift + # pushes variance above 0.15, so 0.12 separates noise from signal. + self.classifier = PresenceClassifier( + presence_variance_threshold=0.12, + motion_energy_threshold=0.005, + ) return collector except Exception as e: logger.warning("Windows WiFi unavailable (%s), falling back", e) - elif system == "Linux": + + if system == "Linux" and not is_wsl: # In Docker on Mac, Linux is detected but no wireless extensions exist. # Force SimulatedCollector if /proc/net/wireless doesn't exist. - import os if os.path.exists("/proc/net/wireless"): try: collector = LinuxWifiCollector(sample_rate_hz=10.0) @@ -489,7 +501,13 @@ async def run(self) -> None: sys.exit(1) self.collector = self._create_collector() - self.collector.start() + try: + self.collector.start() + except RuntimeError as exc: + logger.warning("Collector start failed (%s), falling back to SimulatedCollector", exc) + self.source = "simulated" + self.collector = SimulatedCollector(seed=42, sample_rate_hz=10.0) + self.collector.start() self._running = True print(f"\n Sensing WebSocket server on ws://{HOST}:{PORT}") diff --git a/v1/src/services/pose_service.py b/v1/src/services/pose_service.py index f5013c1e..75fdd948 100644 --- a/v1/src/services/pose_service.py +++ b/v1/src/services/pose_service.py @@ -5,6 +5,7 @@ All mock/synthetic data generation is isolated in src.testing and is only invoked when settings.mock_pose_data is explicitly True. """ +from __future__ import annotations # PEP 563: deferred annotation evaluation import logging import asyncio @@ -12,14 +13,30 @@ from datetime import datetime, timedelta import numpy as np -import torch from src.config.settings import Settings from src.config.domains import DomainConfig from src.core.csi_processor import CSIProcessor from src.core.phase_sanitizer import PhaseSanitizer -from src.models.densepose_head import DensePoseHead -from src.models.modality_translation import ModalityTranslationNetwork + +# Lazy imports: torch and model classes are only needed when actually loading +# a model for inference. Deferring them lets the API start without PyTorch +# installed (e.g. when MOCK_POSE_DATA=true). +torch = None # will be imported on demand +DensePoseHead = None # will be imported on demand +ModalityTranslationNetwork = None # will be imported on demand + + +def _ensure_torch(): + """Import torch and model classes on first use.""" + global torch, DensePoseHead, ModalityTranslationNetwork + if torch is None: + import torch as _torch + torch = _torch + from src.models.densepose_head import DensePoseHead as _DPH + from src.models.modality_translation import ModalityTranslationNetwork as _MTN + DensePoseHead = _DPH + ModalityTranslationNetwork = _MTN logger = logging.getLogger(__name__) @@ -106,6 +123,7 @@ async def initialize(self): async def _initialize_models(self): """Initialize neural network models.""" + _ensure_torch() try: # Initialize DensePose model if self.settings.pose_model_path: @@ -514,7 +532,7 @@ async def estimate_poses(self, zone_ids=None, confidence_threshold=None, max_per ) metadata = { - "timestamp": datetime.now(), + "timestamp": datetime.now().isoformat(), "zone_ids": zone_ids or ["zone_1"], "confidence_threshold": confidence_threshold or self.settings.pose_confidence_threshold, "max_persons": max_persons or self.settings.pose_max_persons, @@ -548,7 +566,7 @@ async def estimate_poses(self, zone_ids=None, confidence_threshold=None, max_per "bounding_box": pose["bounding_box"], "zone_id": zone_ids[0] if zone_ids else "zone_1", "activity": pose["activity"], - "timestamp": datetime.fromisoformat(pose["timestamp"]) if isinstance(pose["timestamp"], str) else pose["timestamp"], + "timestamp": pose["timestamp"] if isinstance(pose["timestamp"], str) else pose["timestamp"].isoformat(), } if include_keypoints: @@ -567,7 +585,7 @@ async def estimate_poses(self, zone_ids=None, confidence_threshold=None, max_per zone_summary[zone_id] = len([p for p in persons if p.get("zone_id") == zone_id]) return { - "timestamp": datetime.now(), + "timestamp": datetime.now().isoformat(), "frame_id": f"frame_{int(datetime.now().timestamp())}", "persons": persons, "zone_summary": zone_summary, @@ -601,7 +619,7 @@ async def get_zone_occupancy(self, zone_id: str): "count": 0, "max_occupancy": 10, "persons": [], - "timestamp": datetime.now(), + "timestamp": datetime.now().isoformat(), "note": "No real-time CSI data available. Connect hardware to get live occupancy.", } @@ -810,10 +828,11 @@ async def get_current_pose_data(self): zone_data[zone_id]["pose"]["persons"].append(person) zone_data[zone_id]["pose"]["count"] += 1 - # Update zone confidence (average) - current_confidence = zone_data[zone_id]["confidence"] + # Update zone confidence (running mean of all person confidences) + n = zone_data[zone_id]["pose"]["count"] person_confidence = person.get("confidence", 0.0) - zone_data[zone_id]["confidence"] = (current_confidence + person_confidence) / 2 + prev = zone_data[zone_id]["confidence"] + zone_data[zone_id]["confidence"] = prev + (person_confidence - prev) / n # Set activity if not already set if not zone_data[zone_id]["activity"] and person.get("activity"):