Update quickstart to rp2 blinky

Change-Id: If2776ca18cf100b132a23c10f3d99c1840256441
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/quickstart/bazel/+/228453
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Armando Montanez <amontanez@google.com>
Commit-Queue: Taylor Cramer <cramertj@google.com>
diff --git a/.bazelignore b/.bazelignore
index 912eacc..882c16b 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1 +1,12 @@
+.cipd
+.presubmit
+.environment
+environment
+node_modules
+out
+bazel-bin
+bazel-out
+bazel-pigweed
+bazel-testlogs
+outbazel
 third_party
diff --git a/.bazelrc b/.bazelrc
index 293ee0d..aec79d0 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,4 +1,4 @@
-# Copyright 2023 The Pigweed Authors
+# 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
@@ -11,35 +11,165 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-#
-# TODO: https://pwbug.dev/258836641#comment4: Enabling bzlmod breaks the build.
-common --noenable_bzlmod
 
+# Standard Pigweed flags
+# ======================
+# All Pigweed projects are expected to set these flags. They mostly pre-adopt
+# future Bazel settings.
+#
+# The source of truth for these flags is pw_build/pigweed.bazelrc in the main
+# Pigweed repo.
+#
 # Do not attempt to configure an autodetected (local) toolchain. We vendor all
 # our toolchains, and CI VMs may not have any local toolchain to detect.
 common --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
 
-# Don't propagate flags or defines to the exec config. This will become the
-# default one day (https://github.com/bazelbuild/bazel/issues/22457) and will
-# improve cache hit rates between builds targeting different platforms.
-common --experimental_exclude_defines_from_exec_config
-common --experimental_exclude_starlark_flags_from_exec_config
-
 # Required for new toolchain resolution API.
 build --incompatible_enable_cc_toolchain_resolution
 
+# Expose exec toolchains for Python.
+build --@rules_python//python/config_settings:exec_tools_toolchain=enabled
+
+# Don't propagate flags or defines to the exec config. This will become the
+# default one day (https://github.com/bazelbuild/bazel/issues/22457) and will
+# improve cache hit rates between builds targeting different platforms. This is
+# especially impactful for large host tools like protoc, which will have its
+# cache invalidated when your host C++ config changes.
+common --experimental_exclude_defines_from_exec_config
+common --experimental_exclude_starlark_flags_from_exec_config
+
+# Don't automatically create __init__.py files.
+#
+# This prevents spurious package name collisions at import time, and should be
+# the default (https://github.com/bazelbuild/bazel/issues/7386). It's
+# particularly helpful for Pigweed, because we have many potential package name
+# collisions due to a profusion of stuttering paths like
+# pw_transfer/py/pw_transfer.
+common --incompatible_default_to_explicit_init_py
+
+# Don't inherit system PATH. Improves hermeticity and cache hit rates. Should
+# be true by default one day (https://github.com/bazelbuild/bazel/issues/7026).
+common --incompatible_strict_action_env
+
+# Expose exec toolchains for Python. We use these toolchains in some rule
+# implementations (git grep for
+# "@rules_python//python:exec_tools_toolchain_type").
+build --@rules_python//python/config_settings:exec_tools_toolchain=enabled
+
+# C++ toolchain configuration
+# ===========================
+
+# Ignore all warnings in third-party code.
+common --per_file_copt=external/.*@-w
+common --host_per_file_copt=external/.*@-w
+
+# Picotool needs to build with exceptions and RTTI enabled.
+common --per_file_copt=external.*picotool.*@-fexceptions,-frtti
+common --host_per_file_copt=external.*picotool.*@-fexceptions,-frtti
+
+# Keep debugging symbols, but don't send them when flashing.
+build --strip=never
+
+build --@pico-sdk//bazel/config:PICO_STDIO_USB=True
+build --@pico-sdk//bazel/config:PICO_STDIO_UART=True
+
+# Sanitizer configs
+# =================
+common:asan --@pigweed//pw_toolchain/host_clang:asan
+common:tsan --@pigweed//pw_toolchain/host_clang:tsan
+common:ubsan --@pigweed//pw_toolchain/host_clang:ubsan
+
+# Presubmit
+# =========
 # Default targets to build when running:
 # bazel build --config=presubmit
 build:presubmit -- \
   //... \
-  //src:echo.elf
+  //apps/blinky:blinky \
+  //apps/blinky:rp2040_blinky.elf \
+  //apps/blinky:rp2040_console \
+  //apps/blinky:simulator_blinky \
+  //apps/blinky:simulator_console \
+  //tools:console \
 
-# Use the remote cache. This will only work for users who have permission to access it.
+# UX settings
+# ===========
+# Error output settings.
+common --verbose_failures
+test --test_output=errors
+
+# Suppress the DEBUG: log messages from bazel. We get spammy DEBUG:
+# messages from rules_python.
+#
+# TODO: https://github.com/bazelbuild/rules_python/issues/1818 - Re-enable DEBUG
+# messages once rules_python stops spamming us.
+common --ui_event_filters=-debug
+
+# Remote cache
+# ============
+# Use the remote cache. This will only work for users who have permission to
+# access it (including the CI system!).
 common:remote_cache --remote_cache=grpcs://remotebuildexecution.googleapis.com
 common:remote_cache --google_default_credentials=true
 common:remote_cache --remote_instance_name=projects/pigweed-rbe-open/instances/default-instance
 common:remote_cache --remote_upload_local_results=false
 
+# Platform configuration
+# ======================
+common --custom_malloc=//targets:malloc
+build --@pigweed//pw_build:default_module_config=//system:module_config
+
+# Host platform default backends.
+common --@pigweed//pw_log:backend=@pigweed//pw_log_string
+common --@pigweed//pw_log:backend_impl=@pigweed//pw_log_string:impl
+common --@pigweed//pw_log_string:handler_backend=@pigweed//pw_system:log_backend
+common --@pigweed//pw_sys_io:backend=@pigweed//pw_sys_io_stdio
+common --@pigweed//pw_system:io_backend=@pigweed//pw_system:socket_target_io
+
+# RP2040 platform configuration
+build:rp2040 --platforms=//targets/rp2:rp2040
+build:rp2040 --//system:system=//targets/rp2:system
+build:rp2040 --@pigweed//pw_assert:assert_backend=@pigweed//pw_assert_trap
+build:rp2040 --@pigweed//pw_assert:assert_backend_impl=@pigweed//pw_assert_trap:impl
+build:rp2040 --@pigweed//pw_assert:check_backend=@pigweed//pw_assert_trap
+build:rp2040 --@pigweed//pw_assert:check_backend_impl=@pigweed//pw_assert_trap:impl
+build:rp2040 --@pigweed//pw_cpu_exception:entry_backend=@pigweed//pw_cpu_exception_cortex_m:cpu_exception
+build:rp2040 --@pigweed//pw_cpu_exception:entry_backend_impl=@pigweed//pw_cpu_exception_cortex_m:cpu_exception_impl
+build:rp2040 --@pigweed//pw_cpu_exception:handler_backend=@pigweed//pw_cpu_exception:basic_handler
+build:rp2040 --@pigweed//pw_cpu_exception:support_backend=@pigweed//pw_cpu_exception_cortex_m:support
+build:rp2040 --@pigweed//pw_interrupt:backend=@pigweed//pw_interrupt_cortex_m:context
+build:rp2040 --@pigweed//pw_log:backend=@pigweed//pw_log_tokenized
+build:rp2040 --@pigweed//pw_log:backend_impl=@pigweed//pw_log_tokenized:impl
+build:rp2040 --@pigweed//pw_log_tokenized:handler_backend=@pigweed//pw_system:log_backend
+build:rp2040 --@pigweed//pw_sync:binary_semaphore_backend=@pigweed//pw_sync_freertos:binary_semaphore
+build:rp2040 --@pigweed//pw_sync:interrupt_spin_lock_backend=@pigweed//pw_sync_freertos:interrupt_spin_lock
+build:rp2040 --@pigweed//pw_sync:mutex_backend=@pigweed//pw_sync_freertos:mutex
+build:rp2040 --@pigweed//pw_sync:thread_notification_backend=@pigweed//pw_sync_freertos:thread_notification
+build:rp2040 --@pigweed//pw_sync:timed_thread_notification_backend=@pigweed//pw_sync_freertos:timed_thread_notification
+build:rp2040 --@pigweed//pw_sys_io:backend=@pigweed//pw_sys_io_rp2040
+build:rp2040 --@pigweed//pw_system:device_handler_backend=@pigweed//targets/rp2040:device_handler
+build:rp2040 --@pigweed//pw_system:extra_platform_libs=//targets/rp2:extra_platform_libs
+build:rp2040 --@pigweed//pw_system:io_backend=@pigweed//pw_system:sys_io_target_io
+build:rp2040 --@pigweed//pw_thread_freertos:config_override=//targets/rp2:thread_config_overrides
+build:rp2040 --@pigweed//pw_thread:id_backend=@pigweed//pw_thread_freertos:id
+build:rp2040 --@pigweed//pw_thread:iteration_backend=@pigweed//pw_thread_freertos:thread_iteration
+build:rp2040 --@pigweed//pw_thread:sleep_backend=@pigweed//pw_thread_freertos:sleep
+build:rp2040 --@pigweed//pw_thread:thread_backend=@pigweed//pw_thread_freertos:thread
+build:rp2040 --@pigweed//pw_thread:test_thread_context_backend=@pigweed//pw_thread_freertos:test_thread_context
+build:rp2040 --@pigweed//pw_unit_test:config_override=//targets/rp2:64k_unit_tests
+build:rp2040 --@pigweed//pw_unit_test:main=//targets/rp2:unit_test_rpc_main
+build:rp2040 --@freertos//:freertos_config=//targets/rp2:freertos_config
+build:rp2040 --@pico-sdk//bazel/config:PICO_STDIO_USB=True
+build:rp2040 --@pico-sdk//bazel/config:PICO_STDIO_UART=True
+build:rp2040 --@pico-sdk//bazel/config:PICO_CLIB=llvm_libc
+build:rp2040 --@pico-sdk//bazel/config:PICO_TOOLCHAIN=clang
+build:rp2040 --@pigweed//pw_toolchain:cortex-m_toolchain_kind=clang
+test:rp2040 --run_under=@pigweed//targets/rp2040/py:unit_test_client
+
+# RP2350 is the same as rp2040 but with a different --platforms setting.
+build:rp2350 --config=rp2040
+build:rp2350 --platforms=//targets/rp2:rp2350
+
 # User bazelrc file; see
 # https://bazel.build/configure/best-practices#bazelrc-file
 #
diff --git a/.bazelversion b/.bazelversion
index d6139c1..e44dad1 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-8.0.0-pre.20240618.2
+0ddcfd327ffd012d348deeae08ec0836409706ad
diff --git a/.buildifier.json b/.buildifier.json
new file mode 100644
index 0000000..d0ffbbe
--- /dev/null
+++ b/.buildifier.json
@@ -0,0 +1,8 @@
+{
+  "type": "auto",
+  "warningsList": [
+    "load",
+    "native-build",
+    "unsorted-dict-items"
+  ]
+}
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..7f63e9b
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,8 @@
+BasedOnStyle: Google
+BinPackArguments: false
+BinPackParameters: false
+DerivePointerAlignment: false
+PointerAlignment: Left
+AllowShortIfStatementsOnASingleLine: false
+AllowShortLoopsOnASingleLine: false
+LineEnding: LF
diff --git a/.clangd.shared b/.clangd.shared
new file mode 100644
index 0000000..ef516ad
--- /dev/null
+++ b/.clangd.shared
@@ -0,0 +1,2 @@
+CompileFlags:
+  Add: -ferror-limit=0
diff --git a/.github/workflows/postsubmit.yaml b/.github/workflows/postsubmit.yaml
index 0edaf94..d7f571c 100644
--- a/.github/workflows/postsubmit.yaml
+++ b/.github/workflows/postsubmit.yaml
@@ -22,7 +22,15 @@
           disk-cache: ${{ github.workflow }}
           # Share repository cache between workflows.
           repository-cache: true
-      - name: Bazel Build
-        run: bazel build ...
-      - name: Bazel Test
-        run: bazel test ...
+      - name: Presubmit
+        run: bazel build --config=presubmit
+      - name: RP2040
+        run: bazel build --config=rp2040 //...
+      - name: Test
+        run: bazel test //...
+      - name: ASAN
+        run: bazel test --config=asan //...
+      - name: TSAN
+        run: bazel test --config=tsan //...
+      - name: UBSAN
+        run: bazel test --config=ubsan //...
diff --git a/.github/workflows/presubmit.yaml b/.github/workflows/presubmit.yaml
index e9aa5c0..28e6d7e 100644
--- a/.github/workflows/presubmit.yaml
+++ b/.github/workflows/presubmit.yaml
@@ -23,7 +23,15 @@
           disk-cache: ${{ github.workflow }}
           # Share repository cache between workflows.
           repository-cache: true
-      - name: Bazel Build
-        run: bazel build ...
-      - name: Bazel Test
-        run: bazel test ...
+      - name: Presubmit
+        run: bazel build --config=presubmit
+      - name: RP2040
+        run: bazel build --config=rp2040 //...
+      - name: Test
+        run: bazel test //...
+      - name: ASAN
+        run: bazel test --config=asan //...
+      - name: TSAN
+        run: bazel test --config=tsan //...
+      - name: UBSAN
+        run: bazel test --config=ubsan //...
diff --git a/.gitignore b/.gitignore
index 13fa984..2cd1c37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,64 @@
+# Build
+out/
+.presubmit/
+compile_commands.json
 user.bazelrc
-bazel-*
+/bazel-*
 
-# Editor files
-*.swo
+# Ignore until https://github.com/bazelbuild/bazel/issues/20369 is fixed.
+MODULE.bazel.lock
+
+# Environment
+.environment/
+environment/
+build_overrides/pigweed_environment.gni
+
+# Editors
+.pw_ide/
+.vscode/settings.json
+.vscode/tasks.json
+.vscode/pw_user_*.json
+.vscode/*.bak.json
+.idea/
+.project
+.cproject
+.clangd
+.clangd/
 *.swp
+*.swo
+.#*
+
+# Python
+python*-env/
+.python*-env/
+venv/
+*.pyc
+*.egg/
+*.eggs/
+*.egg-info/
+.cache/
+.mypy_cache/
+__pycache__/
+
+# Mac
+.DS_Store
+
+# GDB
+.gdb_history
+
+# Git
+*.orig
+*.BACKUP.*
+*.BASE.*
+*.LOCAL.*
+*.REMOTE.*
+*_BACKUP_*.txt
+*_BASE_*.txt
+*_LOCAL_*.txt
+*_REMOTE_*.txt
+
+# Other
+logfile.txt
+pw_console-*logs.txt
+.pw_console.user.yaml
+factory-logs-*.txt
diff --git a/.pw_console.yaml b/.pw_console.yaml
new file mode 100644
index 0000000..4870700
--- /dev/null
+++ b/.pw_console.yaml
@@ -0,0 +1,99 @@
+# This is a pw_console config file that defines a default window layout.
+# For more info on what can be added to this file see:
+#   https://pigweed.dev/pw_console/py/pw_console/docs/user_guide.html#configuration
+config_title: pw_console
+
+# Window layout
+windows:
+  # Left split with tabbed views.
+  Split 1 tabbed:
+    Python Repl:
+      hidden: False
+  # Right split with stacked views.
+  Split 2 stacked:
+    Device Logs:
+      hidden: False
+    Host Logs:
+      hidden: False
+
+window_column_split_method: vertical
+# window_column_split_method: horizontal
+
+# Default colors:
+ui_theme: dark
+code_theme: pigweed-code
+swap_light_and_dark: False
+
+# A few other choices:
+# ui_theme: high-contrast-dark
+# ui_theme: nord
+# ui_theme: synthwave84
+# code_theme: gruvbox-dark
+# code_theme: pigweed-code-light
+# code_theme: synthwave84
+
+# Log display options:
+spaces_between_columns: 2
+hide_date_from_log_time: True
+recolor_log_lines_to_match_level: False
+
+column_order:
+  # Column name
+  - time
+  - level
+  - timestamp
+  - module
+  # Hidden:
+  # - source_name
+
+column_order_omit_unspecified_columns: True
+
+snippets:
+  Echo RPC:
+    code: |
+      device.rpcs.pw.rpc.EchoService.Echo(msg='hello world')
+    description: |
+      Send a string to the device and receive a response with the same
+      message back.
+
+      >>> device.rpcs.pw.rpc.EchoService.Echo(msg='hello world')
+      (Status.OK, pw.rpc.EchoMessage(msg='hello world'))
+
+  Reboot to bootloader:
+    code: |
+      device.rpcs.board.Board.Reboot(reboot_type=1)
+    description: |
+      Sent a reboot request to the device. The console must be
+      relaunched to reconnect.
+
+  Get onboard temp sensor value:
+    code: |
+      device.rpcs.board.Board.OnboardTemp()
+    description: |
+      Get the current value of the onboard temperature sensor.
+
+      ```pycon
+      >>> device.rpcs.board.Board.OnboardTemp()
+      (Status.OK, board.OnboardTempResponse(temp=24.797744750976562))
+      ```
+
+  Log onboard temp sensor values every 1000ms:
+    code: |
+      call = device.rpcs.board.Board.OnboardTempStream.invoke(
+          request_args=dict(sample_interval_ms=1000),
+          on_next=lambda _, message: LOG.info(message)
+      )
+    description: |
+      Start logging with:
+
+      ```pycon
+      >>> call = device.rpcs.board.Board.OnboardTempStream.invoke(request_args=dict(sample_interval_ms=1000), on_next=lambda _, message: LOG.info(message))
+      ```
+
+      Log messages will appear in the 'Host Logs' window.
+      To stop the streaming RPC run `call.cancel()`:
+
+      ```pycon
+      >>> call.cancel()
+      True
+      ```
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..bf6ac5c
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,9 @@
+{
+  "recommendations": [
+    "pigweed.pigweed"
+  ],
+  "unwantedRecommendations": [
+    "ms-vscode.cpptools",
+    "ms-vscode.cpptools-extension-pack"
+  ]
+}
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..b13fcec
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,7 @@
+# This is the list of Pigweed authors for copyright purposes.
+#
+# This does not necessarily list everyone who has contributed code, since in
+# some cases, their employer may be the copyright holder.  To see the full list
+# of contributors, see the revision history in source control.
+
+Google LLC
diff --git a/BUILD.bazel b/BUILD.bazel
index 1767ea4..6ddb18f 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -1,4 +1,4 @@
-# Copyright 2023 The Pigweed Authors
+# 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
@@ -11,11 +11,42 @@
 # 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("@rules_python//python:pip.bzl", "compile_pip_requirements")
 
-# Run  bazel run //:pip_requirements.update to regenerate requirements_lock.txt.
-compile_pip_requirements(
-    name = "pip_requirements",
-    requirements_in = "requirements.in",
-    requirements_txt = "requirements_lock.txt",
+load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
+load("@hedron_compile_commands//:refresh_compile_commands.bzl", "refresh_compile_commands")
+load("@pigweed//pw_build:compatibility.bzl", "incompatible_with_mcu")
+
+package(default_visibility = ["//visibility:public"])
+
+copy_file(
+    name = "copy_clangd",
+    src = "@pigweed//pw_toolchain/host_clang:clangd",
+    out = "clangd",
+    allow_symlink = True,
+)
+
+refresh_compile_commands(
+    name = "refresh_compile_commands",
+    out_dir = ".compile_commands",
+    target_compatible_with = incompatible_with_mcu(),
+    target_groups = {
+        "host_simulator": [
+            "//apps/blinky:simulator_blinky",
+            "//modules/blinky:blinky_test",
+        ],
+        "rp2040": [
+            "//apps/blinky:rp2040_blinky.elf",
+            [
+                "//modules/blinky:blinky_test",
+                "--config=rp2040",
+            ],
+        ],
+    },
+)
+
+filegroup(
+    name = "pw_console_config",
+    srcs = [
+        ".pw_console.yaml",
+    ],
 )
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..677be08
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,29 @@
+# Contributing to Pigweed
+
+Pigweed lets anyone contribute to the project, regardless of their employer.
+The Pigweed project reviews and encourages well-tested, high-quality
+contributions from anyone who wants to contribute to Pigweed.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement (CLA).
+
+To see any Contributor License Agreements on file or to sign a CLA, go to
+<https://cla.developers.google.com/>.
+
+For more information about the Google CLA, see
+[Contributor License Agreements](https://cla.developers.google.com/about).
+
+## Contributing changes and submitting code reviews
+
+All changes require review, including changes by project members.
+
+For detailed instructions on how to contribute changes,
+see [the Gerrit docs](https://gerrit-review.googlesource.com/Documentation/intro-user.html).
+
+## Community guidelines
+
+This project observes the following community guidelines:
+
+  * [Google's Open Source Community Guidelines](https://opensource.google/conduct/)
diff --git a/MODULE.bazel b/MODULE.bazel
new file mode 100644
index 0000000..22367e6
--- /dev/null
+++ b/MODULE.bazel
@@ -0,0 +1,81 @@
+# 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.
+module(
+    name = "quickstart",
+)
+
+bazel_dep(name = "bazel_skylib", version = "1.7.1")
+bazel_dep(name = "freertos", version = "10.5.1.bcr.2")
+bazel_dep(name = "nanopb", repo_name = "com_github_nanopb_nanopb")
+bazel_dep(name = "pico-sdk", version = "2.0.0")
+bazel_dep(name = "pigweed")
+bazel_dep(name = "platforms", version = "0.0.10")
+bazel_dep(name = "pw_toolchain")
+bazel_dep(name = "rules_cc")
+bazel_dep(name = "rules_python", version = "0.34.0")
+
+bazel_dep(name = "hedron_compile_commands", dev_dependency = True)
+
+# Module overrides
+# ================
+# TODO: https://pwbug.dev/349880767 - Point this back to the upstream repo once
+# this PR is merged.
+archive_override(
+    module_name = "hedron_compile_commands",
+    strip_prefix = "bazel-compile-commands-extractor-163521345aa6366fd1ed801b989b668b5c806f69",
+    urls = ["https://github.com/chadnorvell/bazel-compile-commands-extractor/archive/163521345aa6366fd1ed801b989b668b5c806f69.tar.gz"],
+)
+
+# TODO: https://pwbug.dev/354274498 - nanopb is not yet in the BCR.
+git_override(
+    module_name = "nanopb",
+    commit = "7c6c581bc6f7406a4f01c3b9853251ff0a68458b",
+    remote = "https://github.com/nanopb/nanopb.git",
+)
+
+git_override(
+    module_name = "pigweed",
+    # ROLL: Warning: this entry is automatically updated.
+    # ROLL: Last updated 2024-08-07.
+    # ROLL: By https://cr-buildbucket.appspot.com/build/8740243540358493793.
+    commit = "146fd4b5c55fb45c390515b9d5faa2f5abd00be2",
+    remote = "https://pigweed.googlesource.com/pigweed/pigweed",
+)
+
+git_override(
+    module_name = "pw_toolchain",
+    # ROLL: Warning: this entry is automatically updated.
+    # ROLL: Last updated 2024-08-07.
+    # ROLL: By https://cr-buildbucket.appspot.com/build/8740243540358493793.
+    commit = "146fd4b5c55fb45c390515b9d5faa2f5abd00be2",
+    remote = "https://pigweed.googlesource.com/pigweed/pigweed",
+    strip_prefix = "pw_toolchain_bazel",
+)
+
+# TODO: https://pwbug.dev/258836641 - Pre-release version needed for the Pico
+# SDK. Remove this once rules_cc 0.10.0 is released and the Pico SDK
+# MODULE.bazel declares its dependency on it.
+archive_override(
+    module_name = "rules_cc",
+    integrity = "sha256-NddP6xi6LzsIHT8bMSVJ2NtoURbN+l3xpjvmIgB6aSg=",
+    strip_prefix = "rules_cc-1acf5213b6170f1f0133e273cb85ede0e732048f",
+    urls = [
+        "https://github.com/bazelbuild/rules_cc/archive/1acf5213b6170f1f0133e273cb85ede0e732048f.zip",
+    ],
+)
+
+http_archive = use_repo_rule(
+    "@bazel_tools//tools/build_defs/repo:http.bzl",
+    "http_archive",
+)
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..528c611
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,7 @@
+# Remove the `*` from this file and add specific owners to this file and to
+# subdirectories where appropriate to enforce OWNERS approval in this
+# repository.
+#
+# Syntax for OWNERS files can be found at:
+# https://pigweed-review.googlesource.com/plugins/code-owners/Documentation/backend-find-owners.html#syntax
+*
diff --git a/README.md b/README.md
index 7da4f53..21acbde 100644
--- a/README.md
+++ b/README.md
@@ -1,59 +1,67 @@
 # Pigweed: minimal Bazel example
 
 This repository contains a minimal example of a Bazel-based Pigweed project.
-It's an echo application for the STM32F429 Discovery Board.
+It is a LED-blinking service (featuring RPC control!) for the
+[Raspberry Pi Pico](https://www.raspberrypi.com/products/raspberry-pi-pico/).
+It can also be run on any computer using the included simulator.
 
-## Cloning
+## Getting the code
 
 ```
-git clone --recursive https://pigweed.googlesource.com/pigweed/quickstart/bazel
+git clone https://pigweed.googlesource.com/pigweed/quickstart/bazel pw_bazel_quickstart
+cd pw_bazel_quickstart
 ```
 
-If you already cloned but forgot to include `--recursive`, run `git submodule
-update --init` to pull all submodules.
+## Dependencies
 
-TODO: b/300695111 - Don't require submodules for this example.
+The only dependency that must be installed is Bazelisk.
 
-## Building
+Bazelisk is a launcher for the Bazel build system that allows for easy
+management of multiple Bazel versions.
 
-We'll assume you already have Bazel on your system. If you don't, the
-recommended way to get it is through
-[Bazelisk](https://github.com/bazelbuild/bazelisk/blob/master/README.md).
+[Instructions for installing Bazelisk can be found here.](https://github.com/bazelbuild/bazelisk/blob/master/README.md)
 
-To build the entire project (including building the application for both the
-host and the STM32 Discovery Board), run
+## Running on the simulator
+
+To run the simulator, type:
+`bazelisk run //apps/blinky:simulator_blinky`
+Then, in a new console, connect to the simulator using:
+`bazelisk run //apps/blinky:simulator_console`
+
+## Running on hardware
+
+To start, connect a Raspberry Pi Pico, Pico 2, or debug probe via USB.
+
+To run on the Raspberry Pi Pico, type:
+`bazelisk run //apps/blinky:flash_rp2040`
+Then, in a new console, connect to the device using:
+`bazelisk run //apps/blinky:rp2040_console`
+
+## Controlling the LED
+
+Once connected with a console, RPCs can be sent to control the LED.
+Try running:
 
 ```
-bazel build //...
+device.set_led(True)
+device.set_led(False)
+device.toggle_led()
+device.blink(blink_count=3)
 ```
 
-To run the application locally on your machine, run,
+## Running unit tests on the host device
 
-```
-bazel run //src:echo
-```
+`bazelisk test //...` will run the unit tests defined in this project,
+such as the ones in `modules/blinky/blinky_test.cc`.
 
-## Flashing
+## Running unit tests on hardware
 
-To flash the firmware to a STM32F429 Discovery Board connected to your machine,
-run,
+`bazelisk run @pigweed//targets/rp2040/py:unit_test_server` in one console followed by
+`bazelisk test //... --config=rp2040` will also allow running the unit tests on-device.
 
-```
-bazel run //tools:flash
-```
+## Next steps
 
-Note that you _don't need to build the firmware first_: Bazel knows that the
-firmware images are needed to flash the board, and will build them for you. And
-if you edit the source of the firmware or any of its dependencies, it will get
-rebuilt when you flash.
-
-## Communicating
-
-Run,
-
-```
-bazel run //tools:miniterm -- /dev/ttyACM0 --filter=debug
-```
-
-to communicate with the board. When you transmit a character, you should get
-the same character back!
+Try poking around the codebase for inspiration about how Pigweed projects can be
+organized. Most of the relevant code in this quickstart (including RPC
+definitions) is inside `modules/blinky`, with some client-side Python code in
+`tools/console.py`.
diff --git a/WORKSPACE b/WORKSPACE
deleted file mode 100644
index 38523cb..0000000
--- a/WORKSPACE
+++ /dev/null
@@ -1,143 +0,0 @@
-# Copyright 2023 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("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
-
-# Load Pigweed's own dependencies that we'll need.
-#
-# TODO: b/274148833 - Don't require users to spell these out.
-http_archive(
-    name = "platforms",
-    sha256 = "8150406605389ececb6da07cbcb509d5637a3ab9a24bc69b1101531367d89d74",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz",
-        "https://github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz",
-    ],
-)
-
-http_archive(
-    name = "bazel_skylib",  # 2022-09-01
-    sha256 = "4756ab3ec46d94d99e5ed685d2d24aece484015e45af303eb3a11cab3cdc2e71",
-    strip_prefix = "bazel-skylib-1.3.0",
-    urls = ["https://github.com/bazelbuild/bazel-skylib/archive/refs/tags/1.3.0.zip"],
-)
-
-http_archive(
-    name = "rules_proto",
-    sha256 = "dc3fb206a2cb3441b485eb1e423165b231235a1ea9b031b4433cf7bc1fa460dd",
-    strip_prefix = "rules_proto-5.3.0-21.7",
-    urls = [
-        "https://github.com/bazelbuild/rules_proto/archive/refs/tags/5.3.0-21.7.tar.gz",
-    ],
-)
-
-http_archive(
-    name = "rules_python",
-    sha256 = "0a8003b044294d7840ac7d9d73eef05d6ceb682d7516781a4ec62eeb34702578",
-    strip_prefix = "rules_python-0.24.0",
-    url = "https://github.com/bazelbuild/rules_python/releases/download/0.24.0/rules_python-0.24.0.tar.gz",
-)
-
-load("@rules_python//python:repositories.bzl", "py_repositories")
-
-py_repositories()
-
-http_archive(
-    name = "com_google_protobuf",
-    sha256 = "c6003e1d2e7fefa78a3039f19f383b4f3a61e81be8c19356f85b6461998ad3db",
-    strip_prefix = "protobuf-3.17.3",
-    url = "https://github.com/protocolbuffers/protobuf/archive/v3.17.3.tar.gz",
-)
-
-load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
-
-protobuf_deps()
-
-# TODO(b/311746469): Switch back to a released version when possible.
-git_repository(
-    name = "rules_fuzzing",
-    commit = "67ba0264c46c173a75825f2ae0a0b4b9b17c5e59",
-    remote = "https://github.com/bazelbuild/rules_fuzzing",
-)
-
-load("@rules_fuzzing//fuzzing:repositories.bzl", "rules_fuzzing_dependencies")
-
-rules_fuzzing_dependencies()
-
-load("@rules_fuzzing//fuzzing:init.bzl", "rules_fuzzing_init")
-
-rules_fuzzing_init()
-
-git_repository(
-    name = "pigweed",
-    # ROLL: Warning: this entry is automatically updated.
-    # ROLL: Last updated 2024-08-07.
-    # ROLL: By https://cr-buildbucket.appspot.com/build/8740220870553192513.
-    commit = "04109d522e8b54b140d5d6dca2aa2171e4b5e22d",
-    remote = "https://pigweed.googlesource.com/pigweed/pigweed.git",
-)
-
-git_repository(
-    name = "pw_toolchain",
-    # ROLL: Warning: this entry is automatically updated.
-    # ROLL: Last updated 2024-08-07.
-    # ROLL: By https://cr-buildbucket.appspot.com/build/8740220870553192513.
-    commit = "04109d522e8b54b140d5d6dca2aa2171e4b5e22d",
-    remote = "https://pigweed.googlesource.com/pigweed/pigweed.git",
-    strip_prefix = "pw_toolchain_bazel",
-)
-
-# Get ready to grab CIPD dependencies. For this minimal example, the only
-# dependencies will be the toolchains and OpenOCD (used for flashing).
-load(
-    "@pigweed//pw_env_setup/bazel/cipd_setup:cipd_rules.bzl",
-    "cipd_client_repository",
-    "cipd_repository",
-)
-
-cipd_client_repository()
-
-
-load("@pigweed//pw_toolchain:register_toolchains.bzl", "register_pigweed_cxx_toolchains")
-
-register_pigweed_cxx_toolchains()
-
-# Get the OpenOCD binary (we'll use it for flashing).
-cipd_repository(
-    name = "openocd",
-    path = "infra/3pp/tools/openocd/${os}-${arch}",
-    tag = "version:2@0.11.0-3",
-)
-
-load("@rules_python//python:repositories.bzl", "python_register_toolchains")
-
-# Set up the Python interpreter and PyPI dependencies we'll need.
-python_register_toolchains(
-    name = "python3",
-    python_version = "3.11",
-)
-
-load("@python3//:defs.bzl", "interpreter")
-load("@rules_python//python:pip.bzl", "pip_parse")
-
-pip_parse(
-    name = "pypi",
-    python_interpreter_target = interpreter,
-    requirements_lock = "//:requirements_lock.txt",
-)
-
-load("@pypi//:requirements.bzl", "install_deps")
-
-install_deps()
diff --git a/apps/blinky/BUILD.bazel b/apps/blinky/BUILD.bazel
new file mode 100644
index 0000000..0cec3ed
--- /dev/null
+++ b/apps/blinky/BUILD.bazel
@@ -0,0 +1,99 @@
+# 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//targets/host_device_simulator:transition.bzl", "host_device_simulator_binary")
+load("@pigweed//targets/rp2040:flash.bzl", "flash_rp2040")
+load("//targets/rp2:binary.bzl", "rp2040_binary", "rp2350_binary")
+load("//tools:tools.bzl", "device_console", "host_console")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_binary(
+    name = "blinky",
+    srcs = ["main.cc"],
+    deps = [
+        "//modules/blinky:service",
+        "//system",
+        "@pigweed//pw_log",
+        "@pigweed//pw_system:async",
+
+        # These should be provided by pw_system:async.
+        "@pigweed//pw_assert:assert_backend_impl",
+        "@pigweed//pw_assert:check_backend_impl",
+        "@pigweed//pw_log:backend_impl",
+        "@pigweed//pw_system:extra_platform_libs",
+    ],
+)
+
+# Create an rp2040 flashable ELF
+rp2040_binary(
+    name = "rp2040_blinky.elf",
+    binary = ":blinky",
+)
+
+# Create an rp2350 flashable ELF
+rp2350_binary(
+    name = "rp2350_blinky.elf",
+    binary = ":blinky",
+)
+
+# Create a host binary using the Pigweed upstream pw_system host_device_simulator.
+host_device_simulator_binary(
+    name = "simulator_blinky",
+    binary = ":blinky",
+)
+
+host_console(
+    name = "simulator_console",
+    binary = ":simulator_blinky",
+)
+
+host_console(
+    name = "simulator_webconsole",
+    binary = ":simulator_blinky",
+    extra_args = ["--browser"],
+)
+
+device_console(
+    name = "rp2040_console",
+    binary = ":rp2040_blinky.elf",
+)
+
+device_console(
+    name = "rp2040_webconsole",
+    binary = ":rp2040_blinky.elf",
+    extra_args = ["--browser"],
+)
+
+device_console(
+    name = "rp2350_console",
+    binary = ":rp2350_blinky.elf",
+)
+
+device_console(
+    name = "rp2350_webconsole",
+    binary = ":rp2350_blinky.elf",
+    extra_args = ["--browser"],
+)
+
+flash_rp2040(
+    name = "flash_rp2040",
+    rp2040_binary = "rp2040_blinky.elf",
+)
+
+# Note: Despite the name, the rule works for the 2350.
+flash_rp2040(
+    name = "flash_rp2350",
+    rp2040_binary = "rp2350_blinky.elf",
+)
diff --git a/apps/blinky/main.cc b/apps/blinky/main.cc
new file mode 100644
index 0000000..3032d43
--- /dev/null
+++ b/apps/blinky/main.cc
@@ -0,0 +1,34 @@
+// 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.
+#define PW_LOG_MODULE_NAME "MAIN"
+
+#include "modules/blinky/service.h"
+#include "pw_log/log.h"
+#include "pw_system/system.h"
+#include "system/system.h"
+
+int main() {
+  demo::system::Init();
+  auto& rpc_server = pw::System().rpc_server();
+  auto& monochrome_led = demo::system::MonochromeLed();
+
+  static demo::BlinkyService blinky_service;
+  blinky_service.Init(
+      pw::System().dispatcher(), pw::System().allocator(), monochrome_led);
+  rpc_server.RegisterService(blinky_service);
+
+  PW_LOG_INFO("Started blinky app; waiting for RPCs...");
+  demo::system::Start();
+  PW_UNREACHABLE;
+}
diff --git a/echo.bzl b/echo.bzl
deleted file mode 100644
index 4151508..0000000
--- a/echo.bzl
+++ /dev/null
@@ -1,64 +0,0 @@
-# Copyright 2023 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.
-"""Build rules and transitions for firmware."""
-
-def _stm32_transition_impl(settings, attr):
-    # buildifier: disable=unused-variable
-    _ignore = attr
-    return {
-        "//command_line_option:platforms": "//targets:stm32",
-        "@pigweed//pw_boot:backend": "@pigweed//pw_boot_cortex_m",
-        "@pigweed//pw_assert:backend": "@pigweed//pw_assert_basic",
-        "@pigweed//pw_assert:backend_impl": "@pigweed//pw_assert_basic:impl",
-        "@pigweed//pw_assert:check_backend": "@pigweed//pw_assert_basic",
-        "@pigweed//pw_assert:check_backend_impl": "@pigweed//pw_assert_basic:impl",
-        "@pigweed//pw_log:backend": "@pigweed//pw_log_basic",
-        "@pigweed//pw_log:backend_impl": "@pigweed//pw_log_basic:impl",
-        "@pigweed//pw_sys_io:backend": "@pigweed//pw_sys_io_baremetal_stm32f429",
-    }
-
-_stm32_transition = transition(
-    implementation = _stm32_transition_impl,
-    inputs = [],
-    outputs = [
-        "//command_line_option:platforms",
-        "@pigweed//pw_boot:backend",
-        "@pigweed//pw_assert:backend",
-        "@pigweed//pw_assert:backend_impl",
-        "@pigweed//pw_assert:check_backend",
-        "@pigweed//pw_assert:check_backend_impl",
-        "@pigweed//pw_log:backend",
-        "@pigweed//pw_log:backend_impl",
-        "@pigweed//pw_sys_io:backend",
-    ],
-)
-
-def _binary_impl(ctx):
-    out = ctx.actions.declare_file(ctx.label.name)
-    ctx.actions.symlink(output = out, target_file = ctx.executable.binary)
-    return [DefaultInfo(files = depset([out]), executable = out)]
-
-# TODO(tpudlik): Replace this with platform_data when it is available.
-stm32_cc_binary = rule(
-    _binary_impl,
-    attrs = {
-        "binary": attr.label(
-            doc = "cc_binary to build for stm32",
-            cfg = _stm32_transition,
-            executable = True,
-            mandatory = True,
-        ),
-        "_allowlist_function_transition": attr.label(default = "@bazel_tools//tools/allowlists/function_transition_allowlist"),
-    },
-)
diff --git a/modules/blinky/BUILD.bazel b/modules/blinky/BUILD.bazel
new file mode 100644
index 0000000..967b44d
--- /dev/null
+++ b/modules/blinky/BUILD.bazel
@@ -0,0 +1,97 @@
+# 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",
+)
+load("@rules_python//python:proto.bzl", "py_proto_library")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "blinky",
+    srcs = ["blinky.cc"],
+    hdrs = ["blinky.h"],
+    implementation_deps = [
+        "@pigweed//pw_log",
+        "@pigweed//pw_preprocessor",
+    ],
+    deps = [
+        "//modules/timer_future",
+        "//system",
+        "@pigweed//pw_async2:coro",
+        "@pigweed//pw_async2:coro_or_else_task",
+        "@pigweed//pw_async2:dispatcher",
+        "@pigweed//pw_chrono:system_clock",
+        "@pigweed//pw_function",
+        "@pigweed//pw_sync:interrupt_spin_lock",
+        "@pigweed//pw_sync:lock_annotations",
+        "@pigweed//pw_system:async",
+    ],
+)
+
+pw_cc_test(
+    name = "blinky_test",
+    srcs = ["blinky_test.cc"],
+    deps = [
+        ":blinky",
+        "@pigweed//pw_allocator:testing",
+        "@pigweed//pw_async2:dispatcher",
+        "@pigweed//pw_digital_io:digital_io_mock",
+        "@pigweed//pw_thread:sleep",
+        "@pigweed//pw_unit_test",
+    ],
+)
+
+cc_library(
+    name = "service",
+    srcs = ["service.cc"],
+    hdrs = ["service.h"],
+    deps = [
+        ":blinky",
+        ":nanopb_rpc",
+        "@pigweed//pw_allocator:allocator",
+        "@pigweed//pw_assert:check",
+        "@pigweed//pw_async2:dispatcher",
+    ],
+)
+
+proto_library(
+    name = "proto",
+    srcs = ["blinky.proto"],
+    import_prefix = "blinky_pb",
+    strip_import_prefix = "/modules/blinky",
+    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/blinky/blinky.cc b/modules/blinky/blinky.cc
new file mode 100644
index 0000000..72954c7
--- /dev/null
+++ b/modules/blinky/blinky.cc
@@ -0,0 +1,142 @@
+// 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.
+#define PW_LOG_MODULE_NAME "BLINKY"
+#include "blinky.h"
+
+#include <mutex>
+
+#include "modules/blinky/blinky.h"
+#include "pw_async2/coro.h"
+#include "pw_log/log.h"
+
+namespace demo {
+
+using ::pw::Allocator;
+using ::pw::OkStatus;
+using ::pw::Status;
+using ::pw::async2::Coro;
+using ::pw::async2::CoroContext;
+using ::pw::async2::Dispatcher;
+using ::pw::digital_io::DigitalInOut;
+using ::pw::digital_io::State;
+
+namespace {
+
+bool IsOn(DigitalInOut& io) {
+  auto result = io.GetState();
+  if (!result.ok()) {
+    return false;
+  }
+  return *result == State::kActive;
+}
+
+void TurnOn(DigitalInOut& io) { io.SetState(State::kActive).IgnoreError(); }
+
+void TurnOff(DigitalInOut& io) { io.SetState(State::kInactive).IgnoreError(); }
+
+void ToggleIo(DigitalInOut& io) {
+  io.SetState(IsOn(io) ? State::kInactive : State::kActive).IgnoreError();
+}
+
+}  // namespace
+
+Coro<Status> Blinky::BlinkLoop(CoroContext&,
+                               uint32_t blink_count,
+                               pw::chrono::SystemClock::duration interval) {
+  for (uint32_t blinked = 0; blinked < blink_count || blink_count == 0;
+       ++blinked) {
+    {
+      PW_LOG_INFO("LED blinking: OFF");
+      std::lock_guard lock(lock_);
+      TurnOff(*monochrome_led_);
+    }
+    co_await timer_.WaitFor(interval);
+    {
+      PW_LOG_INFO("LED blinking: ON");
+      std::lock_guard lock(lock_);
+      TurnOn(*monochrome_led_);
+    }
+    co_await timer_.WaitFor(interval);
+  }
+  {
+    std::lock_guard lock(lock_);
+    TurnOff(*monochrome_led_);
+  }
+  PW_LOG_INFO("Stopped blinking");
+  co_return OkStatus();
+}
+
+Blinky::Blinky()
+    : blink_task_(Coro<Status>::Empty(), [](Status) {
+        PW_LOG_ERROR("Failed to allocate blink loop coroutine.");
+      }) {}
+
+void Blinky::Init(Dispatcher& dispatcher,
+                  Allocator& allocator,
+                  pw::digital_io::DigitalInOut& monochrome_led) {
+  dispatcher_ = &dispatcher;
+  allocator_ = &allocator;
+
+  std::lock_guard lock(lock_);
+  monochrome_led_ = &monochrome_led;
+  monochrome_led_->Enable().IgnoreError();
+  TurnOff(*monochrome_led_);
+}
+
+Blinky::~Blinky() { blink_task_.Deregister(); }
+
+void Blinky::Toggle() {
+  blink_task_.Deregister();
+  PW_LOG_INFO("Toggling LED");
+  std::lock_guard lock(lock_);
+  ToggleIo(*monochrome_led_);
+}
+
+void Blinky::SetLed(bool on) {
+  blink_task_.Deregister();
+  std::lock_guard lock(lock_);
+  if (on) {
+    PW_LOG_INFO("Setting LED on");
+    TurnOn(*monochrome_led_);
+  } else {
+    PW_LOG_INFO("Setting LED off");
+    TurnOff(*monochrome_led_);
+  }
+}
+
+pw::Status Blinky::Blink(uint32_t blink_count, uint32_t interval_ms) {
+  if (blink_count == 0) {
+    PW_LOG_INFO("Blinking forever at a %ums interval", interval_ms);
+  } else {
+    PW_LOG_INFO(
+        "Blinking %u times at a %ums interval", blink_count, interval_ms);
+  }
+
+  pw::chrono::SystemClock::duration interval =
+      pw::chrono::SystemClock::for_at_least(
+          std::chrono::milliseconds(interval_ms));
+
+  blink_task_.Deregister();
+  CoroContext coro_cx(*allocator_);
+  blink_task_.SetCoro(BlinkLoop(coro_cx, blink_count, interval));
+  dispatcher_->Post(blink_task_);
+  return OkStatus();
+}
+
+bool Blinky::IsIdle() const {
+  std::lock_guard lock(lock_);
+  return !blink_task_.IsRegistered();
+}
+
+}  // namespace demo
diff --git a/modules/blinky/blinky.h b/modules/blinky/blinky.h
new file mode 100644
index 0000000..33d0a97
--- /dev/null
+++ b/modules/blinky/blinky.h
@@ -0,0 +1,79 @@
+// 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 <chrono>
+
+#include "modules/timer_future/timer_future.h"
+#include "pw_allocator/allocator.h"
+#include "pw_async2/coro_or_else_task.h"
+#include "pw_async2/dispatcher.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_digital_io/digital_io.h"
+#include "pw_status/status.h"
+#include "pw_sync/interrupt_spin_lock.h"
+#include "pw_sync/lock_annotations.h"
+
+namespace demo {
+
+/// Simple component that blinks the on-board LED.
+class Blinky final {
+ public:
+  static constexpr uint32_t kDefaultIntervalMs = 1000;
+  static constexpr pw::chrono::SystemClock::duration kDefaultInterval =
+      pw::chrono::SystemClock::for_at_least(
+          std::chrono::milliseconds(kDefaultIntervalMs));
+
+  Blinky();
+  ~Blinky();
+
+  /// Injects this object's dependencies.
+  ///
+  /// This method MUST be called before using any other method.
+  void Init(pw::async2::Dispatcher& dispatcher,
+            pw::Allocator& allocator,
+            pw::digital_io::DigitalInOut& monochrome_led);
+
+  /// Turns the LED on if it is off, and off if it is on.
+  void Toggle() PW_LOCKS_EXCLUDED(lock_);
+
+  /// Sets the state of the LED.
+  void SetLed(bool on) PW_LOCKS_EXCLUDED(lock_);
+
+  /// Queues a sequence of call backs to blink the configured number of times.
+  ///
+  /// @param  blink_count   The number of times to blink the LED.
+  /// @param  interval_ms   The duration of a blink, in milliseconds.
+  pw::Status Blink(uint32_t blink_count, uint32_t interval_ms)
+      PW_LOCKS_EXCLUDED(lock_);
+
+  /// Returns whether this instance is currently blinking or not.
+  bool IsIdle() const PW_LOCKS_EXCLUDED(lock_);
+
+ private:
+  /// Creates a blinking coroutine.
+  pw::async2::Coro<pw::Status> BlinkLoop(
+      pw::async2::CoroContext&,
+      uint32_t blink_count,
+      pw::chrono::SystemClock::duration interval) PW_LOCKS_EXCLUDED(lock_);
+
+  pw::async2::Dispatcher* dispatcher_;
+  pw::Allocator* allocator_;
+  mutable pw::sync::InterruptSpinLock lock_;
+  pw::digital_io::DigitalInOut* monochrome_led_ PW_GUARDED_BY(lock_) = nullptr;
+  AsyncTimer timer_;
+  mutable pw::async2::CoroOrElseTask blink_task_;
+};
+
+}  // namespace demo
diff --git a/modules/blinky/blinky.proto b/modules/blinky/blinky.proto
new file mode 100644
index 0000000..3d0eced
--- /dev/null
+++ b/modules/blinky/blinky.proto
@@ -0,0 +1,51 @@
+// 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 blinky;
+
+import "pw_protobuf_protos/common.proto";
+
+service Blinky {
+  // Toggles the LED on or off.
+  // If a previous `Blink` request is still running, stops it before toggling.
+  rpc ToggleLed(pw.protobuf.Empty) returns (pw.protobuf.Empty);
+
+  // Sets the LED on or off.
+  rpc SetLed(SetLedRequest) returns (pw.protobuf.Empty);
+
+  // Continuously blinks the board LED a specified number of times.
+  rpc Blink(BlinkRequest) returns (pw.protobuf.Empty);
+
+  // Returns true or false if LED blinking is idle.
+  rpc IsIdle(pw.protobuf.Empty) returns (BlinkIdleResponse);
+}
+
+message SetLedRequest {
+  bool on = 1;
+}
+
+message BlinkIdleResponse {
+  bool is_idle = 1;
+}
+
+message BlinkRequest {
+  // The interval at which to blink or pulse the LED, in milliseconds.
+  uint32 interval_ms = 1;
+
+  // The number of times to blink the LED.
+  // If unset, blinks forever.
+  // If 0, stops blinking.
+  optional uint32 blink_count = 2;
+}
diff --git a/modules/blinky/blinky_test.cc b/modules/blinky/blinky_test.cc
new file mode 100644
index 0000000..66038bf
--- /dev/null
+++ b/modules/blinky/blinky_test.cc
@@ -0,0 +1,168 @@
+// 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/blinky/blinky.h"
+
+#include "pw_allocator/testing.h"
+#include "pw_async2/dispatcher.h"
+#include "pw_digital_io/digital_io_mock.h"
+#include "pw_thread/sleep.h"
+#include "pw_unit_test/framework.h"
+
+namespace demo {
+
+using AllocatorForTest = ::pw::allocator::test::AllocatorForTest<256>;
+using ::pw::async2::Dispatcher;
+
+// Test fixtures.
+
+class BlinkyTest : public ::testing::Test {
+ protected:
+  using Event = ::pw::digital_io::DigitalInOutMockImpl::Event;
+  using State = ::pw::digital_io::State;
+
+  static constexpr uint32_t kIntervalMs = 10;
+  static constexpr pw::chrono::SystemClock::duration kInterval =
+      pw::chrono::SystemClock::for_at_least(
+          std::chrono::milliseconds(kIntervalMs));
+
+  // 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.
+  BlinkyTest() : clock_(pw::chrono::VirtualSystemClock::RealClock()) {}
+
+  pw::InlineDeque<Event>::iterator FirstActive() {
+    pw::InlineDeque<Event>& events = monochrome_led_.events();
+    pw::InlineDeque<Event>::iterator event = events.begin();
+    while (event != events.end()) {
+      if (event->state == State::kActive) {
+        break;
+      }
+      ++event;
+    }
+    return event;
+  }
+
+  uint32_t ToMs(pw::chrono::SystemClock::duration interval) {
+    return std::chrono::duration_cast<std::chrono::milliseconds>(interval)
+        .count();
+  }
+
+  static constexpr const size_t kEventCapacity = 256;
+
+  AllocatorForTest allocator_;
+  Dispatcher dispatcher_;
+  pw::chrono::VirtualSystemClock& clock_;
+  pw::digital_io::DigitalInOutMock<kEventCapacity> monochrome_led_;
+};
+
+// Unit tests.
+
+TEST_F(BlinkyTest, Toggle) {
+  Blinky blinky;
+  blinky.Init(dispatcher_, allocator_, monochrome_led_);
+
+  auto start = clock_.now();
+  blinky.Toggle();
+  pw::this_thread::sleep_for(kInterval * 1);
+  blinky.Toggle();
+  pw::this_thread::sleep_for(kInterval * 2);
+  blinky.Toggle();
+  pw::this_thread::sleep_for(kInterval * 3);
+  blinky.Toggle();
+
+  auto event = FirstActive();
+  ASSERT_NE(event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kActive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs * 0);
+  start = event->timestamp;
+
+  ASSERT_NE(++event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kInactive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs * 1);
+  start = event->timestamp;
+
+  ASSERT_NE(++event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kActive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs * 2);
+  start = event->timestamp;
+
+  ASSERT_NE(++event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kInactive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs * 3);
+}
+
+TEST_F(BlinkyTest, Blink) {
+  Blinky blinky;
+  blinky.Init(dispatcher_, allocator_, monochrome_led_);
+
+  auto start = clock_.now();
+  EXPECT_EQ(blinky.Blink(1, kIntervalMs), pw::OkStatus());
+  while (!blinky.IsIdle()) {
+    dispatcher_.RunUntilStalled().IgnorePoll();
+    pw::this_thread::sleep_for(kInterval);
+  }
+
+  auto event = FirstActive();
+  ASSERT_NE(event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kActive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs);
+  start = event->timestamp;
+
+  ASSERT_NE(++event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kInactive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs);
+}
+
+TEST_F(BlinkyTest, BlinkMany) {
+  Blinky blinky;
+  blinky.Init(dispatcher_, allocator_, monochrome_led_);
+
+  auto start = clock_.now();
+  EXPECT_EQ(blinky.Blink(100, kIntervalMs), pw::OkStatus());
+  while (!blinky.IsIdle()) {
+    dispatcher_.RunUntilStalled().IgnorePoll();
+    pw::this_thread::sleep_for(kInterval);
+  }
+
+  // Every "on" and "off" is recorded.
+  EXPECT_GE(monochrome_led_.events().size(), 200);
+  EXPECT_GE(ToMs(clock_.now() - start), kIntervalMs * 200);
+}
+
+TEST_F(BlinkyTest, BlinkSlow) {
+  Blinky blinky;
+  blinky.Init(dispatcher_, allocator_, monochrome_led_);
+
+  auto start = clock_.now();
+  EXPECT_EQ(blinky.Blink(1, kIntervalMs * 32), pw::OkStatus());
+  while (!blinky.IsIdle()) {
+    dispatcher_.RunUntilStalled().IgnorePoll();
+    pw::this_thread::sleep_for(kInterval);
+  }
+
+  auto event = FirstActive();
+  ASSERT_NE(event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kActive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs * 32);
+  start = event->timestamp;
+
+  ASSERT_NE(++event, monochrome_led_.events().end());
+  EXPECT_EQ(event->state, State::kInactive);
+  EXPECT_GE(ToMs(event->timestamp - start), kIntervalMs * 32);
+}
+
+}  // namespace demo
diff --git a/modules/blinky/service.cc b/modules/blinky/service.cc
new file mode 100644
index 0000000..f795ca0
--- /dev/null
+++ b/modules/blinky/service.cc
@@ -0,0 +1,56 @@
+// 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.
+#define PW_LOG_MODULE_NAME "BLINKY"
+
+#include "modules/blinky/service.h"
+
+#include "pw_assert/check.h"
+
+namespace demo {
+
+void BlinkyService::Init(pw::async2::Dispatcher& dispatcher,
+                         pw::Allocator& allocator,
+                         pw::digital_io::DigitalInOut& monochrome_led) {
+  blinky_.Init(dispatcher, allocator, monochrome_led);
+  // Start binking once every 1000ms.
+  PW_CHECK_OK(blinky_.Blink(/*blink_count=*/0, /*interval_ms=*/1000));
+}
+
+pw::Status BlinkyService::ToggleLed(const pw_protobuf_Empty&,
+                                    pw_protobuf_Empty&) {
+  blinky_.Toggle();
+  return pw::OkStatus();
+}
+
+pw::Status BlinkyService::SetLed(const blinky_SetLedRequest& request,
+                                 pw_protobuf_Empty&) {
+  blinky_.SetLed(request.on);
+  return pw::OkStatus();
+}
+
+pw::Status BlinkyService::IsIdle(const pw_protobuf_Empty&,
+                                 blinky_BlinkIdleResponse& response) {
+  response.is_idle = blinky_.IsIdle();
+  return pw::OkStatus();
+}
+
+pw::Status BlinkyService::Blink(const blinky_BlinkRequest& request,
+                                pw_protobuf_Empty&) {
+  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);
+}
+
+}  // namespace demo
diff --git a/modules/blinky/service.h b/modules/blinky/service.h
new file mode 100644
index 0000000..c1d2266
--- /dev/null
+++ b/modules/blinky/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 "modules/blinky/blinky.h"
+#include "modules/blinky/blinky_pb/blinky.rpc.pb.h"
+#include "pw_allocator/allocator.h"
+#include "pw_async2/dispatcher.h"
+
+namespace demo {
+
+class BlinkyService final
+    : public ::blinky::pw_rpc::nanopb::Blinky::Service<BlinkyService> {
+ public:
+  void Init(pw::async2::Dispatcher& dispatcher,
+            pw::Allocator& allocator,
+            pw::digital_io::DigitalInOut& monochrome_led);
+
+  pw::Status ToggleLed(const pw_protobuf_Empty&, pw_protobuf_Empty&);
+
+  pw::Status SetLed(const blinky_SetLedRequest& request, pw_protobuf_Empty&);
+
+  pw::Status Blink(const blinky_BlinkRequest& request, pw_protobuf_Empty&);
+
+  pw::Status IsIdle(const pw_protobuf_Empty&,
+                    blinky_BlinkIdleResponse& response);
+
+ private:
+  Blinky blinky_;
+};
+
+}  // namespace demo
diff --git a/tools/miniterm.py b/modules/timer_future/BUILD.bazel
similarity index 60%
rename from tools/miniterm.py
rename to modules/timer_future/BUILD.bazel
index 90487a6..ce6f0a4 100644
--- a/tools/miniterm.py
+++ b/modules/timer_future/BUILD.bazel
@@ -1,4 +1,4 @@
-# Copyright 2023 The Pigweed Authors
+# 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
@@ -11,9 +11,16 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Spin up a miniterm for talking to the board."""
 
-from serial.tools import miniterm
+package(default_visibility = ["//visibility:public"])
 
-if __name__ == "__main__":
-  miniterm.main(default_baudrate=115200)
+cc_library(
+    name = "timer_future",
+    srcs = ["timer_future.cc"],
+    hdrs = ["timer_future.h"],
+    deps = [
+        "@pigweed//pw_async2:dispatcher",
+        "@pigweed//pw_chrono:system_clock",
+        "@pigweed//pw_chrono:system_timer",
+    ],
+)
diff --git a/modules/timer_future/timer_future.cc b/modules/timer_future/timer_future.cc
new file mode 100644
index 0000000..6d08972
--- /dev/null
+++ b/modules/timer_future/timer_future.cc
@@ -0,0 +1,53 @@
+// 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/timer_future/timer_future.h"
+
+namespace demo {
+
+using ::pw::async2::Context;
+using ::pw::async2::Pending;
+using ::pw::async2::Poll;
+using ::pw::async2::Ready;
+using ::pw::async2::WaitReason;
+using ::pw::chrono::SystemClock;
+using ::pw::chrono::SystemTimer;
+
+AsyncTimer::AsyncTimer()
+    : waker_(),
+      deadline_(),
+      timer_([this](SystemClock::time_point) { std::move(waker_).Wake(); }) {}
+
+TimerFuture AsyncTimer::WaitUntil(SystemClock::time_point deadline) {
+  timer_.Cancel();
+  waker_.Clear();
+  deadline_ = deadline;
+  timer_.InvokeAt(deadline);
+  return TimerFuture(*this);
+}
+
+TimerFuture AsyncTimer::WaitFor(SystemClock::duration duration) {
+  return WaitUntil(SystemClock::now() + duration);
+}
+
+Poll<> TimerFuture::Pend(Context& cx) {
+  async_timer_.waker_ = cx.GetWaker(WaitReason::Unspecified());
+  if (SystemClock::now() < async_timer_.deadline_) {
+    return Pending();
+  }
+  async_timer_.waker_.Clear();
+  return Ready();
+}
+
+}  // namespace demo
\ No newline at end of file
diff --git a/modules/timer_future/timer_future.h b/modules/timer_future/timer_future.h
new file mode 100644
index 0000000..d622455
--- /dev/null
+++ b/modules/timer_future/timer_future.h
@@ -0,0 +1,57 @@
+// 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 "pw_async2/dispatcher.h"
+#include "pw_chrono/system_clock.h"
+#include "pw_chrono/system_timer.h"
+
+namespace demo {
+
+class TimerFuture;
+
+class AsyncTimer {
+ public:
+  // AsyncTimer is not movable due to the timer keeping a reference to the
+  // waker.
+  AsyncTimer();
+  AsyncTimer(const AsyncTimer&) = delete;
+  AsyncTimer& operator=(const AsyncTimer&) = delete;
+  AsyncTimer(AsyncTimer&&) = delete;
+  AsyncTimer& operator=(AsyncTimer&&) = delete;
+  ~AsyncTimer() { timer_.Cancel(); }
+
+  TimerFuture WaitUntil(pw::chrono::SystemClock::time_point deadline);
+  TimerFuture WaitFor(pw::chrono::SystemClock::duration duration);
+
+ private:
+  friend class TimerFuture;
+
+  pw::async2::Waker waker_;
+  pw::chrono::SystemClock::time_point deadline_;
+  pw::chrono::SystemTimer timer_;
+};
+
+class TimerFuture {
+ public:
+  pw::async2::Poll<> Pend(pw::async2::Context& cx);
+
+ private:
+  friend class AsyncTimer;
+
+  TimerFuture(AsyncTimer& async_timer) : async_timer_(async_timer) {}
+  AsyncTimer& async_timer_;
+};
+
+}  // namespace demo
\ No newline at end of file
diff --git a/pigweed.json b/pigweed.json
index ea9b21d..1778b2a 100644
--- a/pigweed.json
+++ b/pigweed.json
@@ -5,8 +5,35 @@
       "upload_local_results": true,
       "programs": {
         "default": [
-          ["build", "--config=presubmit"],
-          ["test", "//..."]
+          [
+            "build",
+            "--config=presubmit"
+          ],
+          [
+            "build",
+            "--config=rp2040",
+            "//..."
+          ],
+          [
+            "test",
+            "//..."
+          ],
+          [
+            "test",
+            "--config=asan",
+            "//..."
+          ],
+          [
+            "test",
+            "--config=tsan",
+            "//...",
+            "--runs_per_test=10"
+          ],
+          [
+            "test",
+            "--config=ubsan",
+            "//..."
+          ]
         ]
       }
     }
diff --git a/requirements.in b/requirements.in
deleted file mode 100644
index 120041f..0000000
--- a/requirements.in
+++ /dev/null
@@ -1 +0,0 @@
-pyserial ~= 3.5
diff --git a/requirements_lock.txt b/requirements_lock.txt
deleted file mode 100644
index a7b06a3..0000000
--- a/requirements_lock.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-#
-# This file is autogenerated by pip-compile with Python 3.11
-# by the following command:
-#
-#    bazel run //:pip_requirements.update
-#
-pyserial==3.5 \
-    --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \
-    --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0
-    # via -r requirements.in
diff --git a/src/BUILD.bazel b/src/BUILD.bazel
deleted file mode 100644
index 8ec1218..0000000
--- a/src/BUILD.bazel
+++ /dev/null
@@ -1,71 +0,0 @@
-# Copyright 2023 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("//:echo.bzl", "stm32_cc_binary")
-
-load(
-    "@pigweed//pw_build:pigweed.bzl",
-    "pw_cc_test",
-)
-
-package(default_visibility = ["//visibility:public"])
-
-cc_binary(
-    name = "echo",
-    srcs = ["echo.cc"],
-    malloc = select({
-        "@platforms//cpu:armv7e-m": "@pigweed//pw_malloc",
-        "//conditions:default": "@bazel_tools//tools/cpp:malloc",
-    }),
-    deps = [
-        "@pigweed//pw_assert:backend_impl",
-        "@pigweed//pw_assert:check_backend_impl",
-        "@pigweed//pw_assert:assert_backend_impl",
-        "@pigweed//pw_boot",
-        "@pigweed//pw_sys_io",
-    ] + select({
-        "@platforms//cpu:armv7e-m": [
-            "@pigweed//pw_toolchain/arm_gcc:arm_none_eabi_gcc_support",
-            "@pigweed//targets/stm32f429i_disc1:basic_linker_script",
-            "@pigweed//targets/stm32f429i_disc1:pre_init",
-        ],
-        "//conditions:default": [],
-    }),
-)
-
-stm32_cc_binary(
-    name = "echo.elf",
-    binary = ":echo",
-)
-
-cc_library(
-    name = "multiply",
-    hdrs = ["multiply.h"],
-    srcs = ["multiply.cc"],
-)
-
-# Note: Must use pw_cc_test instead of cc_test to enable on-device execution.
-# See http://pigweed.dev/bazel#pw_cc_test for more details.
-pw_cc_test(
-    name = "multiply_test",
-    srcs = ["multiply_test.cc"],
-    deps = [
-        ":multiply",
-    ]
-)
-
-stm32_cc_binary(
-    name = "multiply_test.elf",
-    binary = ":multiply_test",
-    testonly = True,
-)
diff --git a/src/echo.cc b/src/echo.cc
deleted file mode 100644
index a99fa4f..0000000
--- a/src/echo.cc
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright 2023 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 <cstddef>
-
-#include "pw_sys_io/sys_io.h"
-
-
-int main() {
-  while (true) {
-    std::byte data;
-    pw::sys_io::ReadByte(&data).IgnoreError();
-    pw::sys_io::WriteByte(data).IgnoreError();
-  }
-  return 0;
-}
diff --git a/src/multiply.h b/src/multiply.h
deleted file mode 100644
index 3a7115a..0000000
--- a/src/multiply.h
+++ /dev/null
@@ -1,16 +0,0 @@
-// 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.
-
-// Demonstration function; multiplies a and b, returns result.
-int Multiply(int a, int b);
diff --git a/src/multiply_test.cc b/src/multiply_test.cc
deleted file mode 100644
index 81a548b..0000000
--- a/src/multiply_test.cc
+++ /dev/null
@@ -1,26 +0,0 @@
-// 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.
-
-// Note: Bazel/monorepo style include path, not Pigweed style.
-#include "src/multiply.h"
-
-#include "pw_unit_test/framework.h"
-
-namespace {
-
-TEST(Multiply, TwoTimesThreeIsSix) {
-  ASSERT_EQ(Multiply(2, 3), 6);
-}
-
-}  // namespace
diff --git a/system/BUILD.bazel b/system/BUILD.bazel
new file mode 100644
index 0000000..d005a35
--- /dev/null
+++ b/system/BUILD.bazel
@@ -0,0 +1,49 @@
+# 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:compatibility.bzl", "host_backend_alias")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "module_config",
+    defines = [
+        # TODO: https://pwbug.dev/352389854 - Move this to per-platform config
+        # when platform flags  are implemented.
+        #
+        # Allow us to capture two 64bit pointers in a pw::function.
+        "PW_FUNCTION_INLINE_CALLABLE_SIZE=16UL",
+        "PW_ASSERT_BASIC_ACTION=PW_ASSERT_BASIC_ACTION_EXIT",
+    ],
+)
+
+label_flag(
+    name = "system",
+    build_setting_default = ":unspecified_backend",
+)
+
+host_backend_alias(
+    name = "unspecified_backend",
+    backend = "//targets/host:system",
+)
+
+cc_library(
+    name = "headers",
+    hdrs = [
+        "system.h",
+    ],
+    deps = [
+        "@pigweed//pw_digital_io",
+    ],
+)
diff --git a/system/system.h b/system/system.h
new file mode 100644
index 0000000..7d91287
--- /dev/null
+++ b/system/system.h
@@ -0,0 +1,31 @@
+// 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 "pw_digital_io/digital_io.h"
+
+// The functions in this file return specific implementations of singleton types
+// provided by the system.
+
+namespace demo::system {
+
+/// Initializes the system. This must be called before anything else in `main`.
+void Init();
+
+/// Starts the main system scheduler. This function never returns.
+[[noreturn]] void Start();
+
+pw::digital_io::DigitalInOut& MonochromeLed();
+
+}  // namespace demo::system
diff --git a/targets/BUILD.bazel b/targets/BUILD.bazel
index 5a5ce5b..e83b707 100644
--- a/targets/BUILD.bazel
+++ b/targets/BUILD.bazel
@@ -1,4 +1,4 @@
-# Copyright 2023 The Pigweed Authors
+# 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
@@ -12,15 +12,14 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 
-platform(
-    name = "stm32",
-    constraint_values = [
-        "@pigweed//pw_malloc_freelist:backend",
-        "@platforms//cpu:armv7e-m",
-        "@platforms//os:none",
-        "@pigweed//pw_sys_io_baremetal_stm32f429:compatible",
-        # Target the cortex-m4. Bazel selects the right toolchain based on
-        # this.
-        "@pw_toolchain//constraints/arm_mcpu:cortex-m4+nofp",
-    ],
+package(default_visibility = ["//visibility:public"])
+
+# TODO: https://github.com/bazelbuild/bazel/issues/22457 - Move this into the
+# platform once it no longer propagates to the exec configuration.
+alias(
+    name = "malloc",
+    actual = select({
+        "@pico-sdk//bazel/constraint:rp2040": "@pico-sdk//src/rp2_common/pico_malloc",
+        "//conditions:default": "@bazel_tools//tools/cpp:malloc",
+    }),
 )
diff --git a/targets/host/BUILD.bazel b/targets/host/BUILD.bazel
new file mode 100644
index 0000000..e0910b6
--- /dev/null
+++ b/targets/host/BUILD.bazel
@@ -0,0 +1,36 @@
+# 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:compatibility.bzl", "incompatible_with_mcu")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "system",
+    srcs = [
+        "led.cc",
+        "system.cc",
+    ],
+    implementation_deps = [
+        "@pigweed//pw_channel",
+        "@pigweed//pw_channel:stream_channel",
+        "@pigweed//pw_digital_io",
+        "@pigweed//pw_digital_io:digital_io_mock",
+        "@pigweed//pw_multibuf:simple_allocator",
+        "@pigweed//pw_system:async",
+        "@pigweed//pw_system:io",
+    ],
+    target_compatible_with = incompatible_with_mcu(),
+    deps = ["//system:headers"],
+)
diff --git a/src/multiply.cc b/targets/host/led.cc
similarity index 65%
rename from src/multiply.cc
rename to targets/host/led.cc
index b1f6871..c8f4773 100644
--- a/src/multiply.cc
+++ b/targets/host/led.cc
@@ -12,6 +12,15 @@
 // License for the specific language governing permissions and limitations under
 // the License.
 
-int Multiply(int a, int b) {
-  return a * b;
+#include "pw_digital_io/digital_io_mock.h"
+#include "system/system.h"
+
+namespace demo::system {
+
+pw::digital_io::DigitalInOut& MonochromeLed() {
+  static constexpr size_t kCapacity = 256;
+  static pw::digital_io::DigitalInOutMock<kCapacity> digital_io_mock;
+  return digital_io_mock;
 }
+
+}  // namespace demo::system
diff --git a/targets/host/system.cc b/targets/host/system.cc
new file mode 100644
index 0000000..8a221af
--- /dev/null
+++ b/targets/host/system.cc
@@ -0,0 +1,99 @@
+// 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 "system/system.h"
+
+#include <signal.h>
+#include <stdio.h>
+
+#include "pw_assert/check.h"
+#include "pw_channel/stream_channel.h"
+#include "pw_digital_io/digital_io.h"
+#include "pw_multibuf/simple_allocator.h"
+#include "pw_system/io.h"
+#include "pw_system/system.h"
+#include "pw_thread_stl/options.h"
+
+using ::pw::channel::StreamChannel;
+using ::pw::digital_io::DigitalIn;
+using ::pw::digital_io::State;
+
+extern "C" {
+
+void CtrlCSignalHandler(int /* ignored */) {
+  printf("\nCtrl-C received; simulator exiting immediately...\n");
+  // Skipping the C++ destructors since we want to exit immediately.
+  _exit(0);
+}
+
+}  // extern "C"
+
+void InstallCtrlCSignalHandler() {
+  // Catch Ctrl-C to force a 0 exit code (success) to avoid signaling an error
+  // for intentional exits. For example, VSCode shows an alarming dialog on
+  // non-zero exit, which is confusing for users intentionally quitting.
+  signal(SIGINT, CtrlCSignalHandler);
+}
+
+namespace {
+class VirtualInput : public DigitalIn {
+ public:
+  VirtualInput(State state) : state_(state) {}
+
+ private:
+  pw::Status DoEnable(bool) override { return pw::OkStatus(); }
+  pw::Result<State> DoGetState() override { return state_; }
+
+  State state_;
+};
+
+VirtualInput io_sw_a(State::kInactive);
+VirtualInput io_sw_b(State::kInactive);
+VirtualInput io_sw_x(State::kInactive);
+VirtualInput io_sw_y(State::kInactive);
+
+}  // namespace
+
+namespace demo::system {
+
+void Init() {}
+
+void Start() {
+  InstallCtrlCSignalHandler();
+  printf("==========================================\n");
+  printf("=== Pigweed Quickstart: Host Simulator ===\n");
+  printf("==========================================\n");
+  printf("Simulator is now running. To connect with a console,\n");
+  printf("either run one in a new terminal:\n");
+  printf("\n");
+  printf("   $ bazelisk run //blinky:simulator_console\n");
+  printf("\n");
+  printf("one from VSCode under the 'Bazel Build Targets' explorer tab.\n");
+  printf("\n");
+  printf("Press Ctrl-C to exit\n");
+
+  static std::byte channel_buffer[16384];
+  static pw::multibuf::SimpleAllocator multibuf_alloc(channel_buffer,
+                                                      pw::System().allocator());
+  static pw::NoDestructor<StreamChannel> channel(multibuf_alloc,
+                                                 pw::system::GetReader(),
+                                                 pw::thread::stl::Options(),
+                                                 pw::system::GetWriter(),
+                                                 pw::thread::stl::Options());
+
+  pw::SystemStart(*channel);
+  PW_UNREACHABLE;
+}
+
+}  // namespace demo::system
diff --git a/targets/rp2/BUILD.bazel b/targets/rp2/BUILD.bazel
new file mode 100644
index 0000000..3cc2ea9
--- /dev/null
+++ b/targets/rp2/BUILD.bazel
@@ -0,0 +1,134 @@
+# 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.
+
+package(default_visibility = ["//visibility:public"])
+
+# This is an incomplete platform, do NOT try to pass this
+# as a --platforms flag value. Use :rp2040 or :rp2350.
+platform(
+    name = "rp2_common",
+    constraint_values = [
+        "@freertos//:disable_task_statics",
+        "@pigweed//pw_build/constraints/rtos:freertos",
+        "@pigweed//pw_build/constraints/chipset:rp2040",  # TODO: https://pwbug.dev/343487589 - Use Pico SDK constraints.
+        "@pigweed//pw_cpu_exception:enabled",
+        "@pigweed//pw_interrupt_cortex_m:backend",
+        "@platforms//os:none",
+    ],
+)
+
+platform(
+    name = "rp2040",
+    constraint_values = [
+        "@freertos//:port_ARM_CM0",
+        "@pico-sdk//bazel/constraint:rp2040",
+        # For toolchain selection.
+        "@platforms//cpu:armv6-m",
+        "@pw_toolchain//constraints/arm_mcpu:cortex-m0",
+    ],
+    parents = [":rp2_common"],
+)
+
+platform(
+    name = "rp2350",
+    constraint_values = [
+        "@freertos//:port_ARM_CM33_NTZ",
+        "@pico-sdk//bazel/constraint:rp2350",
+        # For toolchain selection.
+        "@platforms//cpu:armv8-m",
+        "@pw_toolchain//constraints/arm_mcpu:cortex-m33",
+    ],
+    parents = [":rp2_common"],
+)
+
+cc_library(
+    name = "extra_platform_libs",
+    deps = [
+        "@pico-sdk//src/rp2_common/pico_stdlib:pico_stdlib",
+        "@pigweed//pw_tokenizer:linker_script",
+    ] + select({
+        "@rules_cc//cc/compiler:clang": [
+            "@pigweed//pw_libcxx",
+        ],
+        "//conditions:default": [],
+    }),
+    alwayslink = 1,
+)
+
+cc_library(
+    name = "freertos_config",
+    hdrs = [
+        "config/FreeRTOSConfig.h",
+    ],
+    includes = ["config"],
+    deps = ["@pigweed//third_party/freertos:config_assert"],
+)
+
+cc_library(
+    name = "thread_config_overrides",
+    defines = [
+        "PW_THREAD_FREERTOS_CONFIG_JOINING_ENABLED=1",
+    ],
+)
+
+cc_library(
+    name = "system",
+    srcs = [
+        "led.cc",
+        "system.cc",
+    ],
+    implementation_deps = [
+        "//system:headers",
+        "@pico-sdk//src/rp2_common/cmsis:cmsis_core",
+        "@pico-sdk//src/rp2_common/hardware_adc",
+        "@pico-sdk//src/rp2_common/hardware_exception:hardware_exception",
+        "@pico-sdk//src/rp2_common/pico_stdlib:pico_stdlib",
+        "@pigweed//pw_channel",
+        "@pigweed//pw_channel:rp2_stdio_channel",
+        "@pigweed//pw_cpu_exception:entry_backend_impl",
+        "@pigweed//pw_digital_io_rp2040",
+        "@pigweed//pw_multibuf:simple_allocator",
+        "@pigweed//pw_system:async",
+        "@pigweed//third_party/freertos:support",
+    ],
+    deps = ["//system:headers"],
+    alwayslink = 1,
+)
+
+cc_library(
+    name = "unit_test_rpc_main",
+    testonly = True,
+    srcs = ["unit_test_rpc_main.cc"],
+    deps = [
+        "//system",
+        "@pigweed//pw_log",
+        "@pigweed//pw_system:async",
+        "@pigweed//pw_unit_test:rpc_service",
+
+        # These should be provided by pw_system:async.
+        "@pigweed//pw_assert:assert_backend_impl",
+        "@pigweed//pw_assert:check_backend_impl",
+        "@pigweed//pw_log:backend_impl",
+        "@pigweed//pw_system:extra_platform_libs",
+    ],
+)
+
+# Several tests store `TestWorker` thread contexts in their unit test object.
+# Since these thread stacks are currently 32k, the test objects may be large.
+cc_library(
+    name = "64k_unit_tests",
+    defines = [
+        "PW_UNIT_TEST_CONFIG_MEMORY_POOL_SIZE=65536",
+    ],
+)
diff --git a/targets/rp2/binary.bzl b/targets/rp2/binary.bzl
new file mode 100644
index 0000000..46c146f
--- /dev/null
+++ b/targets/rp2/binary.bzl
@@ -0,0 +1,103 @@
+# 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.
+
+"""Project-specific bazel transitions for rp2xxx-series chips.
+
+TODO:  b/301334234 - Use platform-based flags and retire these transitions.
+"""
+
+load("@pigweed//pw_build:merge_flags.bzl", "merge_flags_for_transition_impl", "merge_flags_for_transition_outputs")
+load("@pigweed//targets/rp2040:transition.bzl", "RP2_SYSTEM_FLAGS")
+
+_COMMON_FLAGS = merge_flags_for_transition_impl(
+    base = RP2_SYSTEM_FLAGS,
+    override = {
+        "//system:system": "//targets/rp2:system",
+        "@freertos//:freertos_config": "//targets/rp2:freertos_config",
+        "@pico-sdk//bazel/config:PICO_CLIB": "llvm_libc",
+        "@pico-sdk//bazel/config:PICO_TOOLCHAIN": "clang",
+        "@pigweed//pw_build:default_module_config": "//system:module_config",
+        "@pigweed//pw_system:extra_platform_libs": "//targets/rp2:extra_platform_libs",
+        "@pigweed//pw_system:io_backend": "@pigweed//pw_system:sys_io_target_io",
+        "@pigweed//pw_toolchain:cortex-m_toolchain_kind": "clang",
+        "@pigweed//pw_unit_test:config_override": "//targets/rp2:64k_unit_tests",
+    },
+)
+
+_RP2040_FLAGS = {
+    "//command_line_option:platforms": "//targets/rp2:rp2040",
+}
+
+_RP2350_FLAGS = {
+    "//command_line_option:platforms": "//targets/rp2:rp2350",
+}
+
+def _rp2_transition(device_specific_flags):
+    def _rp2_transition_impl(settings, attr):
+        # buildifier: disable=unused-variable
+        _ignore = settings, attr
+        return merge_flags_for_transition_impl(
+            base = _COMMON_FLAGS,
+            override = device_specific_flags,
+        )
+
+    return transition(
+        implementation = _rp2_transition_impl,
+        inputs = [],
+        outputs = merge_flags_for_transition_outputs(
+            base = _COMMON_FLAGS,
+            override = device_specific_flags,
+        ),
+    )
+
+def _rp2_binary_impl(ctx):
+    out = ctx.actions.declare_file(ctx.label.name)
+    ctx.actions.symlink(output = out, is_executable = True, target_file = ctx.executable.binary)
+    return [DefaultInfo(files = depset([out]), executable = out)]
+
+rp2040_binary = rule(
+    _rp2_binary_impl,
+    attrs = {
+        "binary": attr.label(
+            doc = "cc_binary to build for the rp2040",
+            cfg = _rp2_transition(_RP2040_FLAGS),
+            executable = True,
+            mandatory = True,
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+    },
+    doc = "Builds the specified binary for the rp2040 platform",
+    # This target is for rp2040 and can't be run on host.
+    executable = False,
+)
+
+rp2350_binary = rule(
+    _rp2_binary_impl,
+    attrs = {
+        "binary": attr.label(
+            doc = "cc_binary to build for the rp2040",
+            cfg = _rp2_transition(_RP2350_FLAGS),
+            executable = True,
+            mandatory = True,
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+    },
+    doc = "Builds the specified binary for the rp2350 platform",
+    # This target is for rp2350 and can't be run on host.
+    executable = False,
+)
diff --git a/targets/rp2/config/FreeRTOSConfig.h b/targets/rp2/config/FreeRTOSConfig.h
new file mode 100644
index 0000000..65a5433
--- /dev/null
+++ b/targets/rp2/config/FreeRTOSConfig.h
@@ -0,0 +1,113 @@
+// 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 <stdint.h>
+
+// Disable formatting to make it easier to compare with other config files.
+// clang-format off
+
+extern uint32_t SystemCoreClock;
+
+#define vPortSVCHandler         SVC_Handler
+#define xPortPendSVHandler      PendSV_Handler
+#define xPortSysTickHandler     SysTick_Handler
+
+#if __ARM_FP
+#define configENABLE_FPU                        1
+#else
+#define configENABLE_FPU                        0
+#endif  // __ARM_FP
+
+// TODO: Set up the MPU.
+#define configENABLE_MPU                        0
+#define configENABLE_TRUSTZONE                  0
+#define configRUN_FREERTOS_SECURE_ONLY          1
+
+#define configUSE_PREEMPTION                    1
+#define configUSE_PORT_OPTIMISED_TASK_SELECTION 0
+#define configUSE_TICKLESS_IDLE                 0
+#define configCPU_CLOCK_HZ                      (SystemCoreClock)
+#define configTICK_RATE_HZ                      ((TickType_t)1000)
+#define configMAX_PRIORITIES                    5
+#define configMINIMAL_STACK_SIZE                ((uint32_t)(256))
+#define configMAX_TASK_NAME_LEN                 16
+#define configUSE_16_BIT_TICKS                  0
+#define configIDLE_SHOULD_YIELD                 1
+#define configUSE_TASK_NOTIFICATIONS            1
+#define configTASK_NOTIFICATION_ARRAY_ENTRIES   3
+#define configUSE_MUTEXES                       1
+#define configUSE_RECURSIVE_MUTEXES             0
+#define configUSE_COUNTING_SEMAPHORES           1
+#define configQUEUE_REGISTRY_SIZE               10
+#define configUSE_QUEUE_SETS                    0
+#define configUSE_TIME_SLICING                  1
+#define configUSE_NEWLIB_REENTRANT              0
+#define configENABLE_BACKWARD_COMPATIBILITY     0
+#define configNUM_THREAD_LOCAL_STORAGE_POINTERS 5
+#define configSTACK_DEPTH_TYPE                  uint32_t
+#define configMESSAGE_BUFFER_LENGTH_TYPE        size_t
+
+#define configSUPPORT_STATIC_ALLOCATION         1
+#define configSUPPORT_DYNAMIC_ALLOCATION        0
+#define configTOTAL_HEAP_SIZE                   ((size_t)(1 * 1024))
+#define configAPPLICATION_ALLOCATED_HEAP        1
+
+#define configUSE_IDLE_HOOK                     0
+#define configUSE_TICK_HOOK                     0
+#define configCHECK_FOR_STACK_OVERFLOW          0
+#define configUSE_MALLOC_FAILED_HOOK            0
+#define configUSE_DAEMON_TASK_STARTUP_HOOK      0
+
+#define configGENERATE_RUN_TIME_STATS           0
+#define configUSE_TRACE_FACILITY                0
+#define configUSE_STATS_FORMATTING_FUNCTIONS    0
+
+#define configUSE_CO_ROUTINES                   0
+#define configMAX_CO_ROUTINE_PRIORITIES         1
+
+#define configUSE_TIMERS                        1
+#define configTIMER_TASK_PRIORITY               3
+#define configTIMER_QUEUE_LENGTH                10
+#define configTIMER_TASK_STACK_DEPTH            configMINIMAL_STACK_SIZE
+
+/* __NVIC_PRIO_BITS in CMSIS */
+#define configPRIO_BITS 4
+
+#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY ((1U << (configPRIO_BITS)) - 1)
+#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 2
+#define configKERNEL_INTERRUPT_PRIORITY (configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))
+#define configMAX_SYSCALL_INTERRUPT_PRIORITY (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))
+
+// Instead of defining configASSERT(), include a header that provides a
+// definition that redirects to pw_assert.
+#include "pw_third_party/freertos/config_assert.h"
+
+#define INCLUDE_vTaskPrioritySet                1
+#define INCLUDE_uxTaskPriorityGet               1
+#define INCLUDE_vTaskDelete                     1
+#define INCLUDE_vTaskSuspend                    1
+#define INCLUDE_xResumeFromISR                  1
+#define INCLUDE_vTaskDelayUntil                 1
+#define INCLUDE_vTaskDelay                      1
+#define INCLUDE_xTaskGetSchedulerState          1
+#define INCLUDE_xTaskGetCurrentTaskHandle       1
+#define INCLUDE_uxTaskGetStackHighWaterMark     0
+#define INCLUDE_xTaskGetIdleTaskHandle          0
+#define INCLUDE_eTaskGetState                   0
+#define INCLUDE_xEventGroupSetBitFromISR        1
+#define INCLUDE_xTimerPendFunctionCall          0
+#define INCLUDE_xTaskAbortDelay                 0
+#define INCLUDE_xTaskGetHandle                  0
+#define INCLUDE_xTaskResumeFromISR              1
diff --git a/targets/rp2/led.cc b/targets/rp2/led.cc
new file mode 100644
index 0000000..a22bcd5
--- /dev/null
+++ b/targets/rp2/led.cc
@@ -0,0 +1,31 @@
+// 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 "pico/stdlib.h"
+#include "pw_digital_io_rp2040/digital_io.h"
+#include "system/system.h"
+
+namespace demo::system {
+
+static constexpr pw::digital_io::Rp2040Config kDefaultLedConfig = {
+    .pin = PICO_DEFAULT_LED_PIN,
+    .polarity = pw::digital_io::Polarity::kActiveHigh,
+};
+
+pw::digital_io::DigitalInOut& MonochromeLed() {
+  static ::pw::digital_io::Rp2040DigitalInOut led_sio(kDefaultLedConfig);
+  return led_sio;
+}
+
+}  // namespace demo::system
diff --git a/targets/rp2/system.cc b/targets/rp2/system.cc
new file mode 100644
index 0000000..472ab2b
--- /dev/null
+++ b/targets/rp2/system.cc
@@ -0,0 +1,56 @@
+// 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 "system/system.h"
+
+#include "hardware/adc.h"
+#include "hardware/exception.h"
+#include "pico/stdlib.h"
+#include "pw_channel/rp2_stdio_channel.h"
+#include "pw_cpu_exception/entry.h"
+#include "pw_digital_io_rp2040/digital_io.h"
+#include "pw_multibuf/simple_allocator.h"
+#include "pw_system/system.h"
+#if defined(PICO_RP2040) && PICO_RP2040
+#include "system_RP2040.h"
+#endif  // defined(PICO_RP2040) && PICO_RP2040
+#if defined(PICO_RP2350) && PICO_RP2350
+#include "system_RP2350.h"
+#endif  // defined(PICO_RP2350) && PICO_RP2350
+
+using pw::digital_io::Rp2040DigitalIn;
+
+namespace demo::system {
+
+void Init() {
+  // PICO_SDK inits.
+  SystemInit();
+  stdio_init_all();
+  setup_default_uart();
+  stdio_usb_init();
+  adc_init();
+
+  // Install the CPU exception handler.
+  exception_set_exclusive_handler(HARDFAULT_EXCEPTION, pw_cpu_exception_Entry);
+}
+
+void Start() {
+  static std::byte channel_buffer[2048];
+  static pw::multibuf::SimpleAllocator multibuf_alloc(channel_buffer,
+                                                      pw::System().allocator());
+  pw::SystemStart(pw::channel::Rp2StdioChannelInit(multibuf_alloc));
+  PW_UNREACHABLE;
+}
+
+}  // namespace demo::system
diff --git a/targets/rp2/unit_test_rpc_main.cc b/targets/rp2/unit_test_rpc_main.cc
new file mode 100644
index 0000000..ad0d057
--- /dev/null
+++ b/targets/rp2/unit_test_rpc_main.cc
@@ -0,0 +1,35 @@
+// 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 "pw_log/log.h"
+#include "pw_system/system.h"
+#include "pw_unit_test/unit_test_service.h"
+#include "system/system.h"
+
+namespace {
+
+pw::unit_test::UnitTestService unit_test_service;
+
+}  // namespace
+
+int main() {
+  demo::system::Init();
+  auto& rpc_server = pw::System().rpc_server();
+
+  rpc_server.RegisterService(unit_test_service);
+
+  PW_LOG_INFO("Started test_runner app; waiting for RPCs...");
+  demo::system::Start();
+  PW_UNREACHABLE;
+}
diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel
index 6d048f7..89cabc4 100644
--- a/tools/BUILD.bazel
+++ b/tools/BUILD.bazel
@@ -11,28 +11,18 @@
 # 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("@rules_python//python:defs.bzl", "py_binary")
 
 package(default_visibility = ["//visibility:public"])
 
 py_binary(
-    name = "flash",
-    srcs = ["flash.py"],
-    data = [
-        "//src:echo.elf",
-        "@openocd//:bin/openocd",
-        "@pigweed//targets/stm32f429i_disc1/py/stm32f429i_disc1_utils:openocd_stm32f4xx.cfg",
-    ],
+    name = "console",
+    srcs = ["console.py"],
     deps = [
-        "@pypi_pyserial//:pkg",
-        "@rules_python//python/runfiles",
+        "//modules/blinky:py_pb2",
+        "@pigweed//pw_protobuf:common_py_pb2",
+        "@pigweed//pw_rpc:echo_py_pb2",
+        "@pigweed//pw_system/py:pw_system_lib",
     ],
 )
-
-py_binary(
-  name = "miniterm",
-  srcs = ["miniterm.py"],
-  deps = [
-    "@pypi_pyserial//:pkg",
-  ],
-)
diff --git a/tools/console.py b/tools/console.py
new file mode 100644
index 0000000..2e8e895
--- /dev/null
+++ b/tools/console.py
@@ -0,0 +1,100 @@
+# 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.
+"""Wraps pw_system's console to inject quickstart's RPC proto."""
+
+import argparse
+import sys
+
+import logging
+
+import pw_cli
+from pw_system.device import Device as PwSystemDevice
+from pw_system.device_connection import (
+    add_device_args,
+    DeviceConnection,
+    create_device_serial_or_socket_connection,
+)
+import pw_system.console
+from blinky_pb import blinky_pb2
+
+
+_LOG = logging.getLogger(__file__)
+
+
+# Quickstart-specific device classes, new functions can be added here.
+# similar to ones on the parent pw_system.device.Device class:
+# https://cs.opensource.google/pigweed/pigweed/+/main:pw_system/py/pw_system/device.py?q=%22def%20run_tests(%22
+class Device(PwSystemDevice):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def toggle_led(self):
+        """Toggles the onboard (non-RGB) LED."""
+        self.rpcs.blinky.Blinky.ToggleLed()
+
+    def set_led(self, on: bool):
+        """Sets the onboard (non-RGB) LED."""
+        self.rpcs.blinky.Blinky.SetLed(on=on)
+
+    def blink(self, interval_ms=1000, blink_count=None):
+        """Sets the onboard (non-RGB) LED to blink on and off."""
+        self.rpcs.blinky.Blinky.Blink(
+            interval_ms=interval_ms, blink_count=blink_count
+        )
+
+
+def get_device_connection(
+    setup_logging: bool = True,
+    log_level: int = logging.DEBUG,
+) -> DeviceConnection:
+    if setup_logging:
+        pw_cli.log.install(level=log_level)
+
+    parser = argparse.ArgumentParser(
+        prog='quickstart',
+        description=__doc__,
+    )
+    parser = add_device_args(parser)
+    args, _remaning_args = parser.parse_known_args()
+
+    compiled_protos = [blinky_pb2]
+
+    device_context = create_device_serial_or_socket_connection(
+        device=args.device,
+        baudrate=args.baudrate,
+        token_databases=args.token_databases,
+        compiled_protos=compiled_protos,
+        socket_addr=args.socket_addr,
+        ticks_per_second=args.ticks_per_second,
+        serial_debug=args.serial_debug,
+        rpc_logging=args.rpc_logging,
+        hdlc_encoding=args.hdlc_encoding,
+        channel_id=args.channel_id,
+        # Device tracing is not hooked up yet for Pigweed Sense.
+        device_tracing=False,
+        device_class=Device,
+    )
+
+    return device_context
+
+
+def main() -> int:
+    return pw_system.console.main(
+        compiled_protos=[blinky_pb2],
+        device_connection=get_device_connection(),
+    )
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/tools/flash.py b/tools/flash.py
deleted file mode 100644
index 69a6a8b..0000000
--- a/tools/flash.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# Copyright 2023 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.
-"""Flash the echo binary to a connected STM32 Discovery Board.
-
-Usage:
-  bazel run //tools:flash
-"""
-
-import subprocess
-
-from rules_python.python.runfiles import runfiles
-from serial.tools import list_ports
-
-_BINARY_PATH = "__main__/src/echo.elf"
-_OPENOCD_PATH = "openocd/bin/openocd"
-_OPENOCD_CONFIG_PATH = "pigweed/targets/stm32f429i_disc1/py/stm32f429i_disc1_utils/openocd_stm32f4xx.cfg"
-
-# Vendor ID and model ID for the STM32 Discovery Board.
-_ST_VENDOR_ID = 0x0483
-_DISCOVERY_MODEL_ID = 0x374B
-
-
-def get_board_serial() -> str:
-  for dev in list_ports.comports():
-    if dev.vid == _ST_VENDOR_ID and dev.pid == _DISCOVERY_MODEL_ID:
-      return dev.serial_number
-
-  raise IOError("Failed to detect connected board")
-
-
-def flash(board_serial):
-  r = runfiles.Create()
-  openocd = r.Rlocation(_OPENOCD_PATH)
-  binary = r.Rlocation(_BINARY_PATH)
-  openocd_cfg = r.Rlocation(_OPENOCD_CONFIG_PATH)
-
-  print(f"binary Rlocation is: {binary}")
-  print(f"openocd Rlocation is: {openocd}")
-  print(f"openocd config Rlocation is: {openocd_cfg}")
-
-  assert binary is not None
-  assert openocd_cfg is not None
-
-  # Variables referred to by the OpenOCD config.
-  env = {
-      "PW_STLINK_SERIAL": board_serial,
-      "PW_GDB_PORT": "disabled",
-  }
-
-  subprocess.check_call(
-      [
-          openocd,
-          "-f",
-          f"{openocd_cfg}",
-          "-c",
-          f"program {binary} reset exit",
-      ],
-      env=env,
-  )
-
-
-if __name__ == "__main__":
-  flash(get_board_serial())
diff --git a/tools/tools.bzl b/tools/tools.bzl
new file mode 100644
index 0000000..f8a35a4
--- /dev/null
+++ b/tools/tools.bzl
@@ -0,0 +1,67 @@
+# 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("@bazel_skylib//rules:native_binary.bzl", "native_binary")
+
+def host_console(name, binary, extra_args = []):
+    """Create a host binary console run target.
+
+    Args:
+      name: target name
+      binary: target binary the console is for
+      extra_args: additional arguments added to the console invocation
+    """
+    native_binary(
+        name = name,
+        src = "//tools:console",
+        args = [
+            # This arg lets us skip manual port selection.
+            "--socket",
+            "default",
+            "--config-file",
+            "$(rootpath //:pw_console_config)",
+        ] + extra_args,
+        data = [
+            binary,
+            "//:pw_console_config",
+        ],
+    )
+
+def device_console(name, binary, extra_args = []):
+    """Create a device binary console run target.
+
+    Makes running a console for a binary easy, and ensures the associated binary is
+    up to date (but does not flash the device).
+
+    Args:
+      name: target name
+      binary: target binary the console is for
+      extra_args: additional arguments added to the console invocation
+    """
+    native_binary(
+        name = name,
+        src = "//tools:console",
+        args = [
+            "-b",
+            "115200",
+            "--token-databases",
+            "$(rootpath " + binary + ")",
+            "--config-file",
+            "$(rootpath //:pw_console_config)",
+        ] + extra_args,
+        data = [
+            binary,
+            "//:pw_console_config",
+        ],
+    )