diff --git a/sdk/api.ts b/sdk/api.ts index 59905cb..10d318e 100644 --- a/sdk/api.ts +++ b/sdk/api.ts @@ -1,17 +1,21 @@ const encoder = new TextEncoder(); +export function char2byte(c: string): number { + return encoder.encode(c)[0]; +} + export const API_COMMAND = { - GET_DEVICE_INFO: encoder.encode("i")[0], - GET_CONFIG: encoder.encode("g")[0], - GET_CONFIG_V5: encoder.encode("G")[0], - WRITE_CONFIG: encoder.encode("w")[0], - WRITE_CONFIG_V5: encoder.encode("W")[0], - FACTORY_RESET: encoder.encode("f")[0], - SET_LIGHT_STRIP: encoder.encode("L")[0], - FORCE_RECALIBRATION: encoder.encode("C")[0], - GET_SENSOR_TEST_DATA: encoder.encode("y")[0], - SET_SERIAL_NUMBERS: encoder.encode("s")[0], - SET_PANEL_TEST_MODE: encoder.encode("t")[0], + GET_DEVICE_INFO: char2byte("i"), + GET_CONFIG: char2byte("g"), + GET_CONFIG_V5: char2byte("G"), + WRITE_CONFIG: char2byte("w"), + WRITE_CONFIG_V5: char2byte("W"), + FACTORY_RESET: char2byte("f"), + SET_LIGHT_STRIP: char2byte("L"), + FORCE_RECALIBRATION: char2byte("C"), + GET_SENSOR_TEST_DATA: char2byte("y"), + SET_SERIAL_NUMBERS: char2byte("s"), + SET_PANEL_TEST_MODE: char2byte("t"), }; export const SMX_USB_VENDOR_ID = 0x2341; diff --git a/sdk/commands/config.ts b/sdk/commands/config.ts index 5cb9011..5acf043 100644 --- a/sdk/commands/config.ts +++ b/sdk/commands/config.ts @@ -1,7 +1,7 @@ -import { StructBuffer, bits, uint16_t, uint8_t } from "@nmann/struct-buffer"; +import { StructBuffer, bits, uint8_t, uint16_t } from "@nmann/struct-buffer"; import type { EachPanel } from "./inputs.ts"; -type DecodedStruct = ReturnType; +export type DecodedStruct = ReturnType; /** * Each FSR panel has 4 sensors. Make read/write easier by @@ -102,7 +102,14 @@ const step_colors_t = new StructBuffer("step_colors_t", { down_right: rgb_t, }); -const configShape = { +/** + * The configuration for a connected controller. This can be retrieved with SMX_GetConfig + * and modified with SMX_SetConfig. + * + * The order and packing of this struct corresponds to the configuration packet sent to + * the master controller, so it must not be changed. + */ +export const smx_config_t = new StructBuffer("smx_config_t", { /** * The firmware version of the master controller. Where supported (version 2 and up), this * will always read back the firmware version. This will default to 0xFF on version 1. @@ -218,16 +225,7 @@ const configShape = { * Applications should leave any data in here unchanged when setting the Config. */ padding: uint8_t[49], -}; - -/** - * The configuration for a connected controller. This can be retrieved with SMX_GetConfig - * and modified with SMX_SetConfig. - * - * The order and packing of this struct corresponds to the configuration packet sent to - * the master controller, so it must not be changed. - */ -export const smx_config_t = new StructBuffer("smx_config_t", configShape); +}); /** * Class to represent all 4 sensors on a panel. diff --git a/sdk/commands/data_info.ts b/sdk/commands/data_info.ts index ed447cd..9f86335 100644 --- a/sdk/commands/data_info.ts +++ b/sdk/commands/data_info.ts @@ -1,11 +1,11 @@ -import { StructBuffer, char, uint16_t, uint8_t } from "@nmann/struct-buffer"; +import { StructBuffer, char, uint8_t, uint16_t } from "@nmann/struct-buffer"; const data_info_packet_t = new StructBuffer("data_info_packet_t", { - // Always 'I' + /** Always 'I' */ cmd: char, // Not Used packet_size: uint8_t, - // '0' for P1, '1' for P2 (Note there are the characters '0' and '1', not the numbers 0 and 1) + /** '0' for P1, '1' for P2 (Note these are the characters '0' and '1', not the numbers 0 and 1) */ player: char, // Unused and Unknown unused2: char, @@ -18,7 +18,7 @@ const data_info_packet_t = new StructBuffer("data_info_packet_t", { }); export class SMXDeviceInfo { - serial = new Uint8Array(16); + serial = ""; firmware_version = 0; player = 0; @@ -30,7 +30,7 @@ export class SMXDeviceInfo { const info_packet = data_info_packet_t.decode(data, true); this.player = Number.parseInt(String.fromCharCode(info_packet.player)) + 1; - this.serial = info_packet.serial.map((x) => x.toString(16).toUpperCase()).join(""); + this.serial = info_packet.serial.map((x) => `00${x.toString(16).toUpperCase()}`.slice(-2)).join(""); this.firmware_version = info_packet.firmware_version; } } diff --git a/sdk/commands/inputs.ts b/sdk/commands/inputs.ts index b2e951f..8e4491c 100644 --- a/sdk/commands/inputs.ts +++ b/sdk/commands/inputs.ts @@ -1,5 +1,12 @@ import { bitFields, uint16_t } from "@nmann/struct-buffer"; +export type EachSensor = { + up: T; + right: T; + down: T; + left: T; +}; + export type EachPanel = { up_left: T; up: T; @@ -12,6 +19,7 @@ export type EachPanel = { down_right: T; }; +// TODO: This should be a bits object probably export const StageInputs = bitFields(uint16_t, { up_left: 1, up: 1, diff --git a/sdk/commands/sensor_test.ts b/sdk/commands/sensor_test.ts new file mode 100644 index 0000000..07d55a3 --- /dev/null +++ b/sdk/commands/sensor_test.ts @@ -0,0 +1,229 @@ +import { StructBuffer, bitFields, bits, bool, int16_t, uint8_t, uint16_t } from "@nmann/struct-buffer"; +import { API_COMMAND, char2byte } from "../api"; +import type { DecodedStruct } from "./config"; +import type { EachPanel, EachSensor } from "./inputs"; + +/** + * Sensor Test Mode values the stages expect + */ +export const SENSOR_TEST_MODE = { + /** Actual 0 value */ + OFF: 0, + + /** Return the raw uncalibrated value of each sensor */ + UNCALIBRATED_VALUES: char2byte("0"), + + /** Return the calibrated value of each sensor */ + CALIBRATED_VALUES: char2byte("1"), + + /** Return the sensor noise value */ + NOISE: char2byte("2"), + + /** Return the sensor tare value */ + TARE: char2byte("3"), +}; + +/** + * The first byte of the test mode detail data contains + * 3 signal bits, 4 bits showing if the sensor has a fault, + * and one dummy bit. + * + * Valid test data will always have sig1 = 0, sig2 = 1, and sig3 = 0 + */ +const sig_bad_t = bits(uint8_t, { + sig1: 0, + sig2: 1, + sig3: 2, + bad_sensor_0: 3, + bad_sensor_1: 4, + bad_sensor_2: 5, + bad_sensor_3: 6, + dummy: 7, +}); + +/** + * The last byte of the test mode detail data contains + * the value of the panels dip switch, as well as 4 bits showing + * if any sensor has the wrong jumper set. + * + * The name `bad_sensor_dip_x` is taken from the source, and feels like + * kind of a misnomer. + */ +const dips_t = bitFields(uint8_t, { + dip: 4, + bad_sensor_dip_0: 1, + bad_sensor_dip_1: 1, + bad_sensor_dip_2: 1, + bad_sensor_dip_3: 1, +}); + +/** + * Intermediate test mode detail data. + * Contains the `sig_bad_t` data as defined above, + * 4 16-bit signed integers for the actual values of each sensor, + * and the `dips_t` data as defined above. + * + * These values are then used to create the `SMXPanelTestData` for each panel. + */ +const detail_data_t = new StructBuffer("detail_data_t", { + sig_bad: sig_bad_t, + sensors: int16_t[4], + dips: dips_t, +}); + +/** + * This class represents the results of an SensorTestData request for a single + * panel. + */ +class SMXPanelTestData { + have_data_from_panel: boolean; + sensor_level: EachSensor = { + up: 0, + right: 0, + down: 0, + left: 0, + }; + bad_sensor_input: EachSensor = { + up: false, + right: false, + down: false, + left: false, + }; + dip_switch_value = -1; + bad_jumper: EachSensor = { + up: false, + right: false, + down: false, + left: false, + }; + + constructor(data: DecodedStruct) { + /** + * Check the header. this is always `false true false` to identify it as a response, + * and not as random steps from the player. + */ + if (data.sig_bad.sig1 || !data.sig_bad.sig2 || data.sig_bad.sig3) { + this.have_data_from_panel = false; + return; + } + + this.have_data_from_panel = true; + + /** + * These bits are true if that sensor's most recent reading is invalid. + * A sensors reading could be considered invalid if the sensor has been turned + * off in the config tool. + */ + this.bad_sensor_input = { + up: Boolean(data.sig_bad.bad_sensor_0), + right: Boolean(data.sig_bad.bad_sensor_1), + down: Boolean(data.sig_bad.bad_sensor_2), + left: Boolean(data.sig_bad.bad_sensor_3), + }; + + // This is what the dipswitch is set to for this panel + this.dip_switch_value = data.dips.dip; + + // These are true if the sensor has the incorrect jumper set + this.bad_jumper = { + up: Boolean(data.dips.bad_sensor_dip_0), + right: Boolean(data.dips.bad_sensor_dip_1), + down: Boolean(data.dips.bad_sensor_dip_2), + left: Boolean(data.dips.bad_sensor_dip_3), + }; + + this.sensor_level = { + up: data.sensors[0], + right: data.sensors[1], + down: data.sensors[2], + left: data.sensors[3], + }; + } +} + +export class SMXSensorTestData { + private data: Array; + panels: EachPanel; + + constructor(data: Array) { + this.data = data; + + /** + * "y" is a response to our "y" query. This is binary data with the format: + * yAB...... + * where A is our original query mode (currently "0", "1", "2", or "3"), and + * B is the number of bits from each panel in the response. + * Each bit is encoded as a 16-bit int, with each int having the response + * bits from each panel. + */ + console.assert(data[0] === API_COMMAND.GET_SENSOR_TEST_DATA); // Expected to be 'y' + // const mode = data[1]; // If we know what command we sent we could confirm we get the right response + const size = data[2]; + + /** + * Copy the data and convert from Little Endian formatted 8-bit bytes + * and place them into 16-bit bytes. + */ + const sensor_data_t = new StructBuffer("sensor_data_t", { + data: uint16_t[size], + }); + const decoded_data = sensor_data_t.decode(data.slice(3), true); + const panel_count = 9; // TODO: This could be a const somewhere? + + const panel_data = []; + + console.log(data.slice(3)); + console.log(decoded_data); + + // Cycle through each panel and grab the data + // I'm not sure this one can be magic'd away with a struct buffer + for (let panel = 0; panel < panel_count; panel++) { + let idx = 0; + const out_bytes: Array = []; + + /** + * Read each byte in our decoded_data. + * The length here is the size from above (in bits) div by 8 to give bytes. + */ + for (const _ of Array.from({ length: size / 8 })) { + let result = 0; + + // Read each bit in each byte + for (let bit = 0; bit < 8; bit++) { + const new_bit = decoded_data.data[idx] & (1 << panel); + result |= new_bit << bit; + idx++; + } + + // We need to shift the result by the panel to move it back to fit within + // an 8-bit byte + out_bytes.push(result >> panel); + } + + console.log(`Panel ${panel}: ${out_bytes}`); + panel_data.push(detail_data_t.decode(out_bytes, true)); + } + + const panels = []; + for (let panel = 0; panel < 9; panel++) { + panels.push(new SMXPanelTestData(panel_data[panel])); + } + + this.panels = { + up_left: panels[0], + up: panels[1], + up_right: panels[2], + left: panels[3], + center: panels[4], + right: panels[5], + down_left: panels[6], + down: panels[7], + down_right: panels[8], + }; + + console.log(decoded_data); + console.log(panel_data); + + console.log(this); + } +} diff --git a/sdk/index.ts b/sdk/index.ts index ca0bd5e..55f9318 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -1,5 +1,18 @@ +import * as Bacon from "baconjs"; +import type { StateF } from "baconjs/types/withstatemachine"; import { API_COMMAND } from "./api"; -import { process_packets, send_data } from "./packet"; +import { StageInputs } from "./commands/inputs"; +import { SENSOR_TEST_MODE } from "./commands/sensor_test"; +import { + HID_REPORT_INPUT, + HID_REPORT_INPUT_STATE, + PACKET_FLAG_DEVICE_INFO, + PACKET_FLAG_END_OF_COMMAND, + PACKET_FLAG_HOST_CMD_FINISHED, + PACKET_FLAG_START_OF_COMMAND, + process_packets, + send_data, +} from "./packet"; export async function getDeviceInfo(dev: HIDDevice) { await send_data(dev, [API_COMMAND.GET_DEVICE_INFO], true); @@ -10,3 +23,100 @@ export async function getStageConfig(dev: HIDDevice) { await send_data(dev, [API_COMMAND.GET_CONFIG_V5], true); return process_packets(dev, true); } + +export async function getSensorTestData(dev: HIDDevice) { + await send_data(dev, [API_COMMAND.GET_SENSOR_TEST_DATA, SENSOR_TEST_MODE.CALIBRATED_VALUES], true); + return process_packets(dev, true); +} + +interface PacketHandlingState { + currentPacket: Uint8Array; +} + +function hasValue(ev: Bacon.Event): ev is Bacon.Value { + return ev.hasValue; +} + +type Packet = { type: "host_cmd_finished" } | { type: "data"; payload: Uint8Array }; + +/** + * Gets called when a packet is received, returns a tuple of new state and an array of + */ +const handlePacket: StateF = (state, event) => { + if (!hasValue(event)) return [state, []]; + let currentPacket = state.currentPacket; + const data = new Uint8Array(event.value.buffer); + if (data.length <= 3) return [state, []]; + const cmd = data[0]; + const byte_len = data[1]; + + if ((cmd & PACKET_FLAG_DEVICE_INFO) === PACKET_FLAG_DEVICE_INFO) { + // This is a response to RequestDeviceInfo. Since any application can send this, + // we ignore the packet if we didn't request it, since it might be requested + // for a different program. + // TODO: Handle this? Not sure there's anything to handle here tbh + } + + // TODO: Make some consts for these 2's everywhere + if (2 + byte_len > data.length) { + // TODO: Can this even happen??? + console.log("Communication Error: Oversized Packet (ignored)"); + return [state, []]; + } + + // The data exists after the first 2 bytes + const dataBody = data.slice(2, 2 + byte_len); + + if ((cmd & PACKET_FLAG_START_OF_COMMAND) === PACKET_FLAG_START_OF_COMMAND && state.currentPacket.length > 0) { + /** + * When we get a start packet, the read buffer should already be empty. If it isn't, + * we got a command that didn't end with an END_OF_COMMAND packet and something is wrong. + * This shouldn't happen, so warn about it and recover by clearing the junk in the buffer. + * TODO: Again, does this actually happen???!? + */ + console.log( + "Got PACKET_FLAG_OF_START_COMMAND, but we had ${current_packet.length} bytes in the buffer. Dropping it and continuing.", + ); + currentPacket = new Uint8Array(0); + } + + // concat the new data onto the current packet + const nextPacket = new Uint8Array(currentPacket.byteLength + dataBody.byteLength); + nextPacket.set(currentPacket); + nextPacket.set(dataBody, currentPacket.byteLength); + const eventsToPass: Packet[] = []; + + let newState = { currentPacket: nextPacket }; + + // Note that if PACKET_FLAG_HOST_CMD_FINISHED is set, PACKET_FLAG_END_OF_COMMAND will also be set + if ((cmd & PACKET_FLAG_HOST_CMD_FINISHED) === PACKET_FLAG_HOST_CMD_FINISHED) { + // This tells us that a command we wrote to the devide has finished executing, and it's safe to start writing another. + console.log("Packet Complete"); + eventsToPass.push({ type: "host_cmd_finished" }); + } + + if ((cmd & PACKET_FLAG_END_OF_COMMAND) === PACKET_FLAG_END_OF_COMMAND) { + newState = { currentPacket: new Uint8Array(0) }; + eventsToPass.push({ type: "data", payload: nextPacket }); + } + + return [newState, eventsToPass.map((e) => new Bacon.Next(e))]; +}; + +export function SmxStage(device: HIDDevice) { + const input$ = Bacon.fromEvent(device, "inputreport"); + const inputState$ = input$ + .filter((e) => e.reportId === HID_REPORT_INPUT_STATE) + .map((e) => StageInputs.decode(e.data, true)); + const otherReports$ = input$ + .filter((e) => e.reportId === HID_REPORT_INPUT) + .map((e) => e.data) + .filter((d) => d.byteLength !== 0) + .withStateMachine({ currentPacket: new Uint8Array() }, handlePacket); + + return { + device, + inputState$, + otherReports$, + }; +} diff --git a/sdk/packet.ts b/sdk/packet.ts index aa86379..0f0e536 100644 --- a/sdk/packet.ts +++ b/sdk/packet.ts @@ -46,10 +46,10 @@ export const MAX_PACKET_SIZE = 63; const PACKET_PREAMBLE_SIZE = 2; // USB Communication Packet Flags -const PACKET_FLAG_START_OF_COMMAND = 0x04; -const PACKET_FLAG_END_OF_COMMAND = 0x01; -const PACKET_FLAG_HOST_CMD_FINISHED = 0x02; -const PACKET_FLAG_DEVICE_INFO = 0x80; +export const PACKET_FLAG_START_OF_COMMAND = 0x04; +export const PACKET_FLAG_END_OF_COMMAND = 0x01; +export const PACKET_FLAG_HOST_CMD_FINISHED = 0x02; +export const PACKET_FLAG_DEVICE_INFO = 0x80; // HID Report Codes export const HID_REPORT_INPUT_STATE = 0x03; @@ -59,7 +59,7 @@ export const HID_REPORT_INPUT = 0x06; // Acknowledge Code const ACK_COMMAND = 0x7; -function make_packets(data: Array): Array { +export function make_packets(data: Array): Array { const packets = []; const data_len = data.length; let idx = 0; diff --git a/ui/DebugCommands.tsx b/ui/DebugCommands.tsx new file mode 100644 index 0000000..5af7f71 --- /dev/null +++ b/ui/DebugCommands.tsx @@ -0,0 +1,49 @@ +import { useAtomValue } from "jotai"; +import { useState } from "react"; +import { DEBUG_COMMANDS } from "./pad-coms"; +import { devices$ } from "./state.ts"; + +export function DebugCommands() { + const connectedDevices = useAtomValue(devices$); + const [selectedPlayer, setSelectedPlayer] = useState(0); + const [selectedCommand, setSelectedCommand] = useState(""); + const cmds = Array.from(Object.entries(DEBUG_COMMANDS)); + const device = connectedDevices[selectedPlayer]; + const handleSendCommand = + selectedCommand && device + ? () => { + DEBUG_COMMANDS[selectedCommand](device); + } + : undefined; + return ( + <> + {" "} + {" "} + + + ); +} diff --git a/ui/pad-coms.ts b/ui/pad-coms.ts index c786587..1594d73 100644 --- a/ui/pad-coms.ts +++ b/ui/pad-coms.ts @@ -1,7 +1,8 @@ -import { getDeviceInfo, getStageConfig } from "../sdk"; +import { getDeviceInfo, getSensorTestData, 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 { SMXSensorTestData } from "../sdk/commands/sensor_test"; import { devices$, nextStatusTextLine$, statusText$, uiState } from "./state"; // function formatDataForDisplay(data: DataView) { @@ -62,17 +63,23 @@ export async function open_smx_device(dev: HIDDevice) { 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()); } } + +export async function requestTestData(dev: HIDDevice) { + const response = await getSensorTestData(dev); + const test_obj = new SMXSensorTestData(response); +} + +/** anything here will appear in the debug UI to dispatch at will */ +export const DEBUG_COMMANDS: Record unknown> = { + requestConfig, + requestTestData, +}; diff --git a/ui/simple-pad.tsx b/ui/simple-pad.tsx index 1c8ed24..944696b 100644 --- a/ui/simple-pad.tsx +++ b/ui/simple-pad.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { SmxStage } from "../sdk"; import { StageInputs } from "../sdk/commands/inputs.ts"; import { HID_REPORT_INPUT_STATE } from "../sdk/packet.ts"; @@ -9,27 +10,25 @@ interface Props { export function SimplePad({ dev }: Props) { const [panelStates, setPanelStates] = useState([]); - const handleInputReport = useCallback((ev: HIDInputReportEvent) => { - if (ev.reportId === HID_REPORT_INPUT_STATE) { - const panelStates = StageInputs.decode(ev.data, true); - setPanelStates([ - panelStates.up_left, - panelStates.up, - panelStates.up_right, - panelStates.left, - panelStates.center, - panelStates.right, - panelStates.down_left, - panelStates.down, - panelStates.down_right, - ]); - } - }, []); - - useEffect(() => { - dev.addEventListener("inputreport", handleInputReport); - return () => dev.removeEventListener("inputreport", handleInputReport); - }, [handleInputReport, dev]); + useEffect( + () => + SmxStage(dev) + .inputState$.throttle(67) + .onValue((panelStates) => { + setPanelStates([ + panelStates.up_left, + panelStates.up, + panelStates.up_right, + panelStates.left, + panelStates.center, + panelStates.right, + panelStates.down_left, + panelStates.down, + panelStates.down_right, + ]); + }), + [dev], + ); return (
diff --git a/ui/styles.css b/ui/styles.css index 01788b8..45f4c9b 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -43,7 +43,8 @@ body { } button, -input { +input, +select { font-family: inherit; font-size: 100%; background: #FFFFFF; diff --git a/ui/ui.tsx b/ui/ui.tsx index b01acc5..7b61b50 100644 --- a/ui/ui.tsx +++ b/ui/ui.tsx @@ -1,11 +1,8 @@ import { useAtomValue } from "jotai"; import { useEffect } from "react"; -import { - open_smx_device, - promptSelectDevice, - requestConfig, -} from "./pad-coms.ts"; +import { DebugCommands } from "./DebugCommands.tsx"; +import { open_smx_device, promptSelectDevice, requestConfig, requestTestData } from "./pad-coms.ts"; import { browserSupported, p1Dev$, statusText$ } from "./state.ts"; export function UI() { @@ -24,32 +21,18 @@ export function UI() { return ( <>

SMX Web Config

- + ); -} -function PickDeviceButton() { - return ( - - ); -} - -function FetchConfigButton() { - const device = useAtomValue(p1Dev$); - const handleClick = device ? () => requestConfig(device) : undefined; - return ( - - ); + function PickDeviceButton() { + return ( + + ); + } } function StatusDisplay() {