Room-level presence detection that weights evidence from 24 GHz radar (LD2412 / LD2450), WiFi Channel-State-Information (ESPectre), and Home Assistant reference sensors (PIR, microwave, camera motion/person) into a single confidence score. Runs as a small Python service. No cloud, no ML training loop, no sensor is required for the system to work.
Tip
Why fusion? Any single presence sensor lies some of the time. Radars ghost on HVAC drafts, PIRs miss still bodies, CSI drifts on router reboots. Combine them, and a weighted majority gets you dramatically better recall without wrecking precision — in our deployment: F1 = 0.95 with all four sensor classes cooperating.
- In 3 Points
- Why Fusion?
- Who Is This For
- What You Need
- Quick Start
- How It Works
- Features
- System Architecture
- Configuration Reference
- MQTT Topics
- Home Assistant Integration
- Offline Analysis Tools
- Observed Sensor Behavior
- Known Issues & Limitations
- Troubleshooting
- Roadmap
- FAQ
- Related Projects
- Development History
- Acknowledgments
- Contributing
- License
- It's a fusion service, not a sensor driver. It does not talk to radar hardware directly — each sensor node publishes its own MQTT feed. This service listens, scores, and emits one unified presence state per room.
- Every sensor is optional. Any room can run with just a microwave sensor from Home Assistant, or just one CSI node, or any combination. When a sensor drops offline, it's excluded from the weighted average and reappears automatically once it returns.
- The data is what makes it work. The service logs every decision to
SQLite so you can replay it offline. Two analysis scripts
(
deep_analysis.py,optimize_weights.py) turn that log into data-driven weight tuning — no guesswork about how much each sensor should count.
| Radar only (LD2412/LD2450) | WiFi CSI only | Home-Assistant-only (PIR/MW) | All fused (this project) | |
|---|---|---|---|---|
| Walking person | Excellent | Good | Good | Excellent (confirmed by multiple physics) |
| Stationary person | Weak (static energy decays) | Good (breathing) | Good (MW) / Weak (PIR) | Strong (breathing + MW agree) |
| Through thin walls | Yes (24 GHz) | Yes (2.4 GHz) | No | Yes |
| HVAC / draft false trigger | Radar ghosting common | CSI can see it | PIR fires on air movement | Lower — cross-validation vetos outliers |
| Tamper / spoof resistance | Single sensor shielded = blind | WiFi jam = blind | Cover MW sensor = blind | Harder — need to defeat all physics classes |
| Cost | $4–8 | $3–5 per node | $15–25 (MW) | Sum of inputs |
| Failure mode | Silent wrong answer | Silent drift | Stuck "on" (HA reports stale) | Degrades gracefully — bad sensor voted out |
| Typical recall | 0.70–0.85 | 0.55–0.75 | 0.60–0.90 (MW), 0.30–0.50 (PIR) | 0.95+ after tuning |
| Typical precision | 0.85–0.95 | 0.80–0.95 | 0.90–0.98 (MW) | 0.95+ |
The weights aren't hand-picked — they fall out of a logistic regression on
the service's own sensor_snapshots log. In our deployment, LD2412 came out
as the dominant sensor (weight ~0.50) and PIR came out near zero. Your own
numbers will differ; that's why the tool ships with the service.
- Home Assistant users who need reliable room-level presence for lighting / HVAC / alarm automations, and who've noticed single-sensor setups miss or false-trigger too often.
- mmWave radar tinkerers who already deployed HLK-LD2412 / HLK-LD2450 and want to combine them with PIRs and microwave sensors under one logical zone.
- WiFi-CSI enthusiasts running ESPectre (or the original) who want to prove, with data, that CSI adds value over their existing sensors.
- Security / alarm builders who need a confidence number, not just a boolean, so their logic can distinguish "99% sure someone's there" from "one flaky PIR just tripped."
Not for: people who want a plug-and-play binary_sensor without writing a config file. Expect to spend an hour dialing in weights and thresholds for each room.
| Sensor type | Example | Role | ~Cost |
|---|---|---|---|
| LD2412 mmWave radar | HLK-LD2412-security | Dominant — 1D distance + motion/static energy | $4–6 |
| LD2450 mmWave radar | HLK-LD2450-security | 2D zone localization, up to 3 targets | $5–8 |
| WiFi CSI node | ESPectre on ESP32 | Supplementary — stationary breathing, cross-room | $3–5 per node |
| Microwave (Sonoff) | Any Zigbee/Z-Wave MW occupancy sensor | Ground truth + input when not stuck | $15–25 |
| PIR | Generic HA-compatible | Low-weight input, useful mainly for motion onset | $5–15 |
| Camera motion/person | ESP32-CAM + HA, Frigate, etc. | Optional — highest-weight semantic input | varies |
You do not need all of them. The service is built around graceful degradation: offline sensors are skipped, not failed-over.
- Python 3.10+ (or Docker, which ships its own)
- Docker + Docker Compose (recommended) or a Linux host with systemd
- An MQTT broker reachable by both sensors and the fusion host (Mosquitto works; so does the one bundled with HA)
- Optional: Home Assistant 2024+ with a long-lived access token for ground-truth sensors and HA discovery
- Reading and editing a YAML config
- Running a Docker container or a systemd service
- Knowing your sensors' MQTT device IDs (or letting auto-discovery find them)
# 1. Clone
git clone https://github.com/PeterkoCZ91/multi-sensor-fusion.git
cd multi-sensor-fusion
# 2. Create your config
cp config.yaml.template config.yaml
# edit config.yaml — set `mqtt.host` and at minimum one `pairs:` entry
# 3. (Optional) secrets go in .env, which is gitignored
cp .env.example .env
# edit .env — set MQTT_PASSWORD, TELEGRAM_TOKEN, HA_TOKEN as needed
# 4. Build and run
docker compose up -d --build
# 5. Tail the logs
docker compose logs -f fusionYou should see, within a few seconds of the first sensor publishing a message:
[INFO] MQTT connected to mqtt.local:1883
[INFO] Subscribed to N sensor pair(s)
[INFO] FUSION [living] DETECTED conf=0.87 src=ld2450+csi fsm=present ...
python3 install.pyinstall.py is a wizard that:
- Creates a Python virtual environment and installs dependencies.
- Scans your MQTT broker for LD2412 / LD2450 / CSI nodes and offers to auto-configure pairs from them.
- Prompts for Telegram and Home Assistant integration (both optional).
- Writes
config.yamland optionally installs a systemd unit that runs at boot.
Sensor feeds Fusion engine Outputs
+------------------+ +---------------------+ +-----------+
| LD2412 (MQTT) | | 1. per-sensor | | MQTT |
| LD2450 (MQTT) |------>| confidence |--->| (HA disc) |
| ESPectre CSI(MQ) | | 2. weighted average | | Telegram |
| HA REST poll | | 3. cross-validation | | SQLite |
+------------------+ | 4. FSM + hysteresis | | Tapo PTZ |
+---------------------+ +-----------+
|
v
sensor_snapshots table
(every decision, for replay)
Each sensor produces a 0..1 confidence score using method appropriate to its physics:
| Sensor | Raw signal | Confidence formula |
|---|---|---|
| LD2412 | presence flag, distance, energy (motion + static) | base 0.70 + energy z-score bonus, capped 1.00 |
| LD2450 | target count, distance per target | distance-attenuated base + target-count bonus |
| CSI | on-chip composite score (4-domain weighted: variance, phase turbulence, ratio turbulence, breathing) | nonlinear map → 0..1; fallback to variance ratio |
| MW | binary | 1.0 when ON and not stuck; 0 otherwise |
| PIR | binary | 1.0 when ON; small decay window |
| Camera | binary (motion) + binary (person) | 1.0 per flag, summed with weights |
Σ (weight_i · confidence_i)
conf = ──────────────────────────────── · cross_validation_bonus
Σ weight_i
Default weights (data-derived from ~700k snapshots):
| Sensor | Weight |
|---|---|
| LD2412 | 0.50 |
| LD2450 | 0.35 |
| CSI (composite) | 0.25 |
| MW (Sonoff) | 0.40 |
| Camera person | 0.35 |
| Camera motion | 0.20 |
| PIR | 0.10 |
These are defaults. Run tools/optimize_weights.py against your own
fusion.db to retune for your own deployment.
| Agreeing sources | Bonus multiplier |
|---|---|
| 1 | 1.00 |
| 2 | 1.15 |
| 3+ | 1.25 |
conf ≥ confidence_threshold (e.g. 0.55)
CLEAR ────────────────────────────────────> DEBOUNCING
^ |
| clearing_s elapsed | debounce_in_s elapsed
| AND conf < exit_threshold v
| PRESENT ◄──┐
| | │ conf recovers
| | │ before min_present_s
+─────────── CLEARING <── conf < exit_threshold ──────┘
(hysteresis: exit_threshold ≈ 0.2)
Two mechanisms kill oscillation:
exit_threshold— you only leave PRESENT when confidence falls below a lower threshold than the one you needed to enter it. This is textbook Schmitt-trigger hysteresis.min_present_s— once in PRESENT, you stay there at least this long, even if confidence drops. Tuned to room semantics (30 s for busy rooms, 60 s for a bedroom where you want zero risk of clearing during sleep).
| Feature | Description |
|---|---|
| Weighted-average scoring | Each sensor contributes proportional to its trained weight |
| Cross-validation bonus | Multiplicative boost when 2+ sensors agree |
| Optional sensors | Any pair can run with a subset; offline sensors auto-skipped |
| Per-sensor staleness | Each feed has an age threshold; stale data excluded |
| Stuck-sensor filter | Sensors stuck in one state beyond a timeout are ignored |
| Continuity bonus | Persistent presence raises confidence of individual frames |
| Dual-CSI zone inference | Which of two CSI nodes detected motion first → active zone |
| Armed mode | Optional lower thresholds for alarm/security use |
| Feature | Description |
|---|---|
| MQTT auto-discovery | Subscribes to esphome/discover/# for new CSI nodes |
| MQTT Home Assistant discovery | Each pair publishes HA-compatible discovery config |
| Home Assistant REST polling | Per-entity polling for ground-truth / fusion input |
| Telegram bot | Startup banner, presence alerts, daily summary |
| Tapo PTZ | Non-blocking camera preset control on zone transitions |
| Graceful degradation | Any sensor/broker/HA can go offline without fusion crashing |
| Feature | Description |
|---|---|
| SQLite logging | Every fusion decision stored for replay |
sensor_snapshots table |
Full input + output state per cycle |
events table |
State transitions only (smaller, faster queries) |
| Hourly CSI stats aggregation | Background rollup for long-term trends |
| Auto DB rotation | Oldest rows pruned above max_size_mb, followed by VACUUM |
| Offline analysis tools | deep_analysis.py, optimize_weights.py |
| Feature | Description |
|---|---|
| Watchdog snapshot | Independent timer forces a snapshot even if MQTT is silent |
| MQTT reconnect | Exponential backoff, topic resubscribe on reconnect |
| HA session retry | REST polling continues with per-entity fallback on timeouts |
| CSI calibration persistence | thresholds.json survives restarts |
| DB cleanup bounded | Pruning loop breaks on rowcount=0 (no infinite loop) |
+-----------------+ +-----------------+ +-----------------+
| HLK-LD2412 | | HLK-LD2450 | | ESPectre CSI |
| security node | | security node | | node (ESP32) |
| (separate repo) | | (separate repo) | | (separate repo) |
+--------+--------+ +--------+--------+ +--------+--------+
| | |
| MQTT: security/ld2412_<r> | MQTT: security/ld2450_<r> | MQTT: esphome/csi_<r>
| | |
v v v
+----------------------------------------------------------------+
| MQTT broker (Mosquitto) |
+---------------------------+------------------------------------+
|
v
+--------------------------------+
| Fusion service (this repo) |
| |
| FusionManager per pair: |
| - collects sensor frames |
| - computes per-sensor conf |
| - weighted average + x-check |
| - FSM + hysteresis |
| - anti-pingpong guard |
| |
| SensorSnapshotWriter: |
| - SQLite persistence |
| |
| HA REST poller (optional): |
| - PIR / MW / camera feeds |
+--------------------------------+
|
+----------------------+----------------------+
| | |
v v v
+-------------+ +---------------+ +------------+
| MQTT topic | | Telegram bot | | SQLite DB |
| fusion/<r>/ | | notifications | | for offline|
| state | | | | analysis |
| confidence | | | | |
| source | | | | |
+-------------+ +---------------+ +------------+
|
v
Home Assistant
(auto-discovery)
multi-sensor-fusion/
├── fusion_service.py # Main service (~2900 LOC, single file by design)
│ ├── FusionDatabase # SQLite layer + rotation / VACUUM
│ ├── FusionManager # per-pair state, scoring, FSM
│ ├── LD2412Frame / LD2450Frame # MQTT payload dataclasses
│ ├── CSIFrame # ESPectre payload dataclass
│ ├── SensorSnapshotWriter # per-cycle snapshot persistence
│ ├── HAPoller # Home Assistant REST polling
│ ├── TapoCameraController # Non-blocking Tapo PTZ preset driver
│ ├── TelegramNotifier # Async Telegram bot client
│ └── AutoDiscoverySubscriber # ESPHome `discover/#` handler
├── config.yaml.template # Annotated configuration reference
├── install.py # Interactive installer (venv + systemd)
├── docker-compose.yml # Docker Compose deployment
├── Dockerfile # Python 3.12-slim base
├── fusion-service.service # systemd unit for bare-metal install
├── requirements.txt # Runtime Python dependencies
├── .env.example # Env var template (secrets go here)
├── .gitignore # Excludes data/, config.yaml, .env
├── tools/
│ ├── deep_analysis.py # Comprehensive per-pair analysis + plots
│ ├── optimize_weights.py # Logistic regression + grid search tuning
│ └── analyze_weights.py # Legacy analysis (kept for reproducibility)
├── docs/ # (future) diagrams, notes
├── pictures/ # (future) screenshots for README
├── CHANGELOG.md # Version history (Keep a Changelog format)
├── LICENSE # MIT
└── README.md # This file
Runtime files (gitignored, created on first run):
├── config.yaml # Your actual config — copy from .template
├── .env # Your secrets — copy from .env.example
├── data/
│ ├── fusion.db # SQLite store (rotated above max_size_mb)
│ ├── fusion.db-shm # SQLite WAL shared memory
│ └── fusion.db-wal # SQLite write-ahead log
└── calibration/
└── thresholds.json # Live per-node CSI auto-calibration
See config.yaml.template for the annotated reference.
Key sections:
mqtt:
host: "mqtt.local" # broker hostname / IP
port: 1883
user: "fusion" # optional; or env MQTT_USER
password: "..." # optional; or env MQTT_PASSWORD (preferred)One entry per fusion zone. Minimum viable entry:
pairs:
- name: "living"
csi_ids: ["csi_living"]
fusion_mode: "cross_validate"
confidence_threshold: 0.55
exit_threshold: 0.20Full option reference:
| Option | Default | Description |
|---|---|---|
name |
required | zone label (used in logs, MQTT, HA discovery) |
ld2412_id |
unset | MQTT client ID of the LD2412 radar node |
ld2450_id |
unset | MQTT client ID of the LD2450 radar node |
csi_ids |
[] |
List of CSI node IDs (1..N supported) |
csi_zones |
auto | Mapping csi_<id> → zone_label for multi-CSI setups |
fusion_mode |
cross_validate |
cross_validate / single / weighted_avg |
confidence_threshold |
0.55 |
Confidence to enter PRESENT |
exit_threshold |
0.20 |
Hysteresis — confidence to leave PRESENT |
max_latency_ms |
2000 |
Data older than this is "stale" and excluded |
debounce_in_s |
1.0 |
Seconds to confirm presence (DEBOUNCING) |
clearing_s |
20.0 |
Seconds in CLEARING before CLEAR |
min_present_s |
30.0 |
Anti-pingpong — min time in PRESENT |
optional_sensors |
true |
Tolerate missing sensor feeds |
noise_diff_threshold |
0.3 |
Differential threshold for dual-CSI noise detection |
score_norm_max |
0.5 |
CSI score divisor for 0..1 normalization |
transition_window_s |
5.0 |
Window for A→B zone transition detection |
database:
path: "data/fusion.db"
max_size_mb: 500 # oldest rows pruned above thisListens on esphome/discover/# for newly-flashed CSI nodes.
auto_discovery:
enabled: true
default_pair: "living"
csi_prefix: "csi_"Polls HA REST API for reference sensors. Each sensor can serve as
ground-truth-only (logged for analysis) or as a fusion input
(fusion_inputs: true).
See the template for the full ground_truth_sensors: structure.
Drives Tapo PTZ presets from zone transitions. Non-blocking, with retries. See the template for the config shape.
| Topic | Payload | When |
|---|---|---|
fusion/<pair>/state |
"on" / "off" |
FSM transitions to/from PRESENT |
fusion/<pair>/confidence |
float 0..1 | Every fusion cycle |
fusion/<pair>/source |
"ld2450+csi", "ld2412", ... |
Every cycle |
fusion/<pair>/zone |
zone label or "none" |
Every cycle |
fusion/<pair>/fsm |
clear / debouncing / present / clearing |
On transition |
homeassistant/binary_sensor/fusion_<pair>/config |
HA discovery JSON | Once on start |
| Topic pattern | Source |
|---|---|
security/ld2412_<pair>/# |
HLK-LD2412 node |
security/ld2450_<pair>/# |
HLK-LD2450 node |
esphome/csi_<name>/# |
ESPectre node |
esphome/discover/# |
Any ESPHome node announcing itself |
The service publishes MQTT discovery configs so each fusion pair shows up
as a binary_sensor.fusion_<pair> in HA automatically. Attributes include
confidence, source, zone, and FSM state.
To use HA sensors as fusion inputs (not just as ground truth for analysis),
set fusion_inputs: true on the relevant entries in ground_truth_sensors
and generate a long-lived access token in HA (Profile → Security → Long-Lived
Access Tokens), then export it as HA_TOKEN.
The service logs every decision to sensor_snapshots. That table is the
closed-loop you need to tune fusion without guessing.
Comprehensive report against the collected data:
python3 tools/deep_analysis.py --pair livingOutputs (on stdout, plus writes plots to tools/plots/ if matplotlib is
installed):
- Ground truth accuracy — fusion vs. your best reference sensor (e.g. Sonoff microwave). Precision, recall, F1.
- Per-sensor contribution — for each sensor, how often it agreed with ground truth, and how often its absence correlated with a wrong fusion call.
- CSI feature distributions — composite_score, breathing_score, phase_turbulence: detected vs. clear, per node.
- LD2450 failure modes — phantom targets, missed targets, distance drift.
- Confidence histogram — how fusion confidence distributes over genuine vs. false events.
- FSM ping-pong analysis — how many PRESENT→CLEARING→PRESENT oscillations per hour, at which confidence levels.
- Hourly / daily patterns — detection rate by time-of-day.
- Per-pair breakdown — one summary block per zone.
Data-driven weight tuning via logistic regression + grid search:
python3 tools/optimize_weights.py --pair livingOutput:
- Fitted logistic-regression weights per sensor
- Grid-search-optimal
confidence_thresholdandexit_thresholdfor best F1 - Suggested
config.yamlpatch - Observed ceiling F1 (what's achievable with your current sensors)
Legacy analysis. Retained for historical reproducibility; prefer
optimize_weights.py for new tuning.
Notes accumulated from extended deployments. Your mileage will vary — these are symptoms to watch for, not hard rules.
- Dominant sensor for most rooms. Regression consistently gives it the highest weight. If you have one sensor, make it this.
- Energy readings drift after firmware config writes. Wait 40 s post-boot before trusting the first frames.
- Static energy is the tell for stationary occupants. Motion alone misses breathing bodies. Our scoring uses both.
- Phantom targets in rooms with HVAC vents, ceiling fans, or metal blinds. Tune target count and distance gates carefully.
- Target number fluctuates rapidly (same person reported as 1 → 2 → 1 within 200 ms). Average over a short window before using target_count.
- Distance is trustworthy; target_id is not (re-used across unrelated targets on frame boundaries).
composite_score(the on-chip 4-domain weighted signal) is the primary input.anomaly_ratioalone showed near-zero discrimination in our data and was dropped.breathing_scoreis a useful supplementary for stationary presence. Typical values: ~1–3 clear, ~10–20 occupied, sometimes much higher during strong turbulence (HVAC airflow).- Calibrates automatically. A new node typically reaches stable thresholds in a few thousand frames.
- Sensitive to router reboots. The idle baseline shifts; let the auto-calibration re-settle for ~15 minutes before trusting confidence.
- Microwave (Sonoff) is usually your best ground truth when powered.
But these sensors are known to get stuck at
onif power is cut — this service detects stuck-state sensors and excludes them from fusion until they change. - PIR weight often comes out near zero in regression — PIRs fire for reasons uncorrelated with occupancy (HVAC airflow through the beam, pets, etc.). Keep them as inputs, but don't expect them to lift F1 much.
- Camera motion is noisy; camera person (when available, e.g. Frigate person detection) is the highest-weight HA input.
- Low-recall is the typical failure. The service is conservative by
default. In our deployment: precision ~0.96 but recall ~0.42 for the
busiest room. Retune via
optimize_weights.pyand lowerconfidence_thresholdif this is your problem too. - Zone inference from dual CSI is experimental. Works when two CSI nodes bracket the zone boundary; fails when they're placed asymmetrically.
- No built-in web UI. State is exposed via MQTT, logs are stdout, and
history lives in SQLite. Use HA's UI, or run
deep_analysis.py, or wire a Grafana dashboard against the SQLite DB. - Python 3.10+ only.
matchstatements and dict-merge operators are used throughout. - CSI node firmware mismatch. Nodes on old firmware (no composite_score field) fall back to variance_ratio, which is weaker. Flash all CSI nodes to ESPectre 2.3+ for best results.
| Symptom | Likely cause | Fix |
|---|---|---|
Service starts but no FUSION [<pair>] DETECTED ... in logs |
MQTT not connecting | Verify mqtt.host/port, credentials (MQTT_USER/MQTT_PASSWORD), firewall. Logs should show MQTT connected to .... |
| Only some pairs emit detections | Subscribed topic name mismatch | Run mosquitto_sub -h <broker> -t '#' -v; confirm your ld2412_<pair> / ld2450_<pair> / csi_<pair> IDs match what the firmware publishes. |
Offline sensors: <name>: 60m warning |
Feed went silent | Check the sensor node's own logs. Fusion excludes feeds that go quiet; it re-includes them automatically when they return. |
DB size … > limit … MB, cleaning up … then silence |
Pre-v1.0.0 cleanup bug | Upgrade to v1.0.0+. Root cause: DELETE loop without rowcount=0 break + sensor_snapshots missing from prune list — see CHANGELOG. |
| Rapid PRESENT↔CLEARING oscillation | exit_threshold too close to confidence_threshold, or min_present_s too low |
Widen the hysteresis gap (lower exit_threshold) and/or raise min_present_s. Run deep_analysis.py to identify the flaky sensor. |
HA entities listed under ground_truth_sensors don't affect fusion score |
Missing fusion_inputs: true flag |
Set fusion_inputs: true on the entries you want fed into scoring. Without it they're ground-truth only (for analysis). |
| Telegram silent / returns 400 | Missing or wrong TELEGRAM_TOKEN / TELEGRAM_CHAT_ID |
Verify both env vars. Test with curl https://api.telegram.org/bot<TOKEN>/getMe. cooldown_s: 0 disables throttling during testing. |
| CSI thresholds keep drifting | Radio-hostile spot (metal, microwave, crowded 2.4 GHz) | Move node, or lock the threshold manually in thresholds.json after a known-good calibration and disable auto-recalc for that node. |
VACUUM failed: cannot VACUUM from within a transaction |
Pre-v1.0.0 fix missed | Upgrade to v1.0.0+. VACUUM now runs in autocommit (isolation_level=None). |
| High CPU with no output | Cleanup loop stuck (pre-v1.0.0) | Upgrade. Diagnostic: docker stats fusion-service — 100% CPU with 0 KB net I/O is the signature. |
ImportError on install.py |
Wrong Python version | Python 3.10+ required (match, dict ` |
| Home Assistant binary_sensor never appears | HA MQTT discovery disabled, or different prefix | Confirm HA's MQTT integration uses default homeassistant/ prefix. Use HA's MQTT integration UI → "Configure" → "Enable discovery". |
# Watch fusion decisions live
docker logs -f fusion-service | grep FUSION
# Dump the last hour of events from SQLite
sqlite3 -readonly data/fusion.db \
"SELECT datetime(timestamp,'unixepoch','localtime'), pair_name, event_type, confidence, source
FROM events
WHERE timestamp > strftime('%s','now','-1 hour')
ORDER BY timestamp DESC LIMIT 50;"
# Quick-check which sensors were seen in the last 5 min
sqlite3 -readonly data/fusion.db \
"SELECT node_id, datetime(MAX(timestamp_hour),'unixepoch','localtime')
FROM csi_stats_hourly GROUP BY node_id;"
# Force recalibration of a CSI node (restart the service)
docker compose restart fusion- Per-pair armed/disarmed state exported to MQTT (not just internal)
- Live Grafana dashboard template against the SQLite DB
- Native HA custom component (avoid MQTT round-trip for HA-only users)
- Auto-weight retuning as a periodic background task
- Zigbee2MQTT sensors as first-class inputs (currently go via HA REST)
Q: Can I use this with ESPHome's own presence_sensor?
A: Yes — its state is exposed over MQTT; add it to ground_truth_sensors
with fusion_inputs: true.
Q: Does it need the internet? A: No. All traffic is local MQTT + local HA. Telegram needs internet only if enabled.
Q: How much CPU / RAM?
A: Idle under 1% of one core on a Raspberry Pi 4. Steady-state ~30 MB RSS
per 1000 snapshots/day. SQLite DB grows ~5 MB per 10 000 decisions;
max_size_mb caps it.
Q: Can I run multiple fusion instances?
A: Yes, as long as their MQTT topic prefixes don't collide. We
deliberately hard-code fusion/ as the output prefix today; if you need
two instances, patch it.
Q: Why weighted-average and not a neural net? A: Because the first few hundred snapshots of a new deployment are enough to train this. A learned model would need you to run it for a month before it mattered, and it wouldn't be any more explainable when something went wrong.
Fusion consumes feeds from these sensor-node firmware projects. All are MIT or GPL-3.0 licensed and self-contained — you only need the ones matching the hardware you actually own.
| Repo | Hardware | Network | License | Role in fusion |
|---|---|---|---|---|
| HLK-LD2412-security | ESP32 + HLK-LD2412 | WiFi | MIT | Dominant sensor (1D radar) |
| HLK-LD2412-POE-security | ESP32 + LAN8720A + LD2412 | PoE/Ethernet | MIT | Dominant sensor (wired) |
| HLK-LD2450-security | ESP32 + HLK-LD2450 | WiFi | MIT | 2D radar, zone localization |
| HLK-LD2412-POE-WiFi-CSI-security | ESP32-PoE + LD2412 + CSI | PoE + WiFi (CSI-only) | GPL-3.0 | Dual-sensor node (both radar and CSI in one board) |
| espectre | ESP32 (any) | WiFi | GPL-3.0 | Standalone CSI node |
Any combination of these (or none — just HA sensors) produces a valid fusion deployment. Start with whichever hardware you already have.
Released milestones (detail in CHANGELOG.md):
| Version | Highlights |
|---|---|
| v1.0.0 | Initial public release: weighted-average fusion, FSM with hysteresis, anti-pingpong, cross-validation bonus, optional sensors, auto-discovery, HA REST polling, MQTT HA discovery, Tapo PTZ, Telegram notifications, SQLite logging with safe DB rotation, offline analysis tools |
Pre-release internal development (no tagged builds):
| Phase | Highlights |
|---|---|
| Phase 1 | Initial 3-sensor fusion (LD2412 + LD2450 + single CSI) with additive scoring |
| Phase 2 | HA integration: REST polling, stuck-sensor filter, HA discovery output |
| Phase 3 | CSI rewrite: dropped anomaly_ratio, adopted on-chip composite_score, added breathing_score, dual-CSI zone inference |
| Phase 4 | Weighted-average rewrite: data-derived weights from logistic regression, cross-validation bonus, lowered thresholds, exit-threshold hysteresis |
| Phase 5 | Anti-pingpong: min_present_s, continuity bonus, FSM state machine formalized |
| Phase 6 | Public release hardening: DB cleanup fix, VACUUM autocommit, full docs, scrubbed secrets |
- Francesco Pace — ESPectre WiFi CSI motion detection (GPLv3). The CSI side of this fusion is built on ESPectre's on-chip composite score; the service just consumes its MQTT output.
- Home Assistant — reference sensors, MQTT discovery protocol, long-lived access token support, and the ecosystem that makes this service useful.
- Hi-Link — HLK-LD2412 and HLK-LD2450 mmWave radar modules, and the community-reverse-engineered UART protocol.
- Eclipse Paho / Mosquitto — the MQTT broker and client library this service depends on.
- Tasmota / Staars — community LD2412 protocol research that the upstream firmware projects build on.
- SA-WiSense — inspiration for the ratio- turbulence CSI feature.
Contributions are welcome — especially:
- Weight-tuning reports for different hardware mixes (we genuinely want to know what the regression spits out on non-Sonoff MW sensors, on Frigate+camera setups, on Zigbee PIR, etc.).
- Firmware-quirk notes for LD2412 / LD2450 versions we haven't seen.
- New fusion inputs — any binary sensor behind HA can already be
wired in via
ground_truth_sensors+fusion_inputs: true, but native support for Zigbee2MQTT / Z-Wave JS is on the roadmap and welcome. - Bug reports with: your
config.yaml(secrets redacted!), a few lines of log output, and, if possible, a dump of the offending rows fromsensor_snapshots(SQLite.dumpis fine).
- Fork the repository on GitHub.
- Create a feature branch (
git checkout -b feat/my-feature). - Make your changes and add a line to
CHANGELOG.mdunder an[Unreleased]heading. - Run a smoke test (
python3 -c "import fusion_service"must succeed, and the service should start cleanly against a real broker). - Commit with a clear imperative message (
feat: ...,fix: ...,docs: ...,refactor: ...). - Push to the branch and open a Pull Request against
master.
- Python 3.10+. Use
matchstatements and dict-merge operators where they improve readability. - Type hints on public methods and dataclasses. Keep internal helpers
pragmatic — not every
_tmpvariable needs one. - Single-file
fusion_service.pyis deliberate. Resist the urge to split until the file crosses ~3500 LOC and a clear logical boundary emerges. New classes go at the bottom of their topical section. - Keep
__init__methods short. Long setup goes in_configure_*helpers. - Log sparingly at INFO (one line per fusion cycle max), loudly at WARNING, and noisily at DEBUG.
Please include:
- Service version (first line of
docker logs fusion-serviceafter start-up). - A full fusion cycle log line showing
FUSION [<pair>] ... scores=[...]. - Your
config.yamlwith secrets replaced byREDACTED. - If the bug involves DB state:
sqlite3 data/fusion.db .schemaand the row counts per table.
MIT — see LICENSE.
This service is under the permissive MIT license. The upstream firmware projects it consumes have their own licenses (MIT or GPL-3.0 per the table in Related Projects); using them as data sources over MQTT does not create a derivative work, so the fusion service remains MIT-licensable even when paired with GPL-3.0 nodes.
If you fork and bundle any GPL-3.0 source into this project, however, re-license accordingly.