CrossConnect, Connect. Monitor. Optimize.
by CybrIQ

Wireless & Occupancy Intelligence

A wireless engineer's walk through every occupancy feature CrossConnect ships: how the Wi-Fi you already run becomes a trustworthy, privacy-respecting picture of how your spaces are used, the exact math behind every number, and where the technology is heading next, all the way to 802.11bf Wi-Fi sensing.

Audience: wireless / network engineers, facilities and space planning, security and privacy reviewers
Scope: sources, the occupancy engine, zones and calibration, every metric, presence and dwell, scheduling, planning, coverage, energy, privacy, delivery, and 802.11bf Wi-Fi sensing
Posture: aggregate counts only, no personal data, a confidence band on every figure, advisory
Document: engineering reference, 24 June 2026
Contact: contact_us@cybriq.io

0 How to read this document

I am going to take you from the radio to the rooftop dashboard, one honest step at a time. CrossConnect measures occupancy from the wireless network you already own. No cameras, no badge readers, no motion PIRs, and it never learns who anyone is. It counts the devices associated to your access points, turns those counts into a careful estimate of people, and stamps every figure with how much you should trust it. Each section below explains one piece in plain language, shows you the actual formula we use, and draws the picture. Read it top to bottom and you will understand the whole system; jump to a section and it will stand on its own.

Production shipped and working today Configurable shipped, operator-enabled Experimental a preview, advisory only
1What it does, and how it fits 2The wireless sources 3From devices to people 4Calibration: teaching it your building 5Zones, geometry & AP placement 6The metrics, and what each one tells you 7Presence & dwell 8Patterns & the week-ahead forecast 9Scheduled vs actual 10Space planning & recommendations 11Find a seat 12Energy & carbon opportunity 13Coverage & RF 14Confidence & honest numbers 15Privacy & data handling 16Delivery & integration 17The switch-derived complement (Sense) 18802.11bf Wi-Fi sensing 19What ships today vs roadmap AFormula reference BData model & retention

1 What it does, and how it fits Production

Here is the whole idea in one breath. Every access point already knows how many client devices are talking to it right now. CrossConnect reads that number, figures out which room each access point lives in, adds the rooms up into floors and buildings, divides devices by a calibrated ratio to get an estimate of people, and writes a clean reading every fifteen minutes. That one engine then feeds a live dashboard, a read-only API, a plain-language assistant, and the room-readiness picture the rest of the platform uses. You did not install anything new in the ceiling. You are reading a sensor you already paid for.

flowchart LR
  WIFI["Your Wi-Fi network
access points you already run"] --> ENG["CrossConnect
occupancy engine"] ENG --> DASH["Live dashboard"] ENG --> API["API & BI feed"] ENG --> AI["Plain-language answers"] ENG --> RDY["Room & AV readiness"] classDef src fill:#ffffff,stroke:#9aa8c0,color:#173a6b; classDef eng fill:#173a6b,stroke:#0f2a4f,color:#ffffff; classDef out fill:#e3f3f6,stroke:#1797b3,color:#173a6b; class WIFI src; class ENG eng; class DASH,API,AI,RDY out;
Figure 1. One engine, many windows. The Wi-Fi you already run feeds a single occupancy engine; every screen, feed, and answer reads from the same record.

Four promises run through everything that follows. Keep them in mind and the rest of the document makes sense.

Counts, never identities

The unit is an associated device, aggregated to a zone. No MAC address, no client id, no personal data is ever stored. Identity is dropped at the door, before anything is written.

People, with a stated confidence

A calibrated devices-per-person ratio turns device counts into a headcount, and every figure carries a HIGH, MEDIUM, or LOW band so a rough number is never mistaken for a precise one.

Real first, never a mock

If a richer vendor feed is connected it is used; if not, the engine still produces occupancy from the access-point poll the platform already runs. It never waits on an integration to start working.

Part of one model

Zones, floors, and buildings are the same source-of-truth locations the rest of CrossConnect uses. Occupancy is not a bolt-on silo; it sits next to coverage, RF, and the bill of materials, so a weak reading can hand you the fix.

2 The wireless sources Production

Four kinds of source feed the engine. They look different on the wire, but every one of them is boiled down to the same simple shape before it is stored: a zone, a timestamp, and a device count. The vendor-specific code stops at the parser. Everything downstream is identical no matter where the number came from.

flowchart LR
  AP["Access-point radios
always on, zero setup"] -->|poll| N MIST["Juniper Mist"] -->|poll + webhooks| N CAT["Cisco Catalyst Center"] -->|poll| N SPACES["Cisco Spaces"] -->|webhook push| N N{"Normalize
one clean shape"} --> SAMP[("Occupancy sample
zone · time · count")] classDef src fill:#ffffff,stroke:#9aa8c0,color:#173a6b; classDef gate fill:#fff6e9,stroke:#e0892a,color:#173a6b; classDef store fill:#1797b3,stroke:#0d7d90,color:#ffffff; class AP,MIST,CAT,SPACES src; class N gate; class SAMP store;
Figure 2. Many radios in, one record out. Polls and pushes from any supported source are normalized into one append-only series. The baseline access-point poll means there is always a signal, even with nothing else connected.
SourceHow it arrivesWhat it gives usAccess
Access-point radios
the baseline
The controller poll the platform already runs, every cycle.Associated client count per radio, plus band, channel, and transmit power. On Cisco AireOS this is the SNMP OID bsnAPIfLoadNumOfClients; on Catalyst 9800 it is the RESTCONF field num-assoc-clients (the 9800 dropped the old client-count OID).Read-only, no extra config.
Juniper MistPoll the site zones API every 15 minutes; optional WebSocket for live coordinates; webhooks for zone enter/exit.GET /sites/:id/zones returns per-zone client counts and average dwell. Enter/exit webhooks feed the dwell engine.Org API token, Observer (read-only) role, encrypted at rest.
Cisco Catalyst CenterPoll the client trend API every 15 minutes.True 15-minute and hourly client-count buckets from clients/trendAnalytics. We keep our own copy because Catalyst only retains a couple of weeks.X-Auth-Token, encrypted at rest.
Cisco SpacesSpaces pushes events to our webhook (Firehose).DEVICE_COUNT and SPACE_OCCUPANCY events, matched to a space by name.Issued key in X-API-Key, validated strictly. A tenant id is not a secret.
Calendar / schedulingCSV import today (location, expected people, start, end); a direct 25Live / Series25 pull is on the roadmap.The room booking schedule, used in Section 9 to compare what was booked against what actually happened.Operator import, advisory only.
Floor plans & room inventoryGeoJSON / IMDF import; CAD/DWG conversion is done upstream on purpose.Zone polygons, room numbers, and (joined from an IWMS like Archibus or TRIRIGA) the authoritative room name, type, and rated capacity.Operator import.
A small engineering honesty. Mist's polished 15-minute heatmap lives in Premium Analytics, which has no API. So we do not screen-scrape it. We rebuild our own 15-minute buckets from the /zones poll and the zone webhooks. Every source, no matter how fancy its own dashboard, lands in the same humble row: zone, time, count, and where it came from.

Every collector is read-only, dormant until you give it a credential, and built to fail quietly: a five-second connect timeout, a ten-second read timeout, and on any error it logs and returns nothing rather than throwing. If a vendor feed goes dark, the access-point baseline keeps the lights on.

3 From devices to people Production

This is the heart of it, so let me slow down. A radio hands us a device count. We need a people count. Five steps get us there, and I will show you the math at each one.

flowchart LR
  C["Associated devices
per access point"] --> ZONE["Place each AP in a zone
(point in polygon)"] ZONE --> ROLL["Roll up
zone → floor → building"] ROLL --> CAL["Divide by devices-per-person
(calibrated)"] CAL --> CONF["Attach a confidence band"] CONF --> GUARD["Hide tiny counts
(privacy)"] GUARD --> OUT["Estimated people"] classDef a fill:#173a6b,stroke:#0f2a4f,color:#ffffff; classDef g fill:#fff6e9,stroke:#e0892a,color:#173a6b; class C,ZONE,ROLL,OUT a; class CAL,CONF,GUARD g;
Figure 3. The estimate pipeline. Device counts become a people estimate through placement, roll-up, calibration, a confidence band, and a privacy guard, in that order.

Step one, place the radio. Each access point sits on a floor plan at an (x, y) coordinate. A tested point-in-polygon routine asks "which room polygon contains this point?" and assigns the AP to exactly one zone. If we do not have geometry for an AP yet, it falls back to its nominal location, so a partial setup still works.

Step two, roll it up. Each zone's devices are added up the location tree, zone into floor into building into campus, so you can ask the question at any altitude. We only sum the leaf zones for the estate total, so nobody gets counted twice.

Step three, divide by the ratio. People do not carry exactly one device. They carry a phone, maybe a laptop, maybe a watch. So we divide the device count by a devices-per-person ratio that we measured for your building (Section 4 is all about measuring it).

estimatedPeople = round( associatedDevices ÷ devicesPerPerson ) // 0 if there are no devices. If the ratio is somehow ≤ 0, fall back to 1.0 // (one device ≈ one person) instead of dividing by zero. Never negative.

Until a building is calibrated, the ratio defaults to 1.0 (one device equals one person) and the reading is automatically marked LOW confidence. That is deliberate: an honest rough number, clearly labelled, beats a confident wrong one.

Step four, attach a confidence band, covered in Section 14. Step five, guard small numbers, covered in Section 15. By the time a people estimate reaches a screen it has already passed through privacy.

4 Calibration: teaching it your building Production

The devices-per-person ratio is the one number that turns a generic counter into your counter. We learn it the old-fashioned way: someone walks a space during a busy hour and writes down the real headcount a few times. We pair each headcount with the device count the radios saw at that moment, and we pool them.

devicesPerPerson = Σ(associatedDevices) ÷ Σ(groundTruthHeadcount) across all observations = max(0.1, ratio) // never below 0.1

We sum the totals rather than averaging per-sample ratios, because pooling is far more robust to the noise in any single count. If we have no usable ground truth yet, the ratio stays at 1.0 and confidence stays LOW.

We also report how good the fit is, so you can trust the calibration itself. The model is the simplest possible straight line through the origin, devices ≈ factor × people, and we score it with R-squared: the fraction of the variation the ratio explains, on a 0 to 1 scale.

R² = clamp01( 1 − ssRes ÷ ssTot ) ssRes = Σ ( associatedDevices − devicesPerPerson × groundTruthHeadcount )² // what the ratio misses ssTot = Σ ( associatedDevices − mean(associatedDevices) )² // total spread

An R-squared near 1.0 means the ratio predicts your device counts almost perfectly; near 0 means it is no better than guessing the average. We require at least two observations and some real spread before we claim any fit. Each calibration is stamped with the ratio, the R-squared, and a 90-day validity window; after that, confidence quietly drops until you re-measure. In the field this is usually a one-to-two-week ground-truth pass, then it just runs.

Where it is strong, where it is humble. Aggregated to a building or a floor, this approach is genuinely strong, with published work showing R-squared around 0.95. At the single small-room level it is weaker, on the order of a few people of error, which is exactly why per-site calibration matters and why every figure carries a confidence band. We count associated clients on your secured network, not probe requests, which sidesteps the MAC-randomization problem that limits probe-sniffing footfall products to capturing only six to twelve percent of real visitors.

5 Zones, geometry & AP placement Production

Occupancy is a spatial idea, so the system is spatial first, not a spreadsheet with room names. A zone is a real polygon on a floor plan, with an optional area and a rated capacity. An access point is a point on that same plan. The point-in-polygon test is what marries the two.

flowchart LR
  AP["Access point
at (x, y) on the floor"] --> PIP{"Which polygon
contains it?"} ZP[("Zone polygons
rooms & areas")] --> PIP PIP --> Z["Belongs to one zone"] Z --> U["People ÷ capacity
= utilization %"] classDef a fill:#173a6b,stroke:#0f2a4f,color:#ffffff; classDef s fill:#1797b3,stroke:#0d7d90,color:#ffffff; classDef g fill:#fff6e9,stroke:#e0892a,color:#173a6b; class AP,Z a; class ZP s; class PIP,U g;
Figure 4. Geometry does the bookkeeping. Each access point resolves to exactly one room by polygon containment; capacity turns a headcount into a utilization percentage.

Capacity is what makes a raw count meaningful. Twelve people in a fourteen-seat control room is a packed room; twelve people in a ninety-seat atrium is nearly empty. The conversion is the most basic one in the system.

utilization% = max( 0, 100 × people ÷ capacity ) // blank if no capacity is set

Zone polygons come in through a GeoJSON / IMDF importer. We deliberately keep CAD/DWG conversion upstream, where mature tools (FME, ArcGIS Indoors) already turn architectural drawings into clean room polygons, and we join your space-management system for the authoritative room name, type, and seat count. When a floor plan is revised, the re-import is an operator-confirmed proposal, never a silent overwrite. A wiring-closet marker layer (IDF and MDF) rides on the same floor coordinates and feeds the cable-run math in the coverage tools.

6 The metrics, and what each one tells you Production

Now the payoff. Once a clean series exists, the engine computes a small, deliberate vocabulary of metrics over any window you ask for. We chose these names on purpose to match how facilities and space-planning teams already think, with one column the competition does not have: a confidence band on every single one. Here is every metric, what it answers, and exactly how it is computed.

MetricWhat it tells youHow it is computed
Estimated peopleHow many people are in the space right now.Devices ÷ devices-per-person, per Section 3.
PeakThe busiest it got, and when.The maximum estimated people in the window; ties go to the earliest time.
AverageThe typical level of use.Σ people ÷ number of intervals.
Fill RateHow full versus the seats you have. Peak Fill and Average Fill.100 × people ÷ capacity, at the peak and on average. Can exceed 100% (over capacity).
Occupied RateWhat share of the time the space was actually in use.100 × intervals with ≥ 3 people ÷ total intervals. The threshold (default 3) is configurable.
Intensity
People-Minutes / m²
How hard the floor space works, independent of room size. Good for comparing a big hall to a small office.Σ( people × interval minutes ) ÷ floor area (m²).
DwellHow long people stay (average and median).Mean and median of merged visit lengths, per Section 7.
Pass-through ratioHow much of the traffic is just walking through versus staying.100 × visits under 5 minutes ÷ total visits.
ConfidenceHow much to trust the figures above.The worst band of any sample in the window: one LOW makes the window LOW (Section 14).

The two intensity formulas deserve a second look because they are the ones planners actually fight over.

avgPeople = Σ estimatedPeople ÷ n occupiedRate% = 100 × (intervals with people ≥ 3) ÷ n peakFill% = 100 × peak ÷ capacity avgFill% = 100 × avg ÷ capacity peopleMinutesPerM² = Σ( people × (intervalSeconds ÷ 60) ) ÷ areaM²
A unit note, because we would rather tell you. The area column is historically named "area_sqft," but the importer actually stores square metres (the polygons are measured in metre vertices). So intensity is honestly labelled per square metre everywhere. If you ever need square feet, the factor is ×10.764.

To make this concrete, here is the metric set computed on a live 72-hour window for a working broadcast facility. Read it like a wireless engineer would: Studio A is genuinely over capacity (122% peak fill), and the Atrium is a ninety-seat room that never breaks twelve percent, which is a right-sizing conversation waiting to happen.

ZoneCapacityPeakAvgOccupied RatePeak FillAvg FillIntensity (per m²)
Studio A607338.2100%122%64%1528
Studio B454020.8100%89%46%833
Newsroom503216.5100%64%33%510
Control Room14147.179%100%51%263
Atrium90116.076%12%7%217
Edit Bays1852.854%28%16%111

7 Presence & dwell Production

Counting people is a snapshot. Dwell is the story over time: how long do people actually stay? When a source emits enter and exit events (Mist does today), we pair them into a visit and store only the zone, the start, the end, and the duration. Never an identity. The one subtlety is human behaviour: people step out for coffee and come back. We do not want that to read as two short visits, so we merge any sessions whose gap is small.

For sessions sorted by start time: if next.enter ≤ runningEnd + mergeGap → extend the current visit else → close it, start a new one dwell = sum of the merged visit lengths // mergeGap = 300 seconds (5 minutes)

From those merged visits we report average dwell, median dwell, and the pass-through ratio (the share of visits shorter than five minutes, which is how you tell a corridor from a meeting room). On the same live facility, the dwell engine already shows an average stay of about 29.5 minutes across the populated zones.

8 Patterns & the week-ahead forecast Production

People are creatures of habit, and buildings breathe on a weekly rhythm. The patterns view collapses your history into a grid of 168 cells, one for every hour of every weekday, and colours each by how full the space typically is at that hour. Tuesday at 10am lights up; Friday at 4pm fades. It is the single most useful picture for scheduling.

cell[weekday][hour] = average estimated people for that slot cell colour = 100 × avgPeople ÷ capacity // the Fill % for that slot peak slot = the highest non-suppressed cell

From that same grid we offer a deliberately simple, honest forecast. We do not pretend to do deep learning on a building's calendar. We take the typical level for each weekday-hour and nudge it by the recent trend.

trend% = 100 × (last 7 days − prior 7 days) ÷ prior 7 days predictedPeak = round( slotAverage × (1 + trend% ÷ 100) ) confidence = HIGH if ≥ 14 samples behind the slot, MEDIUM if ≥ 4, else LOW

That is the whole forecast: last week's shape, tilted by this fortnight's direction, with a confidence that honestly reflects how much history stands behind it.

9 Scheduled vs actual Production

This is where occupancy earns its keep with facilities and registrars. Import the booking schedule, overlay it on what we actually measured, and label each hour. It writes nothing back to your calendar; it is pure, advisory comparison.

flowchart LR
  BOOK["What was booked
(schedule)"] --> CMP{"Compare,
hour by hour"} MEAS["What we measured
(people)"] --> CMP CMP --> M["Match"] CMP --> NS["No-show
booked, empty"] CMP --> INF["Informal use
busy, not booked"] CMP --> OV["Over capacity"] CMP --> UN["Underused"] classDef ok fill:#e3f5ea,stroke:#1f9d57,color:#0f5132; classDef warn fill:#fff6e9,stroke:#e0892a,color:#7a4a12; class M ok; class NS,INF,OV,UN warn;
Figure 5. Booked versus busy. Each hour is labelled by comparing the reservation to the measured headcount. No-shows and informal use are the two findings rooms-bookers care about most.
scheduled = sum of overlapping bookings for the hour actual = measured people nearest the hour if scheduled = 0 → UNSCHEDULED if actual > 3, else MATCH if actual ≤ 3 → NO-SHOW // booked, but empty if actual < 0.5 × scheduled → UNDERUTILIZED // under half the booking showed if actual > 1.5 × scheduled → OVERFULL // half again more than booked otherwise → MATCH

Roll those labels up to a term or a semester and you get an attendance-validation report: registered versus occupied, building by building, with the no-shows that free up rooms and the informal use that reveals demand the schedule never captured.

10 Space planning & recommendations Production

A pile of metrics is not a plan. The planning engine reads the estate and hands you a short, ranked list of things worth doing, each with the evidence behind it and a one-click way to act. The rules are simple on purpose, so you can always see why a recommendation appeared.

over-capacity : utilization > 100% // priority 0, fix first low-confidence-coverage: confidence LOW and fewer than 2 APs // priority 1, the closed loop right-size : 0% ≤ utilization < 15% // priority 2, under-used low-confidence : confidence LOW but enough APs // priority 3, calibrate or refresh

The second rule is the one no occupancy-only tool can offer, and it is the reason occupancy lives next to coverage in this product. If a room reads LOW only because it is thin on access points, we do not just shrug; we send you straight to the coverage map and the bill of materials to add an AP. The occupancy problem and the wireless fix are the same screen. That is the whole argument for building these two things in one platform.

11 Find a seat Production

The same numbers, pointed at a person standing in a hallway with a laptop. Find-a-seat turns occupancy into a live "where can I sit right now" board across study spaces, lounges, and open areas.

freeSeats = max( 0, capacity − estimatedPeople ) status: Open if utilization < 70% Filling if 70% ≤ utilization < 90% Full if utilization ≥ 90%

It sorts the most-open spaces first. It is a small feature that quietly demonstrates the whole system: a real measurement, a capacity, a threshold, and a useful answer, with no camera in sight.

12 Energy & carbon opportunity Configurable

If you know how often a space is empty, you know how much conditioning is being wasted on nobody. This view turns the Occupied Rate into an estimate of avoidable energy, cost, and carbon. The occupancy is ours and real; the energy factors are industry benchmarks you can override, so the result is labelled an inference until you connect a meter or building-management feed, at which point it becomes measured.

emptyHoursPerYear = conditioningHoursPerWeek × 52 × (1 − occupiedRate% ÷ 100) avoidableKWh = emptyHours × area(m²) × kwhPerM²PerHour cost = avoidableKWh × costPerKwh co2 = avoidableKWh × co2PerKwh

The default benchmarks, all adjustable, are 60 conditioning hours per week, 0.04 kWh per square metre per hour, $0.13 per kWh, and 0.40 kg of CO₂ per kWh. Change the tariff and the grid factor to your region and the estimate follows.

13 Coverage & RF Production

Because occupancy depends on access points, the platform also models the radio side, on the same floor geometry. The coverage view predicts signal strength across a grid using the standard indoor log-distance path-loss model. This is a prediction, clearly labelled, not a survey; the client counts on it, however, are the real ones from the radios.

RSSI(dBm) = txPower − ( 40 + 30 × log10( distanceMetres ) ) // 40 dB is the loss at 1 metre; 30 = 10 × path-loss exponent (n = 3.0, typical indoor). // Each grid cell takes the strongest signal from any access point.

Cells are then banded the way any wireless engineer reads a heat map, and coverage is the share of the floor that is usable.

Strong ≥ −58 Good ≥ −67 Fair ≥ −75 Weak ≥ −82 Dead otherwise // dBm coverage% = 100 × cells at or above −72 dBm ÷ total cells

On top of this sit the practical tools: a draggable AP and IDF/MDF placement, a co-channel overlap check (same 2.4 GHz channel and too close together), a per-site health roll-up (Good when coverage is at least 90% with no overlaps), and a Bill of Materials with a model-lifecycle check so you do not spec an access point that is going end-of-sale. This is the screen the planning engine sends you to when a room reads LOW for lack of coverage.

14 Confidence & honest numbers Production

Here is the philosophy that separates this from a vanity dashboard: we never print a bare number. Every figure carries HIGH, MEDIUM, or LOW, and the rule is mechanical, not a vibe. Three things have to be true for HIGH.

flowchart TB
  S["A number to report"] --> Q1{"Feed fresh?
(seen in the last 30 min)"} Q1 -- no --> LOW["LOW"] Q1 -- yes --> Q2{"Calibrated?
(within 90 days)"} Q2 -- no --> LOW Q2 -- yes --> Q3{"At least 2 access points
covering the zone?"} Q3 -- yes --> HIGH["HIGH"] Q3 -- no --> MED["MEDIUM"] classDef hi fill:#e3f5ea,stroke:#1f9d57,color:#0f5132; classDef me fill:#fff6e9,stroke:#e0892a,color:#7a4a12; classDef lo fill:#fdeceb,stroke:#c5483b,color:#7a241c; class HIGH hi; class MED me; class LOW lo;
Figure 6. How a number earns its band. A stale feed or a missing calibration is always LOW. Thin access-point density caps a good reading at MEDIUM. HIGH requires all three.
calibValid = calibrated AND calibration age ≤ 90 days feedFresh = last poll ≤ 30 minutes ago adequateAps = at least 2 access points cover the zone if NOT feedFresh OR NOT calibValid → LOW else if adequateAps → HIGH else → MEDIUM

Over a window, the confidence is the worst band of any sample inside it: one stale reading drags the whole window down, which is the conservative, defensible choice. The two things that cause LOW, a stale feed and a missing calibration, are surfaced as fixable operational issues with named severities, not buried. A weak number tells you the truth about itself and points at the remedy.

15 Privacy & data handling Production

The strongest privacy guarantee is the one the database enforces, not the one in a policy PDF. There is no column to store a person in. The occupancy tables hold a zone, a time, and a count. That is all there is room for.

flowchart LR
  IN["Wireless signal"] --> DROP["Drop identity at the door
no MAC, no client id"] DROP --> S15["15-minute counts"] S15 -->|after ~2 days| HR["Hourly"] HR -->|after ~60 days| DAY["Daily"] DAY -->|after ~2 years| GONE["Pruned"] classDef d fill:#fff6e9,stroke:#e0892a,color:#173a6b; classDef s fill:#e3f3f6,stroke:#1797b3,color:#173a6b; class DROP d; class S15,HR,DAY s;
Figure 7. Identity never lands. Any client identifier in an incoming event is hashed with a per-run salt and dropped at ingest; only counts survive, and they age down a retention ladder.

16 Delivery & integration Production

One engine, many ways to consume it. The dashboard gives a utilization-banded floor-plan heatmap, per-zone trends, a KPI rail, and role-oriented framing for executives, facilities, and planners. Beyond the screen there is a read-only REST API (current occupancy, trend series at 15-minute, hourly, and daily resolution, and average dwell), an OData v4 feed that drops natively into Power BI and Domo, a one-page executive PDF briefing, and the conversational assistant, which answers occupancy questions, cites the records it used, honours role-based access, and says "I don't know" rather than guessing. Occupancy also feeds the room and AV readiness picture the rest of the platform shows: empty, in use, headroom, and booking variance.

17 The switch-derived complement (Sense) Experimental

There is a second, independent way to tell whether a room is in use that needs no wireless feed at all. Sense reads the wired switch: how much Power-over-Ethernet a room is drawing, what services are announcing themselves over mDNS, and which multicast groups have joined over IGMP. From those three wired signals it infers whether a room is occupied or idle, whether AV is live, and whether something was physically unplugged. It is a complement, not a replacement, and it is off by default.

Because it is an inference rather than a count, Sense uses a different, additive confidence model. Each present signal adds points, and the remainder is shown as the model's honest uncertainty.

confidence = PoE draw (30) + IGMP AV group (28) + mDNS AV announcement (22) + recent observation (14) capped at 100; the leftover is shown as "honest uncertainty" presence: OCCUPIED if AV in use, or PoE draw ≥ 25 W IDLE if powered but quiet UNKNOWN if not seen in the last 30 minutes

Keep the two engines straight: the Wi-Fi engine counts people and bands them by a rule; Sense infers room state from wired signals and scores it by points. They answer different questions and never share a number.

18 802.11bf Wi-Fi sensing Experimental

Every method so far needs a device. We count phones and laptops and divide by a ratio, which works beautifully for a working population but has one real blind spot: the person who left their phone at the desk, the visitor with nothing connected, the quiet room of people simply listening. 802.11bf closes that gap, and CrossConnect reads it as one more source. Ratified in this generation of the IEEE spec as WLAN Sensing, it turns the radio itself into a presence sensor.

The idea is elegant. Your access points and clients already measure, hundreds of times a second, exactly how the radio waves bounce around a room, the channel state information, or CSI. When a person moves, breathes, or simply sits in the path, that pattern shifts. 802.11bf standardizes a way to read those shifts on purpose, so the Wi-Fi becomes a motion-and-presence sensor. No camera, no device on the person, no badge.

Inside the platform, sensing arrives exactly the way every other source does. A sensing collector reads per-zone CSI summaries from the access points, reduces them to a simple motion-and-presence signal, writes it into the same occupancy sample, earns the same confidence band, and lives under the same no-identity, small-number-suppressed privacy rules. We do not try to read gestures or count exact bodies from the waveform. We keep it to the honest question the physics answers well, "is this space active, and roughly how much," and then we fuse that with the device count.

motionIndex = how much the channel pattern moved versus the room's quiet baseline // CSI variance presence: ACTIVE if motionIndex is above the room's learned quiet threshold STILL if a steady occupied pattern holds without motion EMPTY if the pattern matches the learned empty baseline fused reading = device count where devices are present; sensing fills in when a zone reads empty of devices but active in the RF, and raises confidence when the two signals agree

So the device count and the sensing signal reinforce each other: associations tell you how many, sensing tells you that someone is there at all, and together they are stronger than either alone, including the case of a busy room with almost nothing connected.

flowchart LR
  subgraph COUNT["Device counting"]
    A1["Devices on the Wi-Fi"] --> P1["Count, then estimate people"]
  end
  subgraph SENSE["802.11bf sensing"]
    A2["Channel state changes
motion in the radio, no device needed"] --> P2["Presence, even with no phone"] end P1 --> ENG["The same engine
same sample, same confidence, same privacy"] P2 --> ENG classDef a fill:#173a6b,stroke:#0f2a4f,color:#ffffff; classDef f fill:#efe9fb,stroke:#5b3a9e,color:#3b2469; classDef e fill:#1797b3,stroke:#0d7d90,color:#ffffff; class A1,P1 a; class A2,P2 f; class ENG e;
Figure 8. Two ways to know a room is busy. Device counting and 802.11bf sensing feed the same engine; sensing adds device-free presence and fills the gaps counting cannot see, without touching the privacy and confidence model.

There are real things to respect, and we treat them the same honest way as everything else. Sensing is exquisitely sensitive, which is exactly why the privacy posture matters more, not less: we keep it to coarse presence and motion at the zone level, never gesture or biometric inference, and the no-identity guarantee holds by construction because there is still no person to name. Support also matures vendor by vendor and radio generation by radio generation, so it ships Experimental: a labelled, advisory signal that strengthens a zone's reading and provides a device-free fallback, not a precise new truth. It is the same discipline you have read in every section above, now pointed at the newest capability in the standard.

19 What ships today vs roadmap

CapabilityStatusNote
Occupancy engine, counts & peopleProductionAP-radio baseline plus Mist, Catalyst, and Cisco Spaces.
Calibration & confidence bandsProductionPer-building ratio, R-squared, 90-day validity.
All metrics & the day-by-hour patternsProductionFill, Occupied Rate, Intensity, dwell, pass-through.
Forecast (next-week peak)ProductionDeliberately simple: typical shape, recent trend.
Scheduled vs actual, attendanceProductionCSV import today; direct 25Live/Series25 on the roadmap.
Space planning, find-a-seat, coverage, BoMProductionThe occupancy-to-coverage closed loop.
Energy & carbon opportunityConfigurableInferred from benchmarks until a meter or BMS connects.
Sense (switch-derived presence)ExperimentalPoE, mDNS, IGMP. Off by default.
Automated CAD/DWG zone import; per-room PoERoadmapGeoJSON in place; CAD conversion stays upstream.
802.11bf Wi-Fi sensingExperimentalDevice-free presence and motion from CSI, fused with device counts. Coarse, advisory.

A Formula reference

NameFormulaKey constants
Estimated peopleround(devices ÷ devicesPerPerson)default ratio 1.0 (uncalibrated → LOW)
Calibration ratioΣdevices ÷ Σheadcount, flooredfloor 0.1; validity 90 days
Fit qualityR² = clamp01(1 − ssRes/ssTot)needs ≥ 2 observations
Utilization / Fill100 × people ÷ capacitymay exceed 100%
Occupied Rate100 × intervals(people ≥ 3) ÷ intervalsthreshold 3
IntensityΣ(people × minutes) ÷ area(m²)per square metre
Dwellsum of merged visit lengthsmerge gap 300 s; pass-through < 5 min
Confidencefresh & calibrated & ≥2 APs → HIGHfeed 30 min; calib 90 days
Small-N suppressionhide if 1 ≤ people ≤ 3floor 3
Forecast peakslotAvg × (1 + trend%)≥14 HIGH, ≥4 MEDIUM
Schedule vs actualempty 3, under 0.5×, over 1.5×hourly buckets
Find a seatcapacity − people; Open/Filling/Full70% / 90%
EnergycondHrs×52×(1−occRate) × area × kWh/m²/h60 hrs/wk, 0.04 kWh, $0.13, 0.40 kg
Coverage RSSItxPower − (40 + 30·log10(d))n = 3.0; usable −72 dBm
Sense confidencePoE 30 + IGMP 28 + mDNS 22 + recency 14occupied ≥ 25 W; stale 30 min

B Data model & retention

TableHoldsPrivacy / retention
occupancy_sampleAppend-only series: zone, bucket time, interval (900/3600/86400 s), raw devices, estimated people, confidence, source, method note.No identity column exists. 15-min → hourly (~2 days) → daily (~60 days) → pruned (~2 years).
zone_geometryZone polygon (GeoJSON/IMDF), floor, area (m²), rated capacity.Spatial only.
ap_placementAccess point (x, y) on a floor; resolved zone.Point-in-polygon resolution.
occupancy_calibrationPer-building devices-per-person ratio, the raw (devices, headcount) pairs, R-squared, calibrated-at, valid-until.One active row per building.
occupancy_visitAnonymized dwell: zone, start, end, dwell seconds, source.No device or client identity.
mist_source_config / catalyst_source_configSite id, host/base URL, encrypted token, enabled flag.Credentials encrypted at rest.
scheduled_occupancyImported bookings: location, title, expected people, start, end, source (CSV/25Live).Advisory only.
floor_markerIDF/MDF wiring-closet markers on the floor, for cable-run math.Same floor coordinates as AP placement.