pw_router: Add module and static router implementation

This adds a pw_router module for routing packets over network links.
Initially, this module contains a basic StaticRouter which uses a static
routing table.

Change-Id: I5534c7fec5c622e7af6548c48793ff7f7d6dd098
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/29440
Commit-Queue: Alexei Frolov <frolv@google.com>
Reviewed-by: Ewout van Bekkum <ewout@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index e9aca66..e5f94c0 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -280,6 +280,7 @@
       "$dir_pw_random:tests",
       "$dir_pw_result:tests",
       "$dir_pw_ring_buffer:tests",
+      "$dir_pw_router:tests",
       "$dir_pw_rpc:tests",
       "$dir_pw_span:tests",
       "$dir_pw_status:tests",
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ff37967..20870ee 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -48,6 +48,7 @@
 add_subdirectory(pw_preprocessor EXCLUDE_FROM_ALL)
 add_subdirectory(pw_random EXCLUDE_FROM_ALL)
 add_subdirectory(pw_result EXCLUDE_FROM_ALL)
+add_subdirectory(pw_router EXCLUDE_FROM_ALL)
 add_subdirectory(pw_rpc EXCLUDE_FROM_ALL)
 add_subdirectory(pw_span EXCLUDE_FROM_ALL)
 add_subdirectory(pw_status EXCLUDE_FROM_ALL)
diff --git a/docs/BUILD.gn b/docs/BUILD.gn
index 7c2f2bf..644a32b 100644
--- a/docs/BUILD.gn
+++ b/docs/BUILD.gn
@@ -93,6 +93,7 @@
     "$dir_pw_random:docs",
     "$dir_pw_result:docs",
     "$dir_pw_ring_buffer:docs",
+    "$dir_pw_router:docs",
     "$dir_pw_rpc:docs",
     "$dir_pw_span:docs",
     "$dir_pw_status:docs",
diff --git a/modules.gni b/modules.gni
index 49679be..337a7fb 100644
--- a/modules.gni
+++ b/modules.gni
@@ -70,6 +70,7 @@
   dir_pw_random = get_path_info("pw_random", "abspath")
   dir_pw_result = get_path_info("pw_result", "abspath")
   dir_pw_ring_buffer = get_path_info("pw_ring_buffer", "abspath")
+  dir_pw_router = get_path_info("pw_router", "abspath")
   dir_pw_rpc = get_path_info("pw_rpc", "abspath")
   dir_pw_span = get_path_info("pw_span", "abspath")
   dir_pw_status = get_path_info("pw_status", "abspath")
diff --git a/pw_router/BUILD b/pw_router/BUILD
new file mode 100644
index 0000000..f6f33c6
--- /dev/null
+++ b/pw_router/BUILD
@@ -0,0 +1,63 @@
+# Copyright 2021 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(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_library",
+    "pw_cc_test",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_library(
+    name = "static_router",
+    hdrs = ["public/pw_router/static_router.h"],
+    srcs = ["static_router.cc"],
+    deps = [
+        ":egress",
+        ":packet_parser",
+        "//pw_log",
+        "//pw_metric",
+        "//pw_sync:mutex",
+    ],
+)
+
+pw_cc_library(
+    name = "egress",
+    hdrs = ["public/pw_router/egress.h"],
+    deps = ["//pw_bytes"],
+)
+
+pw_cc_library(
+    name = "packet_parser",
+    hdrs = ["public/pw_router/packet_parser.h"],
+    deps = ["//pw_bytes"],
+)
+
+pw_cc_library(
+    name = "egress_function",
+    hdrs = ["public/pw_router/egress_function.h"],
+    deps = [":egress"],
+)
+
+pw_cc_test(
+    name = "static_router_test",
+    srcs = ["static_router_test.cc"],
+    deps = [
+        ":egress_function",
+        ":static_router",
+    ],
+)
diff --git a/pw_router/BUILD.gn b/pw_router/BUILD.gn
new file mode 100644
index 0000000..867f871
--- /dev/null
+++ b/pw_router/BUILD.gn
@@ -0,0 +1,88 @@
+# Copyright 2021 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.
+
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_bloat/bloat.gni")
+import("$dir_pw_build/target_types.gni")
+import("$dir_pw_docgen/docs.gni")
+import("$dir_pw_sync/backend.gni")
+import("$dir_pw_unit_test/test.gni")
+
+config("public_include_path") {
+  include_dirs = [ "public" ]
+  visibility = [ ":*" ]
+}
+
+pw_source_set("static_router") {
+  public_configs = [ ":public_include_path" ]
+  public_deps = [
+    ":egress",
+    ":packet_parser",
+    "$dir_pw_sync:mutex",
+    dir_pw_metric,
+  ]
+  public = [ "public/pw_router/static_router.h" ]
+  sources = [ "static_router.cc" ]
+  deps = [ dir_pw_log ]
+}
+
+pw_source_set("egress") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_router/egress.h" ]
+  public_deps = [ dir_pw_bytes ]
+}
+
+pw_source_set("packet_parser") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_router/packet_parser.h" ]
+  public_deps = [ dir_pw_bytes ]
+}
+
+pw_source_set("egress_function") {
+  public_configs = [ ":public_include_path" ]
+  public = [ "public/pw_router/egress_function.h" ]
+  public_deps = [ ":egress" ]
+}
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+  # TODO(frolv): This size report can't currently be built as the docs target
+  # does not have a mutex backend.
+  # report_deps = [ ":static_router_size" ]
+}
+
+pw_test_group("tests") {
+  tests = [ ":static_router_test" ]
+}
+
+pw_test("static_router_test") {
+  deps = [
+    ":egress_function",
+    ":static_router",
+  ]
+  sources = [ "static_router_test.cc" ]
+  enable_if = pw_sync_MUTEX_BACKEND != ""
+}
+
+pw_size_report("static_router_size") {
+  title = "pw::router::StaticRouter size report"
+  binaries = [
+    {
+      target = "size_report:static_router_with_one_route"
+      base = "size_report:base"
+      label = "Static router with a single route"
+    },
+  ]
+}
diff --git a/pw_router/CMakeLists.txt b/pw_router/CMakeLists.txt
new file mode 100644
index 0000000..cf80e79
--- /dev/null
+++ b/pw_router/CMakeLists.txt
@@ -0,0 +1,47 @@
+# Copyright 2021 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($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+
+pw_add_module_library(pw_router.static_router
+  SOURCES
+    static_router.cc
+  PUBLIC_DEPS
+    pw_metric
+    pw_router.egress
+    pw_router.packet_parser
+    pw_sync.mutex
+  PRIVATE_DEPS
+    pw_log
+)
+
+pw_add_module_library(pw_router.egress
+  PUBLIC_DEPS
+    pw_bytes
+)
+
+pw_add_module_library(pw_router.packet_parser
+  PUBLIC_DEPS
+    pw_bytes
+)
+
+pw_add_module_library(pw_router.egress_function
+  PUBLIC_DEPS
+    pw_rpc.egress
+)
+
+pw_auto_add_module_tests(pw_router
+  PRIVATE_DEPS
+    pw_router.static_router
+)
diff --git a/pw_router/docs.rst b/pw_router/docs.rst
new file mode 100644
index 0000000..76fec30
--- /dev/null
+++ b/pw_router/docs.rst
@@ -0,0 +1,65 @@
+.. _module-pw_router:
+
+---------
+pw_router
+---------
+The ``pw_router`` module provides transport-agnostic classes for routing packets
+over network links.
+
+Common router interfaces
+========================
+
+PacketParser
+------------
+To work with arbitrary packet formats, routers require a common interface for
+extracting relevant packet data, such as the destination. This interface is
+``pw::router::PacketParser``, defined in ``pw_router/packet_parser.h``, which
+must be implemented for the packet framing format used by the network.
+
+Egress
+------
+The Egress class is a virtual interface for sending packet data over a network
+link. Egress implementations provide a single ``SendPacket`` function, which
+takes the raw packet data and transmits it.
+
+Some common egress implementations are provided upstream in Pigweed.
+
+StaticRouter
+============
+``pw::router::StaticRouter`` is a router with a static table of address to
+egress mappings. Routes in a static router never change; packets with the same
+address are always sent through the same egress. If links are unavailable,
+packets will be dropped.
+
+Static routers are suitable for basic networks with persistent links.
+
+Usage example
+-------------
+
+.. code-block:: c++
+
+  namespace {
+
+  // Define packet parser and egresses.
+  HdlcFrameParser hdlc_parser;
+  UartEgress uart_egress;
+  BluetoothEgress ble_egress;
+
+  // Define the routing table.
+  constexpr pw::router::StaticRouter::Route routes[] = {{1, uart_egress},
+                                                        {7, ble_egress}};
+  pw::router::StaticRouter router(hdlc_parser, routes);
+
+  }  // namespace
+
+  void ProcessPacket(pw::ConstByteSpan packet) {
+    router.RoutePacket(packet);
+  }
+
+.. TODO(frolv): Re-enable this when the size report builds.
+.. Size report
+.. -----------
+.. The following size report shows the cost of a ``StaticRouter`` with a simple
+.. ``PacketParser`` implementation and a single route using an ``EgressFunction``.
+
+.. .. include:: static_router_size
diff --git a/pw_router/public/pw_router/egress.h b/pw_router/public/pw_router/egress.h
new file mode 100644
index 0000000..09097e0
--- /dev/null
+++ b/pw_router/public/pw_router/egress.h
@@ -0,0 +1,35 @@
+// Copyright 2021 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 <span>
+
+#include "pw_bytes/span.h"
+#include "pw_status/status.h"
+
+namespace pw::router {
+
+// Data egress for a router to send packets over some transport system.
+class Egress {
+ public:
+  virtual ~Egress() = default;
+
+  // Sends a complete packet/frame over the transport. Returns OK on success, or
+  // an error status on failure.
+  //
+  // TODO(frolv): Document possible return values.
+  virtual Status SendPacket(ConstByteSpan packet) = 0;
+};
+
+}  // namespace pw::router
diff --git a/pw_router/public/pw_router/egress_function.h b/pw_router/public/pw_router/egress_function.h
new file mode 100644
index 0000000..e766766
--- /dev/null
+++ b/pw_router/public/pw_router/egress_function.h
@@ -0,0 +1,33 @@
+// Copyright 2021 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 <span>
+
+#include "pw_router/egress.h"
+
+namespace pw::router {
+
+// Router egress that dispatches to a free function.
+class EgressFunction final : public Egress {
+ public:
+  constexpr EgressFunction(Status (*func)(ConstByteSpan)) : func_(*func) {}
+
+  Status SendPacket(ConstByteSpan packet) final { return func_(packet); }
+
+ private:
+  Status (&func_)(ConstByteSpan);
+};
+
+}  // namespace pw::router
diff --git a/pw_router/public/pw_router/packet_parser.h b/pw_router/public/pw_router/packet_parser.h
new file mode 100644
index 0000000..c4ecc8a
--- /dev/null
+++ b/pw_router/public/pw_router/packet_parser.h
@@ -0,0 +1,46 @@
+// Copyright 2021 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+#pragma once
+
+#include <optional>
+#include <span>
+
+#include "pw_bytes/span.h"
+
+namespace pw::router {
+
+// A PacketParser is an abstract interface for extracting data from different
+// kinds of transport layer packets or frames. It is used by routers to examine
+// fields within packets to know how to route them.
+class PacketParser {
+ public:
+  virtual ~PacketParser() = default;
+
+  // Parses a packet, storing its data for subsequent calls to Get* functions.
+  // Any currently stored packet is cleared. Returns true if successful, or
+  // false if the packet is incomplete or corrupt.
+  //
+  // The raw binary data passed to this function is guaranteed to remain valid
+  // through all subsequent Get* calls made for the packet's information, so
+  // implementations may store and use it directly.
+  virtual bool Parse(ConstByteSpan packet) = 0;
+
+  // Extracts the destination address the last parsed packet, if it exists.
+  //
+  // Guaranteed to only be called if Parse() succeeded and while the data passed
+  // to Parse() is valid.
+  virtual std::optional<uint32_t> GetDestinationAddress() const = 0;
+};
+
+}  // namespace pw::router
diff --git a/pw_router/public/pw_router/static_router.h b/pw_router/public/pw_router/static_router.h
new file mode 100644
index 0000000..6697456
--- /dev/null
+++ b/pw_router/public/pw_router/static_router.h
@@ -0,0 +1,77 @@
+// Copyright 2021 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 <span>
+
+#include "pw_bytes/span.h"
+#include "pw_metric/metric.h"
+#include "pw_router/egress.h"
+#include "pw_router/packet_parser.h"
+#include "pw_status/status.h"
+#include "pw_sync/mutex.h"
+
+namespace pw::router {
+
+// A packet router with a static routing table.
+//
+// Thread-safety:
+//   Internal packet parsing and calls to the provided PacketParser are
+//   synchronized. Synchronization at the egress level must be implemented by
+//   derived egresses.
+//
+class StaticRouter {
+ public:
+  struct Route {
+    // TODO(frolv): Consider making address size configurable.
+    uint32_t address;
+    Egress& egress;
+  };
+
+  StaticRouter(PacketParser& parser, std::span<const Route> routes)
+      : parser_(parser), routes_(routes) {}
+
+  StaticRouter(const StaticRouter&) = delete;
+  StaticRouter(StaticRouter&&) = delete;
+  StaticRouter& operator=(const StaticRouter&) = delete;
+  StaticRouter& operator=(StaticRouter&&) = delete;
+
+  uint32_t dropped_packets() const {
+    return parser_errors_.value() + route_errors_.value() +
+           egress_errors_.value();
+  }
+
+  const metric::Group& metrics() { return metrics_; }
+
+  // Routes a single packet through the appropriate egress.
+  // Returns one of the following to indicate a router-side error:
+  //
+  //   OK - Packet sent successfully.
+  //   DATA_LOSS - Packet corrupt or incomplete.
+  //   NOT_FOUND - No registered route for the packet.
+  //   UNAVAILABLE - Route egress did not accept packet.
+  //
+  Status RoutePacket(ConstByteSpan packet);
+
+ private:
+  PacketParser& parser_;
+  std::span<const Route> routes_;
+  sync::Mutex mutex_;
+  PW_METRIC_GROUP(metrics_, "static_router");
+  PW_METRIC(metrics_, parser_errors_, "parser_errors", 0u);
+  PW_METRIC(metrics_, route_errors_, "route_errors", 0u);
+  PW_METRIC(metrics_, egress_errors_, "egress_errors", 0u);
+};
+
+}  // namespace pw::router
diff --git a/pw_router/size_report/BUILD b/pw_router/size_report/BUILD
new file mode 100644
index 0000000..f90c83b
--- /dev/null
+++ b/pw_router/size_report/BUILD
@@ -0,0 +1,45 @@
+# Copyright 2021 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(
+    "//pw_build:pigweed.bzl",
+    "pw_cc_binary",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
+
+pw_cc_binary(
+    name = "base",
+    srcs = ["base.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_log",
+        "//pw_sys_io",
+    ],
+)
+
+pw_cc_binary(
+    name = "static_router_with_one_route",
+    srcs = ["static_router_with_one_route.cc"],
+    deps = [
+        "//pw_assert",
+        "//pw_bloat:bloat_this_binary",
+        "//pw_log",
+        "//pw_router:static_router",
+        "//pw_sys_io",
+    ],
+)
diff --git a/pw_router/size_report/BUILD.gn b/pw_router/size_report/BUILD.gn
new file mode 100644
index 0000000..e30430e
--- /dev/null
+++ b/pw_router/size_report/BUILD.gn
@@ -0,0 +1,34 @@
+# Copyright 2021 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.
+
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/target_types.gni")
+
+_common_deps = [
+  "$dir_pw_bloat:bloat_this_binary",
+  dir_pw_assert,
+  dir_pw_log,
+  dir_pw_sys_io,
+]
+
+pw_executable("base") {
+  sources = [ "base.cc" ]
+  deps = _common_deps
+}
+
+pw_executable("static_router_with_one_route") {
+  sources = [ "static_router_with_one_route.cc" ]
+  deps = _common_deps + [ "..:static_router" ]
+}
diff --git a/pw_router/size_report/base.cc b/pw_router/size_report/base.cc
new file mode 100644
index 0000000..8d63f6f
--- /dev/null
+++ b/pw_router/size_report/base.cc
@@ -0,0 +1,49 @@
+// Copyright 2021 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_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_log/log.h"
+#include "pw_sys_io/sys_io.h"
+
+namespace {
+
+struct BasicPacket {
+  static constexpr uint32_t kMagic = 0x8badf00d;
+
+  constexpr BasicPacket(uint32_t addr, uint64_t data)
+      : magic(kMagic), address(addr), payload(data) {}
+
+  uint32_t magic;
+  uint32_t address;
+  uint64_t payload;
+};
+
+}  // namespace
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  BasicPacket packet(0x1, 0x2);
+  PW_CHECK_UINT_EQ(packet.magic, BasicPacket::kMagic, "Some CHECK logic");
+  PW_LOG_INFO("Packet has address %u", static_cast<unsigned>(packet.address));
+  PW_LOG_INFO("pw_StatusString %s", pw::OkStatus().str());
+
+  std::array<std::byte, sizeof(BasicPacket)> packet_buffer;
+  pw::sys_io::ReadBytes(packet_buffer);
+  pw::sys_io::WriteBytes(packet_buffer);
+
+  return static_cast<int>(packet.payload);
+}
diff --git a/pw_router/size_report/static_router_with_one_route.cc b/pw_router/size_report/static_router_with_one_route.cc
new file mode 100644
index 0000000..a285954
--- /dev/null
+++ b/pw_router/size_report/static_router_with_one_route.cc
@@ -0,0 +1,85 @@
+// Copyright 2021 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_assert/assert.h"
+#include "pw_bloat/bloat_this_binary.h"
+#include "pw_log/log.h"
+#include "pw_router/egress_function.h"
+#include "pw_router/static_router.h"
+#include "pw_sys_io/sys_io.h"
+
+namespace {
+
+struct BasicPacket {
+  static constexpr uint32_t kMagic = 0x8badf00d;
+
+  constexpr BasicPacket(uint32_t addr, uint64_t data)
+      : magic(kMagic), address(addr), payload(data) {}
+
+  uint32_t magic;
+  uint32_t address;
+  uint64_t payload;
+};
+
+}  // namespace
+
+// All the new router-specific stuff.
+namespace {
+
+class BasicPacketParser : public pw::router::PacketParser {
+ public:
+  constexpr BasicPacketParser() : packet_(nullptr) {}
+
+  bool Parse(pw::ConstByteSpan packet) final {
+    packet_ = reinterpret_cast<const BasicPacket*>(packet.data());
+    return packet_->magic == BasicPacket::kMagic;
+  }
+
+  std::optional<uint32_t> GetDestinationAddress() const final {
+    return packet_->address;
+  }
+
+ private:
+  const BasicPacket* packet_;
+};
+
+BasicPacketParser parser;
+pw::router::EgressFunction sys_io_egress(+[](pw::ConstByteSpan packet) {
+  return pw::sys_io::WriteBytes(packet).status();
+});
+constexpr pw::router::StaticRouter::Route routes[] = {{1, sys_io_egress}};
+pw::router::StaticRouter router(parser, routes);
+
+}  // namespace
+
+int main() {
+  pw::bloat::BloatThisBinary();
+
+  // Ensure we are paying the cost for log and assert.
+  BasicPacket packet(0x1, 0x2);
+  PW_CHECK_UINT_EQ(packet.magic, BasicPacket::kMagic, "Some CHECK logic");
+  PW_LOG_INFO("Packet has address %u", static_cast<unsigned>(packet.address));
+  PW_LOG_INFO("pw_StatusString %s", pw::OkStatus().str());
+
+  std::array<std::byte, sizeof(BasicPacket)> packet_buffer;
+  pw::sys_io::ReadBytes(packet_buffer);
+  pw::sys_io::WriteBytes(packet_buffer);
+
+  while (true) {
+    pw::sys_io::ReadBytes(packet_buffer);
+    router.RoutePacket(packet_buffer);
+  }
+
+  return static_cast<int>(packet.payload);
+}
diff --git a/pw_router/static_router.cc b/pw_router/static_router.cc
new file mode 100644
index 0000000..72c7242
--- /dev/null
+++ b/pw_router/static_router.cc
@@ -0,0 +1,73 @@
+// Copyright 2021 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_router/static_router.h"
+
+#include <algorithm>
+#include <mutex>
+
+#include "pw_log/log.h"
+
+namespace pw::router {
+
+Status StaticRouter::RoutePacket(ConstByteSpan packet) {
+  uint32_t address;
+
+  {
+    // Only packet parsing is synchronized within the router; egresses must be
+    // synchronized externally.
+    std::lock_guard lock(mutex_);
+
+    if (!parser_.Parse(packet)) {
+      PW_LOG_ERROR("StaticRouter failed to parse packet; dropping");
+      parser_errors_.Increment();
+      return Status::DataLoss();
+    }
+
+    std::optional<uint32_t> result = parser_.GetDestinationAddress();
+    if (!result.has_value()) {
+      PW_LOG_ERROR("StaticRouter packet does not have address; dropping");
+      parser_errors_.Increment();
+      return Status::DataLoss();
+    }
+
+    address = result.value();
+  }
+
+  auto route = std::find_if(routes_.begin(), routes_.end(), [&](auto r) {
+    return r.address == address;
+  });
+  if (route == routes_.end()) {
+    PW_LOG_ERROR("StaticRouter no route for address %u; dropping packet",
+                 static_cast<unsigned>(address));
+    route_errors_.Increment();
+    return Status::NotFound();
+  }
+
+  PW_LOG_DEBUG("StaticRouter routing %u-byte packet to address %u",
+               static_cast<unsigned>(packet.size()),
+               static_cast<unsigned>(address));
+
+  if (Status status = route->egress.SendPacket(packet); !status.ok()) {
+    PW_LOG_ERROR("StaticRouter egress error for address %u: %s",
+                 static_cast<unsigned>(address),
+                 status.str());
+    egress_errors_.Increment();
+    return Status::Unavailable();
+  }
+
+  return OkStatus();
+}
+
+}  // namespace pw::router
diff --git a/pw_router/static_router_test.cc b/pw_router/static_router_test.cc
new file mode 100644
index 0000000..72ca116
--- /dev/null
+++ b/pw_router/static_router_test.cc
@@ -0,0 +1,116 @@
+// Copyright 2021 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_router/static_router.h"
+
+#include "gtest/gtest.h"
+#include "pw_router/egress_function.h"
+
+namespace pw::router {
+namespace {
+
+struct BasicPacket {
+  static constexpr uint32_t kMagic = 0x8badf00d;
+
+  constexpr BasicPacket(uint32_t addr, uint64_t data)
+      : magic(kMagic), address(addr), payload(data) {}
+
+  ConstByteSpan data() const { return std::as_bytes(std::span(this, 1)); }
+
+  uint32_t magic;
+  uint32_t address;
+  uint64_t payload;
+};
+
+class BasicPacketParser : public PacketParser {
+ public:
+  constexpr BasicPacketParser() : packet_(nullptr) {}
+
+  bool Parse(pw::ConstByteSpan packet) final {
+    packet_ = reinterpret_cast<const BasicPacket*>(packet.data());
+    return packet_->magic == BasicPacket::kMagic;
+  }
+
+  std::optional<uint32_t> GetDestinationAddress() const final {
+    PW_DCHECK_NOTNULL(packet_);
+    return packet_->address;
+  }
+
+ private:
+  const BasicPacket* packet_;
+};
+
+EgressFunction GoodEgress(+[](ConstByteSpan) { return OkStatus(); });
+EgressFunction BadEgress(+[](ConstByteSpan) {
+  return Status::ResourceExhausted();
+});
+
+TEST(StaticRouter, RoutePacket_RoutesToAnEgress) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  EXPECT_EQ(router.RoutePacket(BasicPacket(1, 0xdddd).data()), OkStatus());
+  EXPECT_EQ(router.RoutePacket(BasicPacket(2, 0xdddd).data()),
+            Status::Unavailable());
+}
+
+TEST(StaticRouter, RoutePacket_ReturnsParserError) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  BasicPacket bad_magic(1, 0xdddd);
+  bad_magic.magic = 0x1badda7a;
+  EXPECT_EQ(router.RoutePacket(bad_magic.data()), Status::DataLoss());
+}
+
+TEST(StaticRouter, RoutePacket_ReturnsNotFoundOnInvalidRoute) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  EXPECT_EQ(router.RoutePacket(BasicPacket(42, 0xdddd).data()),
+            Status::NotFound());
+}
+
+TEST(StaticRouter, RoutePacket_TracksNumberOfDrops) {
+  BasicPacketParser parser;
+  constexpr StaticRouter::Route routes[] = {{1, GoodEgress}, {2, BadEgress}};
+  StaticRouter router(parser, std::span(routes));
+
+  // Good
+  EXPECT_EQ(router.RoutePacket(BasicPacket(1, 0xdddd).data()), OkStatus());
+
+  // Egress error
+  EXPECT_EQ(router.RoutePacket(BasicPacket(2, 0xdddd).data()),
+            Status::Unavailable());
+
+  // Parser error
+  BasicPacket bad_magic(1, 0xdddd);
+  bad_magic.magic = 0x1badda7a;
+  EXPECT_EQ(router.RoutePacket(bad_magic.data()), Status::DataLoss());
+
+  // Good
+  EXPECT_EQ(router.RoutePacket(BasicPacket(1, 0xdddd).data()), OkStatus());
+
+  // Bad route
+  EXPECT_EQ(router.RoutePacket(BasicPacket(42, 0xdddd).data()),
+            Status::NotFound());
+
+  EXPECT_EQ(router.dropped_packets(), 3u);
+}
+
+}  // namespace
+}  // namespace pw::router