/

Developer Guide

External Simulators

CrowdVision ships two simulators that exist outside the main application cluster. They are not microservices — they are test stand-ins for systems that in production would be real external hardware or third-party services.

SimulatorLocationWhat it replaces
Sensor Simulatorsimulators/sensor-simulator/Physical IoT sensors installed in buildings (people count + temperature)
Air Quality Simulatorsimulators/aq-simulator/External AQ monitoring stations sending air-quality readings

Architectural Decision: Keep Simulators External

Decision: Simulators are never included in Kubernetes manifests and are not part of the production system definition.

Why: In production, CrowdVision receives data from physical IoT sensors and third-party AQ stations. Those devices are external — they know nothing about the cluster; they just POST to public API endpoints. The simulators are designed to model exactly this reality.

Running simulators outside Kubernetes enforces that boundary at the architecture level:

This is the same reasoning behind not running load-test tooling inside a production cluster: the test tool is not the system under test.


Running the Simulators

Both simulator images are built and pushed to GHCR as part of the CD pipeline. To run them locally:

# Sensor simulator (people count + temperature):
docker run -p 3001:3000 ghcr.io/nickghignatti/crowdvision-simulator:latest

# Air quality simulator:
docker run -p 3002:3000 ghcr.io/nickghignatti/crowdvision-aq-simulator:latest

Alternatively, both simulators are included in the Docker Compose stack and start automatically with just stack dev (unless excluded):

just stack dev exclude="simulator"   # skip both simulators
just stack dev exclude="aq"          # skip only the AQ simulator

To stop a standalone container:

docker stop <container-id>

How Connections Work

The simulator sends data to CrowdVision’s /sensor/ endpoints via HTTP POST. When the user starts the simulator from the dashboard UI, the Vue client:

  1. Reads window.location.origin to determine the current application’s base URL (e.g., http://localhost).
  2. Appends /sensor/ to form the target URL (e.g., http://localhost/sensor/).
  3. POSTs this target URL to the simulator’s /control/start endpoint, along with the building ID and room IDs.
  4. The simulator stores this URL and begins firing data at it every 10 seconds.
sequenceDiagram
    participant UI as Vue Dashboard (Browser)
    participant Sim as Simulator Container (Docker)
    participant K8s as K8s Cluster (Istio ingress :80)
    participant SS as sensor-service Pod

    UI->>Sim: POST http://localhost:3001/control/start\n{targetUrl: "http://localhost/sensor", buildingId, roomIds}
    Sim->>Sim: Stores targetUrl, starts tick loop
    
    loop Every 10 seconds
        Sim->>K8s: POST http://host.docker.internal/sensor/temperature
        Sim->>K8s: POST http://host.docker.internal/sensor/peopleCount
        K8s->>SS: Forwards request (prefix /sensor stripped by Ingress)
        SS->>SS: Saves to sensor-db MongoDB
    end

The host.docker.internal Resolution

This is the most important implementation detail to understand when running the simulator against a local k3d cluster.

The problem: When the Vue frontend runs in a browser, window.location.origin returns http://localhost. The simulator receives this as its target URL. But the simulator is a Docker container — from inside a Docker container, localhost resolves to 127.0.0.1 of the container itself, not your laptop. The k3d load balancer is not listening inside the simulator container, so every fetch() call fails with ECONNREFUSED.

The solution: Docker Desktop (Windows and macOS) provides a special hostname host.docker.internal that always resolves to the host machine from inside any container. The simulator automatically rewrites localhost and 127.0.0.1 to host.docker.internal before making any outbound request:

// simulatorService.ts — startOrAdd()
if (parsedUrl.includes("localhost") || parsedUrl.includes("127.0.0.1")) {
  parsedUrl = parsedUrl
    .replace(/localhost/g, "host.docker.internal")
    .replace(/127\.0\.0\.1/g, "host.docker.internal");
}

The rewrite is transparent to the user — they type http://localhost:3001 as the simulator URL in the dashboard, and the simulator resolves the routing correctly.

Note

Linux Docker users: host.docker.internal is not automatically available on Linux. Start the simulator with --add-host host.docker.internal:host-gateway to enable it:

docker run --add-host host.docker.internal:host-gateway -p 3001:3000 ghcr.io/nickghignatti/crowdvision-simulator:latest

Note

Remote VPS deployment: When the application is deployed on a VPS, the target URL the frontend sends will be https://yourdomain.com/sensor — a fully qualified hostname with no localhost in it. The rewrite block never runs, and the simulator sends directly to the public URL. No configuration changes are required.


Simulator Control API

The simulator exposes three HTTP endpoints that the Vue dashboard calls directly (bypassing the cluster Ingress entirely):

MethodEndpointBodyDescription
POST/control/start{ buildingId, roomIds, targetUrl }Starts sending simulated data for the given building. If the building is already registered, it replaces it (idempotent).
POST/control/stop{ buildingId }Stops sending data for the given building. If no buildings remain active, the simulator loop halts entirely.
GET/control/status?buildingId=XReturns { isRunning: boolean } indicating whether the given building is currently being simulated.

The simulator can manage multiple buildings simultaneously. Each building sends signals for all its rooms independently.


Simulation Parameters

The following values are currently fixed in simulatorService.ts. They can be adjusted by editing the service and rebuilding the image:

ParameterDefaultDescription
delay10000 msInterval between signal batches (10 seconds).
peopleCountRange[0, 50]Random headcount generated per room per tick.
temperatureRange[18.0, 30.0]Random temperature (°C) generated per room per tick. Rounded to 2 decimal places.

Connecting the Simulator from the Dashboard

From the Vue dashboard (Graph view), a gear icon opens the simulator configuration modal. Enter the simulator’s base URL (e.g., http://localhost:3001) and click Save. The dashboard remembers this URL in localStorage, so you only need to enter it once per browser.

Once configured: