production: Refactor air quality alarms

This CL move the threshold and alarm handling code to StateManager,
allowing the air sensor to simply deal with reporting air quality
scores. This reduces the number of events sent to PubSub.

Change-Id: Ia5b0ecbf6d34be1c241e00abf2851624191782f6
Reviewed-on: https://pigweed-internal-review.git.corp.google.com/c/pigweed/showcase/rp2/+/73158
Commit-Queue: Aaron Green <aarongreen@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/apps/factory/service.cc b/apps/factory/service.cc
index 4079781..71e968d 100644
--- a/apps/factory/service.cc
+++ b/apps/factory/service.cc
@@ -58,7 +58,7 @@
       break;
     case factory_Test_Type_BME688:
       PW_LOG_INFO("Configured for BME688 air sensor test");
-      air_sensor_->Init(*pubsub_, pw::chrono::VirtualSystemClock::RealClock());
+      air_sensor_->Init();
       break;
   }
 
diff --git a/apps/production/main.cc b/apps/production/main.cc
index 0cc951c..abc7157 100644
--- a/apps/production/main.cc
+++ b/apps/production/main.cc
@@ -40,7 +40,9 @@
 
 void InitEventTimers() {
   auto& pubsub = system::PubSub();
-  static EventTimers<0> event_timers(pubsub);
+  static EventTimers<2> event_timers(pubsub);
+  event_timers.AddEventTimer(StateManager::kSilenceAlarmsToken);
+  event_timers.AddEventTimer(StateManager::kThresholdModeToken);
   pubsub.SubscribeTo<TimerRequest>(
       [](TimerRequest request) { event_timers.OnTimerRequest(request); });
 }
@@ -91,26 +93,6 @@
   static AirSensor& air_sensor = sense::system::AirSensor();
   static sense::AirSensorService air_sensor_service;
   air_sensor_service.Init(system::GetWorker(), air_sensor);
-
-  // Publish LED values based on gas resistance samples.
-  system::PubSub().SubscribeTo<AirQuality>([](AirQuality event) {
-    system::PubSub().Publish(
-        LedValueAirQualityMode(AirSensor::GetLedValue(event.score)));
-  });
-
-  // Update the alarm threshold based on button presses.
-  system::PubSub().SubscribeTo<AirQualityThreshold>(
-      [](AirQualityThreshold event) {
-        system::AirSensor().SetThresholds(event.alarm, event.silence);
-      });
-
-  system::PubSub().SubscribeTo<AlarmSilenceRequest>(
-      [](AlarmSilenceRequest request) {
-        auto duration = pw::chrono::SystemClock::for_at_least(
-            std::chrono::seconds(request.seconds));
-        system::AirSensor().Silence(duration);
-      });
-
   pw::System().rpc_server().RegisterService(air_sensor_service);
 }
 
diff --git a/modules/air_sensor/BUILD.bazel b/modules/air_sensor/BUILD.bazel
index f3651b5..84da72d 100644
--- a/modules/air_sensor/BUILD.bazel
+++ b/modules/air_sensor/BUILD.bazel
@@ -30,9 +30,7 @@
         "@pigweed//pw_log",
     ],
     deps = [
-        "//modules/edge_detector:hysteresis_edge_detector",
         "//modules/pubsub:events",
-        "@pigweed//pw_chrono:system_clock",
         "@pigweed//pw_metric:metric",
         "@pigweed//pw_result",
         "@pigweed//pw_status",
@@ -59,9 +57,6 @@
     deps = [
         ":air_sensor",
         ":air_sensor_fake",
-        "//modules/pubsub:events",
-        "//modules/worker:test_worker",
-        "@pigweed//pw_chrono:simulated_system_clock",
         "@pigweed//pw_sync:timed_thread_notification",
         "@pigweed//pw_thread:test_thread_context",
         "@pigweed//pw_thread:thread",
diff --git a/modules/air_sensor/air_sensor.cc b/modules/air_sensor/air_sensor.cc
index ece66d0..221610b 100644
--- a/modules/air_sensor/air_sensor.cc
+++ b/modules/air_sensor/air_sensor.cc
@@ -45,10 +45,6 @@
   return LedValue(red, green, blue);
 }
 
-AirSensor::AirSensor() : edge_detector_(0, 0) {
-  SetThresholds(Score::kYellow, Score::kLightGreen);
-}
-
 float AirSensor::temperature() const {
   std::lock_guard lock(lock_);
   return temperature_.value();
@@ -74,37 +70,6 @@
   return score_.value();
 }
 
-pw::Status AirSensor::Init(PubSub& pubsub,
-                           pw::chrono::VirtualSystemClock& clock) {
-  pubsub_ = &pubsub;
-  clock_ = &clock;
-  return DoInit();
-}
-
-void AirSensor::SetThresholds(uint16_t alarm, uint16_t silence) {
-  std::lock_guard lock(lock_);
-  edge_detector_.set_low_and_high_thresholds(alarm, silence);
-  PW_LOG_INFO(
-      "Air quality thresholds set: alarm at %u, silence at %u", alarm, silence);
-  edge_detector_.Update(kMaxScore);
-}
-
-void AirSensor::SetThresholds(Score alarm, Score silence) {
-  SetThresholds(static_cast<uint16_t>(alarm), static_cast<uint16_t>(silence));
-}
-
-void AirSensor::Silence(pw::chrono::SystemClock::duration duration) {
-  // Set by `Init`.
-  PW_CHECK_NOTNULL(clock_);
-  PW_CHECK_NOTNULL(pubsub_);
-
-  {
-    std::lock_guard lock(lock_);
-    ignore_until_ = clock_->now() + duration;
-  }
-  pubsub_->Publish(AlarmStateChange{.alarm = false});
-}
-
 pw::Result<uint16_t> AirSensor::MeasureSync() {
   pw::sync::ThreadNotification notification;
   PW_TRY(Measure(notification));
@@ -117,14 +82,6 @@
                        float humidity,
                        float gas_resistance) {
   std::lock_guard lock(lock_);
-  // If the alarmed was silenced for some duration that has now elapsed, reset
-  // the edge detector to the highest value.
-  if (ignore_until_.has_value() && clock_ != nullptr &&
-      *ignore_until_ <= clock_->now()) {
-    ignore_until_.reset();
-    edge_detector_.Update(kMaxScore);
-  }
-
   // Record the sensor data.
   temperature_.Set(temperature);
   pressure_.Set(pressure);
@@ -158,20 +115,6 @@
   float score = ((quality - average) / stddev) + 3.f;
   score = std::min(std::max(score * 256.f, 0.f), static_cast<float>(kMaxScore));
   score_.Set(static_cast<uint32_t>(score));
-
-  // Check if a threshold was crossed.
-  if (pubsub_ != nullptr) {
-    switch (edge_detector_.Update(score_.value())) {
-      case Edge::kFalling:
-        pubsub_->Publish(AlarmStateChange{.alarm = true});
-        break;
-      case Edge::kRising:
-        pubsub_->Publish(AlarmStateChange{.alarm = false});
-        break;
-      case Edge::kNone:
-        break;
-    }
-  }
 }
 
 }  // namespace sense
diff --git a/modules/air_sensor/air_sensor.h b/modules/air_sensor/air_sensor.h
index 2cf864c..07f58bc 100644
--- a/modules/air_sensor/air_sensor.h
+++ b/modules/air_sensor/air_sensor.h
@@ -13,9 +13,7 @@
 // the License.
 #pragma once
 
-#include "modules/edge_detector/hysteresis_edge_detector.h"
 #include "modules/pubsub/pubsub_events.h"
-#include "pw_chrono/system_clock.h"
 #include "pw_metric/metric.h"
 #include "pw_result/result.h"
 #include "pw_status/status.h"
@@ -55,8 +53,6 @@
 
   static constexpr uint16_t kMaxScore = static_cast<uint16_t>(Score::kBlue);
   static constexpr uint16_t kAverageScore = static_cast<uint16_t>(Score::kCyan);
-  static constexpr uint16_t kDefaultTheshold =
-      static_cast<uint16_t>(Score::kYellow);
 
   static LedValue GetLedValue(uint16_t score);
 
@@ -78,15 +74,7 @@
   uint16_t score() const PW_LOCKS_EXCLUDED(lock_);
 
   /// Sets up the sensor.
-  pw::Status Init(PubSub& pubsub, pw::chrono::VirtualSystemClock& clock);
-
-  /// Sets the thresholds at which the air sensor will raise or silence an
-  /// alarm.
-  void SetThresholds(uint16_t alarm, uint16_t silence);
-  void SetThresholds(Score alarm, Score silence);
-
-  /// Silences the alarm until the given time has elapsed.
-  void Silence(pw::chrono::SystemClock::duration duration);
+  pw::Status Init() { return DoInit(); }
 
   /// Requests an air measurement.
   ///
@@ -105,7 +93,7 @@
   void LogMetrics() { metrics_.Dump(); }
 
  protected:
-  AirSensor();
+  AirSensor() = default;
 
   /// Records the results of an air measurement.
   void Update(float temperature,
@@ -125,29 +113,20 @@
 
   mutable pw::sync::InterruptSpinLock lock_;
 
-  // Thread safety: set by `Init`, `const` after that.
-  PubSub* pubsub_ = nullptr;
-  pw::chrono::VirtualSystemClock* clock_ = nullptr;
-
-  HysteresisEdgeDetector<uint16_t> edge_detector_ PW_GUARDED_BY(lock_);
-  std::optional<pw::chrono::SystemClock::time_point> ignore_until_
-      PW_GUARDED_BY(lock_);
-
   // Thread safety: metric values should be atomic.
   //
   // Currently, they are not due to a bug, so they are guarded by
   // `lock_`. Unfortunately it isn't possible to use a PW_GUARDED_BY annotation
   // on them.
-
   PW_METRIC_GROUP(metrics_, "air sensor");
 
-  // // Directly read values.
+  // Directly read values.
   PW_METRIC(metrics_, temperature_, "ambient temperature", kDefaultTemperature);
   PW_METRIC(metrics_, pressure_, "barometric pressure", kDefaultPressure);
   PW_METRIC(metrics_, humidity_, "relative humidity", kDefaultHumidity);
   PW_METRIC(metrics_, gas_resistance_, "gas resistance", kDefaultGasResistance);
 
-  // // Derived values.
+  // Derived values.
   PW_METRIC(metrics_, count_, "number of measurements", 0u);
   PW_METRIC(metrics_, quality_, "current air quality", 0.f);
   PW_METRIC(metrics_, average_, "average air quality", 0.f);
diff --git a/modules/air_sensor/air_sensor_test.cc b/modules/air_sensor/air_sensor_test.cc
index b2d769d..f9b28f5 100644
--- a/modules/air_sensor/air_sensor_test.cc
+++ b/modules/air_sensor/air_sensor_test.cc
@@ -15,11 +15,6 @@
 #include "modules/air_sensor/air_sensor.h"
 
 #include "modules/air_sensor/air_sensor_fake.h"
-#include "modules/pubsub/pubsub.h"
-#include "modules/pubsub/pubsub_events.h"
-#include "modules/worker/test_worker.h"
-#include "pw_chrono/simulated_system_clock.h"
-#include "pw_status/try.h"
 #include "pw_sync/timed_thread_notification.h"
 #include "pw_thread/test_thread_context.h"
 #include "pw_thread/thread.h"
@@ -30,25 +25,10 @@
 // Test fixtures.
 
 class AirSensorTest : public ::testing::Test {
- public:
-  AirSensorTest()
-      : ::testing::Test(),
-        pubsub_(worker_, event_queue_, subscribers_buffer_) {}
-
  protected:
   using Score = AirSensor::Score;
 
-  void SetUp() override {
-    air_sensor_.Init(pubsub_, clock_);
-    air_sensor_.SetThresholds(Score::kYellow, Score::kLightGreen);
-    pubsub_.SubscribeTo<AlarmStateChange>([this](AlarmStateChange event) {
-      {
-        std::lock_guard lock(alarm_lock_);
-        alarm_state_ = event.alarm;
-      }
-      response_.release();
-    });
-  }
+  void SetUp() override { air_sensor_.Init(); }
 
   void MeasureRepeated(size_t n) {
     for (size_t i = 0; i < n; ++i) {
@@ -65,34 +45,9 @@
     }
   }
 
-  pw::Status TriggerAlarm() {
-    air_sensor_.set_gas_resistance(AirSensor::kDefaultGasResistance / 10);
-    uint16_t score;
-    PW_TRY_ASSIGN(score, air_sensor_.MeasureSync());
-    auto threshold = static_cast<uint16_t>(Score::kYellow);
-    EXPECT_LT(score, threshold);
-    return score < threshold ? pw::OkStatus() : pw::Status::OutOfRange();
-  }
-
-  void TearDown() override { worker_.Stop(); }
-
-  bool alarm_state() {
-    std::lock_guard lock_(alarm_lock_);
-    return alarm_state_;
-  }
-
-  sense::TestWorker<> worker_;
-  pw::InlineDeque<Event, 4> event_queue_;
-  std::array<PubSub::Subscriber, 4> subscribers_buffer_;
-  PubSub pubsub_;
-  pw::chrono::SimulatedSystemClock clock_;
-
   AirSensorFake air_sensor_;
   pw::sync::TimedThreadNotification request_;
   pw::sync::TimedThreadNotification response_;
-
-  pw::sync::InterruptSpinLock alarm_lock_;
-  bool alarm_state_ PW_GUARDED_BY(alarm_lock_) = false;
 };
 
 // Unit tests.
@@ -188,95 +143,4 @@
   thread.join();
 }
 
-TEST_F(AirSensorTest, TriggerAlarm) {
-  MeasureRepeated(1000);
-  ASSERT_EQ(TriggerAlarm(), pw::OkStatus());
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-}
-
-TEST_F(AirSensorTest, RecoverFromAlarm) {
-  MeasureRepeated(1000);
-  ASSERT_EQ(TriggerAlarm(), pw::OkStatus());
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-
-  air_sensor_.set_gas_resistance(50000.f);
-  pw::Result<uint16_t> score = air_sensor_.MeasureSync();
-  ASSERT_EQ(score.status(), pw::OkStatus());
-  ASSERT_GT(*score, 640);
-  response_.acquire();
-  EXPECT_FALSE(alarm_state());
-}
-
-TEST_F(AirSensorTest, SilenceAlarm) {
-  MeasureRepeated(1000);
-  ASSERT_EQ(TriggerAlarm(), pw::OkStatus());
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-
-  air_sensor_.Silence(
-      pw::chrono::SystemClock::for_at_least(std::chrono::hours(1)));
-  response_.acquire();
-  EXPECT_FALSE(alarm_state());
-}
-
-TEST_F(AirSensorTest, RetriggerAlarmLater) {
-  MeasureRepeated(1000);
-  ASSERT_EQ(TriggerAlarm(), pw::OkStatus());
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-
-  auto duration =
-      pw::chrono::SystemClock::for_at_least(std::chrono::milliseconds(100));
-  air_sensor_.Silence(duration);
-  response_.acquire();
-  EXPECT_FALSE(alarm_state());
-
-  clock_.AdvanceTime(duration);
-  air_sensor_.MeasureSync().IgnoreError();
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-}
-
-TEST_F(AirSensorTest, DelayAlarmWhenSilenced) {
-  MeasureRepeated(1000);
-
-  auto duration =
-      pw::chrono::SystemClock::for_at_least(std::chrono::milliseconds(100));
-  air_sensor_.Silence(duration);
-  response_.acquire();
-  EXPECT_FALSE(alarm_state());
-
-  auto start = clock_.now();
-  ASSERT_EQ(TriggerAlarm(), pw::OkStatus());
-
-  clock_.AdvanceTime(duration);
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-
-  EXPECT_GE(clock_.now() - start, duration);
-}
-
-TEST_F(AirSensorTest, ChangeThresholdWhileSilenced) {
-  MeasureRepeated(1000);
-  ASSERT_EQ(TriggerAlarm(), pw::OkStatus());
-  response_.acquire();
-  EXPECT_TRUE(alarm_state());
-
-  auto duration =
-      pw::chrono::SystemClock::for_at_least(std::chrono::milliseconds(100));
-  air_sensor_.Silence(duration);
-  response_.acquire();
-  EXPECT_FALSE(alarm_state());
-
-  uint16_t score = air_sensor_.score();
-  air_sensor_.SetThresholds(score / 2,
-                            static_cast<uint16_t>(Score::kLightGreen));
-
-  // Alarm should not retrigger.
-  EXPECT_FALSE(response_.try_acquire_for(duration));
-  EXPECT_FALSE(alarm_state());
-}
-
 }  // namespace sense
diff --git a/modules/pubsub/pubsub.proto b/modules/pubsub/pubsub.proto
index 6e2d8b6..dc8adb7 100644
--- a/modules/pubsub/pubsub.proto
+++ b/modules/pubsub/pubsub.proto
@@ -39,11 +39,6 @@
   uint32 repeat = 2;
 }
 
-message AirQualityThreshold {
-  uint32 alarm = 1;
-  uint32 silence = 2;
-}
-
 message TimerRequest {
   uint32 token = 1;
   uint32 timeout_s = 2;
@@ -57,21 +52,17 @@
   // This definition must be kept up to date with
   // modules/pubsub/pubsub_events.h.
   oneof type {
-    bool alarm = 1;
-    bool button_a_pressed = 2;
-    bool button_b_pressed = 3;
-    bool button_x_pressed = 4;
-    bool button_y_pressed = 5;
-    bool proximity = 6;
-    uint32 proximity_level = 7;
-    uint32 air_quality = 8;
-    MorseCodeValue morse_code_value = 9;
-    LedValue led_value_air_quality = 10;
-    TimerRequest timer_request = 11;
-    TimerExpired timer_expired = 12;
-    morse_code.SendRequest morse_encode_request = 13;
-    AirQualityThreshold air_quality_threshold = 14;
-    uint32 alarm_silence = 15;
-    float ambient_light_lux = 16;
+    bool button_a_pressed = 1;
+    bool button_b_pressed = 2;
+    bool button_x_pressed = 3;
+    bool button_y_pressed = 4;
+    bool proximity = 5;
+    uint32 proximity_level = 6;
+    uint32 air_quality = 7;
+    MorseCodeValue morse_code_value = 8;
+    TimerRequest timer_request = 9;
+    TimerExpired timer_expired = 10;
+    morse_code.SendRequest morse_encode_request = 11;
+    float ambient_light_lux = 12;
   }
 }
diff --git a/modules/pubsub/pubsub_events.h b/modules/pubsub/pubsub_events.h
index 9efd3bf..404bddf 100644
--- a/modules/pubsub/pubsub_events.h
+++ b/modules/pubsub/pubsub_events.h
@@ -20,11 +20,6 @@
 
 namespace sense {
 
-// VOC / CO2 crossed over the threshold.
-struct AlarmStateChange {
-  bool alarm;
-};
-
 // Base for button state changes.
 class ButtonStateChange {
  public:
@@ -78,20 +73,6 @@
   uint16_t score;
 };
 
-/// Air quality thresholds.
-///
-/// When the score falls below `alarm`, the air sensor will trigger an alarm.
-/// When the score rises above `silence`, the air sensor will silence the alarm.
-struct AirQualityThreshold {
-  uint16_t alarm;
-  uint16_t silence;
-};
-
-/// Request to suppress alarms for some time, regardless of air quality score.
-struct AlarmSilenceRequest {
-  uint16_t seconds;
-};
-
 class LedValue {
  public:
   explicit constexpr LedValue(uint8_t r, uint8_t g, uint8_t b)
@@ -110,12 +91,6 @@
   uint8_t b_;
 };
 
-class LedValueAirQualityMode : public LedValue {
- public:
-  using LedValue::LedValue;
-  explicit LedValueAirQualityMode(const LedValue& parent) : LedValue(parent) {}
-};
-
 struct TimerRequest {
   uint32_t token;
   uint16_t timeout_s;
@@ -137,39 +112,31 @@
 
 // This definition must be kept up to date with modules/pubsub/pubsub.proto and
 // the EventType enum.
-using Event = std::variant<AlarmStateChange,
-                           AlarmSilenceRequest,
-                           ButtonA,
+using Event = std::variant<ButtonA,
                            ButtonB,
                            ButtonX,
                            ButtonY,
-                           LedValueAirQualityMode,
                            TimerRequest,
                            TimerExpired,
                            ProximityStateChange,
                            ProximitySample,
                            AmbientLightSample,
                            AirQuality,
-                           AirQualityThreshold,
                            MorseEncodeRequest,
                            MorseCodeValue>;
 
 // Index versions of Event variants, to support finding the event
 enum EventType : size_t {
-  kAlarmStateChange,
-  kAlarmSilenceRequest,
   kButtonA,
   kButtonB,
   kButtonX,
   kButtonY,
-  kLedValueAirQualityMode,
   kTimerRequest,
   kTimerExpired,
   kProximityStateChange,
   kProximitySample,
   kAmbientLightSample,
   kAirQuality,
-  kAirQualityThreshold,
   kMorseEncodeRequest,
   kMorseCodeValue,
   kLastEventType = kMorseCodeValue,
diff --git a/modules/pubsub/service.cc b/modules/pubsub/service.cc
index 13b3321..8d992c0 100644
--- a/modules/pubsub/service.cc
+++ b/modules/pubsub/service.cc
@@ -20,25 +20,10 @@
 namespace sense {
 namespace {
 
-pubsub_LedValue LedValueToProto(const LedValue& value) {
-  pubsub_LedValue proto;
-  proto.r = value.r();
-  proto.g = value.g();
-  proto.b = value.b();
-  return proto;
-}
-
-LedValue LedValueFromProto(const pubsub_LedValue& proto) {
-  return LedValue(proto.r, proto.g, proto.b);
-}
-
 pubsub_Event EventToProto(const Event& event) {
   pubsub_Event proto = pubsub_Event_init_default;
 
-  if (std::holds_alternative<AlarmStateChange>(event)) {
-    proto.which_type = pubsub_Event_alarm_tag;
-    proto.type.alarm = std::get<AlarmStateChange>(event).alarm;
-  } else if (std::holds_alternative<ButtonA>(event)) {
+  if (std::holds_alternative<ButtonA>(event)) {
     proto.which_type = pubsub_Event_button_a_pressed_tag;
     proto.type.button_a_pressed = std::get<ButtonA>(event).pressed();
   } else if (std::holds_alternative<ButtonB>(event)) {
@@ -50,10 +35,6 @@
   } else if (std::holds_alternative<ButtonY>(event)) {
     proto.which_type = pubsub_Event_button_y_pressed_tag;
     proto.type.button_y_pressed = std::get<ButtonY>(event).pressed();
-  } else if (std::holds_alternative<LedValueAirQualityMode>(event)) {
-    proto.which_type = pubsub_Event_led_value_air_quality_tag;
-    proto.type.led_value_air_quality =
-        LedValueToProto(std::get<LedValueAirQualityMode>(event));
   } else if (std::holds_alternative<TimerRequest>(event)) {
     proto.which_type = pubsub_Event_timer_request_tag;
     auto& timer_request = std::get<TimerRequest>(event);
@@ -76,14 +57,6 @@
   } else if (std::holds_alternative<AirQuality>(event)) {
     proto.which_type = pubsub_Event_air_quality_tag;
     proto.type.air_quality = std::get<AirQuality>(event).score;
-  } else if (std::holds_alternative<AirQualityThreshold>(event)) {
-    proto.which_type = pubsub_Event_air_quality_threshold_tag;
-    auto& air_quality_threshold = std::get<AirQualityThreshold>(event);
-    proto.type.air_quality_threshold.alarm = air_quality_threshold.alarm;
-    proto.type.air_quality_threshold.silence = air_quality_threshold.silence;
-  } else if (std::holds_alternative<AlarmSilenceRequest>(event)) {
-    proto.which_type = pubsub_Event_alarm_silence_tag;
-    proto.type.alarm_silence = std::get<AlarmSilenceRequest>(event).seconds;
   } else if (std::holds_alternative<MorseEncodeRequest>(event)) {
     proto.which_type = pubsub_Event_morse_encode_request_tag;
     const auto& morse = std::get<MorseEncodeRequest>(event);
@@ -104,8 +77,6 @@
 
 pw::Result<Event> ProtoToEvent(const pubsub_Event& proto) {
   switch (proto.which_type) {
-    case pubsub_Event_alarm_tag:
-      return AlarmStateChange{.alarm = proto.type.alarm};
     case pubsub_Event_button_a_pressed_tag:
       return ButtonA(proto.type.button_a_pressed);
     case pubsub_Event_button_b_pressed_tag:
@@ -129,16 +100,10 @@
           .turn_on = proto.type.morse_code_value.turn_on,
           .message_finished = proto.type.morse_code_value.message_finished,
       };
-    case pubsub_Event_led_value_air_quality_tag:
-      return LedValueAirQualityMode(
-          LedValueFromProto(proto.type.led_value_air_quality));
     case pubsub_Event_proximity_tag:
       return ProximityStateChange{.proximity = proto.type.proximity};
     case pubsub_Event_air_quality_tag:
       return AirQuality{.score = static_cast<uint16_t>(proto.type.air_quality)};
-    case pubsub_Event_alarm_silence_tag:
-      return AlarmSilenceRequest{
-          .seconds = static_cast<uint16_t>(proto.type.alarm_silence)};
     default:
       return pw::Status::Unimplemented();
   }
diff --git a/modules/sampling_thread/sampling_thread.cc b/modules/sampling_thread/sampling_thread.cc
index 9f0c4e1..3cffd05 100644
--- a/modules/sampling_thread/sampling_thread.cc
+++ b/modules/sampling_thread/sampling_thread.cc
@@ -67,12 +67,9 @@
 
 // Reads sensor samples in a loop and publishes PubSub events for them.
 void SamplingLoop() {
-  auto& pubsub = system::PubSub();
-  auto& clock = pw::chrono::VirtualSystemClock::RealClock();
-
   PW_CHECK_OK(system::AmbientLightSensor().Enable());
   PW_CHECK_OK(system::ProximitySensor().Enable());
-  PW_CHECK_OK(system::AirSensor().Init(pubsub, clock));
+  PW_CHECK_OK(system::AirSensor().Init());
 
   SystemClock::time_point deadline = SystemClock::now();
 
diff --git a/modules/state_manager/BUILD.bazel b/modules/state_manager/BUILD.bazel
index 18d398a..20ea034 100644
--- a/modules/state_manager/BUILD.bazel
+++ b/modules/state_manager/BUILD.bazel
@@ -25,12 +25,11 @@
     deps = [
         ":common_base_union",
         "//modules/air_sensor",
+        "//modules/edge_detector:hysteresis_edge_detector",
         "//modules/led:polychrome_led",
         "//modules/pubsub:events",
-        "//modules/stats:simple_moving_average",
         "//modules/worker",
         "@pigweed//pw_assert",
-        "@pigweed//pw_chrono:system_timer",
         "@pigweed//pw_string:string",
     ],
 )
diff --git a/modules/state_manager/state_manager.cc b/modules/state_manager/state_manager.cc
index cb38e4e..9e93851 100644
--- a/modules/state_manager/state_manager.cc
+++ b/modules/state_manager/state_manager.cc
@@ -22,6 +22,17 @@
 
 namespace sense {
 
+template <typename T>
+static void AddAndSmoothExponentially(std::optional<T>& aggregate,
+                                      T next_value) {
+  static constexpr T kDecayFactor = T(4);
+  if (!aggregate.has_value()) {
+    aggregate = next_value;
+  } else {
+    *aggregate += (next_value - *aggregate) / kDecayFactor;
+  }
+}
+
 void LedOutputStateMachine::UpdateLed(uint8_t red,
                                       uint8_t green,
                                       uint8_t blue,
@@ -43,20 +54,17 @@
 }
 
 StateManager::StateManager(PubSub& pubsub, PolychromeLed& led)
-    : pubsub_(&pubsub),
+    : edge_detector_(alarm_threshold_, alarm_threshold_ + kThresholdIncrement),
+      pubsub_(&pubsub),
       led_(led, brightness_),
-      state_(*this),
-      demo_mode_timer_([this](auto) { state_.get().DemoModeTimerExpired(); }) {
+      state_(*this) {
   pubsub_->Subscribe([this](Event event) { Update(event); });
 }
 
 void StateManager::Update(Event event) {
   switch (static_cast<EventType>(event.index())) {
     case kAirQuality:
-      last_air_quality_score_ = std::get<AirQuality>(event).score;
-      break;
-    case kAlarmStateChange:
-      state_.get().AlarmStateChanged(std::get<AlarmStateChange>(event).alarm);
+      UpdateAirQuality(std::get<AirQuality>(event).score);
       break;
     case kButtonA:
       HandleButtonPress(std::get<ButtonA>(event).pressed(),
@@ -74,9 +82,8 @@
       HandleButtonPress(std::get<ButtonY>(event).pressed(),
                         &State::ButtonYReleased);
       break;
-    case kLedValueAirQualityMode:
-      state_.get().AirQualityModeLedValue(
-          std::get<LedValueAirQualityMode>(event));
+    case kTimerExpired:
+      state_.get().OnTimerExpired(std::get<TimerExpired>(event));
       break;
     case kMorseCodeValue:
       state_.get().MorseCodeEdge(std::get<MorseCodeValue>(event));
@@ -86,11 +93,8 @@
       state_.get().AmbientLightUpdate();
       break;
     case kTimerRequest:
-    case kTimerExpired:
-    case kProximitySample:
-    case kAlarmSilenceRequest:
-    case kAirQualityThreshold:
     case kMorseEncodeRequest:
+    case kProximitySample:
     case kProximityStateChange:
       break;  // ignore these events
   }
@@ -105,77 +109,114 @@
   }
 }
 
+void StateManager::DisplayThreshold() {
+  led_.SetColor(AirSensor::GetLedValue(alarm_threshold_));
+  pubsub_->Publish(TimerRequest{
+      .token = kThresholdModeToken,
+      .timeout_s = kThresholdModeTimeout,
+  });
+}
+
+void StateManager::IncrementThreshold() {
+  if (alarm_threshold_ < kMaxThreshold) {
+    SetAlarmThreshold(alarm_threshold_ + kThresholdIncrement);
+  }
+  DisplayThreshold();
+}
+
+void StateManager::DecrementThreshold() {
+  if (alarm_threshold_ > 0) {
+    SetAlarmThreshold(alarm_threshold_ - kThresholdIncrement);
+  }
+  DisplayThreshold();
+}
+
+void StateManager::SetAlarmThreshold(uint16_t alarm_threshold) {
+  alarm_threshold_ = alarm_threshold;
+  auto silence_threshold =
+      static_cast<uint16_t>(alarm_threshold_ + kThresholdIncrement);
+  edge_detector_.set_low_and_high_thresholds(alarm_threshold_,
+                                             silence_threshold);
+  PW_LOG_INFO("Air quality thresholds set: alarm at %u, silence at %u",
+              alarm_threshold_,
+              silence_threshold);
+  edge_detector_.Update(AirSensor::kMaxScore);
+}
+
+void StateManager::UpdateAirQuality(uint16_t score) {
+  AddAndSmoothExponentially(air_quality_, score);
+  state_.get().OnLedValue(AirSensor::GetLedValue(*air_quality_));
+  if (alarm_silenced_) {
+    return;
+  }
+  switch (edge_detector_.Update(*air_quality_)) {
+    case Edge::kFalling:
+      alarm_ = true;
+      break;
+    case Edge::kRising:
+      alarm_ = false;
+      break;
+    case Edge::kNone:
+      return;
+  }
+  ResetMode();
+}
+
+void StateManager::SilenceAlarms() {
+  alarm_ = false;
+  alarm_silenced_ = true;
+  edge_detector_.Update(AirSensor::kMaxScore);
+  pubsub_->Publish(TimerRequest{
+      .token = kSilenceAlarmsToken,
+      .timeout_s = kSilenceAlarmsTimeout,
+  });
+  ResetMode();
+}
+
+void StateManager::ResetMode() {
+  if (alarm_) {
+    SetState<AirQualityAlarmMode>();
+  } else {
+    SetState<AirQualityMode>();
+  }
+}
+
 void StateManager::StartMorseReadout(bool repeat) {
+  if (!air_quality_.has_value()) {
+    return;
+  }
   pw::Status status = pw::string::FormatOverwrite(
-      air_quality_score_string_, "%hu", last_air_quality_score_);
+      air_quality_score_string_, "%hu", *air_quality_);
   PW_CHECK_OK(status);
   pubsub_->Publish(MorseEncodeRequest{.message = air_quality_score_string_,
                                       .repeat = repeat ? 0u : 1u});
-  PW_LOG_INFO("Current air quality score: %hu", last_air_quality_score_);
-}
-
-void StateManager::DisplayThreshold() {
-  led_.SetColor(AirSensor::GetLedValue(current_threshold_));
-}
-
-void StateManager::IncrementThreshold(
-    pw::chrono::SystemClock::duration timeout) {
-  demo_mode_timer_.Cancel();
-  uint16_t candidate_threshold = current_threshold_ + kThresholdIncrement;
-  current_threshold_ =
-      candidate_threshold < kMaxThreshold ? candidate_threshold : kMaxThreshold;
-  pubsub_->Publish(AirQualityThreshold{
-      .alarm = current_threshold_,
-      .silence =
-          static_cast<uint16_t>(current_threshold_ + kThresholdIncrement),
-  });
-  DisplayThreshold();
-  demo_mode_timer_.InvokeAfter(timeout);
-}
-
-void StateManager::DecrementThreshold(
-    pw::chrono::SystemClock::duration timeout) {
-  demo_mode_timer_.Cancel();
-  if (current_threshold_ > 0) {
-    current_threshold_ -= kThresholdIncrement;
-  }
-  pubsub_->Publish(AirQualityThreshold{
-      .alarm = current_threshold_,
-      .silence =
-          static_cast<uint16_t>(current_threshold_ + kThresholdIncrement),
-  });
-  DisplayThreshold();
-  demo_mode_timer_.InvokeAfter(timeout);
+  PW_LOG_INFO("Current air quality score: %hu", *air_quality_);
 }
 
 void StateManager::UpdateAverageAmbientLight(float ambient_light_sample_lux) {
-  static constexpr float kDecayFactor = 0.25;
-  if (std::isnan(ambient_light_lux_)) {
-    ambient_light_lux_ = ambient_light_sample_lux;
-  } else {
-    ambient_light_lux_ +=
-        (ambient_light_sample_lux - ambient_light_lux_) * kDecayFactor;
-  }
+  AddAndSmoothExponentially(ambient_light_lux_, ambient_light_sample_lux);
 }
 
 void StateManager::UpdateBrightnessFromAmbientLight() {
   static constexpr float kMinLux = 40.f;
   static constexpr float kMaxLux = 3000.f;
-
-  if (ambient_light_lux_ < kMinLux) {
+  if (!ambient_light_lux_.has_value()) {
+    return;
+  }
+  if (*ambient_light_lux_ < kMinLux) {
     brightness_ = kMinBrightness;
-  } else if (ambient_light_lux_ > kMaxLux) {
+  } else if (*ambient_light_lux_ > kMaxLux) {
     brightness_ = kMaxBrightness;
   } else {
     constexpr float kBrightnessRange = kMaxBrightness - kMinBrightness;
     brightness_ = static_cast<uint8_t>(
-        std::lround((ambient_light_lux_ - kMinLux) / (kMaxLux - kMinLux) *
+        std::lround((*ambient_light_lux_ - kMinLux) / (kMaxLux - kMinLux) *
                     kBrightnessRange) +
         kMinBrightness);
   }
 
   PW_LOG_DEBUG(
-      "Ambient light: mean=%.1f, led=%hhu", ambient_light_lux_, brightness_);
+      "Ambient light: mean=%.1f, led=%hhu", *ambient_light_lux_, brightness_);
 
   led_.SetBrightness(brightness_);
 }
diff --git a/modules/state_manager/state_manager.h b/modules/state_manager/state_manager.h
index e3fd8c4..5a28f27 100644
--- a/modules/state_manager/state_manager.h
+++ b/modules/state_manager/state_manager.h
@@ -16,11 +16,10 @@
 #include <cmath>
 
 #include "modules/air_sensor/air_sensor.h"
+#include "modules/edge_detector/hysteresis_edge_detector.h"
 #include "modules/led/polychrome_led.h"
 #include "modules/pubsub/pubsub_events.h"
 #include "modules/state_manager/common_base_union.h"
-#include "modules/stats/simple_moving_average.h"
-#include "pw_chrono/system_timer.h"
 #include "pw_string/string.h"
 
 namespace sense {
@@ -84,21 +83,27 @@
 // thread.
 class StateManager {
  public:
+  using TimerToken = ::pw::tokenizer::Token;
+
+  static constexpr TimerToken kSilenceAlarmsToken =
+      PW_TOKENIZE_STRING("re-enable alarms");
+  static constexpr uint16_t kSilenceAlarmsTimeout = 60;
+
+  static constexpr TimerToken kThresholdModeToken =
+      PW_TOKENIZE_STRING("exit threshold mode");
+  static constexpr uint16_t kThresholdModeTimeout = 3;
+
+  static constexpr uint16_t kThresholdIncrement = 128;
+  static constexpr uint16_t kMaxThreshold = 768;
+
   StateManager(PubSub& pubsub, PolychromeLed& led);
 
  private:
-  /// How long to show demo modes before returning to the regular AQI monitor.
-  static constexpr pw::chrono::SystemClock::duration kDemoModeTimeout =
-      std::chrono::seconds(30);
-
   // LED brightness varies based on ambient light readings.
   static constexpr uint8_t kMinBrightness = 20;
   static constexpr uint8_t kDefaultBrightness = 160;
   static constexpr uint8_t kMaxBrightness = 255;
 
-  static constexpr uint16_t kThresholdIncrement = 128;
-  static constexpr uint16_t kMaxThreshold = 768;
-
   // Represents a state in the Sense app state machine.
   class State {
    public:
@@ -112,41 +117,36 @@
     // Name of the state for logging.
     const char* name() const { return name_; }
 
-    // Events for handling alarms.
-    virtual void AlarmStateChanged(bool alarm) {
-      if (manager_.alarmed_ == alarm) {
-        return;
-      }
-      manager_.alarmed_ = alarm;
-      if (alarm) {
-        manager_.SetState<AirQualityAlarmMode>();
-      } else {
-        manager_.SetState<AirQualityMode>();
-      }
-    }
-
-    // Events for releasing buttons.
+    // Events for releasing buttons. By default, 'A' and 'B' change to
+    // threshold mode, while 'X' and 'Y' change to monitoring mode.
     virtual void ButtonAReleased() {
       manager_.SetState<AirQualityThresholdMode>();
     }
     virtual void ButtonBReleased() {
       manager_.SetState<AirQualityThresholdMode>();
     }
-    virtual void ButtonXReleased() {}
-    virtual void ButtonYReleased() {}
+    virtual void ButtonXReleased() { manager_.ResetMode(); }
+    virtual void ButtonYReleased() { manager_.ResetMode(); }
 
     // Ambient light sensor updates determine LED brightess by default.
     virtual void AmbientLightUpdate() {
       manager().UpdateBrightnessFromAmbientLight();
     }
 
-    // Events for requested LED values from other components.
-    virtual void AirQualityModeLedValue(const LedValue&) {}
+    // Update the LED color by default.
+    virtual void OnLedValue(const LedValue& value) {
+      manager().led_.SetColor(value);
+    }
 
+    // Ignore Morse code edges by default.
     virtual void MorseCodeEdge(const MorseCodeValue&) {}
 
-    // Demo mode returns to air quality mode after a specified time.
-    virtual void DemoModeTimerExpired() {}
+    // Mode returns to air quality mode after a specified time.
+    virtual void OnTimerExpired(const TimerExpired& timer) {
+      if (timer.token == kSilenceAlarmsToken) {
+        manager().alarm_silenced_ = false;
+      }
+    }
 
    protected:
     StateManager& manager() { return manager_; }
@@ -156,51 +156,32 @@
     const char* name_;
   };
 
-  // Base class for all demo states to handle button behavior and timer.
-  class TimeoutState : public State {
-   public:
-    TimeoutState(StateManager& manager,
-                 const char* name,
-                 pw::chrono::SystemClock::duration timeout)
-        : State(manager, name) {
-      manager.demo_mode_timer_.InvokeAfter(timeout);
-    }
-
-    void ButtonYReleased() override { manager().SetState<MorseReadout>(); }
-
-    void DemoModeTimerExpired() override {
-      manager().SetState<AirQualityMode>();
-    }
-  };
-
   class AirQualityMode final : public State {
    public:
     AirQualityMode(StateManager& manager) : State(manager, "AirQualityMode") {}
 
     void ButtonYReleased() override { manager().SetState<MorseReadout>(); }
-
-    void AirQualityModeLedValue(const LedValue& value) override {
-      manager().led_.SetColor(value);
-    }
   };
 
-  class AirQualityThresholdMode final : public TimeoutState {
+  class AirQualityThresholdMode final : public State {
    public:
-    static constexpr auto kThresholdModeTimeout =
-        pw::chrono::SystemClock::for_at_least(std::chrono::seconds(3));
-
     AirQualityThresholdMode(StateManager& manager)
-        : TimeoutState(
-              manager, "AirQualityThresholdMode", kThresholdModeTimeout) {
+        : State(manager, "AirQualityThresholdMode") {
       manager.DisplayThreshold();
     }
 
-    void ButtonAReleased() override {
-      manager().IncrementThreshold(kThresholdModeTimeout);
-    }
+    void ButtonAReleased() override { manager().IncrementThreshold(); }
 
-    void ButtonBReleased() override {
-      manager().DecrementThreshold(kThresholdModeTimeout);
+    void ButtonBReleased() override { manager().DecrementThreshold(); }
+
+    void OnLedValue(const LedValue&) override {}
+
+    void OnTimerExpired(const TimerExpired& timer) override {
+      if (timer.token == kThresholdModeToken) {
+        manager().ResetMode();
+      } else {
+        State::OnTimerExpired(timer);
+      }
     }
   };
 
@@ -211,12 +192,10 @@
       manager.StartMorseReadout(/* repeat: */ true);
     }
 
-    void ButtonYReleased() override {
-      manager().pubsub_->Publish(AlarmSilenceRequest{.seconds = 60});
-    }
-    void AirQualityModeLedValue(const LedValue& value) override {
-      manager().led_.SetColor(value);
-    }
+    void ButtonXReleased() override { manager().SilenceAlarms(); }
+
+    void ButtonYReleased() override {}
+
     void MorseCodeEdge(const MorseCodeValue& value) override {
       manager().led_.SetBrightness(value.turn_on ? manager().brightness_ : 0);
     }
@@ -228,15 +207,10 @@
       manager.StartMorseReadout(/* repeat: */ false);
     }
 
-    void ButtonYReleased() override { manager().SetState<AirQualityMode>(); }
-
-    void AirQualityModeLedValue(const LedValue& value) override {
-      manager().led_.SetColor(value);
-    }
     void MorseCodeEdge(const MorseCodeValue& value) override {
       manager().led_.SetBrightness(value.turn_on ? manager().brightness_ : 0);
       if (value.message_finished) {
-        manager().SetState<AirQualityMode>();
+        manager().ResetMode();
       }
     }
   };
@@ -248,35 +222,46 @@
 
   template <typename StateType>
   void SetState() {
-    demo_mode_timer_.Cancel();  // always reset the timer
     const char* old_state = state_.get().name();
     state_.emplace<StateType>(*this);
     LogStateChange(old_state);
   }
 
-  void LogStateChange(const char* old_state) const;
-
-  void StartMorseReadout(bool repeat);
+  void ResetMode();
 
   void DisplayThreshold();
 
-  void IncrementThreshold(pw::chrono::SystemClock::duration timeout);
+  void IncrementThreshold();
 
-  void DecrementThreshold(pw::chrono::SystemClock::duration timeout);
+  void DecrementThreshold();
+
+  void SetAlarmThreshold(uint16_t alarm_threshold);
+
+  void UpdateAirQuality(uint16_t score);
+
+  void SilenceAlarms();
+
+  void StartMorseReadout(bool repeat);
 
   void UpdateAverageAmbientLight(float ambient_light_sample_lux);
 
   // Recalculates the brightness level when the ambient light changes.
   void UpdateBrightnessFromAmbientLight();
 
-  bool alarmed_ = false;
-  IntegerSimpleMovingAverager<uint16_t, 5> prox_samples_;
-  float ambient_light_lux_ = NAN;  // exponential moving averaged mean lux
-  uint8_t brightness_ = kDefaultBrightness;
-  uint16_t current_threshold_ = AirSensor::kDefaultTheshold;
-  uint16_t last_air_quality_score_ = AirSensor::kAverageScore;
+  void LogStateChange(const char* old_state) const;
+
+  std::optional<uint16_t> air_quality_;
   pw::InlineString<4> air_quality_score_string_;
 
+  bool alarm_ = false;
+  bool alarm_silenced_ = false;
+  uint16_t alarm_threshold_ = static_cast<uint16_t>(AirSensor::Score::kYellow);
+  HysteresisEdgeDetector<uint16_t> edge_detector_;
+
+  uint8_t brightness_ = kDefaultBrightness;
+  std::optional<float>
+      ambient_light_lux_;  // exponential moving averaged mean lux
+
   PubSub* pubsub_;
   LedOutputStateMachine led_;
 
@@ -286,8 +271,6 @@
                   AirQualityAlarmMode,
                   MorseReadout>
       state_;
-
-  pw::chrono::SystemTimer demo_mode_timer_;
 };
 
 }  // namespace sense