Implemented the CSI simulator CLI tool for testing Spaxel without
hardware. The simulator connects to a running mothership via WebSocket
and streams synthetic CSI binary frames.
Features:
- Virtual nodes positioned at corners, evenly distributed
- Walker random walk with Gaussian velocity updates (σ=0.3 m/s per axis per 50ms)
- Synthetic CSI generation using propagation model (path-loss + Fresnel zones)
- Binary frame format matching ingestion/frame.go (24-byte header + n_sub*2 payload)
- RSSI calculation: clamp(-30 - path_loss_dB, -90, -30)
- BLE advertisement simulation every 5s when --ble flag is set
- Reject detection: exits non-zero on {type:'reject'} from mothership
- Per-second stats: frame counts and blob count (from GET /api/blobs poll)
CLI Interface:
spaxel-sim --mothership ws://localhost:8080/ws/node --token <token> \
--nodes 4 --walkers 1 --rate 20 --duration 60s --ble \
--seed 42 --space '6x5x2.5'
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
862 lines
23 KiB
Go
862 lines
23 KiB
Go
// Command spaxel-sim is a CSI simulator CLI for testing Spaxel without hardware.
|
|
// It connects to a running mothership via WebSocket and streams synthetic CSI data.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
// CSI frame header size (24 bytes) — matches ingestion/frame.go
|
|
headerSize = 24
|
|
|
|
// Default values
|
|
defaultMothership = "ws://localhost:8080/ws/node"
|
|
defaultNodes = 4
|
|
defaultWalkers = 1
|
|
defaultRate = 20 // Hz
|
|
defaultDuration = 60 // seconds
|
|
defaultChannel = 6 // 2.4 GHz channel 6
|
|
defaultSeed = 42 // random seed
|
|
defaultSpace = "6x5x2.5" // room dimensions
|
|
|
|
// WiFi physical constants
|
|
wavelength = 0.123 // meters (2.4 GHz)
|
|
halfWavelength = wavelength / 2.0
|
|
nSub = 64 // number of subcarriers for HT20
|
|
|
|
// Path loss model constants
|
|
pl0 = 40.0 // dBm reference power at d0=1m
|
|
n = 2.0 // path loss exponent (free space)
|
|
)
|
|
|
|
var (
|
|
// CLI flags
|
|
flagMothership = flag.String("mothership", defaultMothership, "URL of the mothership WebSocket endpoint")
|
|
flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)")
|
|
flagNodes = flag.Int("nodes", defaultNodes, "Number of virtual nodes")
|
|
flagWalkers = flag.Int("walkers", defaultWalkers, "Number of synthetic walkers")
|
|
flagRate = flag.Int("rate", defaultRate, "CSI transmission rate in Hz per node pair")
|
|
flagDuration = flag.Int("duration", defaultDuration, "Total run time in seconds (0 = run forever)")
|
|
flagBLE = flag.Bool("ble", false, "Include simulated BLE advertisements")
|
|
flagSeed = flag.Int64("seed", defaultSeed, "Random seed for reproducible runs")
|
|
flagSpace = flag.String("space", defaultSpace, "Room dimensions in WxDxH format (meters)")
|
|
)
|
|
|
|
// VirtualNode represents a simulated ESP32 node
|
|
type VirtualNode struct {
|
|
ID int
|
|
MAC [6]byte
|
|
Position Point
|
|
Conn *websocket.Conn
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// Walker represents a simulated person
|
|
type Walker struct {
|
|
ID int
|
|
Position Point
|
|
Velocity Point
|
|
}
|
|
|
|
// Point represents a 3D position
|
|
type Point struct {
|
|
X, Y, Z float64
|
|
}
|
|
|
|
// Space represents the room dimensions
|
|
type Space struct {
|
|
Width, Depth, Height float64
|
|
}
|
|
|
|
// Stats tracks simulation statistics
|
|
type Stats struct {
|
|
FramesSent atomic.Int64
|
|
FramesPerSec float64
|
|
StartTime time.Time
|
|
LastStatsTime time.Time
|
|
LastFramesSent int64
|
|
BlobCount int
|
|
Rejected atomic.Bool // Set to true when any node is rejected
|
|
}
|
|
|
|
var stats Stats
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
|
log.Printf("[SIM] CSI Simulator starting")
|
|
|
|
// Parse space dimensions
|
|
space, err := parseSpace(*flagSpace)
|
|
if err != nil {
|
|
log.Fatalf("[SIM] Invalid space dimensions: %v", err)
|
|
}
|
|
|
|
// Initialize random seed
|
|
rng := rand.New(rand.NewSource(*flagSeed))
|
|
log.Printf("[SIM] Random seed: %d", *flagSeed)
|
|
|
|
// Generate or validate token
|
|
token := *flagToken
|
|
if token == "" {
|
|
// For testing, generate a dummy token
|
|
// In production, this should be derived from the install secret
|
|
token = fmt.Sprintf("%064x", rng.Uint64())
|
|
log.Printf("[SIM] Auto-generated token (first 16 chars): %s...", token[:16])
|
|
}
|
|
|
|
// Create virtual nodes at fixed positions (corners, evenly distributed)
|
|
nodes := createVirtualNodes(*flagNodes, space, rng)
|
|
|
|
// Create walkers with random walk behavior
|
|
walkers := createWalkers(*flagWalkers, space, rng)
|
|
|
|
log.Printf("[SIM] Configuration:")
|
|
log.Printf("[SIM] Mothership: %s", *flagMothership)
|
|
log.Printf("[SIM] Nodes: %d", *flagNodes)
|
|
log.Printf("[SIM] Walkers: %d", *flagWalkers)
|
|
log.Printf("[SIM] Rate: %d Hz", *flagRate)
|
|
log.Printf("[SIM] Duration: %d s", *flagDuration)
|
|
log.Printf("[SIM] Space: %.1fx%.1fx%.1f m", space.Width, space.Depth, space.Height)
|
|
log.Printf("[SIM] BLE: %v", *flagBLE)
|
|
|
|
// Create context for shutdown
|
|
ctx, cancel := contextWithCancel()
|
|
defer cancel()
|
|
|
|
// Channel for reject notifications
|
|
rejectChan := make(chan struct{}, len(nodes))
|
|
|
|
// Connect all nodes to mothership
|
|
if err := connectNodes(ctx, nodes, token, rng, rejectChan); err != nil {
|
|
log.Fatalf("[SIM] Failed to connect nodes: %v", err)
|
|
}
|
|
|
|
// Start blob count polling
|
|
go pollBlobCount()
|
|
|
|
// Start stats reporting
|
|
go reportStats()
|
|
|
|
// Start simulation (monitor for reject)
|
|
runSimulation(ctx, nodes, walkers, space, rng, rejectChan)
|
|
|
|
// Shutdown
|
|
log.Printf("[SIM] Shutting down...")
|
|
for _, node := range nodes {
|
|
node.mu.Lock()
|
|
if node.Conn != nil {
|
|
node.Conn.Close()
|
|
}
|
|
node.mu.Unlock()
|
|
}
|
|
|
|
// Print final statistics
|
|
printFinalStats()
|
|
|
|
// Exit non-zero if rejected
|
|
if stats.Rejected.Load() {
|
|
log.Printf("[SIM] Exiting due to rejection")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// parseSpace parses space dimensions from "WxDxH" format
|
|
func parseSpace(s string) (*Space, error) {
|
|
var w, d, h float64
|
|
_, err := fmt.Sscanf(s, "%fx%fx%f", &w, &d, &h)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid format (expected WxDxH): %w", err)
|
|
}
|
|
if w <= 0 || d <= 0 || h <= 0 {
|
|
return nil, fmt.Errorf("dimensions must be positive")
|
|
}
|
|
return &Space{Width: w, Depth: d, Height: h}, nil
|
|
}
|
|
|
|
// createVirtualNodes creates virtual nodes at corners, evenly distributed
|
|
func createVirtualNodes(count int, space *Space, rng *rand.Rand) []*VirtualNode {
|
|
nodes := make([]*VirtualNode, count)
|
|
|
|
// Position nodes at corners and midpoints
|
|
positions := generateNodePositions(count, space)
|
|
|
|
for i := 0; i < count; i++ {
|
|
mac := generateMAC(i)
|
|
nodes[i] = &VirtualNode{
|
|
ID: i,
|
|
MAC: mac,
|
|
Position: positions[i],
|
|
}
|
|
log.Printf("[SIM] Node %d: MAC=%s pos=(%.2f,%.2f,%.2f)",
|
|
i, macToString(mac), positions[i].X, positions[i].Y, positions[i].Z)
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
// generateNodePositions generates positions for nodes evenly distributed in the space
|
|
func generateNodePositions(count int, space *Space) []Point {
|
|
positions := make([]Point, count)
|
|
|
|
// For small counts, use corners
|
|
// For larger counts, distribute evenly
|
|
if count == 1 {
|
|
positions[0] = Point{X: space.Width / 2, Y: space.Depth / 2, Z: space.Height / 2}
|
|
} else if count == 2 {
|
|
positions[0] = Point{X: 0, Y: 0, Z: space.Height}
|
|
positions[1] = Point{X: space.Width, Y: space.Depth, Z: space.Height}
|
|
} else if count == 3 {
|
|
positions[0] = Point{X: 0, Y: 0, Z: space.Height}
|
|
positions[1] = Point{X: space.Width, Y: 0, Z: space.Height}
|
|
positions[2] = Point{X: space.Width / 2, Y: space.Depth, Z: 0}
|
|
} else if count == 4 {
|
|
positions[0] = Point{X: 0, Y: 0, Z: space.Height}
|
|
positions[1] = Point{X: space.Width, Y: 0, Z: space.Height}
|
|
positions[2] = Point{X: 0, Y: space.Depth, Z: space.Height}
|
|
positions[3] = Point{X: space.Width, Y: space.Depth, Z: space.Height}
|
|
} else {
|
|
// For more than 4 nodes, distribute in a grid pattern
|
|
gridSize := int(math.Ceil(math.Sqrt(float64(count))))
|
|
for i := 0; i < count; i++ {
|
|
row := i / gridSize
|
|
col := i % gridSize
|
|
positions[i] = Point{
|
|
X: float64(col) * space.Width / float64(gridSize-1),
|
|
Y: float64(row) * space.Depth / float64(gridSize-1),
|
|
Z: space.Height / 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
return positions
|
|
}
|
|
|
|
// generateMAC generates a MAC address for a virtual node
|
|
func generateMAC(id int) [6]byte {
|
|
var mac [6]byte
|
|
// Use a predictable OUI + node ID
|
|
mac[0] = 0x02 // Locally administered
|
|
mac[1] = 0x53 // Spaxel OUI (fictional)
|
|
mac[2] = 0xAC
|
|
mac[3] = byte((id >> 16) & 0xFF)
|
|
mac[4] = byte((id >> 8) & 0xFF)
|
|
mac[5] = byte(id & 0xFF)
|
|
return mac
|
|
}
|
|
|
|
// macToString converts a 6-byte MAC to colon-separated hex
|
|
func macToString(mac [6]byte) string {
|
|
return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X",
|
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
|
}
|
|
|
|
// createWalkers creates walkers with random walk behavior
|
|
func createWalkers(count int, space *Space, rng *rand.Rand) []*Walker {
|
|
walkers := make([]*Walker, count)
|
|
for i := 0; i < count; i++ {
|
|
walkers[i] = &Walker{
|
|
ID: i,
|
|
Position: Point{X: space.Width / 2, Y: space.Depth / 2, Z: 1.0}, // Start in center
|
|
Velocity: Point{X: 0, Y: 0, Z: 0},
|
|
}
|
|
log.Printf("[SIM] Walker %d: starting at (%.2f,%.2f,%.2f)",
|
|
i, walkers[i].Position.X, walkers[i].Position.Y, walkers[i].Position.Z)
|
|
}
|
|
return walkers
|
|
}
|
|
|
|
// contextWithCancel creates a context that can be cancelled
|
|
func contextWithCancel() (context.Context, context.CancelFunc) {
|
|
return context.WithCancel(context.Background())
|
|
}
|
|
|
|
// connectNodes connects all virtual nodes to the mothership via WebSocket
|
|
func connectNodes(ctx context.Context, nodes []*VirtualNode, token string, rng *rand.Rand, rejectChan chan<- struct{}) error {
|
|
var wg sync.WaitGroup
|
|
errChan := make(chan error, len(nodes))
|
|
|
|
for _, node := range nodes {
|
|
wg.Add(1)
|
|
go func(n *VirtualNode) {
|
|
defer wg.Done()
|
|
|
|
// Build WebSocket URL with token in header
|
|
u, err := url.Parse(*flagMothership)
|
|
if err != nil {
|
|
errChan <- fmt.Errorf("node %d: invalid URL: %w", n.ID, err)
|
|
return
|
|
}
|
|
|
|
// Create request with token header
|
|
reqHeader := http.Header{}
|
|
reqHeader.Set("X-Spaxel-Token", token)
|
|
|
|
// Connect to WebSocket
|
|
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, u.String(), reqHeader)
|
|
if err != nil {
|
|
if resp != nil {
|
|
// Check for reject response
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
log.Printf("[SIM] Node %d: REJECT response from mothership (status %d): %s", n.ID, resp.StatusCode, string(body))
|
|
stats.Rejected.Store(true)
|
|
select {
|
|
case rejectChan <- struct{}{}:
|
|
case <-ctx.Done():
|
|
}
|
|
errChan <- fmt.Errorf("node %d: rejected by mothership", n.ID)
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
errChan <- fmt.Errorf("node %d: connection failed: %w", n.ID, err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
n.mu.Lock()
|
|
n.Conn = conn
|
|
n.mu.Unlock()
|
|
|
|
log.Printf("[SIM] Node %d: connected to mothership", n.ID)
|
|
|
|
// Send hello message
|
|
hello := map[string]interface{}{
|
|
"type": "hello",
|
|
"mac": macToString(n.MAC),
|
|
"firmware_version": "sim-1.0.0",
|
|
"capabilities": []string{"csi", "ble", "tx", "rx"},
|
|
"chip": "ESP32-S3",
|
|
"flash_mb": 16,
|
|
"uptime_ms": 1000,
|
|
}
|
|
if err := conn.WriteJSON(hello); err != nil {
|
|
log.Printf("[SIM] Node %d: failed to send hello: %v", n.ID, err)
|
|
errChan <- err
|
|
return
|
|
}
|
|
|
|
// Listen for downstream messages (role assignment, config, reject)
|
|
go n.listenForDownstream(ctx, rejectChan)
|
|
}(node)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errChan)
|
|
|
|
// Check for errors
|
|
var errs []error
|
|
for err := range errChan {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("connection errors: %v", errs)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// listenForDownstream listens for downstream messages from the mothership
|
|
func (n *VirtualNode) listenForDownstream(ctx context.Context, rejectChan chan<- struct{}) {
|
|
n.mu.Lock()
|
|
conn := n.Conn
|
|
n.mu.Unlock()
|
|
|
|
defer func() {
|
|
n.mu.Lock()
|
|
if n.Conn == conn {
|
|
n.Conn = nil
|
|
}
|
|
n.mu.Unlock()
|
|
conn.Close()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
var msg json.RawMessage
|
|
if err := conn.ReadJSON(&msg); err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
log.Printf("[SIM] Node %d: read error: %v", n.ID, err)
|
|
return
|
|
}
|
|
|
|
// Parse message type
|
|
var typeMsg struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(msg, &typeMsg); err != nil {
|
|
log.Printf("[SIM] Node %d: invalid message: %s", n.ID, string(msg))
|
|
continue
|
|
}
|
|
|
|
switch typeMsg.Type {
|
|
case "reject":
|
|
log.Printf("[SIM] Node %d: REJECT message received: %s", n.ID, string(msg))
|
|
stats.Rejected.Store(true)
|
|
select {
|
|
case rejectChan <- struct{}{}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
case "role", "config":
|
|
log.Printf("[SIM] Node %d: received %s message", n.ID, typeMsg.Type)
|
|
case "ota", "reboot", "identify", "baseline_request":
|
|
log.Printf("[SIM] Node %d: received %s message (acknowledged)", n.ID, typeMsg.Type)
|
|
default:
|
|
log.Printf("[SIM] Node %d: received unknown message type: %s", n.ID, typeMsg.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// runSimulation runs the main simulation loop
|
|
func runSimulation(ctx context.Context, nodes []*VirtualNode, walkers []*Walker, space *Space, rng *rand.Rand, rejectChan <-chan struct{}) {
|
|
stats.StartTime = time.Now()
|
|
stats.LastStatsTime = stats.StartTime
|
|
|
|
ticker := time.NewTicker(time.Duration(1000/(*flagRate)) * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
bleTicker := time.NewTicker(5 * time.Second)
|
|
defer bleTicker.Stop()
|
|
|
|
durationTimer := time.NewTimer(time.Duration(*flagDuration) * time.Second)
|
|
if *flagDuration == 0 {
|
|
durationTimer.Stop()
|
|
}
|
|
|
|
frameNum := 0
|
|
walkerUpdateTicker := time.NewTicker(50 * time.Millisecond) // Update walkers every 50ms
|
|
defer walkerUpdateTicker.Stop()
|
|
|
|
// Handle interrupt signal
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-sigChan:
|
|
log.Printf("[SIM] Interrupted, shutting down...")
|
|
return
|
|
case <-durationTimer.C:
|
|
log.Printf("[SIM] Duration elapsed, shutting down...")
|
|
return
|
|
case <-rejectChan:
|
|
log.Printf("[SIM] Node rejected by mothership, exiting...")
|
|
stats.Rejected.Store(true)
|
|
return
|
|
case <-ticker.C:
|
|
// Send CSI frames for all TX->RX pairs
|
|
for _, tx := range nodes {
|
|
for _, rx := range nodes {
|
|
if tx.ID == rx.ID {
|
|
continue // Skip self-pairs
|
|
}
|
|
|
|
frame := generateCSIFrame(tx, rx, walkers, frameNum, rng)
|
|
|
|
tx.mu.Lock()
|
|
conn := tx.Conn
|
|
tx.mu.Unlock()
|
|
|
|
if conn != nil {
|
|
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
|
log.Printf("[SIM] Node %d: send error: %v", tx.ID, err)
|
|
continue
|
|
}
|
|
stats.FramesSent.Add(1)
|
|
}
|
|
}
|
|
}
|
|
frameNum++
|
|
case <-walkerUpdateTicker.C:
|
|
// Update walker positions (random walk)
|
|
updateWalkers(walkers, space, rng)
|
|
case <-bleTicker.C:
|
|
if *flagBLE {
|
|
sendBLEAdvertisements(nodes, rng)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// generateCSIFrame generates a synthetic CSI binary frame
|
|
func generateCSIFrame(tx, rx *VirtualNode, walkers []*Walker, frameNum int, rng *rand.Rand) []byte {
|
|
// Calculate combined CSI from all walkers
|
|
amplitude, phaseBase := computeCSIForWalkers(tx, rx, walkers)
|
|
|
|
// Compute RSSI from amplitude
|
|
rssi := amplitudeToRSSI(amplitude)
|
|
|
|
// Create frame buffer
|
|
frame := make([]byte, headerSize+nSub*2)
|
|
|
|
// Write header (matches ingestion/frame.go ParseFrame layout)
|
|
copy(frame[0:6], tx.MAC[:]) // node_mac
|
|
copy(frame[6:12], rx.MAC[:]) // peer_mac
|
|
binary.LittleEndian.PutUint64(frame[12:20], uint64(frameNum*50000)) // timestamp_us
|
|
frame[20] = byte(int8(rssi)) // rssi
|
|
frame[21] = byte(-95 & 0xFF) // noise_floor: -95 dBm
|
|
frame[22] = byte(defaultChannel) // channel
|
|
frame[23] = nSub // n_sub
|
|
|
|
// Generate I/Q pairs for each subcarrier
|
|
for k := 0; k < nSub; k++ {
|
|
// Phase for this subcarrier
|
|
phase := phaseBase + float64(k)*0.1
|
|
|
|
// Add temporal variation
|
|
phase += 0.1 * math.Sin(2*math.Pi*float64(frameNum)/100.0)
|
|
|
|
// Normalize phase to [-π, π]
|
|
for phase > math.Pi {
|
|
phase -= 2 * math.Pi
|
|
}
|
|
for phase < -math.Pi {
|
|
phase += 2 * math.Pi
|
|
}
|
|
|
|
// Add frequency-selective fading
|
|
freqFading := 0.8 + 0.4*math.Sin(2*math.Pi*float64(k)/16.0)
|
|
subAmplitude := amplitude * freqFading
|
|
|
|
// Add Gaussian noise
|
|
amplitudeNoisy := subAmplitude * (1 + randNorm(rng, 0, 0.05))
|
|
|
|
// Generate I/Q
|
|
i, q := generateIQPair(amplitudeNoisy, phase, rng)
|
|
|
|
// Write to payload (interleaved I,Q)
|
|
offset := headerSize + k*2
|
|
frame[offset] = byte(int8(i))
|
|
frame[offset+1] = byte(int8(q))
|
|
}
|
|
|
|
return frame
|
|
}
|
|
|
|
// computeCSIForWalkers computes the combined CSI amplitude and phase from all walkers
|
|
func computeCSIForWalkers(tx, rx *VirtualNode, walkers []*Walker) (float64, float64) {
|
|
if len(walkers) == 0 {
|
|
// No walkers, return baseline noise
|
|
return 0.001, 0.0
|
|
}
|
|
|
|
var totalAmplitude float64
|
|
var totalPhase float64
|
|
var weight float64
|
|
|
|
for _, walker := range walkers {
|
|
// Direct path contribution
|
|
directAmp, directPhase := computeDirectPath(tx.Position, rx.Position, walker.Position)
|
|
|
|
// Scale to reasonable values
|
|
combinedAmp := directAmp * 1000.0
|
|
|
|
// Accumulate
|
|
totalAmplitude += combinedAmp
|
|
totalPhase += directPhase
|
|
weight += 1.0
|
|
}
|
|
|
|
// Normalize phase
|
|
if weight > 0 {
|
|
totalPhase /= weight
|
|
}
|
|
|
|
return totalAmplitude, totalPhase
|
|
}
|
|
|
|
// computeDirectPath computes the CSI contribution from the direct path
|
|
func computeDirectPath(tx, rx, walker Point) (float64, float64) {
|
|
// Distance from TX to walker
|
|
d1 := distance(tx, walker)
|
|
// Distance from walker to RX
|
|
d2 := distance(walker, rx)
|
|
// Total path length
|
|
dTotal := d1 + d2
|
|
|
|
// Direct TX-RX distance (for Fresnel zone calculation)
|
|
dDirect := distance(tx, rx)
|
|
|
|
// Path length excess for Fresnel zone calculation
|
|
excess := dTotal - dDirect
|
|
if excess < 0 {
|
|
excess = 0
|
|
}
|
|
|
|
// Fresnel zone number
|
|
zoneNumber := int(math.Ceil(excess / halfWavelength))
|
|
if zoneNumber < 1 {
|
|
zoneNumber = 1
|
|
}
|
|
|
|
// Zone decay (inverse square)
|
|
decay := 1.0 / math.Pow(float64(zoneNumber), 2.0)
|
|
|
|
// Log-distance path loss model: PL(d) = PL_0 + 10*n*log10(d/d_0)
|
|
var pathLossDB float64
|
|
if dTotal >= 1.0 {
|
|
pathLossDB = pl0 + 10.0*n*math.Log10(dTotal/1.0)
|
|
} else {
|
|
pathLossDB = pl0
|
|
}
|
|
|
|
// Convert to linear amplitude
|
|
amplitude := math.Pow(10.0, -pathLossDB/20.0)
|
|
|
|
// Apply Fresnel zone decay
|
|
amplitude *= decay
|
|
|
|
// Phase at this position (based on total path length)
|
|
phase := 2 * math.Pi * dTotal / wavelength
|
|
|
|
return amplitude, phase
|
|
}
|
|
|
|
// distance computes Euclidean distance between two points
|
|
func distance(a, b Point) float64 {
|
|
dx := a.X - b.X
|
|
dy := a.Y - b.Y
|
|
dz := a.Z - b.Z
|
|
return math.Sqrt(dx*dx + dy*dy + dz*dz)
|
|
}
|
|
|
|
// amplitudeToRSSI converts amplitude to RSSI in dBm
|
|
func amplitudeToRSSI(amplitude float64) int8 {
|
|
// Convert amplitude to dBm (reference: amplitude 1.0 = -30 dBm)
|
|
amplitudeDBm := -30.0 + 20.0*math.Log10(amplitude)
|
|
|
|
// Clamp to realistic range
|
|
if amplitudeDBm < -90 {
|
|
amplitudeDBm = -90
|
|
}
|
|
if amplitudeDBm > -30 {
|
|
amplitudeDBm = -30
|
|
}
|
|
|
|
return int8(amplitudeDBm)
|
|
}
|
|
|
|
// generateIQPair generates a synthetic I/Q pair
|
|
func generateIQPair(amplitude, phase float64, rng *rand.Rand) (float64, float64) {
|
|
i := amplitude * math.Cos(phase)
|
|
q := amplitude * math.Sin(phase)
|
|
return i, q
|
|
}
|
|
|
|
// randNorm generates a normally-distributed random value (Box-Muller)
|
|
func randNorm(rng *rand.Rand, mean, stddev float64) float64 {
|
|
u1 := rng.Float64()
|
|
u2 := rng.Float64()
|
|
z0 := math.Sqrt(-2.0*math.Log(u1)) * math.Cos(2.0*math.Pi*u2)
|
|
return mean + stddev*z0
|
|
}
|
|
|
|
// updateWalkers updates walker positions using random walk behavior
|
|
func updateWalkers(walkers []*Walker, space *Space, rng *rand.Rand) {
|
|
const dt = 0.05 // 50ms in seconds
|
|
const sigma = 0.3 // m/s per axis
|
|
|
|
for _, walker := range walkers {
|
|
// Gaussian velocity update
|
|
dvx := randNorm(rng, 0, sigma)
|
|
dvy := randNorm(rng, 0, sigma)
|
|
dvz := randNorm(rng, 0, sigma)
|
|
|
|
walker.Velocity.X += dvx * dt
|
|
walker.Velocity.Y += dvy * dt
|
|
walker.Velocity.Z += dvz * dt
|
|
|
|
// Clamp velocity to reasonable range
|
|
maxV := 2.0 // m/s
|
|
vMag := math.Sqrt(walker.Velocity.X*walker.Velocity.X +
|
|
walker.Velocity.Y*walker.Velocity.Y +
|
|
walker.Velocity.Z*walker.Velocity.Z)
|
|
if vMag > maxV {
|
|
scale := maxV / vMag
|
|
walker.Velocity.X *= scale
|
|
walker.Velocity.Y *= scale
|
|
walker.Velocity.Z *= scale
|
|
}
|
|
|
|
// Update position
|
|
walker.Position.X += walker.Velocity.X * dt
|
|
walker.Position.Y += walker.Velocity.Y * dt
|
|
walker.Position.Z += walker.Velocity.Z * dt
|
|
|
|
// Reflect at walls
|
|
if walker.Position.X < 0 {
|
|
walker.Position.X = -walker.Position.X
|
|
walker.Velocity.X *= -1
|
|
}
|
|
if walker.Position.X > space.Width {
|
|
walker.Position.X = 2*space.Width - walker.Position.X
|
|
walker.Velocity.X *= -1
|
|
}
|
|
if walker.Position.Y < 0 {
|
|
walker.Position.Y = -walker.Position.Y
|
|
walker.Velocity.Y *= -1
|
|
}
|
|
if walker.Position.Y > space.Depth {
|
|
walker.Position.Y = 2*space.Depth - walker.Position.Y
|
|
walker.Velocity.Y *= -1
|
|
}
|
|
if walker.Position.Z < 0 {
|
|
walker.Position.Z = -walker.Position.Z
|
|
walker.Velocity.Z *= -1
|
|
}
|
|
if walker.Position.Z > space.Height {
|
|
walker.Position.Z = 2*space.Height - walker.Position.Z
|
|
walker.Velocity.Z *= -1
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendBLEAdvertisements sends simulated BLE advertisements from one node
|
|
func sendBLEAdvertisements(nodes []*VirtualNode, rng *rand.Rand) {
|
|
if len(nodes) == 0 {
|
|
return
|
|
}
|
|
|
|
// Send from first node
|
|
node := nodes[0]
|
|
|
|
node.mu.Lock()
|
|
conn := node.Conn
|
|
node.mu.Unlock()
|
|
|
|
if conn == nil {
|
|
return
|
|
}
|
|
|
|
// Generate simulated BLE device address
|
|
addr := fmt.Sprintf("AA:BB:CC:DD:%02X:%02X", rng.Intn(256), rng.Intn(256))
|
|
rssi := -60 + rng.Intn(20) // -60 to -40 dBm
|
|
|
|
ble := map[string]interface{}{
|
|
"type": "ble",
|
|
"mac": macToString(node.MAC),
|
|
"devices": []map[string]interface{}{
|
|
{
|
|
"addr": addr,
|
|
"addr_type": "random",
|
|
"rssi_dbm": rssi,
|
|
"name": "SimPerson",
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := conn.WriteJSON(ble); err != nil {
|
|
log.Printf("[SIM] Failed to send BLE advertisement: %v", err)
|
|
}
|
|
}
|
|
|
|
// pollBlobCount polls the mothership for blob count
|
|
func pollBlobCount() {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
// Build HTTP URL from WebSocket URL
|
|
wsURL, err := url.Parse(*flagMothership)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
httpURL := *wsURL
|
|
if httpURL.Scheme == "ws" {
|
|
httpURL.Scheme = "http"
|
|
} else if httpURL.Scheme == "wss" {
|
|
httpURL.Scheme = "https"
|
|
}
|
|
|
|
blobsURL := httpURL.String()
|
|
blobsURL = strings.TrimSuffix(blobsURL, "/ws")
|
|
blobsURL = strings.TrimSuffix(blobsURL, "/")
|
|
blobsURL += "/api/blobs"
|
|
|
|
resp, err := http.Get(blobsURL)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
var blobs []json.RawMessage
|
|
if err := json.NewDecoder(resp.Body).Decode(&blobs); err == nil {
|
|
stats.BlobCount = len(blobs)
|
|
}
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
// reportStats reports statistics every second
|
|
func reportStats() {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
now := time.Now()
|
|
elapsed := now.Sub(stats.LastStatsTime).Seconds()
|
|
if elapsed < 1 {
|
|
continue
|
|
}
|
|
|
|
framesSent := stats.FramesSent.Load()
|
|
framesInPeriod := framesSent - stats.LastFramesSent
|
|
|
|
stats.FramesPerSec = float64(framesInPeriod) / elapsed
|
|
|
|
log.Printf("[SIM] Stats: frames/s=%.1f total=%d blobs=%d",
|
|
stats.FramesPerSec, framesSent, stats.BlobCount)
|
|
|
|
stats.LastStatsTime = now
|
|
stats.LastFramesSent = framesSent
|
|
}
|
|
}
|
|
|
|
// printFinalStats prints final simulation statistics
|
|
func printFinalStats() {
|
|
elapsed := time.Since(stats.StartTime).Seconds()
|
|
framesSent := stats.FramesSent.Load()
|
|
|
|
log.Printf("[SIM] Final Statistics:")
|
|
log.Printf("[SIM] Frames sent: %d", framesSent)
|
|
log.Printf("[SIM] Duration: %.1f seconds", elapsed)
|
|
if elapsed > 0 {
|
|
log.Printf("[SIM] Average FPS: %.1f", float64(framesSent)/elapsed)
|
|
}
|
|
log.Printf("[SIM] Blobs detected: %d", stats.BlobCount)
|
|
}
|