From ea0bd3124681582331ea414a95be6d3244a895f3 Mon Sep 17 00:00:00 2001 From: Noah Manneschmidt Date: Fri, 29 Mar 2024 13:07:22 -0700 Subject: [PATCH] Rebuild UI with react + jotai --- .../baconjs-npm-3.0.17-1a95474784.patch | 14 +++ index.html | 10 +- package.json | 2 + sdk/commands/config.ts | 7 +- sdk/index.ts | 12 ++ sdk/packet.ts | 2 +- sdk/utils.ts | 2 +- tsconfig.json | 2 +- ui/index.tsx | 17 +++ ui/pad-coms.ts | 78 ++++++++++++ ui/script.ts | 115 ------------------ ui/simple-pad.tsx | 4 +- ui/state.ts | 34 ++++++ ui/styles.css | 7 +- ui/ui.tsx | 58 +++++++++ yarn.lock | 31 +++++ 16 files changed, 262 insertions(+), 133 deletions(-) create mode 100644 .yarn/patches/baconjs-npm-3.0.17-1a95474784.patch create mode 100644 sdk/index.ts create mode 100644 ui/index.tsx create mode 100644 ui/pad-coms.ts delete mode 100644 ui/script.ts create mode 100644 ui/state.ts create mode 100644 ui/ui.tsx diff --git a/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch b/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch new file mode 100644 index 0000000..fe0e090 --- /dev/null +++ b/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch @@ -0,0 +1,14 @@ +diff --git a/package.json b/package.json +index 6df2387cbfaead99f6fa36a8d6a5b11900a05bb4..3b5586d74fb8f7312c64406dda8a8ef0ced929ac 100644 +--- a/package.json ++++ b/package.json +@@ -91,9 +91,5 @@ + }, + "main": "dist/Bacon.js", + "module": "./dist/Bacon.mjs", +- "exports": { +- "import": "./dist/Bacon.mjs", +- "require": "./dist/Bacon.js" +- }, + "types": "types/bacon.d.ts" + } diff --git a/index.html b/index.html index a15ba17..89cc41d 100644 --- a/index.html +++ b/index.html @@ -6,15 +6,9 @@ SMX Web Config Tool - + -

SMX WebUSB

- - - - -

-    

+    
diff --git a/package.json b/package.json index df3868e..21dd4e9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "license": "MIT", "dependencies": { "@nmann/struct-buffer": "^5.3.0", + "baconjs": "patch:baconjs@npm%3A3.0.17#~/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch", + "jotai": "^2.7.2", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/sdk/commands/config.ts b/sdk/commands/config.ts index c86e1dd..5cb9011 100644 --- a/sdk/commands/config.ts +++ b/sdk/commands/config.ts @@ -1,5 +1,5 @@ -import { StructBuffer, bits, uint8_t, uint16_t } from "@nmann/struct-buffer"; -import type { EachPanel } from "./inputs"; +import { StructBuffer, bits, uint16_t, uint8_t } from "@nmann/struct-buffer"; +import type { EachPanel } from "./inputs.ts"; type DecodedStruct = ReturnType; @@ -252,9 +252,8 @@ class Panel { /** * Convert a panels 4 sensors back into a 4-bit LSB byte * TODO: Determine if this ordering is actually correct - * @returns {Number} */ - toByte() { + toByte(): number { return (this.up ? 1 << 3 : 0) + (this.right ? 1 << 2 : 0) + (this.down ? 1 << 1 : 0) + (this.left ? 1 : 0); } } diff --git a/sdk/index.ts b/sdk/index.ts new file mode 100644 index 0000000..ca0bd5e --- /dev/null +++ b/sdk/index.ts @@ -0,0 +1,12 @@ +import { API_COMMAND } from "./api"; +import { process_packets, send_data } from "./packet"; + +export async function getDeviceInfo(dev: HIDDevice) { + await send_data(dev, [API_COMMAND.GET_DEVICE_INFO], true); + return process_packets(dev, true); +} + +export async function getStageConfig(dev: HIDDevice) { + await send_data(dev, [API_COMMAND.GET_CONFIG_V5], true); + return process_packets(dev, true); +} diff --git a/sdk/packet.ts b/sdk/packet.ts index b303a2e..aa86379 100644 --- a/sdk/packet.ts +++ b/sdk/packet.ts @@ -1,4 +1,4 @@ -import { pad_packet } from "./utils.js"; +import { pad_packet } from "./utils.ts"; /** * Gets the next report from the device matching a given reportId, diff --git a/sdk/utils.ts b/sdk/utils.ts index 61b3f32..ab24e8d 100644 --- a/sdk/utils.ts +++ b/sdk/utils.ts @@ -1,4 +1,4 @@ -import { MAX_PACKET_SIZE } from "./packet.js"; +import { MAX_PACKET_SIZE } from "./packet.ts"; /** * Pad incomming packet to `MAX_PACKET_SIZE` and convert to Uint8Array as a diff --git a/tsconfig.json b/tsconfig.json index 21e53c9..54cbc42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "jsx": "react-jsx", /* Linting */ - "strict": true, + "strict": true }, "include": ["ui/", "sdk/"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/ui/index.tsx b/ui/index.tsx new file mode 100644 index 0000000..5254225 --- /dev/null +++ b/ui/index.tsx @@ -0,0 +1,17 @@ +import * as Bacon from "baconjs"; +import { Provider } from "jotai"; +import { createRoot } from "react-dom/client"; + +import { uiState } from "./state.ts"; +import { UI } from "./ui.tsx"; + +Bacon.fromEvent(document, "DOMContentLoaded").onValue(async () => { + const rootEl = document.getElementById("root"); + if (!rootEl) return; + const reactRoot = createRoot(rootEl); + reactRoot.render( + + + , + ); +}); diff --git a/ui/pad-coms.ts b/ui/pad-coms.ts new file mode 100644 index 0000000..c786587 --- /dev/null +++ b/ui/pad-coms.ts @@ -0,0 +1,78 @@ +import { getDeviceInfo, getStageConfig } from "../sdk"; +import { SMX_USB_PRODUCT_ID, SMX_USB_VENDOR_ID } from "../sdk/api"; +import { SMXConfig } from "../sdk/commands/config"; +import { SMXDeviceInfo } from "../sdk/commands/data_info"; +import { devices$, nextStatusTextLine$, statusText$, uiState } from "./state"; + +// function formatDataForDisplay(data: DataView) { +// const len = data.byteLength; + +// // Read raw report into groups of 8 bytes. +// let str = ""; +// for (let i = 0; i !== len; ++i) { +// if (i !== 0 && i % 8 === 0) str += "\n"; + +// // biome-ignore lint/style/useTemplate: +// str += data.getUint8(i).toString(2).padStart(8, "0") + " "; +// } + +// // Read raw report into groups of 8 bytes of hex +// str += "\n\n"; +// for (let i = 0; i !== len; ++i) { +// if (i !== 0 && i % 8 === 0) str += "\n"; + +// // biome-ignore lint/style/useTemplate: +// str += data.getUint8(i).toString(16).padStart(2, "0") + " "; +// } + +// return `report:\n${str}`; +// } + +export async function promptSelectDevice() { + const devices = await navigator.hid.requestDevice({ + filters: [{ vendorId: SMX_USB_VENDOR_ID, productId: SMX_USB_PRODUCT_ID }], + }); + + if (!devices.length || !devices[0]) { + uiState.set(statusText$, "no device selected"); + return; + } + + await open_smx_device(devices[0]); +} + +export async function open_smx_device(dev: HIDDevice) { + if (!dev.opened) { + await dev.open(); + } + // Get the device info an find the player number + const packet = await getDeviceInfo(dev); + const device_info = new SMXDeviceInfo(packet); + uiState.set(devices$, (devices) => { + return { + ...devices, + [device_info.player]: dev, + }; + }); + uiState.set( + nextStatusTextLine$, + `status: device opened: ${dev.productName}:P${device_info.player}:${device_info.serial}`, + ); +} + +export async function requestConfig(dev: HIDDevice) { + const response = await getStageConfig(dev); + + console.log("Raw Bytes: ", response); + const smxconfig = new SMXConfig(response); + console.log("Decoded Config:", smxconfig.config); + + // Right now I just want to confirm that decoding and re-encoding gives back the same data + const encoded_config = smxconfig.encode(); + if (encoded_config) { + console.log("Encoded Config:", smxconfig.config); + const buf = new Uint8Array(encoded_config.buffer); + console.log("Encoded Bytes: ", buf); + console.log("Same Thing:", response.slice(2, -1).toString() === buf.toString()); + } +} diff --git a/ui/script.ts b/ui/script.ts deleted file mode 100644 index ed715f6..0000000 --- a/ui/script.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { API_COMMAND, SMX_USB_PRODUCT_ID, SMX_USB_VENDOR_ID } from "../sdk/api"; -import { nextReportCommand, process_packets, send_data } from "../sdk/packet"; - -import { SMXConfig } from "../sdk/commands/config.js"; -import { SMXDeviceInfo } from "../sdk/commands/data_info.js"; - -function displayData(data: DataView, targetEl: HTMLElement) { - const len = data.byteLength; - - // Read raw report into groups of 8 bytes. - let str = ""; - for (let i = 0; i !== len; ++i) { - if (i !== 0 && i % 8 === 0) str += "\n"; - - // biome-ignore lint/style/useTemplate: - str += data.getUint8(i).toString(2).padStart(8, "0") + " "; - } - - // Read raw report into groups of 8 bytes of hex - str += "\n\n"; - for (let i = 0; i !== len; ++i) { - if (i !== 0 && i % 8 === 0) str += "\n"; - - // biome-ignore lint/style/useTemplate: - str += data.getUint8(i).toString(16).padStart(2, "0") + " "; - } - - targetEl.innerText = `report:\n${str}`; -} - -async function getDeviceInfo(dev: HIDDevice) { - await send_data(dev, [API_COMMAND.GET_DEVICE_INFO], true); - return nextReportCommand(dev); -} - -async function getStageConfig(dev: HIDDevice) { - await send_data(dev, [API_COMMAND.GET_CONFIG_V5], true); - return process_packets(dev, true); - //return nextReportCommand(dev); -} - -const devices: Record = {}; -const status = document.getElementById("status"); - -async function open_smx_device(dev: HIDDevice) { - if (!dev.opened) { - await dev.open(); - } - // Get the device info an find the player number - await send_data(dev, [API_COMMAND.GET_DEVICE_INFO]); - process_packets(dev, true).then((packet) => { - const device_info = new SMXDeviceInfo(packet); - devices[device_info.player] = dev; - if (status) { - status.innerText += `status: device opened: ${dev.productName}:P${device_info.player}:${device_info.serial}\n`; - } - }); -} - -document.addEventListener("DOMContentLoaded", async () => { - const output = document.getElementById("output"); - const btn = document.getElementById("btn"); - const btnp1 = document.getElementById("btnp1"); - - if (!status || !output || !btn || !btnp1) { - return; - } - - if (!("hid" in navigator)) { - status.innerText = "HID API not supported, use Google Chrome or MS Edge browsers for this tool"; - return; - } - - const found_devices = await navigator.hid.getDevices(); - for (const device of found_devices) { - console.log(`Found device: ${device.productName}`); - await open_smx_device(device); - } - - btn.addEventListener("click", async () => { - const dev = await navigator.hid - .requestDevice({ - filters: [{ vendorId: SMX_USB_VENDOR_ID, productId: SMX_USB_PRODUCT_ID }], - }) - .then((devs) => devs[0]); - - if (!dev) { - status.innerText = "no device selected"; - return; - } - - await open_smx_device(dev); - - // add auto-updating UI responding to basic input reports - // Commenting out for now. - // document.body.prepend(new SimplePadElement(dev)); - }); - - btnp1.addEventListener("click", async () => { - getStageConfig(devices[1]).then((response) => { - console.log("Raw Bytes: ", response); - const smxconfig = new SMXConfig(response); - console.log("Decoded Config:", smxconfig.config); - - // Right now I just want to confirm that decoding and re-encoding gives back the same data - const encoded_config = smxconfig.encode(); - if (encoded_config) { - console.log("Encoded Config:", smxconfig.config); - const buf = new Uint8Array(encoded_config.buffer); - console.log("Encoded Bytes: ", buf); - console.log("Same Thing:", response.slice(2, -1).toString() === buf.toString()); - } - }); - }); -}); diff --git a/ui/simple-pad.tsx b/ui/simple-pad.tsx index 198e0ca..1c8ed24 100644 --- a/ui/simple-pad.tsx +++ b/ui/simple-pad.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { StageInputs } from "../sdk/commands/inputs"; -import { HID_REPORT_INPUT_STATE } from "../sdk/packet"; +import { StageInputs } from "../sdk/commands/inputs.ts"; +import { HID_REPORT_INPUT_STATE } from "../sdk/packet.ts"; interface Props { dev: HIDDevice; diff --git a/ui/state.ts b/ui/state.ts new file mode 100644 index 0000000..56a8bc3 --- /dev/null +++ b/ui/state.ts @@ -0,0 +1,34 @@ +import { atom, createStore } from "jotai"; + +export const browserSupported = "hid" in navigator; + +/** actually holds the state of each atom */ +export const uiState = createStore(); + +/** backing atom of all known devices */ +export const devices$ = atom>({}); + +/** current p1 pad, derived from devices$ above */ +export const p1Dev$ = atom( + (get) => get(devices$)[1], + (_, set, dev: HIDDevice | undefined) => { + set(devices$, (prev) => ({ ...prev, [1]: dev })); + }, +); + +/** current p2 pad, derived from devices$ above */ +export const p2Dev$ = atom( + (get) => get(devices$)[2], + (_, set, dev: HIDDevice | undefined) => { + set(devices$, (prev) => ({ ...prev, [2]: dev })); + }, +); + +export const statusText$ = atom( + browserSupported + ? "no device connected" + : "HID API not supported, use Google Chrome or MS Edge browsers for this tool", +); + +/** write-only atom. write to this to append a line to statusText */ +export const nextStatusTextLine$ = atom(null, (_, set, line: string) => set(statusText$, (prev) => prev + line)); diff --git a/ui/styles.css b/ui/styles.css index 40998a9..01788b8 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -57,7 +57,12 @@ input { box-shadow: 2px 2px 1px rgba(0, 0, 0, 0.4); } -button:active { +button:disabled { + background: #ececec; + cursor: not-allowed; +} + +button:active:not(:disabled) { transform: translateY(5px); box-shadow: 0px 0px 0px rgba(0, 0, 0, 1); } diff --git a/ui/ui.tsx b/ui/ui.tsx new file mode 100644 index 0000000..b01acc5 --- /dev/null +++ b/ui/ui.tsx @@ -0,0 +1,58 @@ +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; + +import { + open_smx_device, + promptSelectDevice, + requestConfig, +} from "./pad-coms.ts"; +import { browserSupported, p1Dev$, statusText$ } from "./state.ts"; + +export function UI() { + useEffect(() => { + // once, on load, get paired devices and attempt connection + if (browserSupported) { + navigator.hid.getDevices().then((devices) => + devices.map((device) => { + console.log(`Found device: ${device.productName}`); + open_smx_device(device); + }), + ); + } + }, []); + + return ( + <> +

SMX Web Config

+ + + + ); +} + +function PickDeviceButton() { + return ( + + ); +} + +function FetchConfigButton() { + const device = useAtomValue(p1Dev$); + const handleClick = device ? () => requestConfig(device) : undefined; + return ( + + ); +} + +function StatusDisplay() { + const statusText = useAtomValue(statusText$); + return
{statusText}
; +} diff --git a/yarn.lock b/yarn.lock index 58acff6..2556315 100644 --- a/yarn.lock +++ b/yarn.lock @@ -873,6 +873,20 @@ __metadata: languageName: node linkType: hard +"baconjs@npm:3.0.17": + version: 3.0.17 + resolution: "baconjs@npm:3.0.17" + checksum: 10c0/6e272005ea31947df0b061ef0a9da5039b5083dc4f142d699f238cec6352f6c6757d8453ca27df485d53afdd35c6a620ad0c5048beedb6593939841fb3abd3a4 + languageName: node + linkType: hard + +"baconjs@patch:baconjs@npm%3A3.0.17#~/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch": + version: 3.0.17 + resolution: "baconjs@patch:baconjs@npm%3A3.0.17#~/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch::version=3.0.17&hash=42d06f" + checksum: 10c0/102f286a7eba130866e6b771dba740aa8f1d870c0d1eb2b876c14bc99fe17f0838a1c37374d2d157e68b42e6c69480be797579f07b61ce2d73fe22a90819def8 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1367,6 +1381,21 @@ __metadata: languageName: node linkType: hard +"jotai@npm:^2.7.2": + version: 2.7.2 + resolution: "jotai@npm:2.7.2" + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/247f6a38ded40e92dd814c0b7b3ee25be7890af05342b28c952fe5bc3543a3b3c4a347914113d95cac534924ac0ad5416981b0a02ed16dcb5818be6f5df4e3bd + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -1846,6 +1875,8 @@ __metadata: "@types/react-dom": "npm:^18.2.22" "@types/w3c-web-hid": "npm:1.0.6" "@vitejs/plugin-react": "npm:^4.2.1" + baconjs: "patch:baconjs@npm%3A3.0.17#~/.yarn/patches/baconjs-npm-3.0.17-1a95474784.patch" + jotai: "npm:^2.7.2" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" typescript: "npm:^5.4.3"