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)