mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-26 13:10:40 +00:00
The Rust port lived two directories deep (rust-port/wifi-densepose-rs/) without any sibling under rust-port/ that warranted the extra level. Move the whole workspace up to v2/ to match v1/ (Python) at the same depth and shorten every cd / build command across the repo. git mv preserves history for all tracked files. 60 files updated for path references (CI workflows, ADRs, docs, scripts, READMEs, internal .claude-flow state). Two manual fixes for relative-cd paths in CLAUDE.md and ADR-043 that became wrong after the depth change (cd ../.. → cd ..). Validated: - cargo check --workspace --no-default-features → clean (after target/ nuke; the gitignored target/ was carried by the OS rename and had hard-coded old paths in build scripts) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (same totals as pre-rename) - ESP32-S3 on COM7 → still streaming live CSI (cb #40300, RSSI -64 dBm) After-merge follow-up: contributors should `rm -rf v2/target` once and let cargo regenerate from the new path.
403 lines
12 KiB
Rust
403 lines
12 KiB
Rust
//! Meeting room state tracking — ADR-041 Category 3: Smart Building.
|
|
//!
|
|
//! State machine for meeting room lifecycle:
|
|
//! Empty -> PreMeeting -> Active -> PostMeeting -> Empty
|
|
//!
|
|
//! Distinguishes genuine meetings (multi-person, >5 min) from transient
|
|
//! occupancy (brief walk-through, single person using the room).
|
|
//!
|
|
//! Tracks meeting start/end, peak headcount, and utilization rate.
|
|
//!
|
|
//! Host API used: `csi_get_presence()`, `csi_get_n_persons()`,
|
|
//! `csi_get_motion_energy()`
|
|
|
|
// No sqrt needed — pure arithmetic and comparisons.
|
|
|
|
/// Minimum frames for a genuine meeting (5 min at 20 Hz = 6000 frames).
|
|
const MEETING_MIN_FRAMES: u32 = 6000;
|
|
|
|
/// Minimum persons to qualify as a meeting (vs solo use).
|
|
const MEETING_MIN_PERSONS: u8 = 2;
|
|
|
|
/// Pre-meeting timeout: if not enough people join within 3 min (3600 frames),
|
|
/// revert to Empty.
|
|
const PRE_MEETING_TIMEOUT: u32 = 3600;
|
|
|
|
/// Post-meeting timeout: room goes Empty after 2 min (2400 frames) of vacancy.
|
|
const POST_MEETING_TIMEOUT: u32 = 2400;
|
|
|
|
/// Presence threshold (from host 0/1 signal).
|
|
const PRESENCE_THRESHOLD: i32 = 1;
|
|
|
|
/// Event emission interval.
|
|
const EMIT_INTERVAL: u32 = 20;
|
|
|
|
// ── Event IDs (340-343: Meeting Room) ───────────────────────────────────────
|
|
|
|
pub const EVENT_MEETING_START: i32 = 340;
|
|
pub const EVENT_MEETING_END: i32 = 341;
|
|
pub const EVENT_PEAK_HEADCOUNT: i32 = 342;
|
|
pub const EVENT_ROOM_AVAILABLE: i32 = 343;
|
|
|
|
/// Meeting room state.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum MeetingState {
|
|
/// Room is unoccupied and available.
|
|
Empty,
|
|
/// Someone entered; waiting to see if a meeting materializes.
|
|
PreMeeting,
|
|
/// Genuine meeting in progress (multi-person, sustained).
|
|
Active,
|
|
/// Meeting ended; clearing period before marking room available.
|
|
PostMeeting,
|
|
}
|
|
|
|
/// Meeting room tracker.
|
|
pub struct MeetingRoomTracker {
|
|
state: MeetingState,
|
|
/// Frames in current state.
|
|
state_frames: u32,
|
|
/// Current person count from host.
|
|
n_persons: u8,
|
|
/// Peak headcount during current/last meeting.
|
|
peak_headcount: u8,
|
|
/// Frames where person count was >= MEETING_MIN_PERSONS.
|
|
multi_person_frames: u32,
|
|
/// Total meeting count.
|
|
meeting_count: u32,
|
|
/// Total meeting frames (for utilization calculation).
|
|
total_meeting_frames: u32,
|
|
/// Total frames tracked (for utilization calculation).
|
|
total_frames: u32,
|
|
/// Frame counter.
|
|
frame_count: u32,
|
|
}
|
|
|
|
impl MeetingRoomTracker {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
state: MeetingState::Empty,
|
|
state_frames: 0,
|
|
n_persons: 0,
|
|
peak_headcount: 0,
|
|
multi_person_frames: 0,
|
|
meeting_count: 0,
|
|
total_meeting_frames: 0,
|
|
total_frames: 0,
|
|
frame_count: 0,
|
|
}
|
|
}
|
|
|
|
/// Process one frame.
|
|
///
|
|
/// `presence`: presence indicator from host (0 or 1).
|
|
/// `n_persons`: person count from host.
|
|
/// `motion_energy`: motion energy from host.
|
|
///
|
|
/// Returns events as `(event_type, value)` pairs.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
presence: i32,
|
|
n_persons: i32,
|
|
_motion_energy: f32,
|
|
) -> &[(i32, f32)] {
|
|
self.frame_count += 1;
|
|
self.total_frames += 1;
|
|
self.state_frames += 1;
|
|
|
|
let is_present = presence >= PRESENCE_THRESHOLD;
|
|
self.n_persons = if n_persons > 0 { n_persons as u8 } else { 0 };
|
|
|
|
if self.n_persons > self.peak_headcount {
|
|
self.peak_headcount = self.n_persons;
|
|
}
|
|
|
|
if self.n_persons >= MEETING_MIN_PERSONS {
|
|
self.multi_person_frames += 1;
|
|
}
|
|
|
|
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
|
|
let mut n_events = 0usize;
|
|
|
|
let _prev_state = self.state;
|
|
|
|
match self.state {
|
|
MeetingState::Empty => {
|
|
if is_present {
|
|
self.state = MeetingState::PreMeeting;
|
|
self.state_frames = 0;
|
|
self.peak_headcount = self.n_persons;
|
|
self.multi_person_frames = 0;
|
|
}
|
|
}
|
|
|
|
MeetingState::PreMeeting => {
|
|
if !is_present {
|
|
// Person left before meeting started.
|
|
self.state = MeetingState::Empty;
|
|
self.state_frames = 0;
|
|
self.peak_headcount = 0;
|
|
} else if self.n_persons >= MEETING_MIN_PERSONS
|
|
&& self.state_frames >= 60 // At least 3 seconds of multi-person.
|
|
{
|
|
// Enough people gathered, transition to Active.
|
|
self.state = MeetingState::Active;
|
|
self.state_frames = 0;
|
|
self.meeting_count += 1;
|
|
|
|
if n_events < 4 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_MEETING_START, self.n_persons as f32);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
} else if self.state_frames >= PRE_MEETING_TIMEOUT {
|
|
// Timeout: single person using room, not a meeting.
|
|
// Stay as-is but don't promote to Active.
|
|
// If they leave, we go back to Empty.
|
|
// (Solo room use is not tracked as a "meeting".)
|
|
if !is_present {
|
|
self.state = MeetingState::Empty;
|
|
self.state_frames = 0;
|
|
self.peak_headcount = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
MeetingState::Active => {
|
|
self.total_meeting_frames += 1;
|
|
|
|
if !is_present || self.n_persons == 0 {
|
|
// Everyone left.
|
|
self.state = MeetingState::PostMeeting;
|
|
self.state_frames = 0;
|
|
|
|
// Emit meeting end with duration.
|
|
let duration_mins = self.total_meeting_frames as f32 / (20.0 * 60.0);
|
|
if n_events < 4 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_MEETING_END, duration_mins);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
|
|
// Emit peak headcount.
|
|
if n_events < 4 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_PEAK_HEADCOUNT, self.peak_headcount as f32);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
MeetingState::PostMeeting => {
|
|
if is_present && self.n_persons >= MEETING_MIN_PERSONS {
|
|
// People came back, resume meeting.
|
|
self.state = MeetingState::Active;
|
|
self.state_frames = 0;
|
|
} else if self.state_frames >= POST_MEETING_TIMEOUT || !is_present {
|
|
// Room cleared.
|
|
self.state = MeetingState::Empty;
|
|
self.state_frames = 0;
|
|
self.peak_headcount = 0;
|
|
self.multi_person_frames = 0;
|
|
|
|
if n_events < 4 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_ROOM_AVAILABLE, 1.0);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Periodic status emission.
|
|
if self.frame_count % EMIT_INTERVAL == 0 && self.state == MeetingState::Active {
|
|
if n_events < 4 {
|
|
unsafe {
|
|
EVENTS[n_events] = (EVENT_PEAK_HEADCOUNT, self.peak_headcount as f32);
|
|
}
|
|
n_events += 1;
|
|
}
|
|
}
|
|
|
|
unsafe { &EVENTS[..n_events] }
|
|
}
|
|
|
|
/// Get current meeting room state.
|
|
pub fn state(&self) -> MeetingState {
|
|
self.state
|
|
}
|
|
|
|
/// Get peak headcount for current/last meeting.
|
|
pub fn peak_headcount(&self) -> u8 {
|
|
self.peak_headcount
|
|
}
|
|
|
|
/// Get total meeting count.
|
|
pub fn meeting_count(&self) -> u32 {
|
|
self.meeting_count
|
|
}
|
|
|
|
/// Get utilization rate (fraction of total time spent in meetings).
|
|
pub fn utilization_rate(&self) -> f32 {
|
|
if self.total_frames == 0 {
|
|
return 0.0;
|
|
}
|
|
self.total_meeting_frames as f32 / self.total_frames as f32
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_meeting_room_init() {
|
|
let mt = MeetingRoomTracker::new();
|
|
assert_eq!(mt.state(), MeetingState::Empty);
|
|
assert_eq!(mt.peak_headcount(), 0);
|
|
assert_eq!(mt.meeting_count(), 0);
|
|
assert!((mt.utilization_rate() - 0.0).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_to_pre_meeting() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
// Single person enters.
|
|
mt.process_frame(1, 1, 0.1);
|
|
assert_eq!(mt.state(), MeetingState::PreMeeting);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pre_meeting_to_active() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
// Multiple people enter and stay.
|
|
for _ in 0..100 {
|
|
mt.process_frame(1, 3, 0.2);
|
|
}
|
|
assert_eq!(mt.state(), MeetingState::Active);
|
|
assert!(mt.meeting_count() >= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_meeting_end_and_room_available() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
// Start meeting.
|
|
for _ in 0..100 {
|
|
mt.process_frame(1, 4, 0.3);
|
|
}
|
|
assert_eq!(mt.state(), MeetingState::Active);
|
|
|
|
// Everyone leaves.
|
|
mt.process_frame(0, 0, 0.0);
|
|
assert_eq!(mt.state(), MeetingState::PostMeeting);
|
|
|
|
// Wait for post-meeting timeout.
|
|
let mut found_available = false;
|
|
for _ in 0..POST_MEETING_TIMEOUT + 1 {
|
|
let events = mt.process_frame(0, 0, 0.0);
|
|
for &(et, _) in events {
|
|
if et == EVENT_ROOM_AVAILABLE {
|
|
found_available = true;
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(mt.state(), MeetingState::Empty);
|
|
assert!(found_available, "should emit ROOM_AVAILABLE after clearing");
|
|
}
|
|
|
|
#[test]
|
|
fn test_transient_occupancy_not_meeting() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
// Single person enters briefly.
|
|
for _ in 0..30 {
|
|
mt.process_frame(1, 1, 0.1);
|
|
}
|
|
// Leaves.
|
|
mt.process_frame(0, 0, 0.0);
|
|
|
|
assert_eq!(mt.state(), MeetingState::Empty);
|
|
assert_eq!(mt.meeting_count(), 0, "brief single-person visit is not a meeting");
|
|
}
|
|
|
|
#[test]
|
|
fn test_peak_headcount_tracked() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
// Start meeting with 2 people.
|
|
for _ in 0..100 {
|
|
mt.process_frame(1, 2, 0.2);
|
|
}
|
|
assert_eq!(mt.state(), MeetingState::Active);
|
|
|
|
// More people join.
|
|
for _ in 0..50 {
|
|
mt.process_frame(1, 6, 0.3);
|
|
}
|
|
assert_eq!(mt.peak_headcount(), 6);
|
|
|
|
// Some leave.
|
|
for _ in 0..50 {
|
|
mt.process_frame(1, 3, 0.2);
|
|
}
|
|
// Peak should remain at 6.
|
|
assert_eq!(mt.peak_headcount(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_meeting_events_emitted() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
let mut found_start = false;
|
|
let mut found_end = false;
|
|
|
|
// Start meeting.
|
|
for _ in 0..100 {
|
|
let events = mt.process_frame(1, 3, 0.2);
|
|
for &(et, _) in events {
|
|
if et == EVENT_MEETING_START {
|
|
found_start = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_start, "should emit MEETING_START");
|
|
|
|
// End meeting.
|
|
for _ in 0..10 {
|
|
let events = mt.process_frame(0, 0, 0.0);
|
|
for &(et, _) in events {
|
|
if et == EVENT_MEETING_END {
|
|
found_end = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_end, "should emit MEETING_END");
|
|
}
|
|
|
|
#[test]
|
|
fn test_utilization_rate() {
|
|
let mut mt = MeetingRoomTracker::new();
|
|
|
|
// 100 frames of meeting.
|
|
for _ in 0..100 {
|
|
mt.process_frame(1, 3, 0.2);
|
|
}
|
|
|
|
// 100 frames of empty.
|
|
for _ in 0..100 {
|
|
mt.process_frame(0, 0, 0.0);
|
|
}
|
|
|
|
let rate = mt.utilization_rate();
|
|
// Meeting was active for some of the 200 frames.
|
|
assert!(rate > 0.0, "utilization rate should be positive after a meeting");
|
|
assert!(rate < 1.0, "utilization rate should be less than 1.0");
|
|
}
|
|
}
|