ESPresense-companion

repository·main·Indexed 42 days ago

https://github.com/espresense/espresense-companion

A central processing engine for ESPresense indoor positioning systems, available as a Home Assistant Add-on or Docker container. It uses MQTT data from ESPresense nodes to perform indoor trilateration, manage node firmware, and integrate device presence into Home Assistant. Version 0.3.0 features a C# ASP.NET Core backend and a SvelteKit frontend for visualizing BLE device locations on floorplans.

Tokens
10.7K
Snippets
35
Records
42
Agent score
53%

What's inside ESPresense-companion

  1. Overview of ESPresense-companion

    main

    ESPresense-companion acts as the central brain for an ESPresense indoor positioning system. It processes MQTT data from multiple ESPresense nodes to solve device locations using trilateration.

    Key capabilities include:

    • Positioning: Determines device locations via trilateration of distance readings.
    • Home Assistant Integration: Reports device room presence to Home Assistant via MQTT.
    • Visualization: Displays BLE device locations on a floorplan.
    • Node Management: Configures ESPresense nodes, updates their firmware, and adjusts device-specific settings.
    • Optimization: Monitors and controls automatic node optimization processes.
  2. Understand the accuracy baseline limitations (v1)

    main

    The current v1 accuracy baseline has several documented limitations that developers should account for when interpreting results:

    1. No real RF modeling: Uses Gaussian RSSI jitter (σ = 2.5 dB) and a 10% multipath burst model (-6 dB) instead of actual radio frequency characteristics.
    2. Uniform hardware profile: All stations use identical calibration (cal_rssi = -59 dBm, absorption = 3.0), ignoring per-board variance between different ESP32 variants.
    3. 2D scoring: While the solver runs ILocate in 3D, ground-truth points are 2D (x, y) and error is scored in 2D.
    4. No external smoothing: No Kalman filtering or hysteresis is applied on top of the ILocate output, making it noisier than real-world usage with away_timeout.
    5. Simplified geometry: Only supports single-floor, axis-aligned rooms.
    6. Deterministic boundaries: For points exactly on a shared room boundary, the first room declared in the scenario JSON wins.
  3. Use WebSockets for real-time updates

    main

    The primary WebSocket endpoint is located at /ws. The backend pushes JSON-formatted messages to connected clients whenever system state changes.

    Common message types:

    • deviceChanged: Triggered when device data (position, RSSI, etc.) is updated.
    • configChanged: Triggered when system configuration changes.
    • nodeStateChanged: Triggered when a node's status changes.
    • time: Used for client-side time synchronization.

    Message Format Example:

    {
      "type": "deviceChanged",
      "data": {
        "id": "device_id",
        "name": "Device Name",
        "rssi": -70,
        "distance": 2.5,
        "x": 10.2,
        "y": 5.3,
        "room": "Living Room",
        "floor": 1,
        "track": true
      }
    }
  4. Understand the ESPresense-companion architecture

    main

    ESPresense-companion uses a client-server architecture consisting of a C# backend and a Svelte frontend:

    1. C# Backend (ASP.NET Core): Acts as the central engine. It processes MQTT data from ESPresense nodes, calculates device positions via trilateration, manages device history, and interfaces with Home Assistant. It exposes functionality through REST and WebSocket APIs.
    2. Svelte Frontend (SvelteKit): Acts as the visualization and management layer. It renders device locations on floorplans using D3.js, provides management UIs, and maintains state using reactive stores that synchronize with the backend via REST and WebSockets.
  5. Implement Svelte stores with polling and WebSockets

    main

    The frontend state management relies on Svelte reactive stores (found in stores.ts). These stores use a hybrid approach for reliability and real-time responsiveness:

    1. Polling: Stores periodically fetch data via the REST API (e.g., every 60 seconds) to ensure the state remains consistent even if a WebSocket message was missed.
    2. WebSockets: Stores subscribe to the /ws endpoint to receive immediate updates for specific changes (like deviceChanged or configChanged).
    3. Map-based Storage: Internally, stores often use a Map keyed by ID (e.g., device.id) to allow efficient updates when a WebSocket message arrives.
    4. Cleanup: Stores implement a return function in their setup to clear intervals and close WebSocket connections when the store is no longer in use, preventing memory leaks.
    // Example of a derived store pattern used in the project
    export const devices = derived<[typeof showAll], Device[]>( 
        [showAll], 
        ([$showAll], set) => {
            // ... logic for fetchDevices() polling ...
            // ... logic for setupWebsocket() real-time updates ...
            return () => {
                clearInterval(interval);
                socket.close();
            };
        }
    );
  6. Best practices for Svelte development in ESPresense-companion

    main

    When extending the frontend or building new components, follow these architectural patterns to ensure compatibility with the existing reactive system:

    Reactive Store Usage

    • Use the $ prefix to subscribe to stores (e.g., $myStore).
    • Use derived stores for any computed values to maintain reactivity.
    • If creating custom stores, always implement a cleanup mechanism to prevent memory leaks.

    WebSocket Handling

    • Do not manage WebSocket connections manually in components; let the existing stores handle the communication.
    • Listen for specific event types required by your component.
    • Ensure update handlers are idempotent (applying the same update multiple times should have the same effect as applying it once).

    API Interaction

    • Use the standard fetch API for CRUD (Create, Read, Update, Delete) operations.
    • Crucial: Do not manually update local stores after a successful API call. The system is designed so that the WebSocket will broadcast the change, and the stores will update automatically. Manually updating stores can lead to race conditions or inconsistent state.
    • Always implement appropriate loading and error states for user feedback.

    Component Design

    • Adhere to the Single Responsibility Principle.
    • Pass data downwards via props.
    • Communicate upwards using events.
    • Leverage Svelte's native reactivity system rather than manual DOM manipulation.
  7. Create a new UI component

    main

    To build a new component that integrates with the existing data flow, follow these steps:

    1. Subscribe to existing stores: Import devices, nodes, or config from $lib/stores. Use Svelte's reactive declarations ($:) to derive data from these stores.
    2. Handle updates automatically: Do not manually manage WebSocket connections within your component. By subscribing to the stores, your component will automatically re-render whenever the backend pushes an update via WebSockets.
    3. Perform data updates via REST: When a user interacts with your component to change data (e.g., updating a device), use a fetch call to the appropriate REST API endpoint. Note: Do not manually update the local store after a successful API call. The backend will emit an event, the WebSocket will broadcast it, and the store will update itself automatically.
    4. Type Safety: Ensure your component uses the TypeScript interfaces (like Device or Node) defined in the project's types.ts to maintain consistency with the backend models.
    <script>
      import { devices, nodes, config } from '$lib/stores';
    
      // Reactive declarations using store values
      $: relevantDevices = $devices.filter(d => d.track);
      $: nodeMap = new Map($nodes.map(n => [n.id, n]));
    </script>
    
    <!-- Component UI here -->
    
    <script>
    async function updateDevice(id, data) {
      const response = await fetch(`${base}/api/device/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
    
      if (!response.ok) {
        throw new Error('Failed to update device');
      }
      // No need to update local store - WebSocket will handle it
    }
    </script>
  8. Reproduce the ESPresense positioning accuracy baseline

    main

    The accuracy baseline is a simulated harness used to track positioning performance using the real Companion ILocate implementations. It runs in C# and requires the .NET 8 SDK. To reproduce the current baseline numbers, build the simulation project and run the accuracy baseline command. Note that the harness uses a seeded RNG (default seed 20260530) to ensure reproducibility.

    dotnet build -c Release tests/ESPresense.Companion.Simulation/ESPresense.Simulation.csproj
    dotnet run --no-build -c Release --project tests/ESPresense.Companion.Simulation -- \
      accuracy baseline \
      --output tests/ESPresense.Companion.Simulation/AccuracyHarness/Reports/baseline-v1.json \
      --markdown tests/ESPresense.Companion.Simulation/AccuracyHarness/Reports/baseline-v1.md
  9. Add a new accuracy scenario

    main

    To test a new positioning scenario, create a JSON file that follows the schema of the existing baseline scenarios (e.g., baseline_4node_6x4m.json) and place it in tests/ESPresense.Companion.Simulation/AccuracyHarness/Scenarios/. You can then run the harness specifically for that scenario using the --scenario flag.

    dotnet run --no-build -c Release --project tests/ESPresense.Companion.Simulation -- \
      accuracy baseline --scenario your_scenario.json
  10. Understand the Node and Device data models

    main

    The system distinguishes between Nodes (the ESPresense hardware performing scanning) and Devices (the BLE tags being tracked).

    Node

    Represents an ESPresense hardware unit. It includes telemetry (version, IP), cpu and flavor information, and its own physical location. It tracks which floors it is assigned to and maintains a map of nodes (other nodes) it can see, including dist (distance), var (variance), and lh (likelihood).

    Device

    Represents a tracked BLE entity. It includes its current room and floor, its calculated location (x, y, z), and tracking metrics like confidence, scale, fixes, and timeout. It also tracks rssi@1m and measuredRssi@1m values.

    export interface Node {
    	telemetry: { version: string; ip: string; };
    	cpu: CPU;
    	flavor: Flavor;
    	online: boolean;
    	id: string;
    	name: string;
    	location: { x: number; y: number; z: number };
    	floors: string[];
    	nodes: { [index: string]: { dist: number; var: number; lh: number } };
    	sourceType: 'Config' | 'Discovered';
    }
    
    export interface Device {
    	id: string;
    	name: string;
    	nodes: { [index: string]: { dist: number; var: number; lh: number } };
    	room: { id: string; name: string };
    	floor: { id: string; name: string };
    	location: { x: number; y: number; z: number };
    	confidence: number;
    	scale: number;
    	fixes: number;
    	timeout: number;
    	lastSeen: Date;
    	'rssi@1m'?: number | null;
    	'measuredRssi@1m': number | null;
    	isAnchored?: boolean;
    }
  11. Configure Playwright for ESPresense-companion

    main

    The Playwright configuration for this project defines the base URL for testing, launch arguments for the browser, and the command used to start the local web server.

    Key settings include:

    • use.baseURL: Set to http://localhost:4173.
    • use.launchOptions.args: Uses --use-angle=swiftshader to ensure compatibility in headless environments.
    • webServer.command: Executes pnpm run build && pnpm run preview -- --port 4173 to prepare and serve the application.
    • webServer.url: The address the test runner waits for (http://localhost:4173).
    • webServer.timeout: Set to 60000ms.
    • webServer.reuseExistingServer: Automatically set to true if not running in a CI environment.
    • testDir: Tests are located in ./tests.
    • testMatch: Matches files with the pattern **/*.spec.ts.
    import type { PlaywrightTestConfig } from '@playwright/test';
    
    const config: PlaywrightTestConfig = {
    	use: {
    		baseURL: 'http://localhost:4173',
    		launchOptions: {
    			args: ['--use-angle=swiftshader']
    		}
    	},
    	webServer: {
    		command: 'pnpm run build && pnpm run preview -- --port 4173',
    		url: 'http://localhost:4173',
    		timeout: 60000,
    		reuseExistingServer: !process.env.CI
    	},
    	testDir: './tests',
    	testMatch: '**/*.spec.ts'
    };
    
    export default config;
  12. Define the global Config schema

    main

    The Config interface represents the top-level configuration object for the ESPresense companion. It manages timeouts, floor layouts, tracked devices, GPS settings, and map visualization parameters.

    Key sub-objects include:

    • floors: An array of Floor objects defining the spatial hierarchy.
    • devices: An array of Device objects currently being tracked.
    • gps: Optional ConfigGps settings for location reporting.
    • map: MapConfig for controlling how the floor plan is rendered.
    export interface Config {
    	timeout: number;
    	awayTimeout: number;
    	floors: Floor[];
    	devices: Device[];
    	gps?: ConfigGps | null;
    	map: MapConfig;
    }