pw_web: Use device API in webconsole and add it to REPL context

Change-Id: Ibdb17ad5b91691a679d2ff474e93cf21eedb7248
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/108140
Pigweed-Auto-Submit: Asad Memon <asadmemon@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_web/webconsole/common/device.ts b/pw_web/webconsole/common/device.ts
new file mode 100644
index 0000000..5ba735b
--- /dev/null
+++ b/pw_web/webconsole/common/device.ts
@@ -0,0 +1,30 @@
+// Copyright 2022 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 {Device} from "pigweedjs";
+import {createDefaultProtoCollection} from "./protos";
+
+/**
+ * Returns an instance of Device, ensures there is only one Device in
+ * current session.
+ *
+ * We do this to avoid multiple clients listening on single serial port.
+ */
+export default async function SingletonDevice(): Promise<Device> {
+  if ((window as any).device === undefined) {
+    const protoCollection = await createDefaultProtoCollection();
+    (window as any).device = new Device(protoCollection);
+  }
+  return (window as any).device;
+}
diff --git a/pw_web/webconsole/common/logService.ts b/pw_web/webconsole/common/logService.ts
new file mode 100644
index 0000000..2d49380
--- /dev/null
+++ b/pw_web/webconsole/common/logService.ts
@@ -0,0 +1,41 @@
+// Copyright 2022 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 {Device, pw_rpc} from "pigweedjs";
+type Client = pw_rpc.Client;
+
+function createDefaultRPCLogService(client: Client) {
+  const logService = client.channel()!
+    .methodStub('pw.log.Logs.Listen');
+
+  return logService;
+}
+
+export async function listenToDefaultLogService(
+  device: Device,
+  onFrame: (frame: Uint8Array) => void) {
+  const client = device.client;
+  // @ts-ignore
+  const logService: pw_rpc.ServerStreamingMethodStub = (createDefaultRPCLogService(client))!;
+  const request = new logService.method.responseType();
+  // @ts-ignore
+  const call = logService.invoke(request, (msg) => {
+    // @ts-ignore
+    msg.getEntriesList().forEach(entry => onFrame(entry.getMessage()));
+  });
+
+  return () => {
+    call.cancel();
+  };
+}
diff --git a/pw_web/webconsole/common/protos.ts b/pw_web/webconsole/common/protos.ts
new file mode 100644
index 0000000..3524ad5
--- /dev/null
+++ b/pw_web/webconsole/common/protos.ts
@@ -0,0 +1,20 @@
+// Copyright 2022 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.
+
+
+export async function createDefaultProtoCollection() {
+  // @ts-ignore
+  const ProtoCollection = await import("pigweedjs/protos/collection.umd");
+  return new ProtoCollection.ProtoCollection();
+}
diff --git a/pw_web/webconsole/common/utils.ts b/pw_web/webconsole/common/utils.ts
deleted file mode 100644
index 88dd583..0000000
--- a/pw_web/webconsole/common/utils.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright 2022 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 {pw_hdlc, pw_rpc, WebSerial} from "pigweedjs";
-type WebSerialTransport = WebSerial.WebSerialTransport
-type Client = pw_rpc.Client;
-const encoder = new pw_hdlc.Encoder();
-const decoder = new pw_hdlc.Decoder();
-const RPC_ADDRESS = 82;
-
-function sendPacket(
-  transport: WebSerialTransport,
-  packetBytes: Uint8Array
-) {
-  const hdlcBytes = encoder.uiFrame(RPC_ADDRESS, packetBytes);
-  transport.sendChunk(hdlcBytes);
-}
-
-async function createDefaultRPCLogClient(transport: WebSerialTransport): Promise<Client> {
-  // @ts-ignore
-  const ProtoCollection = await import("pigweedjs/protos/collection.umd");
-  const protoCollection = new ProtoCollection.ProtoCollection();
-  const channels = [
-    new pw_rpc.Channel(1, (bytes: Uint8Array) => {
-      sendPacket(transport, bytes);
-    }),
-  ];
-  // @ts-ignore
-  const client = pw_rpc.Client.fromProtoSet(channels, protoCollection);
-  return client;
-}
-
-function createDefaultRPCLogService(client: Client) {
-  const logService = client.channel()!
-    .methodStub('pw.log.Logs.Listen');
-
-  return logService;
-}
-
-export async function listenToDefaultLogService(
-  transport: WebSerialTransport,
-  onFrame: (frame: Uint8Array) => void) {
-  const client = await createDefaultRPCLogClient(transport);
-  const logService = (createDefaultRPCLogService(client))!;
-  const request = new logService.method.responseType();
-  // @ts-ignore
-  logService.invoke(request, (msg) => {
-    // @ts-ignore
-    msg.getEntriesList().forEach(entry => onFrame(entry.getMessage()));
-  });
-
-  const subscription = transport.chunks.subscribe((item) => {
-    const decoded = decoder.process(item);
-    let elem = decoded.next();
-    while (!elem.done) {
-      const frame = elem.value;
-      if (frame.address === RPC_ADDRESS) {
-        client.processPacket(frame.data);
-      }
-      elem = decoded.next();
-    }
-  });
-
-  return () => subscription.unsubscribe();
-}
diff --git a/pw_web/webconsole/components/connect.tsx b/pw_web/webconsole/components/connect.tsx
index 09f54aa..17961c3 100644
--- a/pw_web/webconsole/components/connect.tsx
+++ b/pw_web/webconsole/components/connect.tsx
@@ -14,21 +14,22 @@
 
 import Button from '@mui/material/Button';
 import {Alert} from '@mui/material';
-import {WebSerial} from "pigweedjs";
+import {Device, WebSerial} from "pigweedjs";
 import {useState} from 'react';
+import DeviceFactory from "../common/device";
 type WebSerialTransport = WebSerial.WebSerialTransport
 
 interface LogProps {
-  onConnection: (transport: WebSerialTransport) => void
+  onConnection: (device: Device) => void
 }
 
 export default function BtnConnect({onConnection}: LogProps) {
   const [connected, setConnected] = useState(false);
   if (connected) return (<Alert severity="success">Connected!</Alert>)
   return (<Button onClick={async () => {
-    const transport = new WebSerial.WebSerialTransport();
-    await transport.connect();
+    const device = await DeviceFactory();
+    await device.connect();
     setConnected(true);
-    onConnection(transport);
+    onConnection(device);
   }} variant="contained">Connect</Button>)
 }
diff --git a/pw_web/webconsole/components/log.tsx b/pw_web/webconsole/components/log.tsx
index 59ca455..4ad1efe 100644
--- a/pw_web/webconsole/components/log.tsx
+++ b/pw_web/webconsole/components/log.tsx
@@ -13,17 +13,16 @@
 // the License.
 
 import {useEffect, useRef, useState} from "react";
-import {WebSerial, pw_tokenizer} from "pigweedjs";
+import {pw_tokenizer, Device} from "pigweedjs";
 import {AutoSizer, Table, Column} from 'react-virtualized';
-import {listenToDefaultLogService} from "../common/utils";
+import {listenToDefaultLogService} from "../common/logService";
 import 'react-virtualized/styles.css';
 import styles from "../styles/log.module.css";
 
-type WebSerialTransport = WebSerial.WebSerialTransport
 type Detokenizer = pw_tokenizer.Detokenizer;
 
 interface LogProps {
-  transport: WebSerialTransport | undefined,
+  device: Device | undefined,
   tokenDB: string | undefined
 }
 
@@ -67,7 +66,7 @@
   "file": "File"
 }
 
-export default function Log({transport, tokenDB}: LogProps) {
+export default function Log({device, tokenDB}: LogProps) {
   const [logs, setLogs] = useState<LogEntry[]>([]);
   const [detokenizer, setDetokenizer] = useState<Detokenizer | null>(null);
   const logTable = useRef<Table | null>(null);
@@ -94,14 +93,14 @@
   }
 
   useEffect(() => {
-    if (transport) {
+    if (device) {
       let cleanupFn: () => void;
-      listenToDefaultLogService(transport, processFrame).then((unsub) => cleanupFn = unsub);
+      listenToDefaultLogService(device, processFrame).then((unsub) => cleanupFn = unsub);
       return () => {
         if (cleanupFn) cleanupFn();
       }
     }
-  }, [transport, detokenizer]);
+  }, [device, detokenizer]);
 
   useEffect(() => {
     if (tokenDB && tokenDB.length > 0) {
diff --git a/pw_web/webconsole/components/repl/index.tsx b/pw_web/webconsole/components/repl/index.tsx
index c77c5cd..a48e8cd 100644
--- a/pw_web/webconsole/components/repl/index.tsx
+++ b/pw_web/webconsole/components/repl/index.tsx
@@ -13,7 +13,7 @@
 // the License.
 
 import {useEffect, useState} from "react";
-import {WebSerial} from "pigweedjs";
+import {Device} from "pigweedjs";
 import {EditorView} from "codemirror"
 import {basicSetup} from "./basicSetup";
 import {javascript, javascriptLanguage} from "@codemirror/lang-javascript"
@@ -26,11 +26,10 @@
 import "xterm/css/xterm.css";
 import styles from "../../styles/repl.module.css";
 
-type WebSerialTransport = WebSerial.WebSerialTransport
 const isSSR = () => typeof window === 'undefined';
 
 interface ReplProps {
-  transport: WebSerialTransport | undefined
+  device: Device | undefined
 }
 
 const globalJavaScriptCompletions = javascriptLanguage.data.of({
@@ -74,13 +73,13 @@
   historyStorage = new LocalStorageArray();
 }
 
-export default function Repl({transport}: ReplProps) {
+export default function Repl({device}: ReplProps) {
   const [terminal, setTerminal] = useState<any>(null);
   const [codeEditor, setCodeEditor] = useState<EditorView | null>(null);
 
   useEffect(() => {
     let cleanupFns: {(): void; (): void;}[] = [];
-    if (!terminal && !isSSR() && transport) {
+    if (!terminal && !isSSR() && device) {
       const futureTerm = createTerminal(document.querySelector('#repl-log-container')!);
       futureTerm.then(async (term) => {
         cleanupFns.push(() => {
@@ -94,11 +93,11 @@
         cleanupFns.forEach(fn => fn());
       }
     }
-    else if (terminal && !transport) {
+    else if (terminal && !device) {
       terminal.dispose();
       setTerminal(null);
     }
-  }, [transport]);
+  }, [device]);
 
   useEffect(() => {
     if (!terminal) return;
diff --git a/pw_web/webconsole/package.json b/pw_web/webconsole/package.json
index b5ff44a..77cd836 100644
--- a/pw_web/webconsole/package.json
+++ b/pw_web/webconsole/package.json
@@ -18,7 +18,7 @@
     "@mui/material": "^5.9.3",
     "codemirror": "^6.0.1",
     "next": "12.2.3",
-    "pigweedjs": "../../",
+    "pigweedjs": "file:../../",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-virtualized": "^9.22.3",
diff --git a/pw_web/webconsole/pages/index.tsx b/pw_web/webconsole/pages/index.tsx
index 349efae..2086687 100644
--- a/pw_web/webconsole/pages/index.tsx
+++ b/pw_web/webconsole/pages/index.tsx
@@ -19,12 +19,12 @@
 import Repl from "../components/repl";
 import Connect from "../components/connect";
 import BtnUploadDB from '../components/uploadDb';
-import {WebSerial} from "pigweedjs";
+import {WebSerial, Device} from "pigweedjs";
 import {useState} from 'react';
 type WebSerialTransport = WebSerial.WebSerialTransport
 
 const Home: NextPage = () => {
-  const [transport, setTransport] = useState<WebSerialTransport | undefined>(undefined);
+  const [device, setDevice] = useState<Device | undefined>(undefined);
   const [tokenDB, setTokenDB] = useState("");
   return (
     <div className={styles.container}>
@@ -37,8 +37,8 @@
       <main className={styles.main}>
         <div className={styles.toolbar}>
           <span className={styles.logo}><span>Pigweed</span> Web Console</span>
-          <Connect onConnection={(transport) => {
-            setTransport(transport);
+          <Connect onConnection={(device) => {
+            setDevice(device);
           }} />
           <BtnUploadDB onUpload={(db) => {
             setTokenDB(db);
@@ -47,10 +47,10 @@
 
         <div className={styles.grid}>
           <div>
-            <Log transport={transport} tokenDB={tokenDB}></Log>
+            <Log device={device} tokenDB={tokenDB}></Log>
           </div>
           <div>
-            <Repl transport={transport}></Repl>
+            <Repl device={device}></Repl>
           </div>
         </div>
       </main>