pw_hdlc_lite: HdlcRpcClient class

Provide a class that configures a pw_rpc.Client for use over HDLC.

Change-Id: I191b9ef85926b11421c870f9d914b6a111dd8795
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/18461
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Wyatt Hepler <hepler@google.com>
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py b/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py
index 1246ff6..4388dff 100644
--- a/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py
+++ b/pw_hdlc_lite/py/pw_hdlc_lite/rpc.py
@@ -15,14 +15,18 @@
 
 import logging
 import os
+from pathlib import Path
+import sys
+import threading
 import time
-from typing import Any, Callable, NoReturn, BinaryIO
-
-import serial
+from types import ModuleType
+from typing import Any, BinaryIO, Callable, Iterable, List, NoReturn, Union
 
 from pw_hdlc_lite.decoder import FrameDecoder
 from pw_hdlc_lite.encoder import encode_information_frame
-from pw_rpc.client import Client
+import pw_rpc
+from pw_rpc import callback_client
+from pw_protobuf_compiler import python_protos
 
 _LOG = logging.getLogger(__name__)
 
@@ -38,7 +42,7 @@
     if delay_s:
 
         def slow_write(data: bytes) -> None:
-            """Slows down writes to support unbuffered serial."""
+            """Slows down writes in case unbuffered serial is in use."""
             for byte in data:
                 time.sleep(delay_s)
                 writer(bytes([byte]))
@@ -48,8 +52,8 @@
     return lambda data: writer(encode_information_frame(address, data))
 
 
-def read_and_process_data(rpc_client: Client,
-                          device: serial.Serial,
+def read_and_process_data(rpc_client: pw_rpc.Client,
+                          device: BinaryIO,
                           output: BinaryIO,
                           output_sep: bytes = os.linesep.encode(),
                           rpc_address: int = DEFAULT_ADDRESS) -> NoReturn:
@@ -73,3 +77,60 @@
             else:
                 _LOG.error('Unhandled frame for address %d: %s', frame.address,
                            frame.data.decoder(errors='replace'))
+
+
+_PathOrModule = Union[str, Path, ModuleType]
+
+
+class HdlcRpcClient:
+    """An RPC client configured to run over HDLC."""
+    def __init__(self,
+                 device: BinaryIO,
+                 proto_paths_or_modules: Iterable[_PathOrModule],
+                 output: BinaryIO = sys.stdout.buffer,
+                 channels: Iterable[pw_rpc.Channel] = None,
+                 client_impl: pw_rpc.client.ClientImpl = None):
+        """Creates an RPC client configured to communicate using HDLC.
+
+        Args:
+          device: serial.Serial (or any BinaryIO class) for reading/writing data
+          proto_paths_or_modules: paths to .proto files or proto modules
+          output: where to write "stdout" output from the device
+        """
+        self.device = device
+
+        proto_modules = []
+        proto_paths: List[Union[Path, str]] = []
+        for proto in proto_paths_or_modules:
+            if isinstance(proto, (Path, str)):
+                proto_paths.append(proto)
+            else:
+                proto_modules.append(proto)
+
+        proto_modules += python_protos.compile_and_import(proto_paths)
+
+        if channels is None:
+            channels = [pw_rpc.Channel(1, channel_output(device.write))]
+
+        if client_impl is None:
+            client_impl = callback_client.Impl()
+
+        self.client = pw_rpc.Client.from_modules(client_impl, channels,
+                                                 proto_modules)
+
+        # Start background thread that reads and processes RPC packets.
+        threading.Thread(target=read_and_process_data,
+                         daemon=True,
+                         args=(self.client, device, output)).start()
+
+    def rpcs(self, channel_id: int = None) -> pw_rpc.client.Services:
+        """Returns object for accessing services on the specified channel.
+
+        This skips some intermediate layers to make it simpler to invoke RPCs
+        from an HdlcRpcClient. If only one channel is in use, the channel ID is
+        not necessary.
+        """
+        if channel_id is None:
+            return next(iter(self.client.channels())).rpcs
+
+        return self.client.channel(channel_id).rpcs
diff --git a/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py b/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py
index 11bbf18..a4bfb96 100644
--- a/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py
+++ b/pw_hdlc_lite/py/pw_hdlc_lite/rpc_console.py
@@ -35,16 +35,12 @@
 import logging
 from pathlib import Path
 import sys
-import threading
 from typing import Collection, Iterable, Iterator, BinaryIO
 
 import IPython
 import serial
 
-from pw_hdlc_lite import rpc
-from pw_protobuf_compiler import python_protos
-from pw_rpc import callback_client, descriptors
-from pw_rpc.client import Client
+from pw_hdlc_lite.rpc import HdlcRpcClient
 
 _LOG = logging.getLogger(__name__)
 
@@ -82,46 +78,22 @@
             yield Path(file)
 
 
-def _start_ipython_terminal(device: serial.Serial, client: Client) -> None:
+def _start_ipython_terminal(client: HdlcRpcClient) -> None:
     """Starts an interactive IPython terminal with preset variables."""
     local_variables = dict(
         client=client,
-        device=device,
-        channel_client=client.channel(1),
-        rpcs=client.channel(1).rpcs,
+        channel_client=client.client.channel(1),
+        rpcs=client.client.channel(1).rpcs,
     )
-    module = argparse.Namespace()  # serves as an empty module
 
-    IPython.terminal.embed.InteractiveShellEmbed(banner1=__doc__).mainloop(
-        local_variables, module)
+    print(__doc__)  # Print the banner
+    IPython.terminal.embed.InteractiveShellEmbed().mainloop(
+        local_ns=local_variables, module=argparse.Namespace())
 
 
-def console(device: serial.Serial, protos: Iterable[Path],
-            output: BinaryIO) -> None:
-    """Starts an interactive RPC console for HDLC.
-
-    Args:
-      device: the serial device from which to read HDLC frames
-      protos: .proto files with RPC services
-    """
-
-    # Compile the proto files that define the RPC services to expose.
-    modules = python_protos.compile_and_import(protos)
-
-    # Set up the pw_rpc server with a single channel with ID 1.
-    channel = descriptors.Channel(1, rpc.channel_output(device.write))
-    client = Client.from_modules(callback_client.Impl(), [channel], modules)
-
-    # Start background thread that reads serial data and processes RPC packets.
-    threading.Thread(target=rpc.read_and_process_data,
-                     daemon=True,
-                     args=(client, device, output)).start()
-
-    _start_ipython_terminal(device, client)
-
-
-def _prepare_console(device: str, baudrate: int, proto_globs: Collection[str],
-                     output: BinaryIO) -> int:
+def console(device: str, baudrate: int, proto_globs: Collection[str],
+            output: BinaryIO) -> int:
+    """Starts an interactive RPC console for HDLC."""
     # argparse.FileType doesn't correctly handle '-' for binary files.
     if output is sys.stdout:
         output = sys.stdout.buffer
@@ -140,12 +112,13 @@
     _LOG.debug('Found %d .proto files found with %s', len(protos),
                ', '.join(proto_globs))
 
-    console(serial.Serial(device, baudrate), protos, output)
+    _start_ipython_terminal(
+        HdlcRpcClient(serial.Serial(device, baudrate), protos, output))
     return 0
 
 
 def main() -> int:
-    return _prepare_console(**vars(_parse_args()))
+    return console(**vars(_parse_args()))
 
 
 if __name__ == '__main__':
diff --git a/pw_rpc/py/pw_rpc/__init__.py b/pw_rpc/py/pw_rpc/__init__.py
index d7a70c7..1f1e72e 100644
--- a/pw_rpc/py/pw_rpc/__init__.py
+++ b/pw_rpc/py/pw_rpc/__init__.py
@@ -14,3 +14,4 @@
 """Package for calling Pigweed RPCs from Python."""
 
 from pw_rpc.client import Client
+from pw_rpc.descriptors import Channel
diff --git a/pw_rpc/py/pw_rpc/client.py b/pw_rpc/py/pw_rpc/client.py
index 755b521..5bd5e4a 100644
--- a/pw_rpc/py/pw_rpc/client.py
+++ b/pw_rpc/py/pw_rpc/client.py
@@ -126,7 +126,7 @@
         """
 
 
-class _MethodClients(descriptors.ServiceAccessor):
+class ServiceClient(descriptors.ServiceAccessor):
     """Navigates the methods in a service provided by a ChannelClient."""
     def __init__(self, rpcs: PendingRpcs, client_impl: ClientImpl,
                  channel: Channel, service: Service):
@@ -149,13 +149,13 @@
         return str(self._service)
 
 
-class _ServiceClients(descriptors.ServiceAccessor[_MethodClients]):
+class Services(descriptors.ServiceAccessor[ServiceClient]):
     """Navigates the services provided by a ChannelClient."""
     def __init__(self, rpcs: PendingRpcs, client_impl, channel: Channel,
                  services: Collection[Service]):
         super().__init__(
             {
-                s: _MethodClients(rpcs, client_impl, channel, s)
+                s: ServiceClient(rpcs, client_impl, channel, s)
                 for s in services
             },
             as_attrs='packages')
@@ -221,7 +221,7 @@
     """
     client: 'Client'
     channel: Channel
-    rpcs: _ServiceClients
+    rpcs: Services
 
     def method(self, method_name: str):
         """Returns a method client matching the given name.
@@ -272,8 +272,7 @@
         self._channels_by_id = {
             channel.id: ChannelClient(
                 self, channel,
-                _ServiceClients(self._rpcs, self._impl, channel,
-                                self.services))
+                Services(self._rpcs, self._impl, channel, self.services))
             for channel in channels
         }
 
@@ -281,6 +280,10 @@
         """Returns a ChannelClient, which is used to call RPCs on a channel."""
         return self._channels_by_id[channel_id]
 
+    def channels(self) -> Iterable[ChannelClient]:
+        """Accesses the ChannelClients in this client."""
+        return self._channels_by_id.values()
+
     def method(self, method_name: str) -> Method:
         """Returns a Method matching the given name.
 
diff --git a/pw_status/py/pw_status/__init__.py b/pw_status/py/pw_status/__init__.py
index 060ac51..6d22bda 100644
--- a/pw_status/py/pw_status/__init__.py
+++ b/pw_status/py/pw_status/__init__.py
@@ -34,3 +34,6 @@
     INTERNAL = 13
     UNAVAILABLE = 14
     DATA_LOSS = 15
+
+    def ok(self) -> bool:
+        return self is self.OK