pw_morse_code: Add encoder service

This CL adds an RPC service that can receive strings and blink them as
Morse code messages using the Pico on-board LED.

Change-Id: I914bc4614123ea9145d244f6e9d28d3cd685aa3b
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/showcase/rp2/+/218432
Commit-Queue: Aaron Green <aarongreen@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
diff --git a/apps/blinky/BUILD.bazel b/apps/blinky/BUILD.bazel
index eaebd05..4c3d624 100644
--- a/apps/blinky/BUILD.bazel
+++ b/apps/blinky/BUILD.bazel
@@ -25,6 +25,7 @@
     deps = [
         "//modules/blinky:service",
         "//modules/board:service",
+        "//modules/morse_code:service",
         "//system:worker",
         "//system",
         "@pigweed//pw_log",
diff --git a/apps/blinky/main.cc b/apps/blinky/main.cc
index 7ae9a95..a2bfb19 100644
--- a/apps/blinky/main.cc
+++ b/apps/blinky/main.cc
@@ -15,6 +15,7 @@
 
 #include "modules/blinky/service.h"
 #include "modules/board/service.h"
+#include "modules/morse_code/service.h"
 #include "pw_log/log.h"
 #include "pw_system/system.h"
 #include "system/system.h"
@@ -22,14 +23,21 @@
 
 int main() {
   am::system::Init();
+  auto& rpc_server = pw::System().rpc_server();
+  auto& worker = am::system::GetWorker();
+  auto& monochrome_led = am::system::MonochromeLed();
 
   static am::BoardService board_service;
-  board_service.Init(am::system::GetWorker(), am::system::Board());
-  pw::System().rpc_server().RegisterService(board_service);
+  board_service.Init(worker, am::system::Board());
+  rpc_server.RegisterService(board_service);
 
   static am::BlinkyService blinky_service;
-  blinky_service.Init(am::system::GetWorker(), am::system::MonochromeLed());
-  pw::System().rpc_server().RegisterService(blinky_service);
+  blinky_service.Init(worker, monochrome_led);
+  rpc_server.RegisterService(blinky_service);
+
+  static am::MorseCodeService morse_code_service;
+  morse_code_service.Init(worker, monochrome_led);
+  rpc_server.RegisterService(morse_code_service);
 
   PW_LOG_INFO("Started blinky app; waiting for RPCs...");
   am::system::Start();
diff --git a/modules/blinky/BUILD.bazel b/modules/blinky/BUILD.bazel
index c9b4b2c..ff6478a 100644
--- a/modules/blinky/BUILD.bazel
+++ b/modules/blinky/BUILD.bazel
@@ -26,6 +26,10 @@
     name = "blinky",
     srcs = ["blinky.cc"],
     hdrs = ["blinky.h"],
+    implementation_deps = [
+        "@pigweed//pw_log",
+        "@pigweed//pw_preprocessor",
+    ],
     deps = [
         "//modules/led:monochrome_led",
         "//modules/worker",
@@ -33,7 +37,6 @@
         "@pigweed//pw_chrono:system_clock",
         "@pigweed//pw_chrono:system_timer",
         "@pigweed//pw_function",
-        "@pigweed//pw_preprocessor",
         "@pigweed//pw_sync:interrupt_spin_lock",
         "@pigweed//pw_sync:lock_annotations",
         "@pigweed//pw_system:async",
diff --git a/modules/blinky/blinky.h b/modules/blinky/blinky.h
index f84369d..e931432 100644
--- a/modules/blinky/blinky.h
+++ b/modules/blinky/blinky.h
@@ -29,8 +29,10 @@
 /// Simple component that blink the on-board LED.
 class Blinky final {
  public:
-  constexpr static pw::chrono::SystemClock::duration kDefaultInterval =
-      std::chrono::seconds(1);
+  static constexpr uint32_t kDefaultIntervalMs = 1000;
+  static constexpr pw::chrono::SystemClock::duration kDefaultInterval =
+      pw::chrono::SystemClock::for_at_least(
+          std::chrono::milliseconds(kDefaultIntervalMs));
 
   Blinky();
 
@@ -38,7 +40,7 @@
 
   /// Injects this object's dependencies.
   ///
-  /// This method MUST be called befire using any other method.
+  /// This method MUST be called before using any other method.
   void Init(Worker& worker, MonochromeLed& led);
 
   /// Returns the currently configured interval for one blink.
diff --git a/modules/blinky/service.cc b/modules/blinky/service.cc
index ac159a8..69b3304 100644
--- a/modules/blinky/service.cc
+++ b/modules/blinky/service.cc
@@ -28,7 +28,8 @@
 
 pw::Status BlinkyService::Blink(const blinky_BlinkRequest& request,
                                 pw_protobuf_Empty&) {
-  uint32_t interval_ms = request.interval_ms == 0 ? 1000 : request.interval_ms;
+  uint32_t interval_ms = request.interval_ms == 0 ? Blinky::kDefaultIntervalMs
+                                                  : request.interval_ms;
   uint32_t blink_count = request.has_blink_count ? request.blink_count : 0;
   return blinky_.Blink(blink_count, interval_ms);
 }
diff --git a/modules/led/monochrome_led_fake.h b/modules/led/monochrome_led_fake.h
index d899621..3746a2e 100644
--- a/modules/led/monochrome_led_fake.h
+++ b/modules/led/monochrome_led_fake.h
@@ -33,18 +33,26 @@
 
   pw::chrono::SystemClock::duration interval() const { return interval_; }
 
+  uint32_t interval_ms() const {
+    return std::chrono::duration_cast<std::chrono::milliseconds>(interval_)
+        .count();
+  }
+
   void set_interval_ms(uint32_t interval_ms) {
     interval_ = pw::chrono::SystemClock::for_at_least(
         std::chrono::milliseconds(interval_ms));
   }
 
-  /// Returns on/off intervals encoded as follows: the top bit indicates whether
+  /// Encodes the parameters as follows: the top bit indicates whether
   /// the LED was on or off, and the lower 7 bits indicate for how many
   /// intervals, up to a max of 127.
+  static uint8_t Encode(bool is_on, size_t num_intervals);
+
+  /// Returns on/off intervals encoded by ``Encode``.
   const pw::Vector<uint8_t>& GetOutput() { return output_; }
 
-  /// Encodes the parameters in the same manner as ``GetOutput``.
-  static uint8_t Encode(bool is_on, size_t num_intervals);
+  /// Clears the saved output.
+  void ResetOutput() { output_.clear(); }
 
  protected:
   /// @copydoc ``MonochromeLed::Set``.
diff --git a/modules/morse_code/BUILD.bazel b/modules/morse_code/BUILD.bazel
new file mode 100644
index 0000000..f956c78
--- /dev/null
+++ b/modules/morse_code/BUILD.bazel
@@ -0,0 +1,102 @@
+# 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.
+
+load("@pigweed//pw_build:pigweed.bzl", "pw_cc_test")
+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(
+    name = "encoder",
+    srcs = ["encoder.cc"],
+    hdrs = ["encoder.h"],
+    implementation_deps = [
+        "@pigweed//pw_log",
+    ],
+    deps = [
+        "//modules/led:monochrome_led",
+        "//modules/worker",
+        "@pigweed//pw_chrono:system_clock",
+        "@pigweed//pw_chrono:system_timer",
+        "@pigweed//pw_sync:interrupt_spin_lock",
+        "@pigweed//pw_sync:lock_annotations",
+        "@pigweed//pw_work_queue",
+    ],
+)
+
+pw_cc_test(
+    name = "encoder_test",
+    srcs = ["encoder_test.cc"],
+    deps = [
+        ":encoder",
+        "//modules/led:monochrome_led_fake",
+        "//modules/worker:test_worker",
+        "@pigweed//pw_containers:vector",
+        "@pigweed//pw_function",
+        "@pigweed//pw_status",
+        "@pigweed//pw_thread:sleep",
+        "@pigweed//pw_thread:test_thread_context",
+        "@pigweed//pw_thread:thread",
+        "@pigweed//pw_unit_test",
+    ],
+)
+
+cc_library(
+    name = "service",
+    srcs = ["service.cc"],
+    hdrs = ["service.h"],
+    deps = [
+        ":encoder",
+        ":nanopb_rpc",
+        "@pigweed//pw_rpc",
+        "@pigweed//pw_string",
+    ],
+)
+
+pw_proto_filegroup(
+    name = "proto_and_options",
+    srcs = ["morse_code.proto"],
+    options_files = ["morse_code.options"],
+)
+
+proto_library(
+    name = "proto",
+    srcs = [":proto_and_options"],
+    strip_import_prefix = "/modules/morse_code",
+    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/morse_code/encoder.cc b/modules/morse_code/encoder.cc
new file mode 100644
index 0000000..6689c36
--- /dev/null
+++ b/modules/morse_code/encoder.cc
@@ -0,0 +1,138 @@
+// 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/morse_code/encoder.h"
+
+#include <cctype>
+#include <mutex>
+
+#include "pw_function/function.h"
+#include "pw_log/log.h"
+
+namespace am {
+
+Encoder::Encoder() : timer_(pw::bind_member<&Encoder::ToggleLed>(this)) {}
+
+Encoder::~Encoder() { timer_.Cancel(); }
+
+void Encoder::Init(Worker& worker, MonochromeLed& led) {
+  worker_ = &worker;
+  led_ = &led;
+}
+
+pw::Status Encoder::Encode(std::string_view msg,
+                           uint32_t repeat,
+                           uint32_t interval_ms) {
+  if (repeat == 0) {
+    PW_LOG_INFO("Encoding message forever at a %ums interval", interval_ms);
+    repeat = std::numeric_limits<uint32_t>::max();
+  } else {
+    PW_LOG_INFO(
+        "Encoding message %u times at a %ums interval", repeat, interval_ms);
+  }
+  pw::chrono::SystemClock::duration interval =
+      pw::chrono::SystemClock::for_at_least(
+          std::chrono::milliseconds(interval_ms));
+
+  timer_.Cancel();
+  {
+    std::lock_guard lock(lock_);
+    led_->TurnOff();
+    msg_ = msg;
+    msg_offset_ = 0;
+    repeat_ = repeat;
+    interval_ = interval;
+  }
+  worker_->RunOnce([this]() { ScheduleUpdate(); });
+  return pw::OkStatus();
+}
+
+bool Encoder::IsIdle() const {
+  std::lock_guard lock(lock_);
+  return repeat_ == 0 && msg_offset_ == msg_.size() && num_bits_ == 0;
+}
+
+void Encoder::ScheduleUpdate() {
+  pw::chrono::SystemClock::duration interval(0);
+  bool want_led_on = false;
+  while (true) {
+    std::lock_guard lock(lock_);
+    if (num_bits_ == 0 && !EnqueueNextLocked()) {
+      return;
+    }
+    want_led_on = (bits_ % 2) != 0;
+    if (want_led_on != led_->IsOn()) {
+      break;
+    }
+    bits_ >>= 1;
+    --num_bits_;
+    interval += interval_;
+  }
+
+  timer_.InvokeAfter(interval);
+}
+
+bool Encoder::EnqueueNextLocked() {
+  bits_ = 0;
+  num_bits_ = 0;
+  char c;
+
+  // Try to get the next character, repeating the message as requested and
+  // merging consecutive whitespace characters.
+  bool needs_word_break = false;
+  while (true) {
+    if (msg_offset_ == msg_.size()) {
+      if (--repeat_ == 0) {
+        return false;
+      }
+      needs_word_break = true;
+      msg_offset_ = 0;
+    }
+    c = msg_[msg_offset_++];
+    if (c == '\0') {
+      msg_offset_ = msg_.size();
+      continue;
+    }
+    if (!isspace(c)) {
+      break;
+    }
+    needs_word_break = true;
+  }
+
+  if (needs_word_break) {
+    // Words are separated by 7 dits worth of blanks.
+    // The previous symbol ended with 3 blanks, so add 4 more.
+    num_bits_ += 4;
+  }
+
+  // Encode the character.
+  auto it = internal::kEncodings.find(toupper(c));
+  if (it == internal::kEncodings.end()) {
+    it = internal::kEncodings.find('?');
+  }
+  const internal::Encoding& encoding = it->second;
+  bits_ |= encoding.bits << num_bits_;
+  num_bits_ += encoding.num_bits;
+  return num_bits_ != 0;
+}
+
+void Encoder::ToggleLed(pw::chrono::SystemClock::time_point) {
+  {
+    std::lock_guard lock(lock_);
+    led_->Toggle();
+  }
+  worker_->RunOnce([this]() { ScheduleUpdate(); });
+}
+
+}  // namespace am
diff --git a/modules/morse_code/encoder.h b/modules/morse_code/encoder.h
new file mode 100644
index 0000000..1bd2f1b
--- /dev/null
+++ b/modules/morse_code/encoder.h
@@ -0,0 +1,144 @@
+// 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 <cstddef>
+#include <cstdint>
+#include <string_view>
+
+#include "modules/led/monochrome_led.h"
+#include "modules/worker/worker.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono/system_timer.h"
+#include "pw_containers/flat_map.h"
+#include "pw_status/status.h"
+#include "pw_sync/interrupt_spin_lock.h"
+#include "pw_sync/lock_annotations.h"
+
+namespace am {
+namespace internal {
+
+struct Encoding {
+  uint32_t bits = 0;
+
+  // Letters are separated by 3 dits worth of blanks.
+  // The symbol will always end with 1 blank, so add 2 more.
+  uint8_t num_bits = 2;
+
+  constexpr explicit Encoding(const char* s) { Encode(s); }
+
+ private:
+  /// Converts a string of "dits" and "dahs", i.e. '.' and '-' respectively,
+  /// into a bit sequence of ons and offs.
+  constexpr void Encode(const char* s) {
+    if (*s == '.') {
+      bits |= (0x1 << num_bits);
+      num_bits += 2;
+      Encode(s + 1);
+    } else if (*s == '-') {
+      bits |= (0x7 << num_bits);
+      num_bits += 4;
+      Encode(s + 1);
+    }
+  }
+};
+
+// clang-format off
+constexpr pw::containers::FlatMap<char, Encoding, 38> kEncodings({{
+  {'A', Encoding(".-") },   {'T', Encoding("-") },
+  {'B', Encoding("-...") }, {'U', Encoding("..-") },
+  {'C', Encoding("-.-.") }, {'V', Encoding("...-") },
+  {'D', Encoding("-..") },  {'W', Encoding(".--") },
+  {'E', Encoding(".") },    {'X', Encoding("-..-") },
+  {'F', Encoding("..-.") }, {'Y', Encoding("-.--") },
+  {'G', Encoding("--.") },  {'Z', Encoding("--..") },
+  {'H', Encoding("....") }, {'0', Encoding("-----") },
+  {'I', Encoding("..") },   {'1', Encoding(".----") },
+  {'J', Encoding(".---") }, {'2', Encoding("..---") },
+  {'K', Encoding("-.-") },  {'3', Encoding("...--") },
+  {'L', Encoding(".-..") }, {'4', Encoding("....-") },
+  {'M', Encoding("--") },   {'5', Encoding(".....") },
+  {'N', Encoding("-.") },   {'6', Encoding("-....") },
+  {'O', Encoding("---") },  {'7', Encoding("--...") },
+  {'P', Encoding(".--.") }, {'8', Encoding("---..") },
+  {'Q', Encoding("--.-") }, {'9', Encoding("----.") },
+  {'R', Encoding(".-.") },  {'?', Encoding("..--..") },
+  {'S', Encoding("...") },  {'@', Encoding(".--.-.") },
+}});
+// clang-format on
+
+}  // namespace internal
+
+class Encoder final {
+ public:
+  static constexpr size_t kCapacity = 256;
+  static constexpr uint32_t kDefaultIntervalMs = 60;
+  static constexpr pw::chrono::SystemClock::duration kDefaultInterval =
+      pw::chrono::SystemClock::for_at_least(
+          std::chrono::milliseconds(kDefaultIntervalMs));
+
+  Encoder();
+
+  ~Encoder();
+
+  /// Injects this object's dependencies.
+  ///
+  /// This method MUST be called before using any other method.
+  void Init(Worker& worker, MonochromeLed& led);
+
+  /// Queues a sequence of callbacks to emit the given message in Morse code.
+  ///
+  /// The message is emitted by toggling the LED on and off. The shortest
+  /// interval the LED is turned on for is a "dit". The longest is a "dah",
+  /// which is three times the interval for a "dit". The LED is kept off for
+  /// one "dit" interval between each symbol, three "dit" intervals between each
+  /// letter, and 7 "dit" intervals between each word.
+  ///
+  /// @param  request       Message to emit in Morse code.
+  /// @param  repeat        Number of times to repeat the message.
+  /// @param  interval_ms   Duration of a "dit" in milliseconds.
+  pw::Status Encode(std::string_view request,
+                    uint32_t repeat,
+                    uint32_t interval_ms) PW_LOCKS_EXCLUDED(lock_);
+
+  /// Returns whether this instance is currently emitting a message or not.
+  bool IsIdle() const PW_LOCKS_EXCLUDED(lock_);
+
+ private:
+  /// Adds a toggle callback to the work queue.
+  void ScheduleUpdate() PW_LOCKS_EXCLUDED(lock_);
+
+  /// Encodes the next character into a sequence of LED toggles.
+  ///
+  /// Returns whether more toggles remain, or if the message is done.
+  bool EnqueueNextLocked() PW_EXCLUSIVE_LOCKS_REQUIRED(lock_);
+
+  /// Callback for toggling the LED.
+  void ToggleLed(pw::chrono::SystemClock::time_point);
+
+  Worker* worker_ = nullptr;
+  MonochromeLed* led_ = nullptr;
+  pw::chrono::SystemTimer timer_;
+
+  mutable pw::sync::InterruptSpinLock lock_;
+  std::string_view msg_ PW_GUARDED_BY(lock_);
+  size_t msg_offset_ PW_GUARDED_BY(lock_) = 0;
+  size_t repeat_ PW_GUARDED_BY(lock_) = 1;
+  pw::chrono::SystemClock::duration interval_ PW_GUARDED_BY(lock_) =
+      kDefaultInterval;
+  uint32_t bits_ PW_GUARDED_BY(lock_) = 0;
+  size_t num_bits_ PW_GUARDED_BY(lock_) = 0;
+};
+
+}  // namespace am
diff --git a/modules/morse_code/encoder_test.cc b/modules/morse_code/encoder_test.cc
new file mode 100644
index 0000000..e601c18
--- /dev/null
+++ b/modules/morse_code/encoder_test.cc
@@ -0,0 +1,167 @@
+// 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/morse_code/encoder.h"
+
+#include <chrono>
+#include <cstdint>
+
+#include "modules/led/monochrome_led_fake.h"
+#include "modules/worker/test_worker.h"
+#include "pw_containers/vector.h"
+#include "pw_status/status.h"
+#include "pw_thread/sleep.h"
+#include "pw_thread/thread.h"
+#include "pw_unit_test/framework.h"
+
+namespace am {
+
+// Test fixtures.
+
+class MorseCodeEncoderTest : public ::testing::Test {
+ protected:
+  // TODO(b/352327457): Ideally this would use simulated time, but no
+  // simulated system timer exists yet. For now, relax the constraint by
+  // checking that the LED was in the right state for _at least_ the expected
+  // number of intervals. On some platforms, the fake LED is implemented using
+  // threads, and may sleep a bit longer.
+  void Expect(bool is_on, size_t num_intervals) {
+    const pw::Vector<uint8_t>& actual = led_.GetOutput();
+    ASSERT_LT(offset_, actual.size());
+    uint8_t encoded = MonochromeLedFake::Encode(is_on, num_intervals);
+    EXPECT_GE(actual[offset_], encoded);
+    ++offset_;
+  }
+
+  void Expect(std::string_view msg) {
+    offset_ = 0;
+    while (!msg.empty()) {
+      size_t off_intervals;
+      size_t offset;
+      if (msg.substr(1, 2) == "  ") {
+        // Word break
+        off_intervals = 7;
+        offset = 3;
+
+      } else if (msg.substr(1, 1) == " ") {
+        // Letter break
+        off_intervals = 3;
+        offset = 2;
+
+      } else {
+        // Symbol break
+        off_intervals = 1;
+        offset = 1;
+      }
+
+      if (msg[0] == '.') {
+        Expect(true, 1);
+      } else if (msg[0] == '-') {
+        Expect(true, 3);
+      } else {
+        FAIL();
+      }
+      msg = msg.substr(offset);
+      if (msg.empty()) {
+        break;
+      }
+      Expect(false, off_intervals);
+    }
+    led_.ResetOutput();
+  }
+
+  /// Waits until the given encoder is idle.
+  void SleepUntilDone() {
+    while (!encoder_.IsIdle()) {
+      pw::this_thread::sleep_for(led_.interval());
+    }
+  }
+
+  Encoder encoder_;
+  MonochromeLedFake led_;
+  size_t offset_ = 0;
+
+ private:
+  pw::Vector<char, MonochromeLedFake::kCapacity> output_;
+};
+
+// Unit tests.
+
+TEST_F(MorseCodeEncoderTest, EncodeEmpty) {
+  TestWorker<> worker;
+  encoder_.Init(worker, led_);
+  EXPECT_EQ(encoder_.Encode("", 1, led_.interval_ms()), pw::OkStatus());
+  SleepUntilDone();
+  worker.Stop();
+  Expect("");
+}
+
+TEST_F(MorseCodeEncoderTest, EncodeHelloWorld) {
+  TestWorker<> worker;
+  encoder_.Init(worker, led_);
+  EXPECT_EQ(encoder_.Encode("hello world", 1, led_.interval_ms()),
+            pw::OkStatus());
+  SleepUntilDone();
+  worker.Stop();
+
+  Expect(".... . .-.. .-.. ---  .-- --- .-. .-.. -..");
+}
+
+TEST_F(MorseCodeEncoderTest, EncodeRepeated) {
+  TestWorker<> worker;
+  encoder_.Init(worker, led_);
+  EXPECT_EQ(encoder_.Encode("hello", 2, led_.interval_ms()), pw::OkStatus());
+  SleepUntilDone();
+  worker.Stop();
+  Expect(".... . .-.. .-.. ---  .... . .-.. .-.. ---");
+}
+
+TEST_F(MorseCodeEncoderTest, EncodeSlow) {
+  TestWorker<> worker;
+  encoder_.Init(worker, led_);
+  led_.set_interval_ms(20);
+  EXPECT_EQ(encoder_.Encode("hello", 1, led_.interval_ms()), pw::OkStatus());
+  SleepUntilDone();
+  worker.Stop();
+  Expect(".... . .-.. .-.. ---");
+}
+
+TEST_F(MorseCodeEncoderTest, EncodeConsecutiveWhitespace) {
+  TestWorker<> worker;
+  encoder_.Init(worker, led_);
+  EXPECT_EQ(encoder_.Encode("hello    world", 1, led_.interval_ms()),
+            pw::OkStatus());
+  SleepUntilDone();
+  worker.Stop();
+  Expect(".... . .-.. .-.. ---  .-- --- .-. .-.. -..");
+}
+
+TEST_F(MorseCodeEncoderTest, EncodeInvalidChars) {
+  TestWorker<> worker;
+  encoder_.Init(worker, led_);
+  char s[2];
+  s[1] = 0;
+  for (char c = 127; c != 0; --c) {
+    if (isspace(c) || isalnum(c) || c == '?' || c == '@') {
+      continue;
+    }
+    s[0] = c;
+    EXPECT_EQ(encoder_.Encode(s, 1, led_.interval_ms()), pw::OkStatus());
+    SleepUntilDone();
+    Expect("..--..");
+  }
+  worker.Stop();
+}
+
+}  // namespace am
diff --git a/modules/morse_code/morse_code.options b/modules/morse_code/morse_code.options
new file mode 100644
index 0000000..a7c1584
--- /dev/null
+++ b/modules/morse_code/morse_code.options
@@ -0,0 +1,2 @@
+// Set an option for a specific field.
+morse_code.SendRequest.msg max_size:256
diff --git a/modules/morse_code/morse_code.proto b/modules/morse_code/morse_code.proto
new file mode 100644
index 0000000..510944b
--- /dev/null
+++ b/modules/morse_code/morse_code.proto
@@ -0,0 +1,41 @@
+// 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 morse_code;
+
+import "pw_protobuf_protos/common.proto";
+
+service MorseCode {
+  rpc Send(SendRequest) returns (pw.protobuf.Empty);
+}
+
+message SendRequest {
+  // The message to send in Morse code using the on-board LED.
+  // Maximum length is 256 characters.
+  string msg = 1;
+
+  // The number of times to repeat the message.
+  // If unset, sends once.
+  // If 0, repeats forever.
+  optional uint32 repeat = 2;
+
+  // The duration of one "dit", in milliseconds.
+  // The duration of a "dah" three times this interval.
+  // Minimum is 10 ms, anything smaller will be increased to that value.
+  // If unset, defaults to 100 ms.
+  optional uint32 interval_ms = 3;
+}
+
+// SendRequest.msg max_size:256
diff --git a/modules/morse_code/service.cc b/modules/morse_code/service.cc
new file mode 100644
index 0000000..d497ab2
--- /dev/null
+++ b/modules/morse_code/service.cc
@@ -0,0 +1,32 @@
+// 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/morse_code/service.h"
+
+namespace am {
+
+void MorseCodeService::Init(Worker& worker, MonochromeLed& led) {
+  encoder_.Init(worker, led);
+}
+
+pw::Status MorseCodeService::Send(const morse_code_SendRequest& request,
+                                  pw_protobuf_Empty&) {
+  msg_ = request.msg;
+  uint32_t repeat = request.has_repeat ? request.repeat : 1;
+  uint32_t interval_ms = request.has_interval_ms ? request.interval_ms
+                                                 : Encoder::kDefaultIntervalMs;
+  return encoder_.Encode(request.msg, repeat, interval_ms);
+}
+
+}  // namespace am
diff --git a/modules/morse_code/service.h b/modules/morse_code/service.h
new file mode 100644
index 0000000..265dd1d
--- /dev/null
+++ b/modules/morse_code/service.h
@@ -0,0 +1,40 @@
+// 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 "modules/morse_code/encoder.h"
+#include "modules/morse_code/morse_code.rpc.pb.h"
+#include "pw_rpc/server.h"
+#include "pw_status/status.h"
+#include "pw_string/string.h"
+
+namespace am {
+
+class MorseCodeService final
+    : public ::morse_code::pw_rpc::nanopb::MorseCode::Service<
+          MorseCodeService> {
+ public:
+  static constexpr uint32_t kDefaultDitInterval = 10;
+
+  void Init(Worker& worker, MonochromeLed& led);
+
+  pw::Status Send(const morse_code_SendRequest& request,
+                  pw_protobuf_Empty& response);
+
+ private:
+  Encoder encoder_;
+  pw::InlineString<sizeof(morse_code_SendRequest::msg)> msg_;
+};
+
+}  // namespace am
diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel
index 722b626..a9ae70d 100644
--- a/tools/BUILD.bazel
+++ b/tools/BUILD.bazel
@@ -22,6 +22,7 @@
     deps = [
         "//modules/blinky:py_pb2",
         "//modules/board:py_pb2",
+        "//modules/morse_code: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/airmaranth/console.py b/tools/airmaranth/console.py
index 18c01a2..2f61079 100644
--- a/tools/airmaranth/console.py
+++ b/tools/airmaranth/console.py
@@ -17,10 +17,11 @@
 import sys
 from typing import Optional
 
-from blinky_pb import blinky_pb2
 from pw_protobuf_protos import common_pb2
 import pw_system.console
+from blinky_pb import blinky_pb2
 from modules.board import board_pb2
+import morse_code_pb2
 from pw_rpc import echo_pb2
 
 
@@ -30,6 +31,7 @@
         echo_pb2,
         board_pb2,
         blinky_pb2,
+        morse_code_pb2,
     ]
     return pw_system.console.main_with_compiled_protos(compiled_protos, args)