/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Wireless People Flow

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.

Two phases, in order

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.


What This Does Not Build

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:

AggregateQuestion it answersMetric
Zone occupancyhow many people are in this zone nowtotalDeviceCount + ratioDeviceCount — new
Zone transitionshow many moved lobby → floor-2 in this windowzoneTransition — 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.


What Is Stored, And Why That Is Safe

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.

The mitigation goes on history, not on live data

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.


Why Routers-Only Works

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.

PlatformBehaviour on the building SSID
Android 10–15Persistent 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.

The objection is correct for the other regime

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.

Two regimes

RegimeWhat the AP seesRouters-only?
Associated — device joined the Wi-FiEvery frame, continuously, stable per-SSID MAC, RSSI per APYes. This is phase 1.
Unassociated — visitor never joinsSporadic probe requests only, and only if a radio is in monitor mode while servingNo. 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.


Zones, Not Coordinates

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.


Phase 1 — Routers-Only

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)]

The collector

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 ubushostapd.<iface> get_clients returns every associated station with its signal in dBm. iw dev <iface> station dump is the equivalent without ubus.

Per tick:

  1. Collect (mac, rssi) from every AP.
  2. Union by MAC, never sum. A device in range of three APs appears in three client lists.
  3. Assign each MAC to the zone of its strongest AP.
  4. Compare against the previous tick’s assignment to derive transitions.
  5. Emit per-zone counts and per-edge transition counts. Discard the MAC table’s identity column; keep only the previous-zone mapping, in memory, under a daily-rotating salt.

Union by MAC, never sum per-AP client counts

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.

Hysteresis

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 Platform Side

Occupancy is two metrics, not one

The collector emits both numbers for every zone, every tick:

MetricWhat it isField
totalDeviceCountDevices associated in the zone. A measurement.totalDeviceCount
ratioDeviceCountDevices ÷ 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.

Optional — per-AP client counts

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 = 14

Zero 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.

Transitions

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.

Registration

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.


Implementation Order

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.

StepOutputWhy here
0 — Read RSSI off one APa 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 stabilitya walk log across two APs, iOS and AndroidThe 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 truthMake-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 offlineconsecutive-poll count and RSSI marginReplays 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 daemondevices/ap-collector, printing to stdoutEvery 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 ingestPOST /telemetry/ingestKept 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 pluginstotalDeviceCount, ratioDeviceCount, zoneTransition — one file each, one line each in main.rsThe 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 passthe devices-per-person factor, in config and in the docsNeeds 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.

Phase 2 — Dedicated Sniffers

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.


Deployment Prerequisites

Phase 1 is only correct if the Wi-Fi is configured for it. These are requirements, not recommendations.

RequirementWhy
One SSID across all APsPer-SSID randomization means a second SSID is a second identity for the same phone.
One SSID across 2.4 and 5 GHzSplit-band SSIDs are two networks; use band steering. Standard practice regardless.
WPA2-AES or WPA3On open networks Apple rotates the private address every 2 weeks. Encryption pins it.
Known AP → zone mappingThe collector needs it. A flat config file; no coordinate survey.

Known Error Sources

SourceEffectHandling
Multiple devices per personPhone + laptop + watch = 3 counts, 1 human. Largest error term by far.A stated conversion factor per building. Cannot be inferred.
People with no deviceUndercount.Inherent. Document the caveat.
Visitors not on the Wi-FiUndercount.Phase 2.
Boundary flappingPhantom transitions.Hysteresis.
Android re-randomize on reconnectSplit 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.

Calibration

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:


Alternative — Consume the Vendor Controller

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.


Prior Art & References

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.

Phase 1 — closest existing implementations

Read these first. Every one solves a piece of the collector.

Phase 1 — the ubus / hostapd surface

Trilateration — reference implementations

Rejected for this design, listed so the rejection is checkable rather than asserted.

Phase 2 — sniffers and probe capture

Phase 3 — device-free CSI sensing

Not planned. Tracked because it removes the identifier problem entirely.

Standards and platform behaviour

Counting and localization research

Datasets and simulation

For validating an estimator before any hardware exists.

Videos

Listed by their published title; orientation, not specification.

Privacy and law

Vendor controller APIs

If the building already runs managed Wi-Fi, the position is computed for you.