CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
Tracking how people move through a building in real time, using the wireless signals their phones already emit. Occupancy per zone, and flow between zones — rendered live on the twin.
Status: design, not implemented. Tracks issue #314.
Applies to: a new devices/ap-collector, plus one telemetry plugin. No new service.
Phase 1 — routers-only. Poll the access points the building already has. Zero new hardware. Covers every device associated to the building Wi-Fi.
Phase 2 — dedicated sniffers. Raspberry Pi rigs in monitor mode, to reach devices that never join the Wi-Fi. Only after phase 1 is proven in a real building.
Per-device trajectories. Not as a simplification — as the constraint the whole design is shaped around.
The Dutch DPA is explicit: “Storing multiple location points per device means that the data is not anonymous.” A sequence of positions keyed to one device is personal data under GDPR however it is hashed, and it stays personal data after the hash. There is no salt rotation that makes a trajectory anonymous.
Two aggregates render the same visualisation and carry none of that weight:
| Aggregate | Question it answers | Metric |
|---|---|---|
| Zone occupancy | how many people are in this zone now | totalDeviceCount + ratioDeviceCount — new |
| Zone transitions | how many moved lobby → floor-2 in this window | zoneTransition — new |
Occupancy plus transitions is enough to animate flow across the twin. A per-person path adds nothing the user can see, and costs the entire legal position.
Invariant: no device identifier ever crosses into CrowdVision. The collector resolves identifiers to zones in memory and emits counts. The platform stores aggregates only. This is what keeps the platform itself free of personal data — nothing to breach, no retention schedule, no subject-access path.
A count carries no identifier and no per-device row, so there is nothing to single out. That is the whole argument, and it is only as strong as the two conditions below. Both are properties of the stored data, not of the collector — state them here or nobody checks them.
Resolution decays with age. readings is a Timescale hypertable with a 14-day retention policy; readings_hourly is a continuous aggregate with none (Telemetry Storage. So per-tick counts exist for a fortnight and hourly averages persist. That is the right shape and it is load-bearing: one minute of a room count is noise, a year of minutes is a behavioural profile. The property is currently inherited from the platform rather than chosen by this feature — a change to either policy is a change to this privacy argument.
A count of 1 in a room of 1 is an identifier. office-3.14, count=1, 08:03 → 17:12, weekdays is an attendance record for whoever sits there; no MAC is required to read it. In the EU that is employee monitoring (Art. 88 and national/works-council law), not merely GDPR. Open floors and lobbies are unaffected — the risk is exactly single-occupancy rooms.
A floor on retained buckets, never on the live reading. Live occupancy has to keep the honest 0 and 1: the collector goes to some length to make “this room is empty” a different fact from “this room’s AP is down”, and a suppression rule on the live path throws that distinction away to solve a problem that only exists in the archive.
The obvious objection: MAC addresses are randomized, so won’t one phone look like a different person to every access point it passes?
No — randomization is per-SSID, not per-AP. Every AP in the building broadcasts the same SSID, so a device roaming across them keeps one address.
| Platform | Behaviour on the building SSID |
|---|---|
| Android 10–15 | Persistent randomized MAC derived from the network profile (SSID + security type). Stable across APs, stable across forget-and-rejoin, until factory reset. |
| Apple (iOS 18 / macOS Sequoia) | On WPA2-AES or WPA3, the private address is fixed per SSID. Only open and pre-WPA2 networks rotate — every 2 weeks. |
Wi-Fi roaming requires this. The DHCP lease, L2 forwarding tables and 802.11r fast transition are all keyed on the station MAC. If it changed on roam, open TCP connections would drop and roaming would not function.
Probe-request MACs are random per scan burst and uncorrelated between listeners. A device is counted once per sniffer that hears it. That is the phase 2 problem, and it is why phase 2 does not count identifiers at all.
| Regime | What the AP sees | Routers-only? |
|---|---|---|
| Associated — device joined the Wi-Fi | Every frame, continuously, stable per-SSID MAC, RSSI per AP | Yes. This is phase 1. |
| Unassociated — visitor never joins | Sporadic probe requests only, and only if a radio is in monitor mode while serving | No. This is phase 2. |
Phase 1 covers everyone on the Wi-Fi and misses the walk-through visitor. For facility management the associated population is generally the population that matters.
It is also the only regime with a workable legal basis: a captive portal, Wi-Fi terms of service or an employment agreement supports consent or legitimate interest. Ambient sniffing of passers-by supports neither — that is what the AP fined a Dutch municipality for in 2021.
Two tempting techniques, both rejected.
RSSI trilateration resolves to roughly 2–5 m in practice. It needs surveyed AP coordinates, a per-site path-loss calibration and Kalman filtering, and it needs three APs hearing the device at once. Real buildings deploy APs for coverage, not overlap — one strong and one weak reading is the common case, which fails the technique outright. And 3 m of error crosses walls: the computed (x, y) cannot be trusted to the room it claims.
Fine Timing Measurement (802.11mc, 1–2 m; 802.11az, sub-metre; 802.11bk, sub-decimetre) is genuinely accurate and genuinely unusable here. FTM is client-initiated: the phone ranges against the APs and holds the result. The infrastructure never learns the position unless an app on the phone reports it back. Android exposes WifiRttManager; iOS has no public API. Recorded here so it is not proposed again.
What is used instead: strongest-RSSI zone assignment. Each AP maps to one zone, the device belongs to the zone of the AP that hears it loudest. No coordinates, no survey, no calibration — and zone granularity is exactly what the twin renders.
graph LR
subgraph Building
AP1[AP - lobby]
AP2[AP - floor 2]
AP3[AP - canteen]
end
C[ap-collector]
AP1 -->|RSSI per station| C
AP2 --> C
AP3 --> C
C -->|aggregate counts, HMAC-signed| T[POST /telemetry/ingest]
T --> RD{{Redis}}
T --> PG[(Timescale)]A small daemon on the building network. Polls each AP every few seconds, holds one in-memory table, emits one signed batch per tick.
On OpenWrt the readings come from hostapd over ubus — hostapd.<iface> get_clients returns every associated station with its signal in dBm. iw dev <iface> station dump is the equivalent without ubus.
Per tick:
(mac, rssi) from every AP.Summing the client lists of overlapping APs is a straight multiplication of the count, and it produces exactly the symptom people expect from MAC randomization. It is the most likely bug in this component. One device, one MAC, one zone.
A device sitting on a zone boundary flips between two APs, generating phantom lobby → hall → lobby traffic at the poll rate. Transitions are the headline metric, so this matters more than the occupancy path.
Require N consecutive polls in the new zone, or an RSSI margin over the incumbent, before emitting a transition. Both thresholds are configuration, not constants.
The collector emits both numbers for every zone, every tick:
| Metric | What it is | Field |
|---|---|---|
totalDeviceCount | Devices associated in the zone. A measurement. | totalDeviceCount |
ratioDeviceCount | Devices ÷ the site’s devices-per-person factor. An estimate. | ratioDeviceCount |
Publishing only the estimate would be the smaller change and the wrong one. The factor is a per-building guess that gets re-measured — see step 7 — and an estimate is only as good as the factor it was derived with. Store the raw count and a corrected factor re-derives the whole history; store the estimate alone and every past bucket is permanently wrong by whatever the old factor was off by. The measurement is also the only one of the two that means the same thing in every building, which is what makes cross-site comparison possible at all.
ratioDeviceCount is emitted only where the site actually configured a factor (useDevicesPerPerson). No factor, no estimate — an estimate silently equal to the device count is a claim about people that nobody made.
Both follow the shape of the existing peopleCount plugin (backend/telemetry/src/plugins/people_count.rs): per building and room, non-negative integer, dashboard catalog, twin overlay, agent tool.
Neither carries a threshold. A device count is an access-point capacity question, not a facility one — it breaches on a room full of laptops. And the estimate beside it is that same count divided by a site-configured factor, so a bound on it fires on the factor as much as on the building. Occupancy alerting stays with peopleCount, which measures people instead of inferring them.
Separate from occupancy, and useful for Wi-Fi capacity planning rather than for the twin: how many stations each AP is carrying. It needs no deduplication, unlike the zone path — a station is associated to exactly one AP at a time, so the per-AP lists cannot double-count the way overlapping RSSI coverage can.
readings is keyed (building_id, room_id, metric, ts) with no AP dimension, and ingest does not validate room_id against building_rooms. So the cheap shape is a distinct metric with the AP name in roomId:
metric = "apClients", roomId = "ap-lobby-1", value = 14Zero migration, existing index, existing hourly rollup. The alternative — the AP name as an extra field in payload — stores fine but is not in the rollup’s group by, so it never aggregates, which is the entire point of collecting it. The cost of the cheap shape is that these rows are only distinguishable from real rooms by their metric key; every query is already metric-scoped, so it works, but it is a convention and not a constraint.
One new plugin, following the same shape:
static DESCRIPTOR: MetricDescriptor = MetricDescriptor {
value_field: "zoneTransition",
key: "zoneTransition",
label: "Zone Transition",
interface_name: "IZoneTransition",
unit: Some("people"),
fields: &[
FieldSpec { name: "buildingId", kind: NonEmptyString, required: true },
FieldSpec { name: "roomId", kind: NonEmptyString, required: true },
FieldSpec { name: "fromRoomId", kind: NonEmptyString, required: true },
FieldSpec { name: "timestamp", kind: Finite, required: true },
FieldSpec { name: "zoneTransition", kind: NonNegativeInt, required: true },
],
};
static BOUNDS: &[BoundSpec] = &[BoundSpec { key: "maxTransitions", field: "zoneTransition", label: "Zone transitions", unit: None, direction: Above }];roomId is the destination; fromRoomId is the extra field, so it lands in the stored payload while the columns stay as they are — consistent with Telemetry Storage. One registration line in backend/telemetry/src/main.rs and the metric is live everywhere.
maxTransitions is not decoration: a sudden flow spike across an edge is the signal for an evacuation or an incident, and the alert path is already built.
devices/, not simulators/. A simulator is a fake you throw away; the collector is the sensor, shipped to every building and running unattended. edge/ would collide with the ingress proxy, which is what “the edge” means everywhere else in this repository.
ap-collector: "devices/ap-collector" in .moon/workspace.yml..github/services.json with no image key. ci-gate.yml gates the docker matrix on select(.image and (.ci != false)), so omitting the image skips the build while the tests still run. Do not copy the simulators’ "ci": false — that also drops the package from coverage and from check-workflow-registry.py, and union-by-MAC is the likeliest bug in the design.ci-gate.yml using tpl-python-ci.yml, listed in both docker.needs and ci-passed.needs — check-workflow-registry.py fails otherwise.moon.yml overrides the inherited test / lint, which shell out to npm. Agent wraps uv in a package.json; this package cannot, because the root .gitignore ignores every package.json. Both overrides use script:, not command: — command: merges with the inherited task’s args and silently runs uv test./telemetry/ingest are both JSON over HTTP, and signing is hmac.new(key, body, sha256). urllib.request covers it.The risky unknowns here are physical, not code. Whether RSSI separates two rooms is a fact about where the APs were mounted, and no amount of software answers it. So steps 0–3 produce no product code at all, and each can kill or reshape the design for the cost of an afternoon. Nothing uncertain is allowed to survive past step 3, because step 4 is where code starts being expensive to throw away.
| Step | Output | Why here |
|---|---|---|
| 0 — Read RSSI off one AP | a shell one-liner returning (mac, rssi) | Hard gate on the whole track. Vendor firmware with no ubus, no SSH and no metrics endpoint means routers-only is dead and the vendor API or phase 2 takes over. A day-one discovery, not a month-two one. |
| 1 — Verify MAC stability | a walk log across two APs, iOS and Android | The load-bearing assumption, currently argued from the standard rather than measured in this building. A failure is almost always a split-SSID or open-guest misconfiguration — fixed in the Wi-Fi config, not in code. Cheapest possible test of the thing most expensive to get wrong. |
| 2 — Measure zone separability | (timestamp, mac, ap, rssi) log with hand-written ground truth | Make-or-break, and unfixable in software: if two zones do not separate, an AP moves or the zones merge. It also produces the zone map. Writing the daemon first means writing it against a map nobody validated. |
| 3 — Tune hysteresis offline | consecutive-poll count and RSSI margin | Replays step 2’s log. Tuning against a live system is slow and unreproducible — the same hallway cannot be re-walked identically. Turns two config values from guesses into measurements. |
| 4 — The collector daemon | devices/ap-collector, printing to stdout | Every parameter it needs was measured in 0–3; written earlier it is written with placeholders. Step 2’s log becomes the test fixture — replay it and assert the counts, with no Wi-Fi to mock. |
| 5 — Sign and ingest | POST /telemetry/ingest | Kept separate from step 4 so a failure is unambiguously a signing or transport bug, never an estimator bug. Golden vectors already exist in schemas/fixtures/internal-signature.json. |
| 6 — The telemetry plugins | totalDeviceCount, ratioDeviceCount, zoneTransition — one file each, one line each in main.rs | The only change to the shipped platform; everything before it is additive and outside the deployed stack. Last means the smallest blast radius, and the payload shapes are known from step 4’s real output rather than guessed. Ingest rejects an unregistered type and a batch is all-or-nothing, so until these exist the collector posts nothing at all. |
| 7 — Accuracy honesty pass | the devices-per-person factor, in config and in the docs | Needs the whole pipe live. It is also the number that decides whether the dashboard can be trusted: shipping without it ships a figure nobody knows how to read. |
Only once phase 1 runs in a real building. Adds coverage of devices that never associate.
Raspberry Pi with a dual-band monitor-mode NIC (mt7612u, ath9k), roughly €60 per unit. An ESP32 is €5 but 2.4 GHz only, and modern phones probe heavily on 5 GHz — systematic undercount. Acceptable for a bench prototype, not for a building.
Do not de-randomize. The literature is good — Bleach reports ~99%, vMac and Espresso combine information elements, sequence numbers and RSSI, one IE-attribute method reports 99% precision across 70+ device types — and every one of them is re-identification engineering. It buys accuracy this feature does not need, at the one cost it cannot pay.
Count without identity instead. RateCount (2025) estimates the device count from a provably unbiased closed form over the rate at which probe frames arrive, with an error model. Learning-free: no training set, no per-site model tuning, no MAC ever inspected. The Keio scheme is the same idea — a phone emits a roughly constant probe rate, so the rate scales with the population.
This is simultaneously the lazier engineering and the defensible privacy story.
Practical corrections the estimator needs: channel hopping means only a fraction of frames are heard, so a duty-cycle term; probe rate varies by phone model and screen state.
Phase 1 is only correct if the Wi-Fi is configured for it. These are requirements, not recommendations.
| Requirement | Why |
|---|---|
| One SSID across all APs | Per-SSID randomization means a second SSID is a second identity for the same phone. |
| One SSID across 2.4 and 5 GHz | Split-band SSIDs are two networks; use band steering. Standard practice regardless. |
| WPA2-AES or WPA3 | On open networks Apple rotates the private address every 2 weeks. Encryption pins it. |
| Known AP → zone mapping | The collector needs it. A flat config file; no coordinate survey. |
| Source | Effect | Handling |
|---|---|---|
| Multiple devices per person | Phone + laptop + watch = 3 counts, 1 human. Largest error term by far. | A stated conversion factor per building. Cannot be inferred. |
| People with no device | Undercount. | Inherent. Document the caveat. |
| Visitors not on the Wi-Fi | Undercount. | Phase 2. |
| Boundary flapping | Phantom transitions. | Hysteresis. |
| Android re-randomize on reconnect | Split identity. | Non-default per-network option. Rare; ignored. |
What is measured is devices; what a facility manager wants is people. That gap is the conversion factor above, and it is why both numbers are published separately rather than one being quietly substituted for the other. The caveat belongs in the user-facing docs too, not only here: ratioDeviceCount is an estimate and reads like a fact.
The conversion rounds up. One device at a factor of 2.5 is 0.4 of a person, and rounding that to zero reports an occupied room as empty — the one fact the collector otherwise takes care to keep distinct from a genuinely empty zone. An empty zone still converts to 0.
A rejected or unreachable ingest POST is logged and dropped, not raised: the run loop calls the post path directly, so an escaping error ends the collector on a single telemetry restart. One lost batch costs one poll interval of resolution; the next tick supersedes it.
Every number that depends on the physical world is configuration, per building:
None of these are constants. A building full of engineers and a building full of visitors have different device ratios, and no model can see that from the code.
Two numbers are not on that list, because they are ceilings rather than tuning:
requestTimeoutS <= pollIntervalS. Holds only because the collector polls every AP of a building concurrently. Polled in sequence a tick would cost the sum of its APs’ timeouts, so enough dark APs would silently slow the real poll rate below what the hysteresis numbers were tuned against. One AP’s own interfaces are still read in series, so an AP with many radios and a long timeout is the one case still worth checking by hand.--frozen-polls, 30 polls). A device behind a silent AP is frozen, not expired, so an AP reboot does not empty the building. Past this ceiling the outage is not a reboot: the device is dropped, or the track table grows for the life of the process and every stale device keeps padding its zone’s count.If the building runs Cisco, Aruba or Mist, the controller already computes client positions. Cisco Spaces Location Cloud API returns cartesian (x, y) per floor; Mist is equivalent.
Zero hardware, zero algorithm — but vendor lock-in, and the vendor holds the MAC addresses. That moves the privacy problem rather than removing it, and the platform then depends on a commercial contract. Worth taking where the hardware already exists; not worth designing for.
Ordered by how directly it feeds the build. The first group is code to read before writing any; the research groups are there to justify — or overturn — a decision, not to be reimplemented.
Read these first. Every one solves a piece of the collector.
wifi_stations collector in prometheus-node-exporter-lua, then polls /metrics on each AP every 5 s for wifi_station_signal_dbm. The simplest transport for the collector: no ubus auth, no custom ACLs.ubus client pulling per-station RSSI, connection quality and which AP interface each device is on.ubus interface (source) — authoritative definition of get_clients and the rest. The docs lag; the source does not.session.login for a token, then call with the object and method.get_hostapd_clients./usr/share/rpcd/acl.d/ needed to grant hostapd methods to a non-root RPC user. Easy to miss; nothing works without it.rpcd, uhttpd-mod-ubus, ACL) in user-facing terms.Rejected for this design, listed so the rejection is checkable rather than asserted.
d = 10 ^ ((Ptx - RSSI) / (10n)) whose exponent n is the per-site calibration that makes this expensive.tshark on a monitor-mode adapter. Small, readable, and the direct ancestor of the rate-based approach.Not planned. Tracked because it removes the identifier problem entirely.
wifi_sensing_demo for motion and human presence with on-site training.For validating an estimator before any hardware exists.
sapienza/probe-requests (IEEE DataPort) — the classic set: four one-shot events of 40 min to 6 h, plus six weeks of fixed capture on a campus.simulators/.Listed by their published title; orientation, not specification.
If the building already runs managed Wi-Fi, the position is computed for you.
(x, y) per floor.