pw_rpc: Reorganize Python console_tools module

Split console_tools.py into a console_tools subpackage with watchdog and
console modules.

Change-Id: I0a4ee25b747a2352516ca25eba57b61a087246eb
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/39621
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Reviewed-by: Ewout van Bekkum <ewout@google.com>
Reviewed-by: Alexei Frolov <frolv@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
diff --git a/pw_rpc/py/BUILD.gn b/pw_rpc/py/BUILD.gn
index 4c49985..a8cfc36 100644
--- a/pw_rpc/py/BUILD.gn
+++ b/pw_rpc/py/BUILD.gn
@@ -30,7 +30,9 @@
     "pw_rpc/codegen.py",
     "pw_rpc/codegen_nanopb.py",
     "pw_rpc/codegen_raw.py",
-    "pw_rpc/console_tools.py",
+    "pw_rpc/console_tools/__init__.py",
+    "pw_rpc/console_tools/console.py",
+    "pw_rpc/console_tools/watchdog.py",
     "pw_rpc/descriptors.py",
     "pw_rpc/ids.py",
     "pw_rpc/packets.py",
diff --git a/pw_rpc/py/pw_rpc/console_tools.py b/pw_rpc/py/pw_rpc/console_tools.py
deleted file mode 100644
index 1b70238..0000000
--- a/pw_rpc/py/pw_rpc/console_tools.py
+++ /dev/null
@@ -1,137 +0,0 @@
-# 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.
-"""Utilities for building tools that interact with pw_rpc."""
-
-import inspect
-import textwrap
-import threading
-from typing import Any, Callable, Dict, Iterable
-
-from pw_rpc.descriptors import Method
-
-
-class Watchdog:
-    """Simple class that times out unless reset.
-
-    This class could be used, for example, to track a device's connection state
-    for devices that send a periodic heartbeat packet.
-    """
-    def __init__(self,
-                 on_reset: Callable[[], Any],
-                 on_expiration: Callable[[], Any],
-                 while_expired: Callable[[], Any] = lambda: None,
-                 timeout_s: float = 1,
-                 expired_timeout_s: float = None):
-        """Creates a watchdog; start() must be called to start it.
-
-        Args:
-          on_reset: Function called when the watchdog is reset after having
-              expired.
-          on_expiration: Function called when the timeout expires.
-          while_expired: Function called repeatedly while the watchdog is
-              expired.
-          timeout_s: If reset() is not called for timeout_s, the watchdog
-              expires and calls the on_expiration callback.
-          expired_timeout_s: While expired, the watchdog calls the
-              while_expired callback every expired_timeout_s.
-        """
-        self._on_reset = on_reset
-        self._on_expiration = on_expiration
-        self._while_expired = while_expired
-
-        self.timeout_s = timeout_s
-
-        if expired_timeout_s is None:
-            self.expired_timeout_s = self.timeout_s * 10
-        else:
-            self.expired_timeout_s = expired_timeout_s
-
-        self.expired: bool = False
-        self._watchdog = threading.Timer(0, self._timeout_expired)
-
-    def start(self) -> None:
-        """Starts the watchdog; must be called for the watchdog to work."""
-        self._watchdog.cancel()
-        self._watchdog = threading.Timer(
-            self.expired_timeout_s if self.expired else self.timeout_s,
-            self._timeout_expired)
-        self._watchdog.daemon = True
-        self._watchdog.start()
-
-    def reset(self) -> None:
-        """Resets the timeout; calls the on_reset callback if expired."""
-        if self.expired:
-            self.expired = False
-            self._on_reset()
-
-        self.start()
-
-    def _timeout_expired(self) -> None:
-        if self.expired:
-            self._while_expired()
-        else:
-            self.expired = True
-            self._on_expiration()
-
-        self.start()
-
-
-_INDENT = '    '
-
-
-class CommandHelper:
-    """Used to implement a help command in an RPC console."""
-    @classmethod
-    def from_methods(cls,
-                     methods: Iterable[Method],
-                     header: str,
-                     footer: str = '') -> 'CommandHelper':
-        return cls({m.full_name: m for m in methods}, header, footer)
-
-    def __init__(self, methods: Dict[str, Any], header: str, footer: str = ''):
-        self._methods = methods
-        self.header = header
-        self.footer = footer
-
-    def help(self, item: Any = None) -> str:
-        """Returns a help string with a command or all commands listed."""
-
-        if item is None:
-            all_rpcs = '\n'.join(self._methods)
-            return (f'{self.header}\n\n'
-                    f'All commands:\n\n{textwrap.indent(all_rpcs, _INDENT)}'
-                    f'\n\n{self.footer}'.strip())
-
-        # If item is a string, find commands matching that.
-        if isinstance(item, str):
-            matches = {n: m for n, m in self._methods.items() if item in n}
-            if not matches:
-                return f'No matches found for {item!r}'
-
-            if len(matches) == 1:
-                name, method = next(iter(matches.items()))
-                return f'{name}\n\n{inspect.getdoc(method)}'
-
-            return f'Multiple matches for {item!r}:\n\n' + textwrap.indent(
-                '\n'.join(matches), _INDENT)
-
-        return inspect.getdoc(item) or f'No documentation for {item!r}.'
-
-    def __call__(self, item: Any = None) -> None:
-        """Prints the help string."""
-        print(self.help(item))
-
-    def __repr__(self) -> str:
-        """Returns the help, so foo and foo() are equivalent in a console."""
-        return self.help()
diff --git a/pw_rpc/py/pw_rpc/console_tools/__init__.py b/pw_rpc/py/pw_rpc/console_tools/__init__.py
new file mode 100644
index 0000000..e7a21bf
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/__init__.py
@@ -0,0 +1,17 @@
+# 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.
+"""Utilities for building tools that interact with pw_rpc."""
+
+from pw_rpc.console_tools.console import *
+from pw_rpc.console_tools.watchdog import Watchdog
diff --git a/pw_rpc/py/pw_rpc/console_tools/console.py b/pw_rpc/py/pw_rpc/console_tools/console.py
new file mode 100644
index 0000000..82637a0
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/console.py
@@ -0,0 +1,69 @@
+# 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.
+"""Utilities for creating an interactive console."""
+
+import inspect
+import textwrap
+from typing import Any, Dict, Iterable
+
+from pw_rpc.descriptors import Method
+
+_INDENT = '    '
+
+
+class CommandHelper:
+    """Used to implement a help command in an RPC console."""
+    @classmethod
+    def from_methods(cls,
+                     methods: Iterable[Method],
+                     header: str,
+                     footer: str = '') -> 'CommandHelper':
+        return cls({m.full_name: m for m in methods}, header, footer)
+
+    def __init__(self, methods: Dict[str, Any], header: str, footer: str = ''):
+        self._methods = methods
+        self.header = header
+        self.footer = footer
+
+    def help(self, item: Any = None) -> str:
+        """Returns a help string with a command or all commands listed."""
+
+        if item is None:
+            all_rpcs = '\n'.join(self._methods)
+            return (f'{self.header}\n\n'
+                    f'All commands:\n\n{textwrap.indent(all_rpcs, _INDENT)}'
+                    f'\n\n{self.footer}'.strip())
+
+        # If item is a string, find commands matching that.
+        if isinstance(item, str):
+            matches = {n: m for n, m in self._methods.items() if item in n}
+            if not matches:
+                return f'No matches found for {item!r}'
+
+            if len(matches) == 1:
+                name, method = next(iter(matches.items()))
+                return f'{name}\n\n{inspect.getdoc(method)}'
+
+            return f'Multiple matches for {item!r}:\n\n' + textwrap.indent(
+                '\n'.join(matches), _INDENT)
+
+        return inspect.getdoc(item) or f'No documentation for {item!r}.'
+
+    def __call__(self, item: Any = None) -> None:
+        """Prints the help string."""
+        print(self.help(item))
+
+    def __repr__(self) -> str:
+        """Returns the help, so foo and foo() are equivalent in a console."""
+        return self.help()
diff --git a/pw_rpc/py/pw_rpc/console_tools/py.typed b/pw_rpc/py/pw_rpc/console_tools/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/py.typed
diff --git a/pw_rpc/py/pw_rpc/console_tools/watchdog.py b/pw_rpc/py/pw_rpc/console_tools/watchdog.py
new file mode 100644
index 0000000..bcc9ffe
--- /dev/null
+++ b/pw_rpc/py/pw_rpc/console_tools/watchdog.py
@@ -0,0 +1,83 @@
+# 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.
+"""Simple watchdog class."""
+
+import threading
+from typing import Any, Callable
+
+
+class Watchdog:
+    """Simple class that times out unless reset.
+
+    This class could be used, for example, to track a device's connection state
+    for devices that send a periodic heartbeat packet.
+    """
+    def __init__(self,
+                 on_reset: Callable[[], Any],
+                 on_expiration: Callable[[], Any],
+                 while_expired: Callable[[], Any] = lambda: None,
+                 timeout_s: float = 1,
+                 expired_timeout_s: float = None):
+        """Creates a watchdog; start() must be called to start it.
+
+        Args:
+          on_reset: Function called when the watchdog is reset after having
+              expired.
+          on_expiration: Function called when the timeout expires.
+          while_expired: Function called repeatedly while the watchdog is
+              expired.
+          timeout_s: If reset() is not called for timeout_s, the watchdog
+              expires and calls the on_expiration callback.
+          expired_timeout_s: While expired, the watchdog calls the
+              while_expired callback every expired_timeout_s.
+        """
+        self._on_reset = on_reset
+        self._on_expiration = on_expiration
+        self._while_expired = while_expired
+
+        self.timeout_s = timeout_s
+
+        if expired_timeout_s is None:
+            self.expired_timeout_s = self.timeout_s * 10
+        else:
+            self.expired_timeout_s = expired_timeout_s
+
+        self.expired: bool = False
+        self._watchdog = threading.Timer(0, self._timeout_expired)
+
+    def start(self) -> None:
+        """Starts the watchdog; must be called for the watchdog to work."""
+        self._watchdog.cancel()
+        self._watchdog = threading.Timer(
+            self.expired_timeout_s if self.expired else self.timeout_s,
+            self._timeout_expired)
+        self._watchdog.daemon = True
+        self._watchdog.start()
+
+    def reset(self) -> None:
+        """Resets the timeout; calls the on_reset callback if expired."""
+        if self.expired:
+            self.expired = False
+            self._on_reset()
+
+        self.start()
+
+    def _timeout_expired(self) -> None:
+        if self.expired:
+            self._while_expired()
+        else:
+            self.expired = True
+            self._on_expiration()
+
+        self.start()