Presence Sensors

Presence-based routing makes MultiTerminal self-driving about where it interrupts you. A real mmWave sensor (and, optionally, your phone's Bluetooth) decides whether you're at your desk — and MultiTerminal automatically routes agent questions and notifications to your desktop when you're present and to phone push when you're away. No human or agent in the loop.

1. The three presence states

A pure, deterministic state machine (PresenceStateMachine) models three states from the latest sensor signals:

StateCondition
AtDeskThe mmWave radar reports occupancy at the desk zone. This is the primary gate — present means at the desk, full stop.
NearbymmWave is absent, but a registered phone's Bluetooth (BLE) is still in range — a fresh BLE presence flag is ON, or its RSSI (Bluetooth signal strength in dBm — a negative number where closer to 0 means nearer; the #1 calibration gotcha) is at or above that device's calibrated threshold.
AwaymmWave is absent and no registered phone is in range (or BLE is degraded / the sensor is offline).

The mmWave radar is the authoritative signal. BLE is only consulted to distinguish Nearby from Away when mmWave already reports the desk as empty.

2. How states drive routing

The presence adapter maps the committed state onto MultiTerminal's binary remote-mode gate — MessageBroker.IsRemoteMode / SetRemoteMode. When remote mode is on, every phone-push path (permission prompts, agent questions and elicitations, task and error notifications) routes to the phone; when off, they stay on the desktop.

StateRemote modeNotifications go to
AtDeskfalseDesktop
NearbyfalseDesktop (v1: Nearby behaves as AtDesk)
AwaytruePhone push

v1 routing collapses Nearby into AtDesk (desktop-only), per the Owner decision. The machine still tracks Nearby as a distinct state; switching to true three-state routing later is a change to a single mapping method (PresenceRouting.ToRemoteMode).

While presence is enabled it is the authoritative source of remote mode: a manual Local/Remote pill toggle is re-corrected on the next presence evaluation, not left latched.

3. Debounce & graceful degradation

To keep brief movements from thrashing your routing, a desired state must persist for a debounce window before it commits — default 5 seconds (the first state commits immediately). Combined with the radar's absence timeout and your walk time, expect roughly 10–15 seconds to switch.

The machine degrades safely when it can't fully trust its inputs:

  • BLE stale or unavailable (no fresh phone signal within the staleness window, default 30 seconds) → it collapses to mmWave-only: desk occupied = AtDesk, otherwise Away.
  • Sensor / bridge offline (reported via the MQTT Last-Will topic — the Last-Will-and-Testament is the message the broker publishes on the sensor's behalf if it drops its connection) → the retained readings are treated as untrustworthy and the machine returns the safe default, Away (so notifications reach your phone rather than getting pinned to a dead desktop).

4. Configuration

Presence is configured through the Presence tab in Settings (and persisted via SettingsService). It is off by default so it never hijacks the manual remote-mode pill until you opt in and calibrate.

Enabling presence in MultiTerminal

  1. Open Settings → Presence and turn it on — or set presence.enabled=1 directly in %APPDATA%\multiterminal\settings.txt.
  2. If you edit settings.txt by hand, do it while MultiTerminal is stopped — the running app owns the file and rewrites it from memory, so a live edit gets clobbered.
  3. Restart MultiTerminal. The adapter only starts at launch, so the toggle takes effect on the next start.

For the full hardware-to-routing walkthrough — flashing firmware, the Mosquitto broker, calibrating the desk zone, and the BLE/IRK capture — see the prominent setup guide at docs/presence/SETUP-GUIDE.md.

SettingKey / defaultNotes
EnabledSet presence.enabled to 1 to enable; default is off (0 or unset)Gates whether the adapter runs at all.
Config blobpresence.config (compact JSON)Holds MQTT, debounce, staleness, and the registered phones.
DebouncedebounceSeconds = 5Hysteresis window before a state commits.
BLE stalenessbleStaleSeconds = 30Age beyond which a phone signal is ignored.
MQTT broker127.0.0.1:1883, prefix mt/presence, QoS 1Where the adapter subscribes for sensor signals.
Registered phonesper device: deviceId, label, rssiThreshold (default -75 dBm), osEach phone has its own calibrated RSSI threshold; readings at or above it count as in-range.

5. The MQTT signal contract

The sensor publishes flat topics under the configured prefix (default mt/presence); the adapter subscribes to <prefix>/# and normalizes each message:

TopicPayloadMeaning
mt/presence/desk/occupancyON / OFFmmWave occupancy — the primary AtDesk gate.
mt/presence/statusonline / offlineSensor availability (Last-Will). Anything not "online" degrades to Away.
mt/presence/ble/<deviceId>/rssiinteger dBm (e.g. -67)Per-phone Bluetooth signal strength.
mt/presence/ble/<deviceId>/presenceON / OFFPer-phone in-range flag (authoritative over raw RSSI when fresh).

Routing readiness is established only by the first desk/occupancy message — a retained status or BLE reading delivered first won't transiently flip you to phone-push before real occupancy data arrives. You can confirm the live routing flag with GET /api/remote-mode, which returns {"remote_mode": false} at the desk and true when away.

How do I know it's working?

Without watching the status-bar pill, you can check the routing flag directly:

curl http://localhost:5050/api/remote-mode
# {"remote_mode": false}  ← at the desk
# {"remote_mode": true}   ← away

Sit at the desk and confirm it reads false; step away past the debounce window and confirm it flips to true. For an end-to-end check that publishes occupancy over MQTT and asserts the flip headlessly, run the bundled smoke test docs/presence/presence-smoke-test.ps1 (pwsh -File docs\presence\presence-smoke-test.ps1).

6. Hardware & setup

The reference build uses an Apollo Automation MSR-2 multisensor — internally an ESP32-C3 paired with an HLK-LD2410B 24 GHz mmWave radar. mmWave (rather than PIR) is the point: it detects you sitting still at your desk. Any ESP32 + LD2410 board works with minor pin changes. The sensor runs ESPHome firmware and publishes to a local Mosquitto MQTT broker.

The end-to-end walkthrough — ESPHome install, Mosquitto with a LAN listener, firewall rule, flashing the firmware, enabling presence in MultiTerminal, calibrating the desk zone, and the gotchas hit along the way — lives in docs/presence/SETUP-GUIDE.md. The firmware config (docs/presence/msr2-presence.yaml) and a headless smoke test (docs/presence/presence-smoke-test.ps1) are alongside it.

Optional phone proximity (the Nearby distinction) adds a BLE tracker per phone, publishing mt/presence/ble/<id>/presence and /rssi; register each phone with a calibrated RSSI threshold in the presence config. Because iOS and Android randomize their BLE MAC, devices are matched by IRK (Identity Resolving Key — a per-phone key that lets the tracker still recognize a device whose MAC keeps rotating) — see the setup guide for the one-time capture.