Skip to content

Commit

Permalink
Webserial config
Browse files Browse the repository at this point in the history
  • Loading branch information
nsfm committed Dec 6, 2022
1 parent 46f4537 commit a852844
Show file tree
Hide file tree
Showing 6 changed files with 1,209 additions and 792 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/styled-components": "^5.1.26",
"@types/w3c-web-serial": "^1.0.3",
"@types/zdog": "^1.1.2",
"barrelsby": "^2.5.1",
"react-is": ">=16.8.0",
Expand Down
33 changes: 19 additions & 14 deletions src/Noskop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import styled from "styled-components";
import { MouseEvent } from "react";
import { FullScreen, useFullScreenHandle } from "react-full-screen";

import { SerialProvider } from "./Serial";
import { useConfig } from "./config";
import { Reticle, ControllerConnection, Inventory } from "./hud";
import { Objective } from "./mechanics";
import { useCamera } from "./Camera";
import { SerialConfig } from "./SerialConfig";
import { ControllerContext, controller } from "./Controller";

const AppContainer = styled.div`
Expand Down Expand Up @@ -42,20 +44,23 @@ export const Noskop = () => {

return (
<ControllerContext.Provider value={controller}>
<FullScreen handle={fullscreen}>
<AppContainer className="AppContainer">
<Camera>
<Reticle />
<ControllerConnection />
<Inventory />
<ConfigToggle />
</Camera>
<ConfigOverlay>
<CameraConfig />
<Objective />
</ConfigOverlay>
</AppContainer>
</FullScreen>
<SerialProvider>
<FullScreen handle={fullscreen}>
<AppContainer className="AppContainer">
<Camera>
<Reticle />
<ControllerConnection />
<Inventory />
<ConfigToggle />
</Camera>
<ConfigOverlay>
<CameraConfig />
<SerialConfig />
<Objective />
</ConfigOverlay>
</AppContainer>
</FullScreen>
</SerialProvider>
</ControllerContext.Provider>
);
};
253 changes: 253 additions & 0 deletions src/Serial.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
// Taken from: https://gist.github.com/joshpensky/426d758c5779ac641d1d09f9f5894153
// Thank you, Josh
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useRef,
useState,
} from "react";

// RESOURCES:
// https://web.dev/serial/
// https://reillyeon.github.io/serial/#onconnect-attribute-0
// https://codelabs.developers.google.com/codelabs/web-serial

export type PortState = "closed" | "closing" | "open" | "opening";

export type SerialMessage = {
value: string;
timestamp: number;
};

type SerialMessageCallback = (message: SerialMessage) => void;

export interface SerialContextValue {
canUseSerial: boolean;
hasTriedAutoconnect: boolean;
portState: PortState;
port?: SerialPort;
connect(): Promise<boolean>;
disconnect(): void;
subscribe(callback: SerialMessageCallback): () => void;
}
export const SerialContext = createContext<SerialContextValue>({
canUseSerial: false,
hasTriedAutoconnect: false,
connect: () => Promise.resolve(false),
disconnect: () => {},
portState: "closed",
subscribe: () => () => {},
});

export const useSerial = () => useContext(SerialContext);

interface SerialProviderProps {}
export const SerialProvider = ({
children,
}: PropsWithChildren<SerialProviderProps>) => {
const [canUseSerial] = useState(() => "serial" in navigator);

const [portState, setPortState] = useState<PortState>("closed");
const [hasTriedAutoconnect, setHasTriedAutoconnect] = useState(false);
const [hasManuallyDisconnected, setHasManuallyDisconnected] = useState(false);

const portRef = useRef<SerialPort | null>(null);
const readerRef = useRef<ReadableStreamDefaultReader | null>(null);
const readerClosedPromiseRef = useRef<Promise<void>>(Promise.resolve());

const currentSubscriberIdRef = useRef<number>(0);
const subscribersRef = useRef<Map<number, SerialMessageCallback>>(new Map());
/**
* Subscribes a callback function to the message event.
*
* @param callback the callback function to subscribe
* @returns an unsubscribe function
*/
const subscribe = (callback: SerialMessageCallback) => {
const id = currentSubscriberIdRef.current;
subscribersRef.current.set(id, callback);
currentSubscriberIdRef.current++;

return () => {
subscribersRef.current.delete(id);
};
};

/**
* Reads from the given port until it's been closed.
*
* @param port the port to read from
*/
const readUntilClosed = async (port: SerialPort) => {
if (port.readable) {
const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
readerRef.current = textDecoder.readable.getReader();

try {
while (true) {
const { value, done } = await readerRef.current.read();
if (done) {
break;
}
const timestamp = Date.now();
Array.from(subscribersRef.current).forEach(([name, callback]) => {
callback({ value, timestamp });
});
}
} catch (error) {
console.error(error);
} finally {
readerRef.current.releaseLock();
}

await readableStreamClosed.catch(() => {}); // Ignore the error
}
};

/**
* Attempts to open the given port.
*/
const openPort = async (port: SerialPort) => {
try {
await port.open({ baudRate: 9600 });
portRef.current = port;
setPortState("open");
setHasManuallyDisconnected(false);
} catch (error) {
setPortState("closed");
console.error("Could not open port");
}
};

const manualConnectToPort = async () => {
if (canUseSerial && portState === "closed") {
setPortState("opening");
const filters = [
// Can identify the vendor and product IDs by plugging in the device and visiting: chrome://device-log/
// the IDs will be labeled `vid` and `pid`, respectively
{
usbVendorId: 0x1a86,
usbProductId: 0x7523,
},
];
try {
const port = await navigator.serial.requestPort({ filters });
await openPort(port);
return true;
} catch (error) {
setPortState("closed");
console.error("User did not select port");
}
}
return false;
};

const autoConnectToPort = async () => {
if (canUseSerial && portState === "closed") {
setPortState("opening");
const availablePorts = await navigator.serial.getPorts();
if (availablePorts.length) {
const port = availablePorts[0];
await openPort(port);
return true;
} else {
setPortState("closed");
}
setHasTriedAutoconnect(true);
}
return false;
};

const manualDisconnectFromPort = async () => {
if (canUseSerial && portState === "open") {
const port = portRef.current;
if (port) {
setPortState("closing");

// Cancel any reading from port
readerRef.current?.cancel();
await readerClosedPromiseRef.current;
readerRef.current = null;

// Close and nullify the port
await port.close();
portRef.current = null;

// Update port state
setHasManuallyDisconnected(true);
setHasTriedAutoconnect(false);
setPortState("closed");
}
}
};

/**
* Event handler for when the port is disconnected unexpectedly.
*/
const onPortDisconnect = async () => {
// Wait for the reader to finish it's current loop
await readerClosedPromiseRef.current;
// Update state
readerRef.current = null;
readerClosedPromiseRef.current = Promise.resolve();
portRef.current = null;
setHasTriedAutoconnect(false);
setPortState("closed");
};

// Handles attaching the reader and disconnect listener when the port is open
useEffect(() => {
const port = portRef.current;
if (portState === "open" && port) {
// When the port is open, read until closed
const aborted = { current: false };
readerRef.current?.cancel();
readerClosedPromiseRef.current.then(() => {
if (!aborted.current) {
readerRef.current = null;
readerClosedPromiseRef.current = readUntilClosed(port);
}
});

// Attach a listener for when the device is disconnected
navigator.serial.addEventListener("disconnect", onPortDisconnect);

return () => {
aborted.current = true;
navigator.serial.removeEventListener("disconnect", onPortDisconnect);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [portState]);

// Tries to auto-connect to a port, if possible
useEffect(() => {
if (
canUseSerial &&
!hasManuallyDisconnected &&
!hasTriedAutoconnect &&
portState === "closed"
) {
autoConnectToPort();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUseSerial, hasManuallyDisconnected, hasTriedAutoconnect, portState]);

return (
<SerialContext.Provider
value={{
canUseSerial,
hasTriedAutoconnect,
subscribe,
portState,
connect: manualConnectToPort,
disconnect: manualDisconnectFromPort,
}}
>
{children}
</SerialContext.Provider>
);
};
32 changes: 32 additions & 0 deletions src/SerialConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Menu, MenuItem, MenuDivider, FormGroup } from "@blueprintjs/core";

import { useSerial } from "./Serial";

export const SerialConfig = () => {
const { canUseSerial, hasTriedAutoconnect, connect, disconnect, portState } =
useSerial();

return (
<FormGroup>
<Menu>
<MenuDivider title="serial" />
<MenuItem
text={`webserial available: ${canUseSerial ? "yes" : "no"}`}
icon={canUseSerial ? "endorsed" : "cross-circle"}
intent={canUseSerial ? undefined : "warning"}
/>
<MenuItem
hidden={!canUseSerial}
text={`port status: ${portState}${
hasTriedAutoconnect ? " (auto-connect failed)" : ""
}`}
icon={portState === "open" ? "heart" : "heart-broken"}
intent={portState === "open" ? "primary" : "warning"}
>
<MenuItem text={"connect"} icon={"plus"} onClick={connect} />
<MenuItem text={"disconnect"} icon={"minus"} onClick={disconnect} />
</MenuItem>
</Menu>
</FormGroup>
);
};
4 changes: 1 addition & 3 deletions src/hud/ControllerConnection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ export const ControllerConnection = () => {
x: (Date.now() / 2000) % Math.PI,
});
}, 1000 / 30);
controller.connection.on("change", ({ state }) => {
setConnected(state);
});
controller.connection.on("change", ({ state }) => setConnected(state));
}, []);

const [state] = useState<ControllerConnectionState>({
Expand Down
Loading

0 comments on commit a852844

Please sign in to comment.