modules/state_manager: Add RPC service

Creates an RPC service to query the current state and update air quality
alarm thresholds.

Change-Id: Ifdf5d89dcbf175680d72501af8718a97f7c82b00
Reviewed-on: https://pigweed-internal-review.git.corp.google.com/c/pigweed/showcase/rp2/+/73888
Commit-Queue: Alexei Frolov <frolv@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/apps/production/BUILD.bazel b/apps/production/BUILD.bazel
index 8ca7488..3b246fb 100644
--- a/apps/production/BUILD.bazel
+++ b/apps/production/BUILD.bazel
@@ -28,10 +28,11 @@
         "//modules/board:service",
         "//modules/event_timers",
         "//modules/morse_code:encoder",
+        "//modules/proximity:manager",
         "//modules/pubsub:service",
         "//modules/state_manager",
+        "//modules/state_manager:service",
         "//system:pubsub",
-        "//modules/proximity:manager",
         "//system:worker",
         "//system",
         ":threads",
diff --git a/apps/production/main.cc b/apps/production/main.cc
index 1f76b39..dbb9669 100644
--- a/apps/production/main.cc
+++ b/apps/production/main.cc
@@ -22,6 +22,7 @@
 #include "modules/proximity/manager.h"
 #include "modules/pubsub/service.h"
 #include "modules/sampling_thread/sampling_thread.h"
+#include "modules/state_manager/service.h"
 #include "modules/state_manager/state_manager.h"
 #include "pw_assert/check.h"
 #include "pw_log/log.h"
@@ -36,6 +37,8 @@
 
 void InitStateManager() {
   static StateManager state_manager(system::PubSub(), system::PolychromeLed());
+  static StateManagerService state_manager_service(system::PubSub());
+  pw::System().rpc_server().RegisterService(state_manager_service);
 }
 
 void InitEventTimers() {
diff --git a/modules/pubsub/BUILD.bazel b/modules/pubsub/BUILD.bazel
index b31c796..692c43c 100644
--- a/modules/pubsub/BUILD.bazel
+++ b/modules/pubsub/BUILD.bazel
@@ -62,10 +62,12 @@
     hdrs = ["service.h"],
     implementation_deps = [
         "@pigweed//pw_log",
+        "@pigweed//pw_string",
     ],
     deps = [
         ":events",
         ":nanopb_rpc",
+        "//modules/state_manager",
     ],
 )
 
@@ -91,6 +93,7 @@
     strip_import_prefix = "/modules/pubsub",
     deps = [
         "//modules/morse_code:proto",
+        "//modules/state_manager:proto",
         "@pigweed//pw_protobuf:common_proto",
     ],
 )
diff --git a/modules/pubsub/pubsub.proto b/modules/pubsub/pubsub.proto
index dc8adb7..9e0bf2e 100644
--- a/modules/pubsub/pubsub.proto
+++ b/modules/pubsub/pubsub.proto
@@ -17,6 +17,7 @@
 
 import "pw_protobuf_protos/common.proto";
 import "morse_code.proto";
+import "state_manager.proto";
 
 service PubSub {
   rpc Publish(Event) returns (pw.protobuf.Empty);
@@ -48,6 +49,16 @@
   uint32 token = 1;
 }
 
+message StateManagerControl {
+  enum Action {
+    UNKNOWN = 0;
+    INCREMENT_THRESHOLD = 1;
+    DECREMENT_THRESHOLD = 2;
+    SILENCE_ALARMS = 3;
+  }
+  Action action = 1;
+}
+
 message Event {
   // This definition must be kept up to date with
   // modules/pubsub/pubsub_events.h.
@@ -64,5 +75,7 @@
     TimerExpired timer_expired = 10;
     morse_code.SendRequest morse_encode_request = 11;
     float ambient_light_lux = 12;
+    state_manager.State sense_state = 13;
+    StateManagerControl state_manager_control = 14;
   }
 }
diff --git a/modules/pubsub/pubsub_events.h b/modules/pubsub/pubsub_events.h
index 404bddf..eb3bb7c 100644
--- a/modules/pubsub/pubsub_events.h
+++ b/modules/pubsub/pubsub_events.h
@@ -110,6 +110,23 @@
   bool message_finished;
 };
 
+struct SenseState {
+  bool alarm;
+  uint16_t alarm_threshold;
+  uint16_t air_quality;
+  const char* air_quality_description;
+};
+
+struct StateManagerControl {
+  enum Action {
+    kIncrementThreshold,
+    kDecrementThreshold,
+    kSilenceAlarms,
+  } action;
+
+  explicit constexpr StateManagerControl(Action a) : action(a) {}
+};
+
 // This definition must be kept up to date with modules/pubsub/pubsub.proto and
 // the EventType enum.
 using Event = std::variant<ButtonA,
@@ -123,7 +140,9 @@
                            AmbientLightSample,
                            AirQuality,
                            MorseEncodeRequest,
-                           MorseCodeValue>;
+                           MorseCodeValue,
+                           SenseState,
+                           StateManagerControl>;
 
 // Index versions of Event variants, to support finding the event
 enum EventType : size_t {
@@ -139,7 +158,9 @@
   kAirQuality,
   kMorseEncodeRequest,
   kMorseCodeValue,
-  kLastEventType = kMorseCodeValue,
+  kSenseState,
+  kStateManagerControl,
+  kLastEventType = kStateManagerControl,
 };
 
 static_assert(kLastEventType + 1 == std::variant_size_v<Event>,
diff --git a/modules/pubsub/service.cc b/modules/pubsub/service.cc
index 8d992c0..0596e4e 100644
--- a/modules/pubsub/service.cc
+++ b/modules/pubsub/service.cc
@@ -15,7 +15,9 @@
 
 #include "modules/pubsub/service.h"
 
+#include "modules/state_manager/state_manager.h"
 #include "pw_log/log.h"
+#include "pw_string/util.h"
 
 namespace sense {
 namespace {
@@ -69,6 +71,31 @@
     const auto& morse = std::get<MorseCodeValue>(event);
     proto.type.morse_code_value.turn_on = morse.turn_on;
     proto.type.morse_code_value.message_finished = morse.message_finished;
+  } else if (std::holds_alternative<SenseState>(event)) {
+    proto.which_type = pubsub_Event_sense_state_tag;
+    const auto& state = std::get<SenseState>(event);
+    proto.type.sense_state.alarm_active = state.alarm;
+    proto.type.sense_state.alarm_threshold = state.alarm_threshold;
+    proto.type.sense_state.aq_score = state.air_quality;
+    pw::string::Copy(state.air_quality_description,
+                     proto.type.sense_state.aq_description);
+  } else if (std::holds_alternative<StateManagerControl>(event)) {
+    proto.which_type = pubsub_Event_state_manager_control_tag;
+    const auto& control = std::get<StateManagerControl>(event);
+    switch (control.action) {
+      case StateManagerControl::kDecrementThreshold:
+        proto.type.state_manager_control.action =
+            pubsub_StateManagerControl_Action_DECREMENT_THRESHOLD;
+        break;
+      case StateManagerControl::kIncrementThreshold:
+        proto.type.state_manager_control.action =
+            pubsub_StateManagerControl_Action_INCREMENT_THRESHOLD;
+        break;
+      case StateManagerControl::kSilenceAlarms:
+        proto.type.state_manager_control.action =
+            pubsub_StateManagerControl_Action_SILENCE_ALARMS;
+        break;
+    }
   } else {
     PW_LOG_WARN("Unimplemented pubsub service event");
   }
@@ -104,6 +131,31 @@
       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_sense_state_tag:
+      return SenseState{
+          .alarm = proto.type.sense_state.alarm_active,
+          .alarm_threshold =
+              static_cast<uint16_t>(proto.type.sense_state.alarm_threshold),
+          .air_quality = static_cast<uint16_t>(proto.type.sense_state.aq_score),
+          .air_quality_description = StateManager::AirQualityDescription(
+              proto.type.sense_state.aq_score),
+      };
+    case pubsub_Event_state_manager_control_tag:
+      StateManagerControl::Action action;
+      switch (proto.type.state_manager_control.action) {
+        case pubsub_StateManagerControl_Action_DECREMENT_THRESHOLD:
+          action = StateManagerControl::kDecrementThreshold;
+          break;
+        case pubsub_StateManagerControl_Action_INCREMENT_THRESHOLD:
+          action = StateManagerControl::kIncrementThreshold;
+          break;
+        case pubsub_StateManagerControl_Action_SILENCE_ALARMS:
+          action = StateManagerControl::kSilenceAlarms;
+          break;
+        case pubsub_StateManagerControl_Action_UNKNOWN:
+          return pw::Status::InvalidArgument();
+      }
+      return StateManagerControl(action);
     default:
       return pw::Status::Unimplemented();
   }
diff --git a/modules/state_manager/BUILD.bazel b/modules/state_manager/BUILD.bazel
index f3f1ce4..ef37a6d 100644
--- a/modules/state_manager/BUILD.bazel
+++ b/modules/state_manager/BUILD.bazel
@@ -12,6 +12,14 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
+load(
+    "@pigweed//pw_protobuf_compiler:pw_proto_library.bzl",
+    "nanopb_proto_library",
+    "nanopb_rpc_proto_library",
+    "pw_proto_filegroup",
+)
+load("@rules_python//python:proto.bzl", "py_proto_library")
+
 package(default_visibility = ["//visibility:public"])
 
 cc_library(
@@ -40,3 +48,47 @@
     hdrs = ["common_base_union.h"],
     visibility = ["//visibility:private"],
 )
+
+cc_library(
+    name = "service",
+    srcs = ["service.cc"],
+    hdrs = ["service.h"],
+    deps = [
+        ":nanopb_rpc",
+        "//modules/pubsub:events",
+        "@pigweed//pw_string",
+        "@pigweed//pw_sync:interrupt_spin_lock",
+        "@pigweed//pw_sync:lock_annotations",
+    ],
+)
+
+pw_proto_filegroup(
+    name = "proto_and_options",
+    srcs = ["state_manager.proto"],
+    options_files = ["state_manager.options"],
+)
+
+proto_library(
+    name = "proto",
+    srcs = [":proto_and_options"],
+    strip_import_prefix = "/modules/state_manager",
+    deps = [
+        "@pigweed//pw_protobuf:common_proto",
+    ],
+)
+
+nanopb_proto_library(
+    name = "nanopb",
+    deps = [":proto"],
+)
+
+nanopb_rpc_proto_library(
+    name = "nanopb_rpc",
+    nanopb_proto_library_deps = [":nanopb"],
+    deps = [":proto"],
+)
+
+py_proto_library(
+    name = "py_pb2",
+    deps = [":proto"],
+)
diff --git a/modules/state_manager/service.cc b/modules/state_manager/service.cc
new file mode 100644
index 0000000..fecc2e6
--- /dev/null
+++ b/modules/state_manager/service.cc
@@ -0,0 +1,69 @@
+// Copyright 2024 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+#include "modules/state_manager/service.h"
+
+#include <mutex>
+
+#include "pw_string/util.h"
+
+namespace sense {
+
+StateManagerService::StateManagerService(PubSub& pubsub) : pubsub_(&pubsub) {
+  pubsub_->SubscribeTo<SenseState>([this](SenseState event) {
+    std::lock_guard lock(current_state_lock_);
+    current_state_ = event;
+  });
+}
+
+pw::Status StateManagerService::ChangeThreshold(
+    const state_manager_ChangeThresholdRequest& request, pw_protobuf_Empty&) {
+  bool success;
+
+  if (request.increment) {
+    success = pubsub_->Publish(
+        StateManagerControl(StateManagerControl::kIncrementThreshold));
+  } else {
+    success = pubsub_->Publish(
+        StateManagerControl(StateManagerControl::kDecrementThreshold));
+  }
+
+  return success ? pw::OkStatus() : pw::Status::Unavailable();
+}
+
+pw::Status StateManagerService::SilenceAlarm(const pw_protobuf_Empty&,
+                                             pw_protobuf_Empty&) {
+  bool success = pubsub_->Publish(
+      StateManagerControl(StateManagerControl::kSilenceAlarms));
+  return success ? pw::OkStatus() : pw::Status::Unavailable();
+}
+
+pw::Status StateManagerService::GetState(const pw_protobuf_Empty&,
+                                         state_manager_State& response) {
+  std::lock_guard lock(current_state_lock_);
+
+  if (!current_state_.has_value()) {
+    return pw::Status::Unavailable();
+  }
+
+  const auto& current_state = current_state_.value();
+  response.alarm_active = current_state.alarm;
+  response.alarm_threshold = current_state.alarm_threshold;
+  response.aq_score = current_state.air_quality;
+  return pw::string::Copy(current_state.air_quality_description,
+                          response.aq_description)
+      .status();
+}
+
+}  // namespace sense
diff --git a/modules/state_manager/service.h b/modules/state_manager/service.h
new file mode 100644
index 0000000..6cca27b
--- /dev/null
+++ b/modules/state_manager/service.h
@@ -0,0 +1,43 @@
+// Copyright 2024 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+#pragma once
+
+#include <optional>
+
+#include "modules/pubsub/pubsub_events.h"
+#include "modules/state_manager/state_manager.rpc.pb.h"
+#include "pw_sync/interrupt_spin_lock.h"
+#include "pw_sync/lock_annotations.h"
+
+namespace sense {
+
+class StateManagerService final
+    : public ::state_manager::pw_rpc::nanopb::StateManager::Service<
+          StateManagerService> {
+ public:
+  StateManagerService(PubSub& pubsub);
+
+  pw::Status ChangeThreshold(
+      const state_manager_ChangeThresholdRequest& request,
+      pw_protobuf_Empty& response);
+  pw::Status SilenceAlarm(const pw_protobuf_Empty&, pw_protobuf_Empty&);
+  pw::Status GetState(const pw_protobuf_Empty&, state_manager_State& response);
+
+ private:
+  PubSub* pubsub_;
+  pw::sync::InterruptSpinLock current_state_lock_;
+  std::optional<SenseState> current_state_ PW_GUARDED_BY(current_state_lock_);
+};
+
+}  // namespace sense
diff --git a/modules/state_manager/state_manager.cc b/modules/state_manager/state_manager.cc
index eddc5d4..efe1e42 100644
--- a/modules/state_manager/state_manager.cc
+++ b/modules/state_manager/state_manager.cc
@@ -77,10 +77,14 @@
       led_.UpdateBrightnessFromAmbientLight(
           std::get<AmbientLightSample>(event).sample_lux);
       break;
+    case kStateManagerControl:
+      HandleControlEvent(std::get<StateManagerControl>(event));
+      break;
     case kTimerRequest:
     case kMorseEncodeRequest:
     case kProximitySample:
     case kProximityStateChange:
+    case kSenseState:
       break;  // ignore these events
   }
 }
@@ -122,12 +126,15 @@
   PW_LOG_INFO("Air quality thresholds set: alarm at %u, silence at %u",
               alarm_threshold_,
               silence_threshold);
+
+  BroadcastState();
 }
 
 void StateManager::UpdateAirQuality(uint16_t score) {
   AddAndSmoothExponentially(air_quality_, score);
   state_.get().OnLedValue(AirSensor::GetLedValue(*air_quality_));
   if (alarm_silenced_) {
+    BroadcastState();
     return;
   }
   switch (edge_detector_.Update(*air_quality_)) {
@@ -141,6 +148,7 @@
       return;
   }
   ResetMode();
+  BroadcastState();
 }
 
 void StateManager::RepeatAlarm() {
@@ -159,6 +167,7 @@
       .timeout_s = kSilenceAlarmTimeout,
   });
   ResetMode();
+  BroadcastState();
 }
 
 void StateManager::ResetMode() {
@@ -173,7 +182,7 @@
   pubsub_.Publish(MorseEncodeRequest{.message = msg, .repeat = 1u});
 }
 
-static constexpr const char* AirQualityDescription(uint16_t score) {
+const char* StateManager::AirQualityDescription(uint16_t score) {
   if (score > AirSensor::kMaxScore) {
     return "INVALID";
   }
@@ -202,7 +211,7 @@
 }
 
 void StateManager::FormatAirQuality(MorseCodeString& msg) {
-  uint16_t score = air_quality_.value_or(AirSensor::kMaxScore + 1);
+  uint16_t score = air_quality();
   pw::Status status = pw::string::FormatOverwrite(
       msg, "AQ %s %hu", AirQualityDescription(score), score);
   PW_LOG_INFO("%s", msg.data());
@@ -249,4 +258,27 @@
   PW_LOG_INFO("StateManager: %s -> %s", old_state, state_.get().name());
 }
 
+void StateManager::BroadcastState() const {
+  pubsub_.Publish(SenseState{
+      .alarm = alarm_,
+      .alarm_threshold = alarm_threshold_,
+      .air_quality = air_quality(),
+      .air_quality_description = AirQualityDescription(air_quality()),
+  });
+}
+
+void StateManager::HandleControlEvent(StateManagerControl& event) {
+  switch (event.action) {
+    case StateManagerControl::kIncrementThreshold:
+      IncrementThreshold();
+      break;
+    case StateManagerControl::kDecrementThreshold:
+      DecrementThreshold();
+      break;
+    case StateManagerControl::kSilenceAlarms:
+      SilenceAlarms();
+      break;
+  }
+}
+
 }  // namespace sense
diff --git a/modules/state_manager/state_manager.h b/modules/state_manager/state_manager.h
index 82fa58c..cf4874b 100644
--- a/modules/state_manager/state_manager.h
+++ b/modules/state_manager/state_manager.h
@@ -80,6 +80,8 @@
   StateManager(const StateManager&) = delete;
   StateManager& operator=(const StateManager&) = delete;
 
+  static const char* AirQualityDescription(uint16_t score);
+
  private:
   static constexpr size_t kMaxMorseCodeStringLen = 16;
   static_assert(kMaxMorseCodeStringLen <= Encoder::kMaxMsgLen);
@@ -254,6 +256,7 @@
   void SetState(Args&&... args) {
     const char* old_state = state_.get().name();
     state_.emplace<StateType>(*this, std::forward<Args>(args)...);
+    BroadcastState();
     LogStateChange(old_state);
   }
 
@@ -261,15 +264,18 @@
   /// air quality.
   void ResetMode();
 
-  /// Sets the LED to reflect the current alarm threshold.
-  void DisplayThreshold();
-
   /// Increases the current alarm threshold.
   void IncrementThreshold();
 
   /// Decreases the current alarm threshold.
   void DecrementThreshold();
 
+  /// Suppresses `AlarmMode` for 60 seconds.
+  void SilenceAlarms();
+
+  /// Sets the LED to reflect the current alarm threshold.
+  void DisplayThreshold();
+
   /// Sets the current alarm threshold.
   void SetAlarmThreshold(uint16_t alarm_threshold);
 
@@ -280,9 +286,6 @@
   /// Send a timer request to repeat an alarm.
   void RepeatAlarm();
 
-  /// Suppresses `AlarmMode` for 60 seconds.
-  void SilenceAlarms();
-
   /// Sends a request to the Morse encoder to send `OnMorseCodeValue` events for
   /// the given message.
   void StartMorseReadout(std::string_view msg);
@@ -296,6 +299,13 @@
 
   void LogStateChange(const char* old_state) const;
 
+  void BroadcastState() const;
+  void HandleControlEvent(StateManagerControl& event);
+
+  constexpr uint16_t air_quality() const {
+    return air_quality_.value_or(AirSensor::kMaxScore + 1);
+  }
+
   std::optional<uint16_t> air_quality_;
 
   bool alarm_ = false;
diff --git a/modules/state_manager/state_manager.options b/modules/state_manager/state_manager.options
new file mode 100644
index 0000000..b6be60a
--- /dev/null
+++ b/modules/state_manager/state_manager.options
@@ -0,0 +1 @@
+state_manager.State.aq_description max_size:32
\ No newline at end of file
diff --git a/modules/state_manager/state_manager.proto b/modules/state_manager/state_manager.proto
new file mode 100644
index 0000000..1c829fd
--- /dev/null
+++ b/modules/state_manager/state_manager.proto
@@ -0,0 +1,37 @@
+// Copyright 2024 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+syntax = "proto3";
+
+package state_manager;
+
+import "pw_protobuf_protos/common.proto";
+
+service StateManager {
+  rpc ChangeThreshold(ChangeThresholdRequest) returns (pw.protobuf.Empty);
+  rpc SilenceAlarm(pw.protobuf.Empty) returns (pw.protobuf.Empty);
+  rpc GetState(pw.protobuf.Empty) returns (State);
+}
+
+message ChangeThresholdRequest {
+  // If true, increments the alarm threshold to its next step.
+  // If false, decrements.
+  bool increment = 1;
+}
+
+message State {
+  bool alarm_active = 1;
+  uint32 alarm_threshold = 2;
+  uint32 aq_score = 3;
+  string aq_description = 4;
+}
\ No newline at end of file
diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel
index ecdff4e..d07fa69 100644
--- a/tools/BUILD.bazel
+++ b/tools/BUILD.bazel
@@ -33,6 +33,7 @@
         "//modules/board:py_pb2",
         "//modules/morse_code:py_pb2",
         "//modules/pubsub:py_pb2",
+        "//modules/state_manager:py_pb2",
         "@pigweed//pw_protobuf:common_py_pb2",
         "@pigweed//pw_rpc:echo_py_pb2",
         "@pigweed//pw_system/py:pw_system_lib",
diff --git a/tools/sense/device.py b/tools/sense/device.py
index a2a6965..c36f32f 100644
--- a/tools/sense/device.py
+++ b/tools/sense/device.py
@@ -37,6 +37,7 @@
 from factory_pb import factory_pb2
 from pubsub_pb import pubsub_pb2
 import morse_code_pb2
+import state_manager_pb2
 
 
 _LOG = logging.getLogger(__file__)
@@ -151,6 +152,7 @@
         factory_pb2,
         morse_code_pb2,
         pubsub_pb2,
+        state_manager_pb2,
     ]