Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ui/components/SensingTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
3 changes: 2 additions & 1 deletion ui/services/sensing.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion v1/src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
193 changes: 163 additions & 30 deletions v1/src/api/websocket/pose_stream.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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."""

Expand Down Expand Up @@ -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
}
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 51 additions & 5 deletions v1/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<service>
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
Expand Down Expand Up @@ -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=["*"],
Expand All @@ -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
)


Expand Down Expand Up @@ -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
Expand Down
Loading